Skip to content

fix: add custody-aware stall recovery - #159

Merged
dnth merged 21 commits into
mainfrom
fm/fm-stall-recovery-ship
Sep 24, 2026
Merged

dnth merged 21 commits into
mainfrom
fm/fm-stall-recovery-ship

Conversation

@dnth

@dnth dnth commented Sep 22, 2026 •

Copy link
Copy Markdown
Owner

Intent

Stall recovery per captain ruling: a live worker is NEVER interrupted - live-but-non-turning endpoints get DETECT+ESCALATE only; positively-absent endpoints may relaunch with NO busy-state proof. All busy-record machinery added by this task is deleted; shared busy-state infrastructure (fm-busy-lib contracts, fm-busy-event) stays for its other consumers.

What Changed

  • Added watcher-triggered stall recovery that escalates live endpoints without interruption and permits one bounded relaunch for positively absent endpoints after custody checks.
  • Added lifecycle-lock rechecks, per-record recovery-attempt tracking, ladder reset, OMP receipt retirement, and audit/status journaling while preserving the worker’s worktree, branch, and commits.
  • Updated operational documentation and added comprehensive regression coverage for recovery outcomes and safety boundaries.

Risk Assessment

✅ Low: The reviewed changes satisfy the stated stall-recovery constraints without a source-verifiable defect or unauthorized busy-record machinery addition.

Testing

Ran the focused product-level stall-recovery integration suite against the real shell entrypoints. Missing-endpoint recovery, live-worker non-interruption, fail-closed custody, race cancellation, lease/worktree preservation, receipt handling, and bounded retries all passed; no source changes or transient worktree artifacts were left behind.

  • Live validation: ✅ go - 4 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A positively absent endpoint is relaunched while preserving the unhandled record and retiring stale receipts. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
A live idle or busy worker is detected and escalated without interrupt, exit, or relaunch. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
Unknown or unsafe custody state fails closed and performs no lifecycle action. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
A handled record or in-lock race cancels recovery before touching the worker. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
Evidence: stall recovery live test output

Focused end-to-end stall recovery suite passed all 19 behavioral scenarios, including missing-endpoint relaunch, live-worker escalation, custody failures, race cancellation, lease preservation, and retry bounds.

ok - missing endpoint: real relaunch publishes, ladder resets, stale receipts retire, record stays unhandled
ok - live idle worker: recovery escalates without interrupt, exit, or relaunch
ok - live busy worker: recovery escalates without any lifecycle action
ok - unknown crew-state: recovery escalates; unproven custody fails closed
ok - handled record: recovery reports the terminal outcome without a relaunch
ok - late handled move during the custody probe cancels the relaunch
ok - dirty worktree: recovery relaunches and preserves uncommitted work in place
ok - unlanded commits: recovery relaunches into the same branch and preserves every commit
ok - attempt bound: a second automatic relaunch for the same record escalates
ok - foreign lease: recovery escalates rather than relaunching into another task's slot
ok - non-pool worktree: recovery escalates without ownership proof
ok - working crew-state: recovery escalates to firstmate
ok - secondmate kind: recovery escalates; secondmates recover through their own path
ok - refused relaunch: prior doorbell receipts survive untouched
ok - held lifecycle lock: recovery defers rather than racing another lifecycle action
ok - in-lock handled record: fm-control cancels the relaunch before touching the agent
ok - next record: a handled record's spent marker does not consume the next record's attempt
ok - corrupt attempt marker: recovery escalates without a lifecycle action
ok - unterminated marker suffix: recovery escalates instead of parsing the valid prefix
ok - all stall-recovery tests

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 2 issues found → auto-fixed (3) ✅
  • 🚨 bin/fm-control.sh:911 - The claimed invariant “a live worker is NEVER interrupted” is still reachable through a missing→live race. fm-stall-recovery.sh proves missing and then invokes fm-control.sh under the lock, but do_relaunch only rechecks the inbox before journaling stopping; at lines 910-922 it treats any state other than missing as permission to call do_exit. If the endpoint is recreated or becomes live after the helper’s final custody proof but before line 911, this path sends the interrupt/exit sequence to the live worker. The final guard must fail closed on alive and other non-absent states immediately before lifecycle control; this contradicts the authoritative live-worker criterion and needs confirmation of the intended remedy.
  • 🚨 bin/fm-stall-recovery.sh:140 - The new recovery path still fails open when the inbox cannot be read. fm_task_inbox_oldest_unhandled collapses an unreadable/read failure into status 1, and the added calls in fm-stall-recovery.sh use || true at lines 140 and 239, treating that failure as an empty/handled inbox and returning verdict=recovered; the new in-lock --stall-record check in fm-control.sh does the same. A transient permission/I/O failure can therefore suppress the stale wake and skip recovery while the durable instruction remains unhandled. Preserve the empty-vs-unreadable distinction at the shared inbox boundary and propagate unreadable status as escalation.

