Skip to content

fix(bin): bound recovery-episode reopen so a restarted watcher stays up - #5514

Open
marcus-vsc-moreira wants to merge 19 commits into
kunchenguid:mainfrom
marcus-vsc-moreira:fm/fm-watcher-arm-exit
Open

marcus-vsc-moreira wants to merge 19 commits into
kunchenguid:mainfrom
marcus-vsc-moreira:fm/fm-watcher-arm-exit

Conversation

@marcus-vsc-moreira

@marcus-vsc-moreira marcus-vsc-moreira commented Sep 24, 2026 •

Copy link
Copy Markdown

Intent

Supervision of this firstmate home does not hold. The watcher is armed, beacons once, and then exits
within seconds, so the home sits unsupervised and the turn-end guard fires on nearly every turn.

This is a firstmate defect, not operator error. It was reproduced with no re-arm loop running, no
session lock present, and 18 tasks in flight. Arming reports success - fm-watch-arm.sh --restart
prints watcher: started pid=... (beacon fresh) - and state/.last-watcher-beat updates, but the
watcher process is gone shortly after and state/.watch.lock is absent.

What is needed: supervision that stays up, so the home does not depend on someone re-arming it by
hand.

What Changed

  • bin/fm-wake-lib.sh now limits reopens of a stuck recovery episode that nobody acknowledged. Before, each plain watcher restart with queued rows reopened the same episode into a new generation, which made the watcher resurface and exit. Now a counter in state/.watcher-down.reopen-count tracks these reopens. After FM_RECOVERY_REOPEN_LIMIT reopens (default 1; a non-numeric value falls back to 1), the episode is set to acked. Its generation is stored in state/.watcher-down.reopen-settled. After that, arm-check does not re-announce that episode because of queued rows, and a watcher lock close (--restart or stale-lock clear) does not publish new downtime for it. The queued rows stay durable for the next session's drain.
  • These events clear the counter and the settle record: a real acknowledgement, an arm that mints a fresh episode from a missing or invalid marker, and a successful durable append that mints a fresh episode from an announced one. So a genuinely acked episode with queued rows still resurfaces. In bin/fm-wake-drain.sh, the ack snapshot clears the settle record only when the ack removed at least one row. A retried stale ack that removes nothing keeps the settle.
  • The change documents FM_RECOVERY_REOPEN_LIMIT and the two new state files in docs/configuration.md, docs/watcher-continuity.md ("Bounded reopen") and the operational-home-layout skill. It adds regression tests to tests/fm-watch-arm.test.sh. It also adds tests/fm-guard-session-lock-repair.test.sh, which checks three things: fm-guard.sh never writes a missing state/.lock, the fm-lock.sh owner path repairs it, and a foreign live lock is never rewritten.

🤖 Generated with Claude Code

Risk Assessment

⚠️ Medium: The latest fix rounds are correct: the reopen counter is now cleared only after a successful append, while FM_WAKE_QUEUE_LOCK is held, so it cannot race arm-check or reopen. The docs match the code and both new tests check behavior. The branch as a whole still changes the watcher recovery state machine and lock handling, which is complex, so medium risk remains.

Testing

I drove the real bin/fm-watch-arm.sh --restart, --stop and bin/fm-wake-drain.sh in disposable marked lab homes and removed each lab in the same run. On base code the reported failure reproduced: every restart exited and no watch lock was left. On the change, the watcher settles on the second restart and holds state/.watch.lock for 60s and more. It also survives a later restart. A config typo in the limit falls back to 1. A genuine ack plus new work still resurfaces. I also ran the 8 new watch-arm tests, the guard lock-repair test and the 4 wake-drain test files, and all passed. Those test-only checks are not live results, so I mark the two scenarios that only they cover as untested. There is no UI surface, so the evidence is CLI transcripts. I did not start a harness CLI primary (claude) in the lab, because the watcher arm scripts are the surface this change touches.

  • Live validation: ✅ go - 6 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Reported setup (announced-unacked episode, 18 queued rows, no session lock, no re-arm loop): repeated --restart ends with a watcher that stays alive and holds state/.watch.lock ✅ pass live live-after.log (restart #2 and #3: arm alive after 12s=YES, watch.lock=YES)
Same setup on base code reproduces the defect (each restart beacons, then exits, no watch.lock) ✅ pass live live-before.log
Settled watcher stays up over a 60s soak ✅ pass live live-adversarial.log (t=20/40/60s alive=YES, watch.lock=YES)
Queued rows are not lost when the episode settles; the next session drain presents all of them ✅ pass live live-after.log (queued rows 18, rows presented 18)
Adversarial: a non-numeric FM_RECOVERY_REOPEN_LIMIT cannot defeat the bound ✅ pass live live-adversarial.log (limit=bogus: restart #2 settles to acked and stays up); test_invalid_reopen_limit_falls_back_to_default
Adversarial: a genuine session ack, followed by a new durable wake, still resurfaces once on the next restart (the bound does not bury real work) ✅ pass live live-adversarial.log (restart after ack + new row -> check: rearm-resurface)
Edge cases: a stale zero-row ack keeps the settle; an append resets the budget; a failed append keeps the count; a late genuine ack still resurfaces ⏸️ untested no The prior payload did not establish a live result. These cases were only checked by focused unit tests (new-watch-arm-tests.log, 8 new tests ok) on temporary state. The failed-append and stale-ack rac…
The missing-lock repair runs only on the verified owner path, and a foreign live lock is refused ⏸️ untested no The prior payload did not establish a live result. It was only checked by tests with a controlled ps fixture (guard-lock-repair-tests.log, 3/3 ok). A live check needs real concurrent harness primaries…
Evidence: Live drive on change: restart #2 and #3 stay up with watch.lock

Source: Live drive on change: restart #2 and #3 stay up with watch.lock

=== [change 52271b1] lab=/var/folders/dm/8yjy9kvn7x1bfl9q4pggbmbw0000gq/T//fm-lab.j09e9V  session lock present? no
--- restart #1 output:
    watcher: started pid=94882 (beacon fresh)
    check: rearm-resurface
    arm alive after 12s: no
    watcher pid=94882 alive: no
    state/.watch.lock present: no
    marker: announced:downtime:94882.1790738342.BP1SVR  reopen-count: 1  settled: -
    beacon age: 8s
--- restart #2 output:
    watcher: started pid=97567 (beacon fresh)
    arm alive after 12s: YES
    watcher pid=97567 alive: YES
    state/.watch.lock present: YES
    marker: acked:downtime:94882.1790738342.BP1SVR  reopen-count: -  settled: 94882.1790738342.BP1SVR
    beacon age: 9s
--- restart #3 output:
    watcher: started pid=1244 (beacon fresh)
    
    arm alive after 12s: YES
    watcher pid=1244 alive: YES
    state/.watch.lock present: YES
    marker: acked:downtime:94882.1790738342.BP1SVR  reopen-count: -  settled: 94882.1790738342.BP1SVR
    beacon age: 9s
--- queued rows still durable: 18
    stop: watcher: stopped pid=1244
--- next-session drain shows rows:
    rows presented: 18
=== lab removed
Evidence: Live drive on base: every restart exits, no watch.lock (bug reproduced)

Source: Live drive on base: every restart exits, no watch.lock (bug reproduced)

=== [base 46d58d6] lab=/var/folders/dm/8yjy9kvn7x1bfl9q4pggbmbw0000gq/T//fm-lab.XZn6lq  session lock present? no
--- restart #1 output:
    watcher: started pid=11572 (beacon fresh)
    check: rearm-resurface
    arm alive after 12s: no
    watcher pid=11572 alive: no
    state/.watch.lock present: no
    marker: announced:downtime:11572.1790738407.EORLWs  reopen-count: -  settled: -
    beacon age: 8s
--- restart #2 output:
    watcher: started pid=17082 (beacon fresh)
    check: rearm-resurface
    arm alive after 12s: no
    watcher pid=17082 alive: no
    state/.watch.lock present: no
    marker: announced:downtime:17082.1790738418.h8DtQK  reopen-count: -  settled: -
    beacon age: 9s
--- restart #3 output:
    watcher: started pid=21869 (beacon fresh)
    check: rearm-resurface
    arm alive after 12s: no
    watcher pid=21869 alive: no
    state/.watch.lock present: no
    marker: announced:downtime:21869.1790738431.DDvxgB  reopen-count: -  settled: -
    beacon age: 8s
--- queued rows still durable: 18
    stop: watcher: none running
--- next-session drain shows rows:
    rows presented: 18
=== lab removed
Evidence: Live adversarial: typo limit, 60s soak, genuine ack still resurfaces

Source: Live adversarial: typo limit, 60s soak, genuine ack still resurfaces

## FM_RECOVERY_REOPEN_LIMIT=bogus (typo) - must fall back to 1
restart#1 (exited): watcher: started pid=46877 (beacon fresh) check: rearm-resurface 
restart#2:  marker=acked:downtime:46877.1790739161.9oyJjA
## soak 60s
t=20s arm alive=YES watch.lock=YES
t=40s arm alive=YES watch.lock=YES
t=60s arm alive=YES watch.lock=YES
## genuine session ack, then a new durable wake: must still resurface once
stop: watcher: stopped pid=50264
drain presented: 1 row(s); WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through 1 --recovery-generation 72483.1790739232.uqJnck
ack rc=0 marker=acked:handling:72483.1790739232.uqJnck settled=- queue=0
marker before restart: acked:handling:72483.1790739232.uqJnck
restart after genuine ack+new row (exited): watcher: started pid=73692 (beacon fresh) check: rearm-resurface 
lab removed
Evidence: Lab drive scripts

Source: Lab drive scripts

#!/usr/bin/env bash
# Drive real bin/fm-watch-arm.sh --restart in a disposable lab home.
# Usage: live-drive.sh <bin-dir> <label>
BIN=$1; LABEL=$2
LAB=$(mktemp -d "${TMPDIR:-/tmp}/fm-lab.XXXXXX")
"$BIN/fm-lab-home.sh" create "$LAB" >/dev/null 2>&1 || ~/.no-mistakes/worktrees/c7ba7bd9b7dd/01M3P5XP3W5C5AYSWV16E9XJES/bin/fm-lab-home.sh create "$LAB" >/dev/null
S=$LAB/state
# Reproduce the report: prior episode announced, never acked; rows queued; no session lock; no re-arm loop.
printf 'announced:downtime:seedgen1\n' > $S/.watcher-down; chmod 600 $S/.watcher-down
now=$(date +%s)
for i in $(seq 1 18); do printf '%s\t%s\tcheck\ttask-%s\tcheck: task %s in flight\n' $now $i $i $i; done > $S/.wake-queue
printf '18\n' > $S/.wake-queue.seq
echo "=== [$LABEL] lab=$LAB  session lock present? $( [ -e $S/.lock ] && echo yes || echo no )"
arm() {
  local n=$1 out=$LAB/arm-$n.out
  env -u NO_MISTAKES_GATE FM_HOME="$LAB" FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 "$BIN/fm-watch-arm.sh" --restart > $out 2>&1 &
  local apid=$!
  sleep 12
  local wpid=$(sed -n 's/^watcher: started pid=\([0-9]*\).*/\1/p' $out | head -1)
  echo "--- restart #$n output:"; sed 's/^/    /' $out | head -8
  echo "    arm alive after 12s: $(kill -0 $apid 2>/dev/null && echo YES || echo no)"
  echo "    watcher pid=$wpid alive: $( [ -n "$wpid" ] && kill -0 $wpid 2>/dev/null && echo YES || echo no)"
  echo "    state/.watch.lock present: $( [ -e $S/.watch.lock ] && echo YES || echo no)"
  echo "    marker: $(cat $S/.watcher-down)  reopen-count: $(cat $S/.watcher-down.reopen-count 2>/dev/null || echo -)  settled: $(cat $S/.watcher-down.reopen-settled 2>/dev/null || echo -)"
  echo "    beacon age: $(( $(date +%s) - $(stat -f %m $S/.last-watcher-beat 2>/dev/null || echo 0) ))s"
  LASTARM=$apid
}
arm 1; arm 2; arm 3
echo "--- queued rows still durable: $(wc -l < $S/.wake-queue | tr -d ' ')"
env -u NO_MISTAKES_GATE FM_HOME="$LAB" "$BIN/fm-watch-arm.sh" --stop 2>&1 | sed 's/^/    stop: /'
kill $LASTARM 2>/dev/null; sleep 1
echo "--- next-session drain shows rows:"; env -u NO_MISTAKES_GATE FM_HOME="$LAB" "$BIN/fm-wake-drain.sh" 2>/dev/null | grep -c 'task-' | sed 's/^/    rows presented: /'
rm -rf "$LAB"; echo "=== lab removed"
Evidence: New watch-arm tests output

Source: New watch-arm tests output

ok - watch-arm: a stuck unacknowledged recovery episode with an empty queue keeps the watcher up on every restart
ok - watch-arm: a settled recovery episode with queued rows keeps the watcher up and leaves the rows for the next drain
ok - watch-arm: a genuinely acknowledged episode with a queued row still resurfaces on re-arm
ok - watch-arm: a genuine ack after a bound settle still resurfaces a newer queued row on re-arm
ok - watch-arm: a stale zero-row ack keeps a bound-settled episode settled and the watcher up
ok - watch-arm: a non-numeric FM_RECOVERY_REOPEN_LIMIT falls back to the default bound
ok - watch-arm: an append that mints a fresh episode resets the reopen budget
ok - watch-arm: a failed append keeps the restored episode's reopen count
Evidence: Guard lock-repair tests output

Source: Guard lock-repair tests output

ok - fm-guard: never repairs a missing state/.lock (main, read-only, or supervision-branch caller)
ok - fm-lock: the verified-owner path repairs a missing state/.lock with the live harness pid
ok - fm-lock/fm-guard: an existing foreign live lock is refused and left untouched
Evidence: Base vs change restart summary
base 46d58d6: restart#1/#2/#3 -> 'check: rearm-resurface', arm alive=no, watch.lock=no
change 52271b1: restart#1 -> resurface (count=1); restart#2 -> alive=YES, watch.lock=YES, marker acked (settled); restart#3 -> alive=YES, watch.lock=YES; 18 rows still presented by next drain

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-wake-lib.sh:991 - Intent conformance: the intent says 'supervision that stays up, so the home does not depend on someone re-arming it by hand'. The reopen bound defaults to FM_RECOVERY_REOPEN_LIMIT=3. The watcher still resurfaces and exits on the first 3 restarts of a stuck announced episode that has queued rows. Only the 4th restart stays up. Sequence: marker announced:downtime:G, queue has rows, no session, no re-arm loop. fm-watch-arm.sh --restart runs, resurfaces and exits (count=1). The operator re-arms by hand (count=2, then count=3). Only then does the settle keep the watcher up. So in the reported setup the operator must still re-arm by hand 3 more times. This repeats for every new episode, because each new durable row mints a fresh generation. Does the default meet the intent, or should it be lower (for example 1 or 0)? This is a product decision.
  • ℹ️ bin/fm-wake-lib.sh:1011 - state/.watcher-down.reopen-count is not per-episode, but docs/watcher-continuity.md says 'consecutive reopens of one episode'. Only these clear the counter: a genuine ack, a settle, or an arm-check mint from a missing or invalid marker. A durable append that mints a fresh generation from an announced marker (_fm_recovery_marker_publish, source=append, bin/fm-wake-lib.sh:752) does not clear it. Sequence (limit 3): episode G1 is reopened twice (count=2). A new wake row is appended, so the marker becomes pending:downtime:G5. The arm announces G5 and resurfaces. The next restart reopens it (count=3). The restart after that settles it (count=4). So the new episode gets 1 reopen, not 3. Its first announce still happens, so no row is lost, but the bound is shorter than documented. Fix: remove the counter in _fm_recovery_marker_publish when an append writes a fresh generation, as arm-check already does for a missing or invalid marker.

🔧 Fix applied.
1 info still open:

  • ℹ️ bin/fm-wake-lib.sh:1011 - state/.watcher-down.reopen-count is not per-episode, but docs/watcher-continuity.md says 'consecutive reopens of one episode'. Only these clear the counter: a genuine ack, a settle, or an arm-check mint from a missing or invalid marker. A durable append that mints a fresh generation from an announced marker (_fm_recovery_marker_publish, source=append, bin/fm-wake-lib.sh:752) does not clear it. Sequence (limit 3): episode G1 is reopened twice (count=2). A new wake row is appended, so the marker becomes pending:downtime:G5. The arm announces G5 and resurfaces. The next restart reopens it (count=3). The restart after that settles it (count=4). So the new episode gets 1 reopen, not 3. Its first announce still happens, so no row is lost, but the bound is shorter than documented. Fix: remove the counter in _fm_recovery_marker_publish when an append writes a fresh generation, as arm-check already does for a missing or invalid marker.

🔧 Fix applied.
3 infos still open:

  • ℹ️ bin/fm-wake-lib.sh:1011 - state/.watcher-down.reopen-count is not per-episode, but docs/watcher-continuity.md says 'consecutive reopens of one episode'. Only these clear the counter: a genuine ack, a settle, or an arm-check mint from a missing or invalid marker. A durable append that mints a fresh generation from an announced marker (_fm_recovery_marker_publish, source=append, bin/fm-wake-lib.sh:752) does not clear it. Sequence (limit 3): episode G1 is reopened twice (count=2). A new wake row is appended, so the marker becomes pending:downtime:G5. The arm announces G5 and resurfaces. The next restart reopens it (count=3). The restart after that settles it (count=4). So the new episode gets 1 reopen, not 3. Its first announce still happens, so no row is lost, but the bound is shorter than documented. Fix: remove the counter in _fm_recovery_marker_publish when an append writes a fresh generation, as arm-check already does for a missing or invalid marker.
  • ℹ️ docs/watcher-continuity.md:230 - Round 2's R-2 fix (0475f89) updated the code comment at bin/fm-wake-lib.sh:992-996 to say that a durable append minting a fresh episode from an announced one also clears the reopen counter. The owner doc was not updated. docs/watcher-continuity.md:230 still says that only a real acknowledgement or a watcher start minting from a missing or invalid marker clears the counter. The doc now describes the bound wrongly. Fix: add the append-from-announced case to that sentence.
  • ℹ️ bin/fm-wake-lib.sh:775 - Round 2's R-2 fix removes .watcher-down.reopen-count inside _fm_recovery_marker_publish (source=append) before the queue row is written. In fm_wake_append_locked (bin/fm-wake-lib.sh:2106-2119), a later seq or queue write failure calls _fm_wake_append_recovery_restore_locked. That restores the marker to the old announced:downtime:G token, but the deleted counter is not restored. Sequence (limit 1): G was reopened once (count=1). An append from announced deletes the counter, then the queue write fails (for example a full disk). The marker goes back to announced:G with no counter. The next restart reopens G again (count=1) and exits, instead of settling and staying up. The cost is one extra resurface-and-exit, and only on a failed write. Fix: delete the counter only after the append succeeds (the success branch at bin/fm-wake-lib.sh:2121), or restore it in the restore path.

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

✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 6 of 8 scenarios driven live against the product
Scenario Result Live Evidence
Reported setup (announced-unacked episode, 18 queued rows, no session lock, no re-arm loop): repeated --restart ends with a watcher that stays alive and holds state/.watch.lock ✅ pass live live-after.log (restart #2 and #3: arm alive after 12s=YES, watch.lock=YES)
Same setup on base code reproduces the defect (each restart beacons, then exits, no watch.lock) ✅ pass live live-before.log
Settled watcher stays up over a 60s soak ✅ pass live live-adversarial.log (t=20/40/60s alive=YES, watch.lock=YES)
Queued rows are not lost when the episode settles; the next session drain presents all of them ✅ pass live live-after.log (queued rows 18, rows presented 18)
Adversarial: a non-numeric FM_RECOVERY_REOPEN_LIMIT cannot defeat the bound ✅ pass live live-adversarial.log (limit=bogus: restart #2 settles to acked and stays up); test_invalid_reopen_limit_falls_back_to_default
Adversarial: a genuine session ack, followed by a new durable wake, still resurfaces once on the next restart (the bound does not bury real work) ✅ pass live live-adversarial.log (restart after ack + new row -> check: rearm-resurface)
Edge cases: a stale zero-row ack keeps the settle; an append resets the budget; a failed append keeps the count; a late genuine ack still resurfaces ⏸️ untested no The prior payload did not establish a live result. These cases were only checked by focused unit tests (new-watch-arm-tests.log, 8 new tests ok) on temporary state. The failed-append and stale-ack rac…
The missing-lock repair runs only on the verified owner path, and a foreign live lock is refused ⏸️ untested no The prior payload did not establish a live result. It was only checked by tests with a controlled ps fixture (guard-lock-repair-tests.log, 3/3 ok). A live check needs real concurrent harness primaries…
  • live-drive.sh &lt;worktree&gt;/bin in a disposable lab (bin/fm-lab-home.sh create): announced-unacked marker, 18 queued rows, no state/.lock, three real bin/fm-watch-arm.sh --restart calls, then --stop and bin/fm-wake-drain.sh
  • Same live-drive.sh against base 46d58d6 bin (via git archive into a temp dir) to reproduce the defect
  • live-adversarial.sh: FM_RECOVERY_REOPEN_LIMIT=bogus fallback, 60s soak of the settled watcher, genuine drain --ack-through plus a new row, then a restart that must resurface
  • The 8 new tests/fm-watch-arm.test.sh cases (bounded reopen, queued settle, genuine-ack resurface, late ack, stale zero-row ack, invalid limit, append resets budget, failed append keeps count)
  • bash tests/fm-guard-session-lock-repair.test.sh
  • bash tests/fm-wake-drain*.test.sh (4 files)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@marcus-vsc-moreira

Copy link
Copy Markdown
Author

Premise update from live production evidence

After this PR's fix landed (bounded recovery-episode reopen), firstmate reproduced the reported defect again live, with this bound already active, using standalone bin/fm-watch-arm.sh restarts. Supervision still does not stay up: every cycle's ledger row records successor=none.

This does not contradict this PR's fix - bin/fm-watch-arm.sh intentionally defers re-arming to whatever live harness owns the arm child, by design; a standalone invocation was never wired to continue itself. What it shows is that this PR closes one real failure mode (an unacknowledged recovery episode reopening forever) but is not sufficient on its own to guarantee "supervision stays up" end-to-end - that also depends on a live harness (e.g. the OpenCode plugin) actually continuing the watcher after an actionable wake, which is a separate mechanism with its own gap under investigation (tracked as follow-up, not part of this PR).

Keeping this PR's fix in scope: the reopen-bound hardening is correct and independently needed (the unacknowledged-resurface case it fixes was confirmed live in the reported home).

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High risk] Changes watcher recovery logic and adds new state files.

The PR appears safe to merge; no outstanding blocking finding was established.

Reviews (4) · Last reviewed commit: "no-mistakes(document): Note bounded reop..."

Comment thread bin/fm-guard.sh Outdated
Comment on lines +164 to +165
if [ "$READ_ONLY" -eq 0 ] && [ ! -e "$STATE/.lock" ] && [ ! -L "$STATE/.lock" ]; then
"$SCRIPT_DIR/fm-lock.sh" >/dev/null 2>&1 || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Foreign session acquires target lock If fm-remote-secondmate-control.sh retire runs from another harness session while the target home has no session lock, this guard calls fm-lock.sh for the target home without checking ownership. The new lock records the caller's PID, so the target session can be refused as a foreign owner and cannot restore its supervision.

Knowledge Base Used:

Comment thread bin/fm-wake-lib.sh
Comment on lines +754 to +756
if [ -n "$acked_generation" ] && [ -n "$FM_RECOVERY_MARKER_TOKEN" ] \
&& [ "${FM_RECOVERY_MARKER_TOKEN##*:}" = "$acked_generation" ]; then
rm -f -- "${marker}.reopen-count" "${marker}.reopen-settled" 2>/dev/null || true

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty ack restarts recovery If an older --ack-through command is retried for a settled generation while newer rows remain queued, it can consume zero rows and still reach this snapshot. The matching generation clears the settlement sentinel, so the next arm re-announces the episode and exits instead of keeping the watcher up until the next drain.

Knowledge Base Used: Watch and wake workflows

Comment thread bin/fm-wake-lib.sh Outdated
# (_fm_recovery_marker_ack) or arm-check minting a fresh episode from a missing
# or invalid marker both clear the counter, so this bound never shortens the
# once-per-genuine-generation resurface a live, attentive session relies on.
FM_RECOVERY_REOPEN_LIMIT=${FM_RECOVERY_REOPEN_LIMIT:-3}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Invalid limit disables reopen bound If FM_RECOVERY_REOPEN_LIMIT is set to a nonnumeric value, it passes through unchanged and each numeric comparison fails. A stuck episode then keeps reopening on every restart. Validating the setting would prevent a configuration typo from defeating this safeguard.

Comment on lines +33 to +37
case "$field" in
comm=) printf '%s\n' opencode ;;
args=) printf '%s\n' opencode ;;
ppid=) printf '%s\n' 1 ;;
esac

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Repair test accepts dead owner The ps stub calls every queried PID opencode, including the short-lived fm-lock.sh process. The test can therefore pass with a lock owned by a PID that is dead when the guard returns: it checks only that the PID is numeric. Using a live harness in the fixture and checking owner liveness would make this test catch ineffective repairs.

Knowledge Base Used: Restore Claude lock ownership after helper recycling

@marcus-vsc-moreira marcus-vsc-moreira changed the title fix(bin): bound recovery-episode reopen so a stuck watcher can stay up fix(bin): bound recovery-episode reopen so a restarted watcher stays up Sep 28, 2026
marcus-vsc-moreira and others added 19 commits September 29, 2026 05:54
An announced-but-unacked recovery episode (state/.watcher-down) reopens
into a brand new generation on every plain restart with no session or
re-arm loop to run the printed acknowledgement, so each restart resurfaces
"check: rearm-resurface" and exits again - the watcher never holds the
lock or lives past its first beacon.

Bound consecutive reopens of one stuck episode with FM_RECOVERY_REOPEN_LIMIT
(default 3): past the limit, settle the episode to acked directly instead
of minting another generation, so the next restart proceeds into its
normal poll loop and stays armed. A real acknowledgement or a fresh
downtime episode both clear the counter, so this never shortens the
once-per-genuine-generation resurface a live session still relies on.
* docs: make watcher-continuity easier to read

Restructure the prose into sections, lists, and tables without changing documented behavior. Every original heading, anchor, identifier, link target, and number is kept.

* no-mistakes(review): Fix actor and supervision-host scope in watcher-continuity doc

* no-mistakes(review): Make readiness TERM and retry conditional on unready successor
… home is gone (kunchenguid#5552)

* fix(bin): refuse watchers from disposable checkouts and exit when the home is gone

Fixes kunchenguid#321
Fixes kunchenguid#4760

A watcher armed from a disposable no-mistakes validation checkout under
.no-mistakes/worktrees/ outlived the validation step and kept writing the
real home's state, and a running watcher never noticed when its home,
state directory, or code root disappeared. The arm now refuses from such
a checkout with the typed failure line, the watcher checks once per poll
that its home, state directory (or its own lock holder record), and bin
directory still exist and exits with a logged reason scoped to itself,
and the shared test helpers reap every watcher a suite armed for a
temporary home through the home-scoped stop.

* no-mistakes(lint): fix SC1007 by assigning empty string in watch-arm test

* no-mistakes(ci): Found and fixed a genuine, reproducible hang introduced by this branch's test-watcher reaper, which is what killed both CI checks (serial-2 cancelled at the 30-min cap; Lint 2 exit 143 = the suite's own TERM-trap code). Root cause: test_drain_asserts_watcher_liveness (tests/fm-wake-queue.test.sh) fabricates a .watch.lock whose pid is the test runner's own $$ with the runner's real identity, to make the drain believe a live watcher exists. The new make_case tracking registers that state dir for reaping, so at fm_test_cleanup the new fm_test_reap_watchers drives fm-watch-arm.sh --stop; its identity check matches (the fixture recorded the runner's identity) and it kill -TERMs the test runner. tests/lib.sh:231 is `trap 'fm_test_cleanup; exit 143' TERM`, so the TERM re-enters cleanup -> reap -> kills $$ again -> infinite loop until the runner cap. I reproduced this locally: the suite ran all tests then looped forever in cleanup spawning fm-watch-arm.sh --stop against a lock naming its own PID. Fix (tests/lib.sh, +5 lines): in fm_test_reap_watchers, skip any tracked lock whose pid equals our own $$ before driving --stop. This is the single shared reap boundary; seven $$-self-lock fixtures across four test files are all covered by the one guard, and real armed watchers (pid != $$) are still reaped. Invariant: the test reaper must only signal real armed watcher processes, never the test runner itself. Verified locally: tests/fm-wake-queue.test.sh -> EXIT 0 (63 ok, no hang); tests/fm-watch-arm.test.sh -> EXIT 0 (21 ok, including test_reaper_stops_a_tracked_watcher, confirming the guard does not over-skip). Lint 2's exit 143 was the same shard/cap signature; a fresh CI run on this new commit will re-evaluate it

---------

Co-authored-by: firstmate-oss <firstmate@kunchenguid.local>
An announced-but-unacked recovery episode (state/.watcher-down) reopens
into a brand new generation on every plain restart with no session or
re-arm loop to run the printed acknowledgement, so each restart resurfaces
"check: rearm-resurface" and exits again - the watcher never holds the
lock or lives past its first beacon.

Bound consecutive reopens of one stuck episode with FM_RECOVERY_REOPEN_LIMIT
(default 3): past the limit, settle the episode to acked directly instead
of minting another generation, so the next restart proceeds into its
normal poll loop and stays armed. A real acknowledgement or a fresh
downtime episode both clear the counter, so this never shortens the
once-per-genuine-generation resurface a live session still relies on.
sessionOwnsLock() in the OpenCode arm plugin reads state/.lock at face
value and treats it as ownerless whenever the file is simply absent,
same as a genuine foreign live owner. Per the successor-gap decision
(Option B), leave that check and beginArm untouched; instead have
fm-guard.sh - which only runs non-read-only after ownership was already
verified this call chain - repair a missing lock via bin/fm-lock.sh's
existing acquire path. An existing lock, foreign or not, is never
touched, so the tested foreign-live-owner refusal keeps failing fast.
…ran pass. I ran the two new watcher tests against the old code, and both failed there. - **ci-3 (lock repair reachable from other callers):** No code change was needed. `bin/fm-guard.sh` is the same as on base, so the guard never calls `fm-lock.sh`. `bin/fm-remote-secondmate-control.sh` does not call `fm-lock.sh` either. Only `bin/fm-lock.sh`'s own acquire, run from the LOCK step in `bin/fm-session-start.sh`, can write a missing `state/.lock`. The test `tests/fm-guard-session-lock-repair.test.sh` checks that the guard never writes it (main, read-only and branch callers). - **ci-4 (retried empty ack):** In `bin/fm-wake-drain.sh`, the snapshot gets the ack generation only when the ack removed at least one row (`ACK_REMOVED > 0`). So a retried stale ack that removes nothing keeps `.watcher-down.reopen-settled`. The ack path that empties the queue (`fm_recovery_marker_ack`) is unchanged, as the earlier R3-2 fix requires. New test: `test_stale_zero_row_ack_keeps_bound_settle`. It checks that the sentinel stays and that the next watcher stays up without a resurface. - **ci-5 (non-numeric limit):** In `bin/fm-wake-lib.sh`, a non-numeric `FM_RECOVERY_REOPEN_LIMIT` now falls back to the default of 3. New test: `test_invalid_reopen_limit_falls_back_to_default`. It sets the limit to `three` and checks that the episode settles and the watcher stays up. - **ci-6 (repair test accepted a dead pid):** The `ps` stub now calls a pid a harness only if that pid is in a list of live harness pids. It sends every other query to the real `ps`. `fm-lock.sh` runs as a child of a live harness process. The test checks that `state/.lock` holds that harness pid and that the pid is alive. The foreign-owner test runs the same way. Checks: - shellcheck is clean on the four changed files. - `tests/fm-guard-session-lock-repair.test.sh`: 3 of 3 pass. - The 6 recovery tests in `tests/fm-watch-arm.test.sh`: all pass. I ran them alone, because on this host the full file stops early on a test that also fails on base (T1). - All four `tests/fm-wake-drain*.test.sh` files pass
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.

3 participants