Context
/work is meant to be something you fire and walk away from. Today, if Pi dies mid-cycle the work is lost and the state file lies about it.
The resume machinery is declared but inert:
resumable: false is a literal in the TYPE, not a value — workflow-state-schema.ts:305. It can never be anything else.
inFlightJobIds is declared (:39), validated (:399) and rendered (work-status.ts:263) — but never written anywhere in src/. The header comment promises it lets the driver "detect we crashed mid-dispatch on resume"; nothing populates it.
dispatch-started is never emitted at all. grep -rn '"dispatch-started"' src/ matches only the event-union declaration and the status renderer. The validator at :399-411 checks in-flight ids against dispatch-started events that no code produces.
- State is only persisted at step boundaries (
work-driver-step-router.ts:145). runSingleDispatch (work-driver-merged.ts:200-243) appends step-started in memory, awaits a dispatch that can run for 30 minutes, and only then persists. A crash inside that window leaves the file at the previous step boundary with status: "running".
Consequence: after a crash the state file says running forever. Re-invoking /work N finds a non-terminal state and falls through; the only documented escape is --restart, which wipes the state file but not GitHub — so it either rebuilds work that is already committed or halts immediately on the #362 existing-pr-detected pre-flight (see #380, which removed the advice to --restart after a failed merge for exactly this reason).
What to build
A. Write-ahead of every dispatch. In runSingleDispatch, before awaiting: emit a dispatch-started event, set pipelineState.inFlightJobIds, and writeState. On settle, append the completion/failure event and clear the marker. The driver-side job id must be generated before the dispatch (the current jobId comes back with the result). The paths that bypass runSingleDispatch — adversarial and lens-review — need the same treatment or an explicit note saying why not.
B. resumable becomes a real boolean field, written true once the write-ahead exists, and left false on state files produced by older versions so a mixed-version repo behaves predictably.
C. Crash detection on entry. runWorkDriver currently only special-cases terminal states (work-driver.ts:186). Add the running case:
- a live owner (recorded pid alive and not us) → refuse, and say which process holds it;
- no live owner and a non-empty
inFlightJobIds → the prior run crashed. Re-enter at currentStep and clear the marker.
Re-entry is safe at the granularity of a step because every step is dispatch-then-verify and the verify gates catch partial work; commit-pr and merged additionally carry their own idempotency (#362 PR pre-flight, already-merged tolerance). Where that is not true, say so rather than papering over it.
D. Persist queue state. runWorkQueue's parked reasons and not-started list are in-memory only and die with the session — the most actionable state in the repo is invisible the next morning.
Acceptance criteria
Out of scope
Notification hooks and the budget ceiling (separate issues); resuming within a dispatch (the child process is gone — step granularity is the contract).
This work must ship as its own separate PR, independent of any other open issue.
Context
/workis meant to be something you fire and walk away from. Today, if Pi dies mid-cycle the work is lost and the state file lies about it.The resume machinery is declared but inert:
resumable: falseis a literal in the TYPE, not a value —workflow-state-schema.ts:305. It can never be anything else.inFlightJobIdsis declared (:39), validated (:399) and rendered (work-status.ts:263) — but never written anywhere insrc/. The header comment promises it lets the driver "detect we crashed mid-dispatch on resume"; nothing populates it.dispatch-startedis never emitted at all.grep -rn '"dispatch-started"' src/matches only the event-union declaration and the status renderer. The validator at:399-411checks in-flight ids againstdispatch-startedevents that no code produces.work-driver-step-router.ts:145).runSingleDispatch(work-driver-merged.ts:200-243) appendsstep-startedin memory, awaits a dispatch that can run for 30 minutes, and only then persists. A crash inside that window leaves the file at the previous step boundary withstatus: "running".Consequence: after a crash the state file says
runningforever. Re-invoking/work Nfinds a non-terminal state and falls through; the only documented escape is--restart, which wipes the state file but not GitHub — so it either rebuilds work that is already committed or halts immediately on the #362existing-pr-detectedpre-flight (see #380, which removed the advice to--restartafter a failed merge for exactly this reason).What to build
A. Write-ahead of every dispatch. In
runSingleDispatch, before awaiting: emit adispatch-startedevent, setpipelineState.inFlightJobIds, andwriteState. On settle, append the completion/failure event and clear the marker. The driver-side job id must be generated before the dispatch (the currentjobIdcomes back with the result). The paths that bypassrunSingleDispatch— adversarial and lens-review — need the same treatment or an explicit note saying why not.B.
resumablebecomes a real boolean field, writtentrueonce the write-ahead exists, and leftfalseon state files produced by older versions so a mixed-version repo behaves predictably.C. Crash detection on entry.
runWorkDrivercurrently only special-cases terminal states (work-driver.ts:186). Add therunningcase:inFlightJobIds→ the prior run crashed. Re-enter atcurrentStepand clear the marker.Re-entry is safe at the granularity of a step because every step is dispatch-then-verify and the verify gates catch partial work;
commit-prandmergedadditionally carry their own idempotency (#362 PR pre-flight, already-merged tolerance). Where that is not true, say so rather than papering over it.D. Persist queue state.
runWorkQueue's parked reasons and not-started list are in-memory only and die with the session — the most actionable state in the repo is invisible the next morning.Acceptance criteria
dispatch-startedevent and a non-emptyinFlightJobIds; the same file after the dispatch settles carries the completion event and an empty one.developand re-invoking/work N(no--restart) resumes: completed steps are NOT re-dispatched, and the count of dispatches issued for already-completed steps is zero.status: "running"whose recorded owner process is alive is refused with a message naming the owner — not silently resumed in parallel.validateState'sinFlightJobIdscheck (:399) is exercised by a fixture that actually populates it — today it can only ever pass vacuously.docs/troubleshooting.mdrecovery section).Out of scope
Notification hooks and the budget ceiling (separate issues); resuming within a dispatch (the child process is gone — step granularity is the contract).
This work must ship as its own separate PR, independent of any other open issue.