Skip to content

fix(bin): name what actually ended a watcher cycle - #3280

Open
sepinali wants to merge 6 commits into
kunchenguid:mainfrom
sepinali:fm/watcher-failure-names-what-ended-the-cycle
Open

fix(bin): name what actually ended a watcher cycle#3280
sepinali wants to merge 6 commits into
kunchenguid:mainfrom
sepinali:fm/watcher-failure-names-what-ended-the-cycle

Conversation

@sepinali

Copy link
Copy Markdown

Intent

Make a watcher cycle that ends say what ended it. This is a contribution to kunchenguid/firstmate answering issue #1375, "watcher: cycle ends without an actionable reason", which the maintainers triaged as ready-for-pr and whose contract class they named themselves: "restore (actionable watcher failure + honest liveness)".

THIS CHANGE IS DELIBERATELY HALF OF THAT, AND THE PR SAYS SO. It fixes the actionable-failure half - the fault in the issue's title. The honest-liveness half (the issue's second diagnostic note, that the beacon can read fresh while supervision is already dead) was attempted at length on this branch and deliberately deferred; the reasoning is recorded in docs/verification/supervision.md and written up for the issue thread. Reviewing this as if it claims to close the whole issue would be reviewing something it does not claim.

THE PROBLEM. "watcher: FAILED - cycle ended without an actionable reason" gives an operator nothing to act on. A watcher that exited cleanly, one that died leaving its lock behind, one another watcher replaced, and one that is still live but no longer beating all reach that same sentence and all need different responses. In the incident that prompted this it twice read as a false alarm when it was not, and cost a twelve-hour supervision gap.

WHAT IT DOES. The typed prefix is unchanged, so anything already matching on it keeps matching, and neither the verdict nor the exit status nor the alarm moves anywhere in this change. Only the operator's evidence improves, with a clause naming what actually ended the cycle.

Where that evidence is a real exit code it is read back from the lifecycle ledger this repo already writes (bin/fm-watch-arm.sh's .watch-cycle-exits.log, which exists on main). An arm that merely ATTACHED holds no handle on its watcher's exit status, which is exactly why the bare sentence was reached in that case. The lookup is bound to the same watcher pid, the same recorded process identity, and a close no earlier than this arm attached, so neither a recycled pid nor an older cycle of the same watcher can be read as this one. It stays diagnostic: a missing or unreadable row degrades to the disposition text and never changes the verdict.

DELIBERATE DECISIONS A REVIEWER WOULD NOT INFER FROM THE DIFF.

  1. Liveness inside the evidence is identity-qualified rather than bare. A recycled pid is a live pid, so bare liveness would both name an unrelated process to the operator and, by implying the watcher never exited, suppress the recorded exit code that is the only remaining evidence in exactly that case.

  2. The ledger probe is rebuilt through the SAME transforms the ledger applied on the way in. lock_snapshot cleans each part, then cycle_log_append cleans the whole "pid:|identity:" composite again, and cut(1) applied to a pair is not cut(1) applied to each half. A probe truncated only once silently stops matching its own row past roughly a 497-character identity. That is reachable in practice, not theoretical: this repo's own pooled worktree paths are long and the identity carries the full command.

  3. The pull warning and the turn-end block keep their verdicts, exit statuses and alarms exactly as they were. Only their wording separates a live identity-matched holder that has not beaten from nothing running at all, so an operator does not read one fault as the other. Both read the holder through a deliberately wait-free lock check that never sleeps and never watches the beacon, because a guard whose job is to block a turn must not wait in order to phrase itself. Nothing in this change waits on anything: the additions to bin/ are pure reads and printf.

  4. Nothing was made quieter and nothing fails open. This change only adds text to failures that already fired.

TEST DISCIPLINE. Both regressions were checked against the code with their own fix removed, because a case that cannot fail proves nothing. The evidence-clause mutant reproduces the reported symptom exactly - "watcher: FAILED - cycle ended without an actionable reason" beside a fresh beacon and nothing to act on. The long-identity mutant keeps the verdict but loses the recorded exit, falling back to the generic disposition text, which is the precise shape of that defect; that fixture asserts its own identity length before relying on it so it cannot pass vacuously on a short path. The banner cases each pair a live unbeaten holder against nothing running at all, with both legs still warning and still blocking respectively.

VERIFICATION on this head: bin/fm-lint.sh clean, bin/fm-doc-audience-check.sh ok, and the fm-watcher-lock, fm-guard-stale-banner, fm-turnend-guard, fm-watch-arm and fm-claude-stop-autoarm suites all pass with failed=0.

HISTORY, so the branch shape is not surprising. An earlier version of this branch also carried the beacon-freshness work. That approach introduced five regressions of its own across four review rounds - each fix bounding one timed window and opening another - and two of them broke guarantees the change itself had stated. It was dropped rather than pursued further, and this branch was rebuilt from origin/main carrying only the half above. No part of that machinery remains: there is no timed window, no observation budget, and no receipt anywhere in this change.

Delivery: a pull request against UPSTREAM kunchenguid/firstmate from the sepinali fork, referencing issue #1375, and explicit that it fixes one of the two halves.

What Changed

  • watcher: FAILED - cycle ended without an actionable reason now carries an evidence clause naming what ended the cycle — a clean exit that released the lock, a death that left the lock behind, a lock that moved to another watcher, or a still-live watcher whose beacon stopped advancing. The typed prefix, the verdict, and the exit status are unchanged. Where the evidence is a real exit code, bin/fm-watch-arm.sh reads it back from the .watch-cycle-exits.log lifecycle ledger it already writes, bound to the same watcher pid, the same recorded process identity, and a close no earlier than this arm attached; only rows classifying the watcher's own termination are eligible, so an arm-interrupted row is not reported as the watcher's fate. A missing or unreadable row degrades to the disposition text and never moves the verdict. Liveness inside the clause is identity-qualified and answers three ways — re-proven live, provably gone, or unknown — and the ledger probe is rebuilt through the same double cycle_clean_field truncation the ledger applied on the way in, so a long identity cannot silently stop matching its own row.
  • bin/fm-guard.sh's pull warning and bin/fm-turnend-guard.sh's turn-end block separate a live identity-matched lock holder that has not beaten from nothing running at all, keeping their verdicts, exit statuses, and alarms exactly as they were. Both read the holder through a new fm_watcher_live_holder_pid in bin/fm-wake-lib.sh, a wait-free lock read that re-proves the recorded identity and never sleeps or watches the beacon.
  • Adds regressions in tests/fm-watcher-lock.test.sh (attached-arm owner-recorded exit, the same fixture from a deep path that asserts its own identity length, the arm-interrupted reason filter, and the unprovable-liveness pair) and paired live-holder-vs-absent-watcher legs in tests/fm-guard-stale-banner.test.sh and tests/fm-turnend-guard.test.sh. docs/verification/supervision.md records each mutant run with its own fix removed, plus one leg (an empty recorded identity) that ships unproven and why it could not be staged. Docs also state that the beacon remains an age reading rather than a liveness proof — the second half of the reported problem, attempted on this branch and deliberately deferred, with the reasoning recorded rather than dropped.

Risk Assessment

✅ Low: The change is additive text on paths that already failed — verdicts, exit statuses and alarms are provably unchanged, the ledger allow-list and double-clean probe are correct against every writer call site, no existing consumer of the typed prefix breaks, and the fix round's three regressions each have a paired-fixture test with recorded mutant output.

Testing

Ran the five targeted suites the change touches (fm-watcher-lock 35 ok, fm-guard-stale-banner 28 ok, fm-turnend-guard 71 ok, plus fm-watch-arm and fm-claude-stop-autoarm as prefix consumers) - all green, no failures, no flakes across repeated runs. Because passing tests are not evidence of the operator experience, I also drove the real scripts over live processes and captured before/after CLI transcripts against the base commit: the attached arm now names the recorded exit instead of the bare unactionable sentence, both banners name the live unbeaten holder pid, and an unprovable liveness reads as an explicit unknown - with the verdict, exit status and typed prefix unchanged in every pair. Two mutation re-checks at this head confirm the round-1 fix genuinely fails without itself (dropping the ledger reason allow-list reproduces "was killed by HUP" for a watcher its own arm TERMed, suite 31 ok / 1 not ok) and that the documented uncovered leg is honestly described (reverting it leaves the suite green at 35 ok, exit 0). This is a CLI/terminal-output change with no rendered UI, HTML or renderer surface, so the reviewer-visible evidence is terminal transcripts of the actual banners and failure lines rather than screenshots. The worktree is clean; all scratch trees were removed.

Evidence: Evidence index for this round

Source: Evidence index for this round

# What a watcher cycle that ends now tells the operator

All transcripts below are the real scripts run against real processes, captured
from `bin/fm-guard.sh`, `bin/fm-turnend-guard.sh` and `bin/fm-watch-arm.sh`.
The "BEFORE" file is the identical harness run against the base commit
`f66be0f`; the "AFTER" file is the same harness against this head `705d59d`.

| file | what it shows |
| --- | --- |
| `01-operator-transcript-BEFORE-base-f66be0f.txt` | the three surfaces on base: the bare `cycle ended without an actionable reason`, and both banners reporting a live unbeaten holder as if nothing were running |
| `02-operator-transcript-AFTER-705d59d.txt` | the same four scenarios on this head: the evidence clause names the recorded exit, and both banners name the holder pid |
| `03-arm-interrupted-row-not-blamed-on-watcher.txt` | the round-1 review fix, reproduced both ways: without the ledger `reason` allow-list an attached arm reports a SIGHUP the watcher never received; with it, the arm-owned row is ignored |

Verdicts and exit statuses are unchanged in every pair: the guard still warns and
exits 0, the Stop hook still blocks and exits 2, the arm still fails and exits 1.
Only the operator's evidence differs.

Additional files from this round:

| file | what it shows |
| --- | --- |
| `04-mutation-recheck-reason-allowlist.txt` | the ledger `reason` allow-list removed at this head: 31 ok / 1 not ok, and the one failure is exactly the case that names the behaviour |
| `05-unprovable-liveness-stays-unknown.txt` | before/after for a cycle whose liveness is genuinely unprovable (the watcher is proven still running at the moment of the report) |
| `06-uncovered-leg-doc-claim-verified.txt` | the one behaviour this change ships unproven, and confirmation that the documentation's claim about it is accurate |
| `07-turnend-guard-stays-wait-free.txt` | the blocking guard's wall time, base vs head, on the same live-unbeaten-holder fixture |
Evidence: Operator transcript BEFORE (base f66be0f)

Source: Operator transcript BEFORE (base f66be0f)


----- SCENARIO 1  fm-guard.sh - a live watcher still holds the lock but stopped beating (suspended host) -----
$ fm-guard.sh          # watcher pid 1458 alive, holding the lock, beacon ancient
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: 841350432s ago, grace 999s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 0)

----- SCENARIO 1b  fm-guard.sh - control: nothing is running at all -----
$ fm-guard.sh          # no watcher process, lock unheld
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher process holds this home lock (last beat: 0s ago).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 0)

----- SCENARIO 2  fm-turnend-guard.sh (Stop hook) - same fault, at the turn-end block -----
$ echo '{"stop_hook_active":false}' | fm-turnend-guard.sh    # watcher pid 1675 alive, not beating
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: 210198433s ago).
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 2)

----- SCENARIO 2b  fm-turnend-guard.sh - control: no watcher at all -----
$ echo '{"stop_hook_active":false}' | fm-turnend-guard.sh    # nothing holds the lock
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: 210198433s ago).
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 2)

----- SCENARIO 3  fm-watch-arm.sh - an ATTACHED arm whose watcher is killed mid-cycle -----
$ fm-watch-arm.sh &    # owning arm; it started watcher pid 1861
$ fm-watch-arm.sh      # second arm, ATTACHES to that watcher (holds no handle on its exit status)
$ kill -TERM 1861     # ...the watcher is then killed, beacon still fresh

--- what the ATTACHED arm printed to the operator ---
watcher: attached pid=1861 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason
(exit 1)
--- lifecycle ledger the owning arm wrote (state/.watch-cycle-exits.log) ---
arm_pid=1838	watcher_pid=1861	origin=started	started_at=1787995634	ended_at=1787995635	exit_code=1	signal=none	reason=nonzero-exit	beacon_age=0	lock_before=pid:1861|identity:Sat Aug 29 19:27:14 2026     bash /tmp/fm-ev/bin/fm-watch.sh	lock_after=pid:none|identity:none	successor=none
arm_pid=2316	watcher_pid=1861	origin=attached	started_at=1787995635	ended_at=1787995641	exit_code=unknown	signal=unknown	reason=attached-cycle-ended	beacon_age=6	lock_before=pid:1861|identity:Sat Aug 29 19:27:14 2026     bash /tmp/fm-ev/bin/fm-watch.sh	lock_after=pid:none|identity:none	successor=none
Evidence: Operator transcript AFTER (head 705d59d)

Source: Operator transcript AFTER (head 705d59d)


----- SCENARIO 1  fm-guard.sh - a live watcher still holds the lock but stopped beating (suspended host) -----
$ fm-guard.sh          # watcher pid 95699 alive, holding the lock, beacon ancient
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but watcher pid 95699 holds this home lock but has not beaten (last beat: 841350421s ago, grace 999s).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 0)

----- SCENARIO 1b  fm-guard.sh - control: nothing is running at all -----
$ fm-guard.sh          # no watcher process, lock unheld
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  WATCHER DOWN - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher process holds this home lock (last beat: 0s ago).
●  Trust the emitted supervision protocol for this harness; do not use shell & for watcher repair.
●  This is a supervision warning only; the guarded operation WILL still run.
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 0)

----- SCENARIO 2  fm-turnend-guard.sh (Stop hook) - same fault, at the turn-end block -----
$ echo '{"stop_hook_active":false}' | fm-turnend-guard.sh    # watcher pid 96166 alive, not beating
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but watcher pid 96166 holds this home lock but has not beaten (last beat: 210198421s ago).
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 2)

----- SCENARIO 2b  fm-turnend-guard.sh - control: no watcher at all -----
$ echo '{"stop_hook_active":false}' | fm-turnend-guard.sh    # nothing holds the lock
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  TURN WOULD END BLIND - SUPERVISION IS OFF
●  1 task(s) in flight, but no live watcher holds this home lock (last beat: 210198422s ago).
●  watcher supervision needs Stop-owned automatic recovery; inspect the hook registration and startup status before ending the turn.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
(exit 2)

