Skip to content

feat(work-driver): a crash mid-dispatch loses the cycle — resume is declared but never implemented #382

Description

@randomm

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

  • A state file written mid-dispatch carries a dispatch-started event and a non-empty inFlightJobIds; the same file after the dispatch settles carries the completion event and an empty one.
  • Killing the driver mid-develop and 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.
  • A state file with status: "running" whose recorded owner process is alive is refused with a message naming the owner — not silently resumed in parallel.
  • validateState's inFlightJobIds check (:399) is exercised by a fixture that actually populates it — today it can only ever pass vacuously.
  • Queue parked-reason state survives a session restart.
  • Quality gate green per AGENTS.md §1; docs updated (README env table, docs/troubleshooting.md recovery 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions