plans: reconcile 'implementing' plans orphaned by a daemon restart - #253
plans: reconcile 'implementing' plans orphaned by a daemon restart#253oranjeai wants to merge 1 commit into
Conversation
A plan in `implementing` asserts a live in-process implementation run, but that run is a bare `asyncio.create_task`. A SIGKILL/OOM/daemon restart destroys it and nothing reconciles plans at startup, so the row keeps asserting an obligation nobody holds. The plan is then terminally wedged: re-approval is refused (both surfaces gate on `pending`), `plan_propose` is refused forever for that task (`get_pending_plan_task_ids` counts `implementing`), and no agent tool or UI flow returns it to `pending` -- the only escape is a hand-crafted PATCH. Workflow runs made the same RAM-only trade and paired it with a startup recovery pass (`WorkflowRunService.start`), whose module docstring states the doctrine: runs do not survive a restart, so the pass marks orphans failed and notifies. Sessions have one too (`recover_orphaned_sessions`). Plans were the holdout. This adds the same guarantee: - `get_implementing_plans` / `fail_orphaned_plan` (a status-only CAS) in `db/plans.py`; - `recover_orphaned_plans` in `plan_service.py`, sending one aggregate notification keyed on what it actually flipped; - one call in the gateway lifespan. `failed`, not back to `pending`: it is already what both approval surfaces write when `engine.run` raises, and it unblocks `plan_propose` without blindly re-running a plan whose partial effects may already exist. No migration, no new status, no frontend diff (`STATUS_STYLES` already carries `failed`). Plans whose implementation session is enrolled for resume and still eligible are left `implementing`. That exclusion is correctness, not caution: `nerve restart --resume <impl-sid>` is a supported action, and a resumed session completes through `task_done`, which closes only an `implementing` plan -- sweeping it would leave the resumed session unable to ever close its own plan, and would immediately allow a duplicate plan while it still works. Eligibility is re-checked against the engine's own four skip predicates, since a session the engine will skip never resumes. The pass runs before `cron.start()`: cron's catch-up can dispatch a planner run whose `plan_propose` would read a stale `implementing` row and permanently skip the task. Two error cases go opposite ways. A missing resume queue is a definite answer from its sole writer (the CLI appends before triggering the restart), so sweep. An OSError is not an answer -- our read and the engine's happen at different instants, so a transient error here can succeed there -- so skip the sweep entirely and let the next restart retry. The queue is only ever read, never drained; the engine's resume task is the sole drainer and runs later. Out of scope, deliberately: a plan reconciled to `failed` is not re-adopted if its session is later resumed (the task still completes; only the plan's label differs -- re-adopting needs durable recovery provenance, i.e. a schema change); settling a plan whose resumed turn ends without `task_done`; the task/plan status mismatch on a late approval failure; the `PlansPage` filter list lacking `failed` and `done` (pre-existing). Tests: `tests/test_plan_restart_recovery.py` (18). Two are behavioural witnesses -- against the pre-change tree the seeded plan is still `implementing` after startup, and still `implementing` when cron starts.
Internal second-model review (2 rounds, 5 findings adjudicated: 4 agreed and fixed, 1 disagreed with evidence)Before this PR was opened it went through two independent review rounds: a cold
Finding 1 (fixed). A new test drives the real Finding 5 (disagreed). The ordering fact is correct and confirmed: MCP
The suggested remedy is also not admissible as written: moving recovery above the The residual the finding points at is real in principle, and it is the same class Finding 4 (noted, not blocking). Measured rather than asserted: prose density Gate spend across both rounds: $17.75 (2 runs). Design-review spend before |
Pre-PR validation gate (a-i)
Mutation matrix: 19 mutants, 19 killed, 0 survivors, 0 vacuous. Each mutation No regression: full |
|
|
|
cc @pufit — could you review this? A plan in |
|
Closing per @pufit's directive on #247: memU is being rewritten and sunset, and Nerve fixes |
Description
A plan in
implementingasserts a live in-process implementation run, but thatrun is a bare
asyncio.create_task. A SIGKILL / OOM / restart destroys it andnothing reconciles plans at startup, so the row keeps asserting an obligation
nobody holds and the plan is terminally wedged: re-approval is refused (both
surfaces gate on
pending),plan_proposeis refused forever for that task(
get_pending_plan_task_idscountsimplementing), and nothing but ahand-crafted
PATCH /api/plans/{id}recovers it.Workflow runs made the same RAM-only trade and paired it with a startup recovery
pass (
WorkflowRunService.start); sessions have one too(
recover_orphaned_sessions). Plans were the holdout. This adds the sameguarantee:
get_implementing_plans+fail_orphaned_plan(a status-only CAS) indb/plans.py,recover_orphaned_plansinplan_service.py, one call in thegateway lifespan.
failed, notpending: it is already what both approvalsurfaces write when
engine.runraises, and it unblocksplan_proposewithoutblindly re-running a plan over partial effects. No migration,
no new status, no frontend diff (the plan pages already style
failed).Plans whose implementation session is enrolled for resume and still eligible are
left
implementing. That exclusion is correctness, not caution:nerve restart --resume <impl-sid>is supported, and a resumed session completesthrough
task_done, which closes only animplementingplan -- sweeping itwould leave that session unable to ever close its own plan. Eligibility is
re-checked against the engine's own four skip predicates, since a skipped session
never resumes. The queue is only read, never drained.
Ordering is load-bearing: the pass runs before
cron.start(), whose catch-upcan dispatch a planner run that would read a stale row and skip the task.
Validation
tests/test_plan_restart_recovery.py(18). Both directions against a baseexport: 18/18 fail there, two behaviourally (the seeded plan is still
implementingafter startup, and when cron starts); the other 16 fail on APIshape, weaker evidence. 19 mutants killed. Full suite: identical 7 pre-existing
failures before and after, +18 passed.
Out of scope, deliberately
failedis not re-adopted if its session is laterresumed: the task still completes, but the row keeps reading
failedwheretoday it would read
done. Re-adopting needs durable provenance -- alegitimately failed plan also keeps its
impl_session_id.task_done(outcomes existonly after a full agent turn, unorderable before
cron.start()).task_donenot enforcingimplementation-session ownership; the plans-page filter list lacking
failedand
done(all pre-existing).No related open issue found.