Status: Accepted
Date: 2026-10-08
Deciders: Stijn Dejongh (owner), via the governed mission serialise-next-advance-01M4EF16.
Technical Story: #5854 (P2, epic #3897) and #5682 (P1). Reuses the next contract fixed by ADR 2026-02-17-1 and follows the lock pattern the run-INDEX lock established (src/runtime/next/run_index.py, #5389/#5390, which shipped without an ADR of its own).
Reader: a maintainer who needs to know why a per-run-directory lock and a caller-evaluated-step compare-and-swap guard the next advance, which writers they cover, and why a contended or stale advance surfaces as blocked.
Context and Problem Statement
spec-kitty next advances a mission run by reading the run cursor (state.json's issued_step_id), evaluating gates against it, then committing the advance. The documented multi-agent loop runs several agents on one mission in parallel, and one agent may retry a slow call — so two advances overlap. Two defects followed, one cause in two layers:
- The advance never recorded which step its caller evaluated. The bridge read
issued_step_idonce at bootstrap, butplan_advancere-readstate.jsonandcommit_advance's stale check compared only that second read againstplan.source. A peer advance that landed between the bootstrap read andplan_advance's read was invisible, so the caller completed a step it never evaluated — a review step marked done while the WP sat infor_review, the loop reportingterminal(#5682). The same window hit both the composition and engine advance paths. - No mutual exclusion covered the read→modify→write of a run cursor.
commit_advancewas a compare-then-write TOCTOU;_write_snapshotstaged through a fixedstate.json.tmpthat two writers collided on;provide_decision_answerwas a third unguarded writer. On a stale plan the engine path re-planned throughnext_stepand re-appliedsuccess, double-completing a step (#5854), while the composition path already refused.
The single-writer assumption was stated only in a code comment. Nothing enforced it.
Considered Options
Recorded in the mission's research.md (pre-spec and post-spec adversarial squads). The chosen shape is the synthesis.
- Hold one lock across the whole advance (bootstrap read → commit). Rejected: the composition advance runs step-contract executors of unbounded duration, so the hold would breach the primitive's 10 s max-hold default and risk the 60 s stale-reclaim. The architect lens rejected it.
- A compare-and-swap alone, no lock. Rejected: the CAS closes the bootstrap→plan staleness window, but two genuinely concurrent writers can both re-read the same cursor, both pass the CAS, and both append — a true-concurrency TOCTOU only a lock closes. The reviewer lens proved this (reverting the lock while keeping the CAS leaves the race live).
- A new
specify_climission-write lock. Rejected: the runtime layer must not importspecify_cli(layer rules), so PR #5890's lock is not reusable here. - A new transient-contention Decision kind / exit code. Rejected: it is a
nextcontract change with no canonical answer; the existingblocked+reason(EDGE-003) already carries a refused advance, so contention and staleness reuse it.
Decision
Advancing a run is serialised by two complementary mechanisms, both load-bearing:
- Caller-step compare-and-swap.
AdvancePlancarries theissued_step_idthe caller evaluated at bootstrap (expected_issued_step), threaded fromplan_advancethrough both the composition and engine paths. The one staleness authority —commit_advance'sStaleAdvancePlan, via_refuse_stale_plan— is widened (not duplicated) to also refuse when the live on-diskissued_step_iddiffers from that caller-evaluated step. This closes the bootstrap→plan window (#5682). - Per-run-directory lock. A dedicated
<run_dir>/state.json.locksidecar, constructed through the one canonical primitivekernel.locks.machine_file_lock(blocking, 10 s timeout), mirroringrun_index.py. It is held only around the commit read→validate→append→write incommit_advance, aroundnext_step's plan and commit together (one hold, so the plan is fresh by construction and needs no separate staleness check), around the whole ofprovide_decision_answer, and around the retrospective capture + rollback writer. The rollback is conditional: after the speculative commit the bridge records the post-commitstate.jsonbytes and event-log size, and under the lock it restores the pre-state only while the live run still equals them; if a peer committed in the gap it logs a warning and leaves the peer's state alone. It is never held across executor dispatch. Run start (start_mission_run) stays lock-free because its run directory is private until the run index publishes it.
Supporting changes: _write_snapshot stages through a unique tempfile.mkstemp temp (no fixed-name collision); the engine path (_dn_advance_engine) refuses a StaleAdvancePlan or a LockAcquireTimeout with a blocked Decision and a named reason — reusing ADR 2026-02-17-1's contract, with no new Decision kind and no new exit code — and no longer falls back to next_step (#5854).
Invariants:
- The run-INDEX lock (taken in
get_or_start_run) is released before any run-cursor lock is acquired; the two are never held nested, so the two locks cannot deadlock. - The run-cursor lock span never encloses executor dispatch. The hold is bounded by the commit work itself, not by an executor, but it is not always sub-second (see the residual on emitter calls below).
Consequences
- A stale or contended advance is refused as
blockedwith a named reason and writes nothing; the loop does not complete an unevaluated step. - Residual (out of scope): a refusing stale path can make an outer retry loop spin on
blocked— the livelock guard tracked by #5112. The CAS guarantees run-state integrity, not executor exactly-once: a peer that landed before the composition executor ran still fired that executor's side effects. - Residual:
next_stepplans and commits under one lock hold, so two concurrent callers serialise and each plans from the state the other left. It carries no caller-evaluated-step CAS: it is reached in production only on the narrow plan-unavailable edge (no previewed step to complete), where there is no evaluated step to compare, so it is not a #5682 exposure. - Residual: the lock is held across emitter calls made inside
_commit_advance(theDecisionGitLogemitter makes git commits) and across thebefore_run_completedretrospective capture, so the hold is not always sub-second and a slow git or capture can push a waiting peer toward the 10 s bounded wait, which then surfaces asblocked(retryable). - Residual: the post-commit state the rollback compares against is read in a separate locked read after the commit's lock hold ends, so a peer that lands in that narrow gap is recorded as this caller's own state and would still be rolled back. If that read fails, the rollback is skipped rather than risk clobbering a peer.
- The acceptance set pins each part independently (deterministic, no-sleep): the CAS, the engine refusal, the
provide_decision_answerguard, the lock (a deterministic held-lock-blocks test plus a barrier true-concurrency proof), and the unique temp — reverting any one part turns at least one test red.