🔧 Fix applied.
1 error still open:

  • 🚨 bin/fm-task-inbox-lib.sh:408 - Unreadable inbox errors are still suppressed by the watcher. fm_task_inbox_due_action now returns status 2 for an existing-but-unreadable inbox (the changed path at bin/fm-task-inbox-lib.sh:408), but inbox_steer_check does action=$(...) || return 0 at bin/fm-watch.sh:405; the unavailable/escalation branches also use || true at lines 352 and 455. A transient permission/I/O failure therefore produces no recovery attempt and no stale wake, suppressing an actually unhandled instruction instead of failing closed.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-control.sh:918 - The supervised recovery path classifies both dead and missing endpoints as the safe absent path in fm-stall-recovery.sh:185, but fm-control.sh rejects every state other than missing at bin/fm-control.sh:918. Therefore a positively classified dead endpoint can never be relaunched: the helper records the attempt, invokes control, and control escalates instead of performing the permitted recovery. Accept the same positively absent states in the final supervised gate (or narrow the helper classification consistently).
  • 🚨 bin/fm-control.sh:917 - The final endpoint check is not actually adjacent to the lifecycle action. After state=$(agent_state) records missing at bin/fm-control.sh:917, journal_write stopping runs at line 921 and further checkpoint/launch work follows; if the endpoint is recreated and becomes live in that gap, the captured missing value still drives the relaunch path and skips do_exit. This leaves the live-worker-never-interrupted invariant reachable through a missing→live race. Reuse one final custody guard immediately before the launch/stop operation and fail closed on any changed state.

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 4 of 4 scenarios driven live against the product
Scenario Result Live Evidence
A positively absent endpoint is relaunched while preserving the unhandled record and retiring stale receipts. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
A live idle or busy worker is detected and escalated without interrupt, exit, or relaunch. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
Unknown or unsafe custody state fails closed and performs no lifecycle action. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
A handled record or in-lock race cancels recovery before touching the worker. ✅ pass live bash tests/fm-stall-recovery.test.sh; live test log
  • bash tests/fm-stall-recovery.test.sh
  • Captured output to ~/.no-mistakes/evidence/01M39KRS3A8CN1Y7Q783HQFY7F/fm-stall-recovery-live.log
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@dnth dnth changed the title Custody-checked bounded auto-recovery for stalled workers fix(bin): add bounded custody-checked stall recovery Sep 23, 2026
@dnth dnth changed the title fix(bin): add bounded custody-checked stall recovery fix: add custody-checked bounded OMP stall recovery Sep 24, 2026
@dnth dnth changed the title fix: add custody-checked bounded OMP stall recovery fix: add custody-checked bounded stall recovery Sep 24, 2026
dnth and others added 20 commits September 24, 2026 19:45
When a steering inbox record stays unhandled past the re-ring ladder or
its endpoint proves dead, the watcher now runs fm-stall-recovery.sh
before publishing the stale wake. The helper re-proves the record is
still the oldest unhandled instruction, classifies the endpoint as
live-non-turning or missing, requires a clean non-run crew-state, a
clean worktree with no unlanded commits, and the durable fm-<id>
Treehouse lease, then re-proves the chain immediately before invoking
fm-control relaunch. A handled record reports recovered; a busy worker
or a just-published relaunch defers; every unprovable or unsafe shape
escalates to the ordinary stale wake with the reason appended. One
automatic relaunch per stalled record is bounded by a per-record
attempt counter that resets only when the inbox empties.

OMP gains semantic busy wiring (omp-ext source on
turn_start/turn_end/session_shutdown) so live OMP workers classify
instead of falling through to unknown, and an OMP relaunch retires the
prior generation's request.* doorbell receipts after every refusal
gate so a stale .acked tombstone cannot suppress the replacement
incarnation's doorbell.
- fm-busy-lib: restore the hermes-hook adapter arm dropped by the
  omp-ext addition; its loss broke the crew-state status fallback
  (serial 3 failure).
- fm-stall-recovery: SC1007 empty assignment and SC2097/SC2098
  env-prefix expansion on the fm-control relaunch call.
- fm-stall-recovery.test: skip at top level when bun is absent; the
  in-function exit only ended the command-substitution subshell and
  left FAKEBIN_DIR holding the skip text (serial 4 failure).
- fm-stall-recovery acquires fm-control's lifecycle lock before the final
  custody/record re-check and holds it across the relaunch via
  --lock-preheld, so a handled record or concurrent lifecycle action can
  never relaunch a now-productive worker or double-relaunch
- fm-control --stall-record re-proves the named inbox record inside the
  lock immediately before the agent is touched, cancelling with exit 3
  when it resolved in flight
- clean-worktree and unlanded-commit gates removed from prove_custody:
  recovery exists to preserve exactly that work in the same worktree,
  branch, and commits
