One authority, three counters: deriving state from git refs
Naming a source of truth is the easy half. The hard half is naming the moment every other view gets rebuilt from it — and the run where a UI, a state file and a git history gave three different answers to "how much of this is built".
Here is a design rule I would now apply to any system that holds the same fact in more than one place.
Asking “which one is the source of truth?” is the easy half. Everyone can
answer it, the answer is usually written in a design document, and it is usually
correct. Dex’s answer is git refs: every completed
stage commits, so the refs are the record of work performed, and state.json is
a cache of what the refs already say.
The hard half is the second question, and almost nobody asks it: name the specific moment when the other views are rebuilt from the authority. A function and a trigger. If you cannot point at both, you do not have a cache. You have a fork — and it will diverge quietly, correctly, with no component anywhere doing anything wrong.
I know this because I shipped the design document and not the moment.
What a missing reconciliation looks like
On the 26th of April I clicked Stop on a run and got three different answers to “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.
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 the 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 source telling the whole truth, and it was the only
one nobody was reading.
Nobody was lying
This is what makes the bug instructive 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 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.
That is a completely normal way to build a live view, and it is the mechanism by which the fork gets created: the renderer maintains a second state machine that agrees with the first only by coincidence. Nobody decides to build a second source of truth. You decide to render events as they arrive, which is the obvious implementation, and the second source of truth is a free gift that comes with it.
Then two forces pushed them apart.
The abort check lived at cycle boundaries. By the time an 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. An
event stream has no undo; an append that should not have counted stays appended.
Skipped stages were emitted as completed stages. When gap analysis returns
RESUME_FEATURE, 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 ran and succeeded” from “this was skipped on purpose”. Both arrived as the
same event with the same shape.
So the renderer painted them green. Which produced my favourite line of code in the whole investigation — the condition deciding whether to render a stage as skipped:
if (actual && actual.status === "completed" && actual.durationMs < 5000) {
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 decision that was never made
Here is the part that stings, and the reason this post exists.
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 — authority named, and the moments named. Had it been implemented, none of the above could have happened.
And in the implementation plan for the same feature, further down:
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 shipped — partially
The synthetic-event problem is fixed. Skipped status is 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 doing its second job. It runs before a resume, which closes the case that motivated it: 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, 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 exempt 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.
What the rule costs
Deriving state from an authority is not free, and the reasons people cache are real reasons.
Rebuilding is O(history). A full reconstruction walks every stage commit on the branch. That is cheap at cycle three and not cheap at cycle sixty, and it has to happen at moments where a user is waiting — project open, a timeline jump. Every honest version of “just rebuild from the authority” eventually acquires an incremental path, which is a cache, which is where you started.
The commit message is a stringly-typed contract. That regex is the schema. Change the subject format and every prior run becomes unreadable to the parser that has to reconstruct it — a migration problem with no migration mechanism, because the records are commits and commits do not get altered. Encoding state in a human-readable string bought inspectability at the cost of a format I can now never change casually.
And state.json is tracked, so it can conflict. Putting the cache in git is
what makes it ride a checkout correctly, and it also means a promote or a merge
can produce a merge conflict in the state file. A conflicted cache is a
strictly worse object than no cache, and the resolution rules for one are not
obvious to anybody.
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 needed was sharper.
Two watches is not the failure. You want a fast local view and a durable authority; that is 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 was declared authoritative and the others were made to match.
And when a design document says “details TBD in implementation”, that is not a note. It is an unresolved decision sitting in a document that reads as finished, and it should be as loud in your tracker as an open bug — because that is what it will become.
Next: driving the Agent SDK — the five options and four hooks that define what an unsupervised agent is allowed to be.