You built tracing for the chat path first. Everyone does. A person types, a request goes out, and you record the prompt, the retrieved context, the tool calls and the reply. Then you add background agents: a queue worker that picks up a task, calls the same model with the same tools, and marks the job done. The job table fills up. Your traces stay empty, and nothing tells you.
That is where I found myself in late August. Vodou has a task board. An agent can create tasks, and a worker process claims and runs them with a bootstrap, tools and memory. I went looking for the record of what those workers had done and counted this:
What the board logged vs what the turn ledger saw
10 Board tasks, 70 task events, 0 turns in the ledger
Everything I had built for turn accounting covered turns that a human starts. The two things the system does on its own were invisible. The ten board tasks had run to completion and written 70 lifecycle events. The workflow graph runner had logged 4,507 runs. The turn ledger held zero rows for either.
The work you are least able to watch had no record at all. A chat turn gets a person reading the reply as it streams. A board task runs at 3am and marks itself done.
The shipped change is small to describe. A board run is now a turn. It carries an identity derived from the task, so the turn ledger can answer "what did task t-42 send the model, and what came back", and it writes the same per-turn receipt a chat turn writes.
One job, one identity, two backends
An env var whose absence raises no error
The board has two backends. One spawns a CLI child (claude -p) per task. The other runs the task inside the gateway process.
The chat path already had a way to identify a turn across a process boundary. The gateway sets a turn id environment variable on its CLI child, the hook running inside that child reads it, and the daemon adopts it. The board's CLI spawner never set that variable. I searched the board code for it and found zero occurrences.
What made this last so long is the kind of bug it is. When that variable is missing, nothing fails. The worker runs perfectly. The model answers. The task closes itself. The only symptom is a row that never gets written, and nobody alerts on a row that was never written. Ten tasks completed that way.
I did not invent a second identification scheme. The spawner now hands the worker the same variable the chat path uses, with the value board:<task_id>. The child's hook picks it up and the run lands as a partial turn. I chose "partial" on purpose. When the request is assembled inside a CLI child, I can honestly record only what Vodou contributed (bootstrap, memory, tools), not the exact bytes the child sent.
The env block used to be built inline inside the spawn function. The only way to check what a worker received was to spawn a real one. I pulled it out into its own function so a test can assert it without a process. Two new tests pin the turn id and the rest of the worker's env contract, so a later refactor cannot quietly drop a variable. The test has this shape:
test('a worker is told which turn it is', () => {
const env = envForWorker({ taskId: 't-42', model: 'sonnet' });
expect(env.TURN_ID).toBe('board:t-42');
});
The gateway backend had a turn id, and it was randomUUID()
The in-process backend looked fine at first. It did pass a turn id. In MCP-servers/Vodou-Console/src/index.ts it was const btTurnId = randomUUID();. That id got logged, and it could never be found again, because nothing maps a UUID back to a task. You can't join a random value against a job table.
So there was a worse state than "no record": one job recorded under two unrelated identities depending on which backend happened to run it. Both backends now use board:${taskId}.
The same path had a second defect, and I had fixed it once already on the scheduler heartbeat. The board task called chat() but never called buildReceipt, the function that projects the turn's log onto its receipt row. The result was a populated log sitting next to a receipt with lanes = NULL. The fix wraps the receipt build so that an accounting failure can never fail the run it describes:
try {
buildReceipt(convId, getLastMemoryUsed(convId), {
ms: Date.now() - btStartedAt,
project: projectContextProjectId(),
turnId: btTurnId,
});
} catch { /* a receipt must never fail the turn it describes */ }
Fixing the same bug twice taught me that it isn't a bug in one code path. Every path that calls the model directly, without going through the chat entry point, loses whatever the chat entry point does implicitly.
The model-id test that failed every night for a correct decision
A related failure had been running for weeks before this. The board's contract test for board_show required worker_context.model to match /^claude-/. The spawner's default had been changed to the CLI alias sonnet on purpose, because the dated model id it used before had retired and returned 404 for every worker. The code was right and the test enforced the retired assumption, so the contract went red every night.
The fix in MCP-servers/Vodou-Board/tests/tools-read.test.ts states what the spawner actually needs: a string that claude -p --model accepts, either an alias or a full id, and never empty. The board suite went 32/32 across 3 files. A test that asserts a format instead of the consumer's requirement will eventually outlive the format. This one was lucky: it failed loudly. The missing turn id failed silently.
The property: every model call made without a human has an id you can derive from its job
Stated as something you can check against a codebase:
For every code path that calls a model without a human-initiated request, the turn identity is a pure function of the job's own key, it is set before the call, and a test asserts it without running the job.
That rules out all three states I found. A missing id fails the property. A random id fails "derived from the job". An id that only a live spawn can reveal fails "asserted without running". Treat the receipt the same way: any path that calls the model directly must also call whatever writes the per-turn accounting, or it has to go through the entry point that does.
Join your job table to your trace table and count the orphans
This takes about five minutes. Rename the tables to match your schema. agent_jobs is whatever your queue or task store is, and traces is wherever turns or spans land.
SELECT COUNT(*) AS done_jobs,
SUM(CASE WHEN t.run_id IS NULL THEN 1 ELSE 0 END) AS untraced
FROM agent_jobs j
LEFT JOIN (SELECT DISTINCT run_id FROM traces) t
ON t.run_id = 'job:' || j.id
WHERE j.status = 'done';
Passing output has untraced at or near 0. Failing output looks like mine did, 10 | 10. If you cannot write the ON clause at all, because your trace ids are UUIDs with no link to a job, that is the failure too, and it is the randomUUID() variant.
Next, find out what your worker is actually handed. Put a shim in front of the worker command for one job:
cat > /tmp/env-shim.sh <<'EOF'
#!/bin/sh
env | grep -Ei '^(trace|turn|run|otel)' > /tmp/worker-env.txt
exec "$@"
EOF
chmod +x /tmp/env-shim.sh
# run one job with the worker command prefixed by /tmp/env-shim.sh, then:
cat /tmp/worker-env.txt
A pass shows something like TURN_ID=job:42 or a TRACEPARENT carrying that job. An empty file is a fail, and the job will still report success.
Finally, look for identities minted at the call site:
grep -rnE 'randomUUID\(\)|uuid4\(\)|uuid\.New\(\)' src/ | grep -iE 'turn|trace|run'
Every hit on a background path is a record nobody can join back to its job.
Retired model ids and alias fallbacks get written up; unrecorded autonomous runs don't
The model-id half of this story is well covered in public. CodeBoarding shipped a hardcoded dated Claude id that started returning 404, which is the same retirement that pushed our spawner onto an alias. Callboard found that a per-chat alias with no target silently bypassed the configured default, and link-assistant let the default model skip strict catalog validation because the catalog lags the provider. Each of those is about choosing the right model string, and each one produces an error someone eventually sees.
The general agent-building guidance, such as OpenAI's practical guide to building agents and Agent Surface, is organized around designing orchestration, tool calls and error handling. Those are all things that happen during a run. What I hit happens after: whether the run can be found at all once it finished without anyone watching. A failure that produces no error doesn't show up in writing organized around errors. It shows up only when you count rows in two tables that should agree.
Still unrecorded: 4,507 graph runs behind 33 call sites
The workflow graph runner is not fixed. Its prose branch uses a pooled raw model call, and that path emits no turn events at all. Giving it a turn id means threading one through the raw call function at 33 call sites. That is a bigger change than the one line the plan gave it, and I want to measure it before I claim it.
Two smaller caveats. Board turns from the CLI backend are recorded as partial, because the child process assembles the final request and I only record what I contributed. And the subagent host now declares recall and flush in hosts.toml, but our host grader still shows a dash for both. It grades evidence, not declarations, and no subagent has used either one yet.
Source: Our autonomous agent finished 10 tasks and left zero turn records by Chad Priest, from Building Vodou in Public.


Top comments (0)