Description
Follow-up surfaced while validating #53 (fixes #48). With Stop/window-death as the only authoritative completion signals, one recovery scenario is left with no automated path:
A dev/review session that genuinely finished its work (terminal artifact written, frontmatter done) but whose final Stop event was lost, and which then fails to answer any stall nudge, now ends status="stalled" with result_json=None (_final(accept_result=False), adapters/generic.py). That verdict is the right call at the adapter layer — an artifact under a live window that ignores nudges is untrustworthy by construction (#48). The gap is downstream:
_reconcile_generic_terminal_status (engine.py ~1576) is called from exactly one site, inside if result.status == "completed": in _dev_phase (~1271). It is structurally unreachable for stalled/crashed/timeout.
decide_dev (escalation.py ~71) treats every non-completed status identically: RETRY while budget remains, else DEFER/PAUSE. On RETRY the verify outcome is always None (the whole verify block is completed-gated), so the engine goes straight to _rollback_or_pause.
Net effect: finished-but-unvouched work is never inspected before disposal. Current mitigations are real but passive — scm.rollback_on_failure defaults OFF (pause for manual recovery, tree untouched), and when it is ON, _preserve_attempt_commits/_preserve_attempt_worktree park the work under attempt-preserve/* refs. But nothing ever re-reads the on-disk state to notice the session actually succeeded.
Likelihood
Low: it requires a lost/undelivered Stop and total nudge-unresponsiveness (default 2 nudges × dev_stall_grace_s). A responsive agent answers a nudge, ends its turn, and the fresh Stop completes normally. But hook-delivery failure modes (hook misconfiguration, events.jsonl write failure, hard-wedged CLI after finishing) make it a plausible long-tail on flaky environments — precisely where the native-Windows backend will live.
Proposed direction
A guarded post-kill reconcile for the stalled verdict — trust is re-established by window death rather than prose:
- Engine kills the session as today (
run()'s finally).
- For a
stalled result only, after the kill (window now provably dead), re-run the artifact scan (find_result_artifact + synthesize_result) against the session's launch floor.
- If the artifact parses terminal-
done AND the frontmatter agrees (status_consistent), treat as completed: run the normal completed-path verify/reconcile/sync instead of RETRY-rollback. Any disagreement → keep the stall verdict.
This preserves #48/#53's invariant — nothing completes while the window is alive — while restoring recovery for the lost-Stop tail. Alternative (cheaper, weaker): keep the stall verdict but skip the destructive _rollback_or_pause when a consistent terminal artifact is detected post-kill, deferring the story with the tree intact for the next reconcile-capable pass.
Which area is this for?
Engine / escalation
Refs #48, #53.
🤖 Generated with Claude Code
Description
Follow-up surfaced while validating #53 (fixes #48). With Stop/window-death as the only authoritative completion signals, one recovery scenario is left with no automated path:
A dev/review session that genuinely finished its work (terminal artifact written, frontmatter
done) but whose final Stop event was lost, and which then fails to answer any stall nudge, now endsstatus="stalled"withresult_json=None(_final(accept_result=False),adapters/generic.py). That verdict is the right call at the adapter layer — an artifact under a live window that ignores nudges is untrustworthy by construction (#48). The gap is downstream:_reconcile_generic_terminal_status(engine.py~1576) is called from exactly one site, insideif result.status == "completed":in_dev_phase(~1271). It is structurally unreachable forstalled/crashed/timeout.decide_dev(escalation.py~71) treats every non-completed status identically: RETRY while budget remains, else DEFER/PAUSE. On RETRY the verifyoutcomeis alwaysNone(the whole verify block is completed-gated), so the engine goes straight to_rollback_or_pause.Net effect: finished-but-unvouched work is never inspected before disposal. Current mitigations are real but passive —
scm.rollback_on_failuredefaults OFF (pause for manual recovery, tree untouched), and when it is ON,_preserve_attempt_commits/_preserve_attempt_worktreepark the work underattempt-preserve/*refs. But nothing ever re-reads the on-disk state to notice the session actually succeeded.Likelihood
Low: it requires a lost/undelivered Stop and total nudge-unresponsiveness (default 2 nudges ×
dev_stall_grace_s). A responsive agent answers a nudge, ends its turn, and the fresh Stop completes normally. But hook-delivery failure modes (hook misconfiguration, events.jsonl write failure, hard-wedged CLI after finishing) make it a plausible long-tail on flaky environments — precisely where the native-Windows backend will live.Proposed direction
A guarded post-kill reconcile for the
stalledverdict — trust is re-established by window death rather than prose:run()'sfinally).stalledresult only, after the kill (window now provably dead), re-run the artifact scan (find_result_artifact+synthesize_result) against the session's launch floor.doneAND the frontmatter agrees (status_consistent), treat as completed: run the normal completed-path verify/reconcile/sync instead of RETRY-rollback. Any disagreement → keep the stall verdict.This preserves #48/#53's invariant — nothing completes while the window is alive — while restoring recovery for the lost-Stop tail. Alternative (cheaper, weaker): keep the stall verdict but skip the destructive
_rollback_or_pausewhen a consistent terminal artifact is detected post-kill, deferring the story with the tree intact for the next reconcile-capable pass.Which area is this for?
Engine / escalation
Refs #48, #53.
🤖 Generated with Claude Code