----- SCENARIO 3  fm-watch-arm.sh - an ATTACHED arm whose watcher is killed mid-cycle -----
$ fm-watch-arm.sh &    # owning arm; it started watcher pid 96452
$ fm-watch-arm.sh      # second arm, ATTACHES to that watcher (holds no handle on its exit status)
$ kill -TERM 96452     # ...the watcher is then killed, beacon still fresh

--- what the ATTACHED arm printed to the operator ---
watcher: attached pid=96452 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason: watcher pid=96452 exited 1 without delivering a wake (last beacon 6s ago, lock now: unheld)
(exit 1)
--- lifecycle ledger the owning arm wrote (state/.watch-cycle-exits.log) ---
arm_pid=96438	watcher_pid=96452	origin=started	started_at=1787995622	ended_at=1787995622	exit_code=1	signal=none	reason=nonzero-exit	beacon_age=0	lock_before=pid:96452|identity:Sat Aug 29 19:27:02 2026     bash /Users/sina/.no-mistakes/worktrees/61f1253b165f/01M169R5D4Z7RAGJWNGZFE9XB2/bin/fm-watch.sh	lock_after=pid:none|identity:none	successor=none
arm_pid=96720	watcher_pid=96452	origin=attached	started_at=1787995622	ended_at=1787995628	exit_code=unknown	signal=unknown	reason=attached-cycle-ended	beacon_age=6	lock_before=pid:96452|identity:Sat Aug 29 19:27:02 2026     bash /Users/sina/.no-mistakes/worktrees/61f1253b165f/01M169R5D4Z7RAGJWNGZFE9XB2/bin/fm-watch.sh	lock_after=pid:none|identity:none	successor=none
Evidence: The arm's own signal is not blamed on the watcher (both ways)

Source: The arm's own signal is not blamed on the watcher (both ways)

Scenario: the OWNING arm is interrupted with SIGHUP. Its handler TERMs the watcher,
then records its OWN 129/HUP against the WATCHER's pid in state/.watch-cycle-exits.log.
An arm that merely ATTACHED then reads that ledger back.

================ WITHOUT the reason allow-list (the round-1 defect, reproduced) ================
$ fm-watch-arm.sh &     # owning arm 22365 started watcher pid 22379
$ fm-watch-arm.sh       # second arm attached to pid 22379
$ kill -HUP 22365   # the OWNING ARM is interrupted - NOT the watcher

--- ledger row the interrupted OWNING arm wrote under the WATCHER's pid ---
1:arm_pid=22365  |  watcher_pid=22379  |  origin=started  |  started_at=1787995723  |  ended_at=1787995724  |  exit_code=129  |  signal=HUP  |  reason=arm-interrupted  |  beacon_age=0  |  lock_before=pid:22379|identity:Sat Aug 29 19:28:43 2026     bash /tmp/fm-mut/bin/fm-watch.sh  |  lock_after=pid:none|identity:none  |  successor=none

--- what the ATTACHED arm printed to the operator ---
watcher: attached pid=22379 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason: watcher pid=22379 was killed by HUP without delivering a wake (last beacon 6s ago, lock now: unheld)
(exit 1)

================ WITH the fix as shipped (705d59d) ================
$ fm-watch-arm.sh &     # owning arm 26752 started watcher pid 26766
$ fm-watch-arm.sh       # second arm attached to pid 26766
$ kill -HUP 26752   # the OWNING ARM is interrupted - NOT the watcher

--- ledger row the interrupted OWNING arm wrote under the WATCHER's pid ---
1:arm_pid=26752  |  watcher_pid=26766  |  origin=started  |  started_at=1787995736  |  ended_at=1787995737  |  exit_code=129  |  signal=HUP  |  reason=arm-interrupted  |  beacon_age=1  |  lock_before=pid:26766|identity:Sat Aug 29 19:28:56 2026     bash /tmp/fm-fixed/bin/fm-watch.sh  |  lock_after=pid:none|identity:none  |  successor=none

--- what the ATTACHED arm printed to the operator ---
watcher: attached pid=26766 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason: watcher pid=26766 exited and released this home lock without recording a delivered wake (last beacon 8s ago)
(exit 1)

The unfiltered read names a SIGHUP the watcher never received, sending the operator after a
signal source that does not exist. The shipped code ignores the arm-owned row and degrades to
the disposition text instead. Verdict, exit status and typed prefix are identical in both.
Evidence: Mutation re-check: ledger reason allow-list

Source: Mutation re-check: ledger reason allow-list

Mutation re-check at the final head 705d59d: remove the ledger reason allow-list
(bin/fm-watch-arm.sh:309) and run the whole tests/fm-watcher-lock.test.sh suite.

