Chapter 2 of 7 · 10 min read
Progress that lives in the conversation dies with the conversation
On this page
The auth extraction is on step eight of twelve when the harness kills it — the harness being the program wrapped around the model that executes its tool calls, applies its edits and enforces its budgets. The cap is forty-five minutes of wall clock per run; the run has used thirty-one on the work and fourteen waiting on test suites, and the scheduler is not interested in the distinction. The process ends between a file write and the next tool call.
An hour later somebody restarts it with the same ticket, the same repository, and a working tree containing seven and a half steps of changes. The agent has no memory of the first run. What it has is the ticket, a directory, and whatever it can infer by looking around. It reads MIGRATION.md, sees the twelve-step plan it wrote earlier, and begins reasoning about where it must have got to.
It gets two things wrong within the first four minutes. Step five moved the session middleware registration into auth.Register(mux), and the agent, seeing auth.Register exist and reading its plan entry as unfinished, applies it again — so mux.Handle("/login", ...) is registered twice and the second registration panics at startup with http: multiple registrations for /login. Step seven had rewritten four of six files before the kill, and the agent, seeing the package compile because the remaining two files still worked against the old signature, marks it done and moves on. The run finishes, reports success, and leaves a service that panics on boot and two files that were never migrated.
Neither mistake is a reasoning failure in any interesting sense. The agent was asked to infer a fact — which steps have completed — from evidence that does not determine it, and it inferred wrongly in both available directions. The first run knew exactly which steps it had finished. That knowledge lived in the conversation, and the conversation is gone.
What has to be written down, beyond a list and a flag
The obvious repair is to write the step list to a file with a status on each entry. That is the right shape and roughly a third of the content. What the file has to become is a task ledger: a durable record, one entry per step, of what the run set out to do, what it attempted, and what the certifying checks said about the result. A resumed run needs to answer more questions than "which step is next", and each of the extra fields exists because a specific recovery decision is impossible without it.
{
"task": "extract auth module from monolith",
"plan_version": 3,
"steps": [
{
"id": "s05",
"title": "move session middleware registration into auth.Register",
"touches": ["internal/auth/register.go", "cmd/api/main.go"],
"depends_on": ["s03", "s04"],
"check": ["go build ./...", "go test ./internal/auth/... -count=1"],
"rollback_to": "s04",
"status": "passed",
"evidence": {
"at": "2026-03-11T09:14:22Z",
"commands": [
{"cmd": "go build ./...", "exit": 0, "secs": 9.1},
{"cmd": "go test ./internal/auth/... -count=1", "exit": 0, "secs": 22.6,
"summary": "ok internal/auth 41 tests"}
],
"tree": "8f3c1ad"
},
"attempts": 2
},
{
"id": "s07",
"title": "migrate token parsing callers (6 files)",
"touches": ["internal/api/**/*.go"],
"depends_on": ["s06"],
"check": ["go build ./...", "go test ./internal/api/... -count=1"],
"rollback_to": "s06",
"status": "in_progress",
"evidence": null,
"attempts": 1
}
]
}The touches list is what tells a resumed run where to look and, more usefully, what a half-finished step could have disturbed. Step seven's entry names internal/api/**/*.go as its territory, so a resume that finds uncommitted changes under that path knows they belong to an uncertified step rather than to some earlier one that succeeded. Without it, the working tree is an undifferentiated pile of edits.
The check field travels with the step for the same reason it was introduced in the previous chapter — it is the only thing that can answer whether the step worked — and it has to be recorded per step rather than derived, because a resumed run has no way to reconstruct that step seven's certifying check was the internal/api package rather than the full suite. Running the full suite instead is not a conservative substitute. It is slower by a factor of about twenty on this repository and it fails for reasons unrelated to step seven, which produces a resumed run that starts by debugging somebody else's flaky test.
depends_on and rollback_to carry the graph and the boundary. On resume they answer the two questions that follow "step seven is not certified": what has to be true before I retry it, and where do I go back to if I cannot. evidence is the part teams leave out most often and regret most specifically — the exact commands, exit codes and durations that produced the current status, with the tree reference they ran against. A status of passed with no evidence is an assertion; a status of passed alongside go test ./internal/auth/... -count=1 → exit 0 at 09:14 against 8f3c1ad is a claim somebody can check, and the checking is what chapter three is about.
attempts matters for a reason that only becomes visible much later in a long run: a step on its fourth attempt is a different situation from a step on its first, and the policy that decides whether to try again needs the count to be durable rather than remembered.
Anyone who has run database migrations has built this
The structure is a version table. Flyway, Alembic, golang-migrate and every hand-rolled equivalent keep a row per applied migration — version, checksum, applied-at, duration, success — in the database itself, and the reason is identical: the process that applies migrations is not durable, the thing it changes is, and the record of what happened has to sit with the durable thing. Nobody would design a migration tool that held its applied-versions list in the memory of the process running it.
The analogy is close enough to be worth borrowing from, including the checksum. Alembic and Flyway store a hash of each migration's content so that a changed migration file is detected rather than silently skipped; a task ledger benefits from the same field, because a plan revised mid-run — step seven rewritten after two failures — should not be certified by evidence produced against the old version of it. That is what plan_version is doing at the top of the file, coarsely.
Where the analogy stops is the point the next chapter turns on. A migration tool on Postgres can apply the DDL and insert the version row inside one transaction, so the record and the reality commit together or not at all. Editing forty files across twelve packages has no such transaction available, and a ledger entry written just before or just after the edits it describes has a window in which the two disagree.
Where the file lives decides what it survives
Writing the ledger down is not one decision; it is a choice of storage, and the failure modes differ sharply. One of the columns below needs a word first: compaction is what a harness does when a conversation grows past the model's context window, replacing older turns with a summary so the run can continue, which means anything recorded only in those turns is now recorded only in whatever the summary happened to keep.
| Location | Survives compaction | Survives process kill | Survives workspace teardown | Human can read it mid-run |
|---|---|---|---|---|
| The conversation itself | no | no | no | only by scrolling the transcript |
| Scratch file in the container | yes | yes, if the container persists | no | no |
| File in the repo working tree, uncommitted | yes | yes | no | on the box only |
| Committed to the task branch | yes | yes | yes | yes, via the branch |
| Row in an external store, keyed by run id | yes | yes | yes | yes, and across runs |
The middle row is where most implementations land and it is the one that quietly fails on managed infrastructure, because the common way a long run ends is that something reclaims the sandbox — the disposable container the run's workspace lives in, which a scheduler is free to destroy the moment the run stops. A ledger in /workspace/.agent/ledger.json is durable against every failure except the one that happens most.
Committing the ledger to the task branch is the cheapest option that survives teardown, since the branch is pushed anyway and the ledger is 1.8 KB for twelve steps. It puts a machine-written file into review history, which some teams dislike, and the file can be stripped in the final squash. An external store — a row per run, a JSON blob per step — is better when several runs share a task, when a human dashboard needs to read progress live, or when the same ledger has to be visible to a supervising process that is not inside the sandbox. It costs a service dependency that the run now fails without, which is a real trade: a ledger write that 500s should not kill a run that is otherwise fine, so the writer needs a local fallback and the two need reconciling.
The size argument is worth making explicitly, because the ledger competes with nothing. Twelve entries with evidence serialise to about 1.8 KB, roughly 600 tokens. The transcript those entries were distilled from, at the point step eight began, was 140,000 tokens of tool calls, file contents and reasoning. The ledger is a 200:1 compression of the only part of that transcript a resumed run needs, and it is authored rather than summarised, which is why it can be trusted to contain specific fields rather than whatever a summariser happened to preserve.
A failed attempt is evidence too
Ledgers written by teams who have only thought about the happy path record success in detail and failure as a boolean. That asymmetry costs the resumed run the single most useful thing the previous one produced, which is the reason the check failed.
Step seven's entry, after a genuine failed attempt, should carry the command, its exit code, and a bounded tail of its output — two kilobytes is enough for a Go test failure and a stack frame, and it wants a cap, because a run that pastes 400 lines of compiler errors into a JSON file three times over turns a 1.8 KB ledger into something nobody re-reads.
"attempts": 2,
"last_failure": {
"at": "2026-03-11T09:41:03Z",
"cmd": "go test ./internal/api/... -count=1",
"exit": 1,
"tail": "--- FAIL: TestTokenRoundTrip (0.00s)\n token_test.go:88: got \"\", want \"abc123\"\nFAIL\tinternal/api\t0.412s",
"hypothesis": "parseSessionToken lowercases the cookie name; callers still send 'Session'"
}The hypothesis field is free text and is the one field in the ledger that is not mechanically derived. It records what the run believed the failure meant, so that a resume — or the human who eventually reads this — starts from an explored position rather than from nothing. It is also the field most likely to be wrong, and treating it as a lead rather than a finding is the correct posture, since a run that failed twice on this step has by definition not yet found the cause.
Keeping failures in the ledger has a second effect that matters much later. A step whose entry shows three attempts with the same failing test and three different hypotheses is a step that is not converging, and that pattern is invisible to anything that only stores the current status.
The things a ledger is not
It is not the plan. The plan is a statement of intent about work not yet done, and it is revisable; the ledger is a record of what was attempted and what the checks said. They are usually stored in the same file because the step definitions are shared, and conflating them causes a specific bug — when a step is re-planned after failure, the intent changes and the evidence from the old framing must not carry over, which is precisely what plan_version and a content checksum prevent.
It is not context management. Automatic compaction, summarisation and the offload-compact-isolate patterns that context engineering work has converged on all address a different problem: keeping a long conversation inside a window. A ledger is a durable artifact with a schema, and it would still be necessary if the window were infinite, because the process still dies. The two combine — chapter four is about the combination — and neither substitutes for the other.
It is not a lock. Two agents resuming the same ledger concurrently will both decide step seven is next, and nothing in the file stops them. Concurrency needs a lease with an expiry, held by run id and renewed, and it is a separate mechanism that the ledger's fields do not imply.
Most importantly, it is not the truth. Every field in that JSON was written by the run that was doing the work, at some moment relative to the work. "status": "passed" on step five means a process once believed step five had passed. When the process was killed between applying step five's edits and writing that line — or between writing that line and applying the edits, depending on which order the implementation chose — the file says something the tree does not support, and a resumed run that trusts it repeats exactly the mistakes the ledger was introduced to prevent. That gap, and how to close it, is the next chapter.
Long-horizon code tasks — read the current version at https://daviddominguez.dev/learn/en/long-horizon-code-tasks/progress-that-lives-in-the-conversation-dies-with-the-conversation