- OMP extension serializes busy-state writes through one awaited chain so
  turn_end's idle can never land after a following turn_start's busy
- fm-spawn writes control_relaunch_tx into the published record so
  post-publish failures classify the successor correctly
- one-relaunch-per-record bound is a fixed invariant; the
  FM_STALL_RECOVERY_MAX override is removed
The cancelled:record-resolved journal write moved RELAUNCH_PHASE past the
rollback trap's checkpoint|noted restore branch, so a record resolved
inside the lifecycle lock left the brief modified despite no relaunch.
Restore the brief byte-exact before journaling the cancellation, assert
it in the in-lock test, and correct docs/architecture.md's stale claim
that recovery requires a clean worktree with no unlanded commits.
…per record

Astra re-verification of 0607ba7 (data/fm-159-astra-review/report.md):

F1 (P1): a worker that published a valid busy event during safe_checkpoint
was still interrupted - the final in-lock gate re-proved only the record's
identity, then do_exit sent Escape + /exit to a now-productive worker. The
supervised --stall-record path now fails closed on any busy verdict that is
not a proven idle: busy returns exit 4, which do_relaunch maps to a clean
cancellation (byte-exact instruction restore, cancelled:worker-busy journal
phase) that fm-stall-recovery reports as a deferred episode. Manual relaunch
and the plain exit verb keep interrupt-first semantics.

F2 (P2): a well-formed .recovery-attempts marker naming a previously handled
record was rejected as malformed, denying the next queued record its first
attempt whenever the watcher never observed an empty inbox. Marker structure
is now validated separately from record identity; a marker naming a
different record starts the current oldest record's count at zero and is
replaced atomically under the lifecycle lock. Corrupt markers still
escalate, and the one-attempt bound per record is unchanged.

Regressions: busy-during-checkpoint defer (no interrupt/exit/relaunch,
byte-exact brief restore), next-record fresh attempt with no intervening
empty-inbox observation, and corrupt-marker fail-closed escalation.
Astra re-verification of 4042180 (data/fm-159-astra-reverify/report.md):

R1 (P2): the marker line-count check accepted a canonical line followed by
an unterminated suffix (001.msg<TAB>1<NL>CORRUPT) because wc -l counts only
newlines and read parses only the first line. The marker is now validated
as exactly one canonical newline-terminated record by byte count, so any
trailing byte or missing terminator escalates; the separate identity reset
and atomic replacement are unchanged.

R2 (P2): an unproven busy verdict at the final do_exit gate died generically,
routing into the stopping rollback where the appended progress note survived
even though no lifecycle action occurred. The supervised --stall-record path
now returns exit 5 for unproven custody before any transport, do_relaunch
routes it through stall_relaunch_cancel (byte-exact instruction restore,
cancelled:custody-unproven journal phase), and fm-stall-recovery maps it to
escalate - never deferral or instruction-handled.

D1 (P3): docs/architecture.md now describes the per-record attempt bound,
new-record replacement, and empty-inbox cleanup separately.
D2 (P3): docs/configuration.md now states deferred/recovered outcomes
suppress the stale wake while escalation or helper failure retains it.

Regressions: unterminated marker suffix escalates; unproven custody during
checkpoint escalates with no transport and byte-exact instructions.
…ng endpoints relaunch

Captain ruling (inbox 031/036): recovery must NEVER interrupt a live worker.

- LIVE-BUT-NON-TURNING (endpoint present, any busy state): detect and
  escalate only. No interrupt keys, no exit, no relaunch, no invalidation.
- POSITIVELY ABSENT endpoint: relaunch allowed with no busy-state proof -
  there is no live worker to interrupt.
- Unknown/unproven crew-state fails closed on every path.
- Deleted the busy-record machinery this task added: OMP extension busy
  events, omp-ext source registration, busy verdict gate in the helper,
  and fm-control's supervised busy-deferred/custody-unproven exits.
- Kept: missing-endpoint relaunch under the lifecycle lock with the
  in-lock record re-check, per-record attempt bound, relaunch_tx, and
  doorbell receipt retirement; shared busy-state infrastructure untouched.

19/19 stall-recovery tests pass; fm-lint clean.
@dnth
dnth force-pushed the fm/fm-stall-recovery-ship branch from cf20fb8 to fd6134a Compare September 24, 2026 12:24
@dnth dnth changed the title fix: add custody-checked bounded stall recovery fix: add custody-aware stall recovery Sep 24, 2026
…te is now captured for all relaunches and the custody guard runs after the stopping journal immediately before lifecycle control. This resolves both portable serial failures (the reproduced unbound-variable error). Focused dead-endpoint relaunch tests pass. The Herdr failure was an external stale-watcher/worktree-state failure, not caused by this change; no Herdr-specific code was modified
@dnth
dnth merged commit a735d55 into main Sep 24, 2026
15 checks passed
@dnth
dnth deleted the fm/fm-stall-recovery-ship branch September 24, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant