# Our autonomous agent finished 10 tasks and left zero turn records

> One environment variable was missing, so every autonomous agent run was absent from our turn ledger while each task still passed. Here is how to check your own stack.

- Author: Chad Priest
- Published: 2026-09-12
- Canonical URL: https://blog.vodou.ai/autonomous-agent-runs-missing-from-traces/
- Tags: ai-agents, observability, llm, architecture

---

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:

**Diagram: What the board logged vs what the turn ledger saw**

Ten completed board tasks produced 70 task events and 0 turn records

```text
  task events  : 70 rows  ################################
  turn records :  0 rows  # (problem)

  10 tasks, all done. The graph runner was worse: 4,507 runs, 0 turns.
```

## 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.

**Diagram: One job, one identity, two backends**

Both board backends now stamp the run with board:task_id, which the hook and daemon adopt into the turn ledger

```text
  [Board task t-42] --> [CLI worker backend]
  [Board task t-42] --> [Gateway backend]
  [CLI worker backend] --> [Hook in the child]
  [Hook in the child] --> [Turn ledger (fixed)]
  [Gateway backend] --receipt--> [Turn ledger (fixed)]

  notes:
    CLI worker backend: env TURN_ID=board:t-42
    Gateway backend: turnId = board:t-42
    Hook in the child: reads the env var
    Turn ledger: partial turn + receipt

  Before this change the CLI path sent no id and the gateway path sent a random one.
```

## 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:

```ts
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:

```ts
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.

```sql
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:

```bash
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:

```bash
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](https://github.com/CodeBoarding/CodeBoarding/issues/278), 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](https://github.com/WolpertingerLabs/callboard/commit/25638e2214711463ea2523f08c8dd60245998db9), and link-assistant [let the default model skip strict catalog validation](https://github.com/link-assistant/agent/commit/24ed10192e9b7d6369119a90310e6c45100e2c01) 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](https://cdn.openai.com/business-guides-and-resources/a-practical-guide-to-building-agents.pdf) and [Agent Surface](https://agentsurface.dev/), 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](https://blog.vodou.ai/autonomous-agent-runs-missing-from-traces/) by Chad Priest, from Building Vodou in Public.
