Three counters, three answers
One Stop click produced a UI, a state file and a git history that disagreed with each other three different ways. None of them was wrong. The reconciliation point that should have caught it was a line in a spec that said "details TBD in implementation".
On the 26th of April I clicked Stop on a Dex run and got three different answers to the question “how much of this project is built”.
The header said 1/3 cycles. The state file said two cycles complete and a third
in progress. Git said something else again. And the stage list — the thing an
actual user looks at — showed cycle 2 with all seven stages green and an orange
pause icon on the cycle itself, which is not a state that exists.
Here is the whole disagreement, from run 79134ace:
| Source | Cycle 1 | Cycle 2 | Cycle 3 | Header counter |
|---|---|---|---|---|
state.json |
done | done | started, paused at gap analysis | — |
| Git history | specify, plan, tasks, verify — no implement, no learnings | gap analysis, verify, implement_fix, verify, learnings | gap analysis | — |
| Stage list (UI) | paused at Implement, Verify struck through, Learnings dim | all seven green | gap analysis green, paused | 1/3 |
Read that middle row again. Cycle 1 ran verify without ever running
implement. Cycle 2 ran verify twice around an implement_fix. That is not
corruption — that is exactly what the loop is supposed to do when a verification
fails and the fixer runs. Git was the only one telling the whole truth, and it
was the only one nobody was reading.
Nobody was lying
This is the part that made the bug interesting rather than annoying. I went in expecting to find a component with a bug. There wasn’t one. Every source was correctly reporting the thing it actually knew:
state.json was right. The orchestrator catches the abort, sets
pauseReason: "user_abort", and deliberately does not increment
cyclesCompleted. Textbook. It described the engine’s state accurately.
Git was right. Every stage that completed had committed. Every stage that hadn’t, hadn’t. The history was a perfect record of work performed.
The UI was right too, given its inputs — and its inputs were the problem.
Three state machines wearing one trench coat
The renderer did not read state. It accumulated it. Events streamed in over
IPC, a hook appended them to loopCycles[], and the UI rendered that array.
Which is a completely normal way to build a live view, and it means the renderer
maintains a second state machine that only ever agrees with the first one by
coincidence.
Then two things pushed them apart.
The abort check lived at cycle boundaries. By the time the abort propagated,
loop_cycle_started for cycle 2 had already been emitted. The renderer dutifully
appended a cycle 2 that the engine had already decided would never exist.
Skipped stages were emitted as completed stages. When gap analysis returns
RESUME_FEATURE, the earlier stages of that cycle don’t run — but the UI still
needs a row for them, so the orchestrator emitted synthetic events through the
same step_completed channel real stages used. The renderer had no way to
distinguish “this stage ran and succeeded” from “this stage was skipped on
purpose”. Both arrived as the same event with the same shape.
So the renderer painted them green. All of them. Which brings me to my favourite line of code in the whole investigation — the condition that decided whether to render a stage as skipped:
if (actual && actual.status === "completed" && actual.durationMs < 5000) {
Read it in English: if the stage says it completed, but it completed suspiciously fast, it probably didn’t really happen.
And the third counter, the 1/3 in the header, was computed from neither — a
third derivation, in a third place, from a third set of inputs.
Three state machines. No reconciliation point that ever fired.
The line in the spec
Here is the part that stings. The design document for the checkpoint system had already specified the correct architecture, months earlier:
Git refs are the shared authoritative layer. […]
state.jsonis a cache, rebuilt from refs + filesystem on Go back, project open, and external git change.
That is exactly right. Had it been implemented, none of the above could have happened, because there would have been one authority and two views of it.
And in the implementation plan for the same feature, further down, this:
Reconciliation when
state.jsondiverges from refs:reconcileStateneeds an authoritative mode that fully rebuildsstate.jsonfrom refs + filesystem. Details TBD in implementation.
That TBD is the bug. Not a missing line of code — a missing decision, parked
inside a document that otherwise read as complete, in a section nobody revisits
because the surrounding text sounds finished. The feature shipped. It worked. The
reconciliation mode was never built, and reconcileState grew into something
that only diffs artifact hashes and checks whether HEAD moved. It does not
derive cyclesCompleted, or currentCycleNumber, or lastCompletedStep from
the refs. It never did.
What actually shipped
Partial, and I would rather say that plainly than imply a tidy resolution.
The synthetic-event problem is fixed. Skipped status is now derived from the gap analysis decision rather than guessed from a clock:
if (getStageVisibility(stageType, decision) === "skip") return "skipped";
The decision is the thing that causes stages to be skipped, so asking it is both correct and stable. The five-second heuristic is gone.
Derivation from refs exists, narrowly. syncStateFromHead reads HEAD’s
step-commit and patches state.json from it:
// Subject pattern: `dex: <step> completed [cycle:N] [feature:<slug-or->]`
const m = subject.match(/^dex: (\w+) completed \[cycle:(\d+)\](?: \[feature:([^\]]+)\])?/);
This is the two-line commit message from the previous post doing its second job. It runs before a resume, which closes the case that motivated it: you navigate the timeline to an earlier stage, hit Resume, and the run continues from where you navigated to rather than from wherever the state file was last frozen.
The general case is still open. reconcileState still only runs when
config.resume is true. Not on project open. Not on Stop. The full rebuild-from-refs
mode described in that quote does not exist yet, and the honest marker of that is
sitting in the file-size allow-list of all places:
ALLOWLIST=(
src/core/state.ts # perpetual — 01X-state-reconciliation
src/core/agent/ClaudeAgentRunner.ts # perpetual — TBD SDK-adapter spec
)
state.ts is permanently exempted from the 600-line limit with a comment naming
the spec that would fix it. That is either good bookkeeping or a very
well-documented piece of debt, and most days I think it is both.
The general lesson
There is a well-worn line about how a person with two watches never knows what time it is. The version I actually needed was sharper than that.
Two watches is not the failure. You want a fast local view and a durable authority; that is just caching, and caching is fine. The failure is having two watches and no ritual for setting one from the other. Every source in that table was accurate about its own inputs. What was missing was any moment, ever, at which one of them was declared authoritative and the others were made to match.
So the question worth asking of any system with more than one representation of the same fact is not “which is the source of truth” — everyone can answer that, and the answer is usually in a document. It is: name the specific moment when the others are rebuilt from it. If you cannot point at a function and a trigger, you do not have a cache. You have a fork.
And when a design document says “details TBD in implementation”, that is not a note. It is an unresolved decision, and it should be as loud in your tracker as an open bug — because that is what it will become.
Next: I built a time machine, then deleted half of it — four timeline verbs, parallel variants in git worktrees, a record mode, and the specs that removed almost all of it.