mutant:
    309c309
    <       && ($8 == "reason=nonzero-exit" || $8 == "reason=signal-exit") {
    ---
    >       {

result: 31 ok, 1 not ok, suite exit 1 - exactly the case that names this behaviour:
not ok - attached arm reported its own arm's signal as the watcher's exit: watcher: attached pid=47079 (beacon 1s)

unmutated head, same suite: 35 ok, 0 not ok, exit 0.
Evidence: Unprovable liveness stays unknown (before/after)

Source: Unprovable liveness stays unknown (before/after)

Scenario: an attached arm's cycle ends while it cannot read process identities,
so the watcher's liveness is genuinely unprovable. The watcher is still running
throughout (the transcript asserts this with kill -0 at the moment of the report).

================ BEFORE, base f66be0f ================
$ fm-watch-arm.sh &     # owning arm started watcher pid 63215
$ fm-watch-arm.sh       # second arm attached to pid 63215
#   ...that arm then loses its ability to read process identities (ps fails for it only)
#   watcher pid 63215 is STILL RUNNING at this moment (kill -0 succeeded)

--- what the blinded ATTACHED arm printed to the operator ---
watcher: attached pid=63215 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason
(exit 1)

================ AFTER, head 705d59d ================
$ fm-watch-arm.sh &     # owning arm started watcher pid 59573
$ fm-watch-arm.sh       # second arm attached to pid 59573
#   ...that arm then loses its ability to read process identities (ps fails for it only)
#   watcher pid 59573 is STILL RUNNING at this moment (kill -0 succeeded)

--- what the blinded ATTACHED arm printed to the operator ---
watcher: attached pid=59573 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason: watcher pid=59573 could not be identified - that pid is in use but the identity recorded for this cycle could not be re-proven against it, so neither a live watcher nor an exit is established (last beacon 1s ago, lock now: 59573)
(exit 1)

The unknown is reported as an unknown: the arm names neither a live watcher it
cannot identify nor an exit it never observed. Verdict and exit status unchanged.
Evidence: The documented uncovered leg, verified

Source: The documented uncovered leg, verified

The change ships one behaviour with no failing regression behind it, and
docs/verification/supervision.md says so under "The uncovered leg". This
verifies that stated claim rather than taking it on trust.

mutant: the empty-recorded-identity leg reverted to the bare-liveness fallback
    338c338
    <   [ -n "$identity" ] || return 2
    ---
    >   [ -n "$identity" ] || return 0

whole tests/fm-watcher-lock.test.sh suite against that mutant:
    EXIT=0
    ok cases: 35
    not ok: none

The doc's claim is accurate: the suite stays green at 35 ok, exit 0, so this
leg is genuinely unproven by test and the documentation says exactly that.
Evidence: Turn-end guard stays wait-free

Source: Turn-end guard stays wait-free

Deliberate decision 3 in the PR: the banner reads the holder wait-free, because a
guard whose job is to block a turn must not wait in order to phrase itself.
Same fixture (a live identity-matched holder with an ancient beacon), interleaved runs:

base run 1: 0.115s
head run 1: 0.125s
base run 2: 0.113s
head run 2: 0.125s
base run 3: 0.112s
head run 3: 0.125s
base run 4: 0.117s
head run 4: 0.134s
base run 5: 0.115s
head run 5: 0.118s

Naming the holder costs about 10ms of extra reads; nothing sleeps or polls.
Evidence: Side-by-side: what the attached arm tells the operator
BEFORE (base f66be0f)
watcher: attached pid=1861 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason
(exit 1)

AFTER (head 705d59d)
watcher: attached pid=96452 (beacon 0s)
watcher: FAILED - cycle ended without an actionable reason: watcher pid=96452 exited 1 without delivering a wake (last beacon 6s ago, lock now: unheld)
(exit 1)

the row it read back, written by the OWNING arm:
watcher_pid=96452 origin=started exit_code=1 signal=none reason=nonzero-exit lock_before=pid:96452|identity:...bash .../bin/fm-watch.sh
Evidence: Side-by-side: the WATCHER DOWN banner
BEFORE ● 1 task(s) in flight, but no watcher has a fresh beacon (last beat: 841350432s ago, grace 999s). [exit 0]
AFTER ● 1 task(s) in flight, but watcher pid 95699 holds this home lock but has not beaten (last beat: 841350421s ago, grace 999s). [exit 0]

control, nothing running at all - unchanged on both:
● 1 task(s) in flight, but no live watcher process holds this home lock (last beat: 0s ago). [exit 0]
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (32m16s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • ⚠️ bin/fm-watch-arm.sh:299 - owner_recorded_exit matches on watcher_pid + origin=started + lock_before but ignores the ledger's reason column ($8), so rows whose exit_code/signal describe the ARM's fate rather than the watcher's get reported as the watcher's own exit. Concrete path: an owner arm that has confirmed its child (so cycle_refresh_lock_before has run and lock_before = pid:W|identity:Wid) receives SIGHUP; handle_arm_signal (bin/fm-watch-arm.sh:551-560) sends the child SIGTERM, waits, then writes watcher_pid=W origin=started exit_code=129 signal=HUP reason=arm-interrupted. An arm attached to the same W then fails its cycle, matches that row, and prints watcher pid=W was killed by HUP without delivering a wake — naming a signal the watcher never received and sending the operator looking for a SIGHUP source that does not exist. handling-handoff-failed (bin/fm-watch-arm.sh:645, cycle_log_append 1 none) has the same shape: the arm kills the watcher, but the evidence reads watcher pid=W exited 1. Suggested fix: restrict the awk match to reasons that classify the watcher's own exit (nonzero-exit, signal-exit, unexpected-clean-exit, clean-exit-delivered-wake, actionable-*), or emit distinct wording ("its arm was interrupted (HUP)") for arm-owned rows. This is user-visible operator wording and touches which ledger rows the author intends to treat as authoritative, so it needs the author's call rather than a silent edit.
  • ℹ️ bin/fm-watch-arm.sh:320 - cycle_watcher_still_live returns success on bare pid liveness when the recorded identity is empty ([ -n &#34;$identity&#34; ] || return 0), which is the exact fallback the change's own rationale rules out ("a recycled pid is a live pid ... bare liveness would name an unrelated process to the operator and suppress the recorded exit code"). It is reachable when cycle_begin &#34;$child&#34; started &#34;$(fm_pid_identity &#34;$child&#34; 2&gt;/dev/null || true)&#34; (bin/fm-watch-arm.sh:585) captures an empty identity because the child died before it could be read, cycle_refresh_lock_before never ran, and the freed pid is reused before unexplained_cycle_evidence probes it: the evidence then reports watcher pid=N is still live and holds this home lock for an unrelated process and skips the ledger lookup entirely. The window is small, but the safe reading of an unknown identity is "cannot prove still live" (return 1), which only affects the unknown-identity case and preserves every path where the identity is known.
  • ℹ️ bin/fm-watch-arm.sh:344 - unexplained_cycle_evidence calls cycle_watcher_still_live twice for the same pid/identity (lines 338 and 344), re-running fm_pid_alive and fm_pid_identity (a ps fork on non-Linux hosts) and letting the two branches disagree if the process state changes between them. Computing it once into a local and branching on that is a straight dedup with no behavior change: if cycle_watcher_still_live &#34;$pid&#34; &#34;$clean_identity&#34;; then live=1; else live=0; fi, then test $live in both places.

🔧 Fix: filter arm-owned ledger rows and keep unprovable liveness unknown
2 infos still open:

  • ℹ️ bin/fm-watch-arm.sh:360 - The live -eq 2 branch returns before owner_recorded_exit is ever consulted, so an independently-bound recorded exit is discarded in the one case the comment above cycle_watcher_still_live says must not lose it ("suppress the recorded exit code that is the only remaining evidence in exactly that case"). The ledger row is bound to the watcher pid, the recorded identity, the lock snapshot and ended_at &gt;= cycle_started_at, so reporting it is not a liveness claim and would not conflict with keeping the identity unknown. Reachability is narrow and I could not construct a realistic path: a matching row only exists after the owning arm reaped the child, so fm_pid_alive succeeding requires PID reuse and fm_pid_identity then failing on the reusing process. Recording it as a documented tension rather than a defect; no change needed unless the author wants the unknown branch to still print the recorded exit alongside "could not be identified".
  • ℹ️ bin/fm-watch-arm.sh:355 - holder is read straight from $WATCH_LOCK/pid and interpolated raw into the typed evidence line at lines 362, 367, 373, 375, 381 and 385, while lock_snapshot (line 101) runs the same file's contents through cycle_clean_field before using it. A lock pid file containing embedded newlines or control characters would therefore split the single-line watcher: FAILED - ... record into several lines; adapters that scan with /^watcher: FAILED/ per line (.opencode/plugins/fm-primary-watch-arm.js:144, :181) still match the first line, so nothing breaks functionally, and a corrupted lock pid file is not a state this code path can produce on its own. Passing holder through cycle_clean_field would make it consistent with every other use of that file in this script.
🔧 **Test** - 1 issue found → auto-fixed ✅
  • ⚠️ bin/fm-watch-arm.sh:338 - The cycle_watcher_still_live fix has two legs, and only one ships a failing regression. Reverting the covered leg ([ -n &#34;$current&#34; ] || return 2 -> return 1) correctly fails test_unprovable_liveness_is_not_reported_as_a_death with the exact invented-death wording. But reverting the leg the review finding actually named — the bare-liveness fallback [ -n &#34;$identity&#34; ] || return 2 back to return 0 — leaves the entire fm-watcher-lock suite green (35/35, exit 0). I could not stage a case that fails against it: an empty RECORDED identity only arises at bin/fm-watch-arm.sh:602 when an owning arm's child dies before fm_pid_identity can read it and cycle_refresh_lock_before never ran, and by the time that arm reaches the evidence path its child pid is already reaped, so fm_pid_alive returns false and both the fixed and unfixed code take the same branch. The two only diverge if that freed pid is recycled to a live process in between, which is not deterministically stageable. The fix looks correct and is defense-in-depth; the author should decide whether an unprovable-by-test branch is acceptable under the stated "every behavioural fix ships a regression that genuinely FAILS" standard, or whether a seam should be added to reach it.
  • bash tests/fm-watcher-lock.test.sh — 35 ok, exit 0 (includes the four new cases)
  • bash tests/fm-guard-stale-banner.test.sh — 20 ok, exit 0 (includes test_persistent_stale_beacon_banner_separates_all_three_causes)
  • bash tests/fm-turnend-guard.test.sh — 71 ok, exit 0 (includes test_hook_block_banner_names_a_live_unbeaten_holder)
  • bash tests/fm-watch-arm.test.sh — 14 ok, exit 0
  • bash tests/fm-claude-stop-autoarm.test.sh — 39 ok, exit 0
  • Manual before/after operator transcript: drove real bin/fm-watch-arm.sh (owning arm + attached arm, watcher TERMed with a fresh beacon) against both base f66be0f and this branch — demo-arm-evidence.sh
  • Manual transcript: same two-arm fixture with the OWNING ARM HUPed, showing the reason=arm-interrupted ledger row is not read back as the watcher's own signal
  • Manual before/after banner transcript: real bin/fm-guard.sh and bin/fm-turnend-guard.sh against a live identity-matched holder with a stale beacon, plus unheld-lock / no-holder controls, recording exit statuses — demo-guard-banners.sh
  • Mutation check (scratch copy of HEAD, each mutant bash -n validated first): removing the evidence clause fails test_attached_arm_reports_the_owner_recorded_exit
  • Mutation check: single-truncating the ledger probe fails test_owner_recorded_exit_survives_a_long_identity
  • Mutation check: dropping the reason= allow-list fails test_attached_arm_does_not_blame_the_watcher_for_its_arms_signal with the exact 'was killed by HUP' misattribution
  • Mutation check: collapsing unknown liveness to dead ([ -n &#34;$current&#34; ] || return 1) fails test_unprovable_liveness_is_not_reported_as_a_death
  • Mutation check: restoring the bare-liveness fallback ([ -n &#34;$identity&#34; ] || return 0) — full fm-watcher-lock suite still green, leg uncovered

🔧 Fix: record the unproven empty-identity liveness leg
✅ Re-checked - no issues remain.

  • bash tests/fm-watcher-lock.test.sh - 35 ok, 0 not ok, exit 0 (carries all four new cycle-evidence regressions)
  • bash tests/fm-guard-stale-banner.test.sh - 28 ok, 0 not ok, exit 0
  • bash tests/fm-turnend-guard.test.sh - 71 ok, 0 not ok, exit 0
  • bash tests/fm-watch-arm.test.sh - 14 ok, exit 0 (consumer that prefix-matches the typed cycle-end failure)
  • bash tests/fm-claude-stop-autoarm.test.sh - 39 ok, exit 0 (same prefix consumer)
  • Manual before/after operator transcript: guard banner, Stop-hook block banner and attached-arm cycle end driven over real processes against base f66be0f and against head, with negative controls (dead holder, unheld lock) in each
  • Manual transcript: kill -HUP the OWNING arm so it writes an arm-interrupted row under the watcher's pid, run both with and without the reason allow-list, compare what the attached arm tells the operator
  • Manual transcript: conditional ps shadow blinds one attached arm's identity read while its watcher is proven still running via kill -0, base vs head
  • Mutation re-check at head: reason allow-list removed from bin/fm-watch-arm.sh:309, full fm-watcher-lock suite -> 31 ok, 1 not ok, exit 1, failing case test_attached_arm_does_not_blame_the_watcher_for_its_arms_signal
  • Mutation re-check at head: [ -n &#34;$identity&#34; ] || return 2 reverted to return 0, full fm-watcher-lock suite -> 35 ok, 0 not ok, exit 0, confirming the documented uncovered leg
  • Latency check: fm-turnend-guard.sh wall time on the same live-unbeaten-holder fixture, base vs head interleaved x5
🔧 **Document** - 2 issues found → auto-fixed ✅
  • ℹ️ docs/verification/supervision.md:582 - The "Beacon freshness under host suspend - attempted and deferred" section records an abandoned approach and its four review rounds. docs/documentation-audiences.md states that failed hypotheses and task chronology stay in private task reports or PR evidence by default, so this sits at the edge of the maintainer-verification audience. I left it as written because the user intent explicitly requires the deferral reasoning to be recorded in this file, and the section does distil a durable design lesson rather than a delivery transcript. Flagging it as a judgment call the author may want to confirm with maintainers rather than a defect.
  • ℹ️ docs/watcher-continuity.md:130 - The "Active limits and verification" section still lists no limit for the half of issue watcher: cycle ends without an actionable reason under the auto-detected herdr backend #1375 this change deliberately does not fix: state/.last-watcher-beat can read fresh while supervision is already dead. That limitation predates this change, so it was not made stale here and is out of scope, but once the PR lands with the deferral stated publicly, a one-line known-limit entry there pointing at verification/supervision.md would be the natural follow-up. Proposing it rather than editing an unrelated section in this change.

🔧 Fix: state the deferred beacon limit and drop internal round vocabulary
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Reviews (2): Last reviewed commit: "no-mistakes: apply CI fixes" | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Reviewed HEAD 12525b7bc4ff2d54ab1604378c7fac8b52302803 vs main. Whole thread read (body, Greptile 5/5, linked #1375 ready-for-pr). Full file list reviewed: bin/fm-watch-arm.sh, bin/fm-wake-lib.sh, bin/fm-guard.sh, bin/fm-turnend-guard.sh, docs, tests. No .github/workflows/*. sepinali is not blocked. Not disguised security.

Contract-class: restore. Issue #1375's actionable-failure half: the typed watcher: FAILED - cycle ended without an actionable reason prefix and verdicts/exit/alarms stay put; only the operator evidence clause and banner wording name what ended the cycle / which fault is present. Body deliberately defers the honest-liveness/beacon half.

VISION (per rule):

  • One captain, one interface — aligns (honest failure evidence below deck).
  • Authority is explicit — aligns (no new autonomy).
  • Scripts own the mechanics — aligns (ledger read + wait-free holder probe).
  • A restart is a non-event — aligns (diagnostics only).
  • Delegation with a spine — aligns (strengthens refusal/alarm clarity).
  • The fleet outlives any vendor — aligns.
  • Scope — aligns (supervision diagnostics, not merge policy).

Attestation HTML binds head_sha 12525b7bc4ff2d54ab1604378c7fac8b52302803 = HEAD (match). Greptile green. Fork CI first-time approve this pass on this HEAD: 33246077468 (CI), 33246077421 (Require no-mistakes). Waiting on CI. Not with the captain. No auto-merge yet. I am not opening a competing PR. Does not claim to close all of #1375.

"watcher: FAILED - cycle ended without an actionable reason" gives the
operator nothing to act on. A watcher that exited cleanly, one that died
leaving its lock behind, one another watcher replaced, and one that is still
live but no longer beating all reach that sentence, and all need different
responses. In the incident that prompted this it twice read as a false alarm
when it was not, and cost a twelve-hour supervision gap.

The typed prefix is unchanged, so existing consumers keep matching, and
neither the verdict nor the exit status moves. Only the evidence improves:

  watcher: FAILED - cycle ended without an actionable reason:
    watcher pid=19771 exited 1 without delivering a wake
    (last beacon 3s ago, lock now: unheld)

An arm that merely ATTACHED holds no handle on its watcher's exit status, so
where the evidence is a real exit code it is read back from the lifecycle
ledger the OWNING arm already wrote. The lookup is bound to the same watcher
pid, the same recorded process identity, and a close no earlier than this arm
attached, so neither a recycled pid nor an older cycle can be read as this one.
It stays diagnostic: a missing or unreadable row degrades to the disposition
text and never changes the verdict.

Two details that are easy to get wrong and are covered by tests:

- Liveness inside the evidence is identity-qualified rather than bare. A
  recycled pid is a live pid, and treating it as the watcher would name an
  unrelated process to the operator while suppressing the recorded exit code
  that is the only remaining evidence in exactly that case.
- The ledger probe is rebuilt through the same transforms the ledger applied
  on the way in. lock_snapshot cleans each part and cycle_log_append cleans the
  whole composite again, and cut(1) over a pair is not cut(1) over each half,
  so a singly-truncated probe stops matching its own row once an identity is
  long enough - which a deep worktree path reaches.

The pull warning and the turn-end block keep their verdicts, exit statuses and
alarms exactly as they were; only their wording now separates a live
identity-matched holder that has not beaten from nothing running at all, so one
fault is not read as the other. Both read the holder through a wait-free lock
check, because a guard whose job is to block a turn must not wait to phrase
itself.

Both regressions were checked against the code with their own fix removed. The
mutant for the evidence clause reproduces the reported symptom exactly - a
fresh beacon, a failed cycle, and nothing to act on - and the long-identity
mutant loses the recorded exit while keeping the verdict, which is the precise
shape of that defect.
@sepinali
sepinali force-pushed the fm/watcher-failure-names-what-ended-the-cycle branch from 12525b7 to d97be75 Compare September 2, 2026 22:58
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.

2 participants