<!-- Authored per the the-loop:writing skill. -->
Bugfix spec: the stop gate blocks on a node the work item never entered
Phase 1 of 4 (bugfix → design → testing plan → tasks). Source: issue-429.
Summary
A daemon-spawned session deadlocks. After every turn the harness stop gate (hooks/the-loop-gate.py) blocks the stop and orders the agent to write an artifact for a phase the work item has not started. The ticket's case: an item parked at phase-selection, told on every turn to write design.md. The agent cannot, the gate fires again, and the item goes quiet.
The gate asks the-loop check <item> --format json --recompute where the item is, and blocks when the node at currentNode is block. Under --recompute, currentNode is not where the item is. It is the first node the artifacts leave unmet (Runtime._first_unmet). Whenever every node before some node N passes on the artifacts, but nothing has advanced the item into N, the recomputed position is N while the recorded pointer is still behind it. The gate then demands N's artifact.
The ticket attributed this to current: null. On main (19.13.0) the report never carries a null currentNode for a repository that resolves, and the gate reads currentNode, not current. The mechanism is the one above, and it matches the ticket's observation exactly: check (no flag, the pointer) says phase-selection; the gate (--recompute, the artifacts) says design. Both of the ticket's suggested fixes still apply in substance, and both are taken (see Requirements and design.md).
Steps to reproduce
On main at 48cb8e6, in any git checkout:
$ mkdir -p docs/specs/issue-1
$ cat > docs/specs/issue-1/work-item-state.json <<'EOF'
{"workItem": "issue-1", "currentNode": "phase-selection",
"decisions": {"phase-selection": {"at": "2026-09-25T00:00:00Z"}},
"parked": {"node": "phase-selection", "reason": "awaiting a human"},
"skips": {"brainstorming": {"by": "op"}, "requirements-definition": {"by": "op"},
"requirements-approval": {"by": "op"}}}
EOF
$ the-loop check issue-1
issue-1: ok (at phase-selection)
$ the-loop check issue-1 --recompute
issue-1: UNMET (at design)
BLOCK design
· required artifact is missing (docs/specs/issue-1/design.md)
$ THE_LOOP_WORK_ITEM=issue-1 python3 hooks/the-loop-gate.py claude; echo $?
the-loop: this step is not complete.
· required artifact is missing (docs/specs/issue-1/design.md)
Fix the above, then finish. (attempt 1/3)
2The state is the one a selection leaves when it has been recorded but the item not yet walked on. It does not need to be exactly this one. Any recorded pointer behind the first node the artifacts leave unmet reproduces it.
Expected vs actual
| Expected | Actual | |
|---|---|---|
the gate, item parked at phase-selection | lets the turn end (nothing the agent can do) | exit 2, demands design.md |
the gate, item at design, design.md missing | exit 2, demands design.md | same (correct today) |
| the gate, no position it can resolve | lets the turn end | lets the turn end, but only by accident: no explicit rule |
check --recompute, the two positions differ | says both | prints only the derived one; check and the gate read as contradicting each other |
Root cause
hooks/the-loop-gate.py blocking_node bounds the gate by currentNode, and the gate always runs check --recompute (run_check). Runtime.status(recompute=True) sets current = self._first_unmet(reports) or current. So the gate asks about the first unmet node anywhere in the graph, not the first unmet node the item has reached.
Its docstring argued this on purpose, for two reasons (issue-109):
- Trust. Work-item state is agent-writable, so a gate must not take a verdict from it. That still holds. Verdicts are the recomputed ones.
- The inert case. A work item whose pointer was never advanced sits at the start node, so a pointer-bounded gate would never fire. This no longer holds.
- Since issue-177 the start node is
phase-selection, a human gate thatwaits until a selection is recorded. - Since issue-370
THE_LOOP_WORK_ITEMis set only in sessions the daemon spawns, and the daemon is what advances the pointer. - So the case the derived position was meant to cover is exactly the case this ticket reports as a bug: an item the daemon has not advanced.
- Since issue-177 the start node is
The ticket's other failure, a report that cannot say where the item is, has no explicit rule. blocking_node returns None for a null currentNode only because nothing in nodes matches None. The ticket's fix 2 asks for that to be deliberate.
Requirements
Requirement 1: the gate asks only about nodes the item has reached
- WHEN the harness stop gate evaluates a work item THEN it SHALL block only on the first node that is not satisfied at or before the recorded pointer, and only when that node's status is
block. - The gate SHALL take every node's verdict from the recomputed report. The pointer bounds which nodes are asked about and never decides whether one passed.
- WHEN the pointer is ahead of an unmet node THEN the gate SHALL still block on that node. A pointer moved forward hides nothing behind it.
Requirement 2: a gate that cannot tell where the item is lets the turn end
- IF the report has no
currentNode, or acurrentNodethat names no node in it, THEN the gate SHALL let the turn end. - IF the report's
pointeris empty (no state file found, or the item never entered the graph) or names no node in the report THEN the gate SHALL let the turn end. - WHEN the report carries no
pointerkey (a CLI older than this change) THEN the gate SHALL fall back tocurrentNodeas the bound, its behaviour before this change.
Requirement 3: check reports both positions
- Every resolving
checkreport (CLI JSON,POST /graph/check, thecheck_work_itemMCP tool) SHALL carrypointer: the node work-item state records,""when none was found. This holds in both modes. currentNodeSHALL keep its meaning in both modes. CI'scheck --recompute --fail-on …reads no pointer and is unchanged.- WHEN
check --recomputeprints a table and a state file was found whose pointer differs from the derived position THEN thestate:line SHALL name the pointer. - issue-238's position-unknown answer SHALL keep its six keys.
Security considerations
- The gate now reads one fact from agent-writable state: the position.
- Moving the pointer forward gains nothing (R1.3).
- Moving it backward lets an agent end turns while later nodes are unmet. But those are nodes the item has not officially entered, and entering them takes an
advancethat evaluates their gates, or a recordedforce. - The stop gate is a nudge, not the merge boundary. CI's
check --recomputereads no pointer and still gates the merge. That residual is accepted here, and the attempt cap already bounds the gate to three nudges per node.
- No new input, route, credential, subprocess or file write.
pointeris an extra key on an existing read-only response, holding a value the same response already exposed throughcurrentNodein non-recompute mode.
Out of scope
- A pointer the operator
forced past an unmet node still gets that node demanded, as today. That is a finding, not an invention: the item did walk past it. - The ticket's "fall back to the graph state the service maintains under the state root". The pointer lives only in the repository's
work-item-state.json(the state layout'spointerattribute,cli/the_loop/state.py). There is no second copy to fall back to. Issue-396 already resolves the checkout through the session registry when the gate's working directory does not hold the item.