← All writing

Done and Disconnected

A headless worker claiming a Wake task from a Claude Desktop queue, completing it, then watching get_causal_graph return 1 node and 0 edges — not a failure, a disclosure. The task record stored the resultContextIds. The causal graph never heard about them. Tracking down where complete_task stopped short and adding the one write that closes the loop.

Michael Shatny··6 min read

A Queue Working Exactly As Designed

The test was deliberate. A task created from Claude Desktop, assigned to claude-cli, objective: claim the task, save a context, complete with that context in resultContextIds, then verify from a separate session whether the status flip and the causal link both landed.

A headless worker — authenticated via OAuth 2.0 client_credentials directly against Signet, no browser, no human in the loop — polled the Wake task queue as claude-cli. The atomic claiming statement is a single SQL round-trip:

Atomic claim — one statement, no race window
UPDATE agent_tasks
SET status = 'claimed', assigned_to = ?, claimed_at = ?, updated_at = ?
WHERE id = (
  SELECT id FROM agent_tasks
  WHERE status = 'queued'
    AND (assigned_to IS NULL OR assigned_to = ?)
  ORDER BY created_at ASC LIMIT 1
) RETURNING *

The assigned_to filter is real, not decorative. A task assigned to claude-cli sits untouched in the queue until a worker identifies itself as exactly that. A second worker polling as worker-agent at the same moment gets nothing. The task was claimed within ten seconds of the worker starting. Context 92663cdc saved. Status flipped to [COMPLETED] with the context ID in results. From Claude Desktop's get_tasks, everything read back exactly right.

One Node, Zero Edges

Then: get_causal_graph on the agent-to-agent-test project. One node. Zero edges.

The task record knew about the context. resultContextIds: ["92663cdc..."] was right there on the row. But Wake's causal graph is not built from task rows. It's built from caused_by columns on context snapshot records — each context can name the context that caused it, and the graph is traversed from those links. The task completion had written one ledger. The other ledger had no entry.

This was exactly the thing the test had been designed to surface: whether resultContextIds on the task row was the full story, or whether completion also wrote something the graph could traverse. The answer, clearly, was no.

Two Explanations

Two candidates for the gap, named cleanly before touching any code.

Explanation 1: completion doesn't write edges at all. resultContextIds is stored as a task field only, and the graph is never notified. Under this reading, fixing it means adding a write to complete_task.

Explanation 2: edges only propagate through sourceContextId — the context that spawned the task in the first place. Since this test task had no prior context in the project to hang from, there was no parent node for the result to attach to. Under this reading the graph is behaving correctly and the test simply hadn't exercised the path.

The discriminator would have been a second task created with sourceContextId: "92663cdc...". But reading the code made the test unnecessary.

Where complete_task Stopped

TaskService.completeTask:

The full body of completeTask — before the fix
async completeTask(taskId: string, resultContextIds: string[]): Promise<void> {
  await this.taskRepository.complete(taskId, resultContextIds);
}

And D1TaskRepository.complete:

The repository — updates one table, touches nothing else
UPDATE agent_tasks
SET status = 'completed', result_context_ids = ?, updated_at = ?
WHERE id = ?

Explanation 1. The IDs are serialized as JSON into result_context_ids on the task row. No context snapshot is touched. There was no path from complete_task to any write on context_snapshots. Explanation 2 described a design that wasn't there.

The Write That Was Missing

The fix belongs server-side. Wherever complete_task is called — by this worker, by a future one, by Claude Desktop directly — the graph should stay consistent without the caller having to know about it.

!

The graph edge is a server responsibility, not a caller convention.

If writing the causal edge requires the caller to know about caused_by, every future worker implementation has to remember it. Moving the write into TaskService means the graph is consistent by construction, not by discipline.

IContextRepository gets one new method:

New port method
updateCausedBy(contextId: string, causedBy: string): Promise<void>;

D1ContextRepository implements it with a targeted update — no other columns touched:

D1 implementation
async updateCausedBy(contextId: string, causedBy: string): Promise<void> {
  await this.db.prepare(
    'UPDATE context_snapshots SET caused_by = ? WHERE id = ?'
  ).bind(causedBy, contextId).run();
}

TaskService gets a second constructor argument — IContextRepository — and completeTask now does the full job:

TaskService.completeTask — after the fix
async completeTask(taskId: string, resultContextIds: string[]): Promise<void> {
  await this.taskRepository.complete(taskId, resultContextIds);

  if (resultContextIds.length === 0) return;
  const task = await this.taskRepository.findById(taskId);
  if (!task?.sourceContextId) return;
  await Promise.all(
    resultContextIds.map(ctxId =>
      this.contextRepository.updateCausedBy(ctxId, task.sourceContextId!)
    )
  );
}

Six test mock classes across the suite needed updateCausedBy stubs. Then: 265 passing, type-check clean, deployed. A task now created with sourceContextId: "92663cdc..." and completed with a new result context will produce an edge in get_causal_graph — not because the caller remembered to write it, but because the service always does.

Recording and Connecting Are Not the Same Write

The task queue worked from the first test. Atomic claiming, assignment routing, cross-session coordination between Claude Desktop and a headless worker: all real and all correct. get_tasks returned the right status and the right context IDs. Every factual claim the task record makes is accurate.

But Wake's causal graph isn't built from task records. It's built from caused_by on context snapshots. A task that knows which contexts it produced, but never tells those contexts who caused them, has recorded the work without connecting it. Two ledgers about the same event, each assuming the other one was writing the entry that mattered.

The fix isn't subtle. It's one SQL statement, called once per result context, from the one place completion already runs. What it closes is the gap between a system that has a causal graph and a system that keeps one.

Related

Michael Shatny is a software developer and methodology engineer and founding contributor to .netTiers (2005–2010), one of the earliest schema-driven code generation frameworks for .NET. His work spans 28 years of the same architectural pattern: structured input, generated output, auditable artifacts. Wake Intelligence is the latest expression of that instinct — applied to the question of what a causal graph looks like when recording work and connecting it are treated as one atomic responsibility, not two.

ORCID: 0009-0006-2011-3258