Cross-Session Loop Resume

Long-running workflows that survive rate limits and session exits

The problem

A /ds-implement-ticket loop that runs multiple engineers and Skeptics across many phases can take longer than a single session allows. Rate limits hit. Sessions exit. Work is interrupted.

Without a resume mechanism, every interruption means:

  • Losing track of which phase the loop was in
  • Not knowing which units are complete versus in-progress
  • Risking a re-run that overwrites already-committed work
  • Losing the Brief or Plan path that governs remaining units

The solution is .agentic/loop-state-<LOOP_KEY>.json - one file per ticket, written by the conductor at every phase transition so any session can pick up exactly where the last one stopped. The unkeyed .agentic/loop-state.json is the legacy form: still read and adopted on resume, no longer written.

Loop state is written atomically (tmp + rename) at every phase boundary. Every loop-state file is gitignored - local ephemeral state, never committed.

loop-state-<LOOP_KEY>.json - the per-ticket phase cursor

LOOP_KEY is derived once per ticket at the Resume check - from the ticket id, else the session id, else a random floor - recorded in the file's own loop_key field, and never re-derived. The conductor writes .agentic/loop-state-$LOOP_KEY.json at initialization and at every phase transition:

Skeptic spawn  ->  write loop-state-$LOOP_KEY.json (last_phase=skeptic, action=spawned)
Skeptic return ->  write loop-state-$LOOP_KEY.json (last_phase=skeptic, action=returned)
Engineer spawn ->  write loop-state-$LOOP_KEY.json (last_phase=engineer, action=spawned)
Engineer return -> write loop-state-$LOOP_KEY.json (last_phase=engineer, action=returned)
QA spawn       ->  write loop-state-$LOOP_KEY.json (last_phase=qa, action=spawned)
QA return      ->  write loop-state-$LOOP_KEY.json (last_phase=qa, action=returned)

last_phase and last_phase_action are the authoritative resume keys. The hooks derive no key: they enumerate every loop-state-*.json plus the legacy path and select by session_id. The Stop hook fires once per turn and only refreshes a last_updated liveness timestamp; on a genuine terminal session end, the SessionEnd hook writes status: "interrupted" on each owned file that is status == "active".

  • Silent Stop hook / SessionEnd hook failure is acceptable - the 10-minute implicit-interrupt heuristic handles missed writes: any status == "active" file with last_updated more than 10 minutes old is treated as interrupted

Resume check on session start

When /ds-implement-ticket is invoked, it derives LOOP_KEY and checks for .agentic/loop-state-$LOOP_KEY.json before reading AGENTS.md. If the file exists with status == "interrupted" (or status == "active" with last_updated more than 10 minutes old), the conductor offers resume or fresh start. Key match is the primary guard, never freshness - other tickets' resumable loops get one informational line and no prompt, because offering the most recently written file would hand you another ticket's live state during any CI wait. A legacy .agentic/loop-state.json is adopted onto a keyed file, then removed.

Resumable phases (automatic):

  • Phase 6/6b Skeptic/QA loop at iteration boundaries - committed engineer output, clean branch
  • Phase 7 quality gate when engineer committed (engineer_returned or rerun_pending action)

Resumable with human confirmation:

  • Mid-engineer (dirty branch) - conductor asks human to discard or commit the partial work

Restart required:

  • Phases 1-4 are cheap to re-run and have no branch side effects. State file is not written until Phase 6 loop initialization.
If a Skeptic is interrupted mid-output, resume re-runs the Skeptic from scratch (last_phase=skeptic, last_phase_action=spawned). Skeptic is read-only and idempotent - re-running it costs one agent turn, not correctness.

Brief and Plan path recording

When a Brief or Plan governs the task, three fields are written to .agentic/loop-state-$LOOP_KEY.json at authoring time:

  • brief_path - absolute path to the Brief file
  • plan_path - absolute path to the Plan directory (when applicable)
  • promotion_tier - enum: none, brief, or plan

On resume, the conductor re-reads the Brief or Plan before spawning the next worker. This ensures the governing artifact is always in context, even if the session that authored it is long gone.

Mid-flight escalation: if a task promotes from Trivial or single-unit Elevated to Brief or Plan tier during execution, a retroactive Brief is authored before the next engineer spawn. The in-flight engineer is allowed to return; already-completed units are not retroactively re-reviewed.

Auto-promotion at the third resume: a Brief-tier task that has been resumed twice already promotes to Plan tier on the third resume - signaling that multi-session scope warrants fuller documentation.

Batch-state coexistence

When /ds-implement-ticket runs with 2 or more ticket IDs, a sibling file .agentic/batch-state.json tracks batch-level cursor alongside each ticket's own loop-state-$LOOP_KEY.json phase cursor.

Session ownership gate: both files carry a session_id field. Every write applies a per-write gate that aborts (with an operator-visible warning) if:

  • The existing session_id belongs to a different session whose liveness timestamp (last_updated for a keyed loop-state file, updated_at for batch-state.json) is within 10 minutes - and on batch-state.json ONLY, status is also active
  • The existing session_id is null or absent, regardless of status (legacy state - force-takeover eligible)

batch-state.json alone carries the status=active precondition: its terminal mark stamps a fresh timestamp on exit, so without it the gate would abort the first write of an approved resume. A keyed loop-state file skips it - a live session can hold a non-active one (the Phase 7 stall path continues to the next ticket), so adding the precondition there would let a foreign session clobber a live file.

This prevents orphan-session corruption uniformly across both files. Note that per-ticket keying removes different-ticket contention from this gate entirely - those sessions write different files - and leaves same-ticket contention, which is exactly what the gate is for.

Concurrency limits:

  • Only one batch per project root is supported
  • A second concurrent N>=2 invocation is refused at Phase 0a-pre
  • N=1 invocations against an active foreign batch warn but do not refuse
  • Single-ticket Trivial invocations never create batch-state.json
The SessionEnd hook mirrors its loop-state terminal interrupted-mark write to batch-state.json via the same best-effort silent-fail discipline. The Stop hook's separate per-turn liveness refresh mirrors similarly.

File hygiene

loop-state-<LOOP_KEY>.json One per ticket. Written atomically (tmp + rename) at every phase transition. Gitignored - never committed. Set to status: "complete" or deleted after the PR is opened. Legacy unkeyed loop-state.json is read and adopted, never written.
batch-state.json Sibling to the keyed loop-state files for multi-ticket runs. Same atomic write discipline. Same gitignore. Never created for single-ticket invocations.

Phase breadcrumbs accompany every phase transition - the conductor emits [phase: label] inline at each boundary. Phase breadcrumbs are emitted separately at each phase boundary and appear in the session context.

No loop-state file may be committed to git - keyed or legacy. Its presence in the repo would mislead the next developer about what phase the loop is in, and it carries findings_log, last_engineer_summary, and session_id. A targeted (non-umbrella) .gitignore needs BOTH the .agentic/loop-state-*.json glob and the bare .agentic/loop-state.json entry - a keyed file does not match the bare one. Gitignore is the contract; these are ephemeral state, not project history.

Phase written. Session exits. Resume.

The loop picks up where it stopped.

github.com/Space-Dinosaurs/DinoStack