From fcaa45681927afeb3766a51a8906cdb4d4369336 Mon Sep 17 00:00:00 2001 From: Vytautas Stankus Date: Wed, 7 Oct 2026 09:11:58 +0300 Subject: [PATCH 1/5] fix(bin): let TERM stop a watcher blocked in a pane capture on bash 3.2 Stock macOS bash 3.2 holds a HUP or TERM until a running command substitution's child exits, and the watcher read every pane through $(fm_backend_capture ...). A blocked backend read therefore held the watcher's stop for as long as the read lasted, and a stopped watcher left the hung read orphaned. tests/fm-watch-triage.test.sh test_term_stops_a_watcher_blocked_inside_a_poll failed on /bin/bash 3.2 for this reason while passing on bash 5. Pane captures now go through watcher_capture, which runs the read as a waited background process group recorded like a check's, so the stop is honored at once and watcher_cleanup stops a read still in flight along with its per-call output file. --- bin/fm-watch.sh | 57 +++++++++++++++++++++++++++++++++----- docs/watcher-continuity.md | 2 +- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index dcd1f7c4933..77660b6ff5a 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -549,7 +549,8 @@ inbox_steer_check() { # return 0 ;; esac - tail40=$(fm_backend_capture "$backend" "$w" 40 "$(window_label "$w")" 2>/dev/null) || tail40= + watcher_capture "$backend" "$w" 40 "$(window_label "$w")" || WATCHER_CAPTURE= + tail40=$WATCHER_CAPTURE if window_is_busy "$w" "$tail40"; then [ "$verb" != retry ] || return 0 if ! count=$(fm_task_inbox_record_busy "$STATE" "$task" "$rec"); then @@ -763,7 +764,8 @@ signal_turnend_panes_churned() { # ... [ "$hash_bytes" = 32 ] || return 1 prev=$(cat "$hash_file" 2>/dev/null) || return 1 [[ $prev =~ ^[0-9a-f]{32}$ ]] || return 1 - now=$(fm_backend_capture "$backend" "$w" 40 "$label" 2>/dev/null) || return 1 + watcher_capture "$backend" "$w" 40 "$label" || return 1 + now=$WATCHER_CAPTURE [ -n "$now" ] || return 1 [ "$(printf '%s' "$now" | hash_pane)" != "$prev" ] || return 1 churned_keys+=("$key") @@ -869,7 +871,8 @@ secondmate_in_active_turn() { # local w=$1 idle=$2 tail40 [ -n "$w" ] || return 1 [ "$idle" -lt "$BUSY_TURN_MAX_SECS" ] || return 1 - tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || return 1 + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || return 1 + tail40=$WATCHER_CAPTURE window_is_busy "$w" "$tail40" } @@ -884,10 +887,11 @@ secondmate_busy_class() { # printf 'unknown' return 0 fi - tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || { + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || { printf 'unknown' return 0 } + tail40=$WATCHER_CAPTURE verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") printf '%s' "${verdict%% *}" } @@ -2138,8 +2142,10 @@ fm_active_check_stop() { # parse of the next command substitution, the body then fails to parse ("trap: # line 2: unexpected EOF while looking for matching `)'", or nothing at all), # and the signal is consumed, so a stop request could leave this watcher -# polling forever while its stopper waits (fixed upstream in bash 5.3). INT -# keeps its trap because bash ignores a direct SIGINT while a child runs. +# polling forever while its stopper waits (fixed upstream in bash 5.3). Bash +# 3.2 holds HUP and TERM until a running command substitution's child exits, so +# pane captures go through watcher_capture instead. INT keeps its trap because +# bash ignores a direct SIGINT while a child runs. watcher_stop_signals() { trap - HUP TERM trap 'exit 1' INT @@ -2176,6 +2182,41 @@ run_check_capture() { fm_check_output_cleanup } +FM_CAPTURE_OUTPUT= +WATCHER_CAPTURE= + +fm_capture_output_cleanup() { + [ -z "$FM_CAPTURE_OUTPUT" ] || rm -f -- "$FM_CAPTURE_OUTPUT" + FM_CAPTURE_OUTPUT= +} + +# watcher_capture: fm_backend_capture into WATCHER_CAPTURE with its exit status. +# The read runs as a waited background process group rather than inside $(...), +# so a stop is not held for a blocked read (watcher_stop_signals). The group is +# recorded like a check's, so watcher_cleanup stops a read still in flight. +watcher_capture() { # [expected-label] + local rc + fm_capture_output_cleanup + WATCHER_CAPTURE= + FM_CAPTURE_OUTPUT=$(mktemp "$STATE/.fm-capture-output.XXXXXX") || return 1 + FM_CHECK_SIGNAL_PENDING= + trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM + set -m + fm_backend_capture "$@" < /dev/null > "$FM_CAPTURE_OUTPUT" 2>/dev/null & + FM_ACTIVE_CHECK_PID=$! + FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID + set +m + watcher_stop_signals + [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 + wait "$FM_ACTIVE_CHECK_PID" + rc=$? + FM_ACTIVE_CHECK_PID= + fm_active_check_stop || { fm_capture_output_cleanup; return 1; } + WATCHER_CAPTURE=$(cat "$FM_CAPTURE_OUTPUT" 2>/dev/null || true) + fm_capture_output_cleanup + return "$rc" +} + # 0 when any signaled status file carries a captain-relevant event in the bytes # appended since this watcher last classified it. The start offset is the # classified-position field in that file's .seen-* marker, and fm-classify-lib.sh's @@ -2539,6 +2580,7 @@ watcher_cleanup() { fi fm_active_check_stop || cleanup_status=1 fm_check_output_cleanup + fm_capture_output_cleanup fm_custom_check_snapshot_cleanup if [ "$owns_lock" -eq 1 ] \ && ! fm_recovery_transition "$WATCHER_DOWNTIME_MARKER" "$transition" "$WATCH_LOCK" \ @@ -3016,7 +3058,8 @@ EOF if [ "$kind" = secondmate ] && ! status_is_paused_or_captain_held "$last"; then continue fi - tail40=$(fm_backend_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" 2>/dev/null) || continue + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || continue + tail40=$WATCHER_CAPTURE h=$(printf '%s' "$tail40" | hash_pane) hf="$STATE/.hash-$key" cf="$STATE/.count-$key" diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index f14717596b4..314a0777bd3 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -402,7 +402,7 @@ Only the watcher process touches `state/.last-watcher-beat`. No helper process can make a wedged watcher appear healthy. An arm whose own script path sits under a disposable no-mistakes validation checkout (`.no-mistakes/worktrees/`) refuses with the typed failure line before touching any state, because a watcher started there outlives the validation step and keeps writing the real home's state from a checkout about to be deleted. Once per poll the watcher checks that its home, its state directory, and its own code root still exist, and exits with a logged reason when one is gone, scoped to itself alone, so a torn-down temporary home or a discarded checkout never leaves an orphan watcher behind. -The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked poll, so both run its EXIT cleanup. +The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked pane capture, so both run its EXIT cleanup and stop that capture. `watcher_stop_signals` in `bin/fm-watch.sh` owns the signal-handling rationale. The EXIT cleanup bounds its wait for `state/.watcher-down.lock` while persisting recovery state with `FM_WATCHER_CLEANUP_LOCK_BOUND` (default 2 seconds). Only positive decimal integers are accepted, including leading-zero forms such as `08`; empty, non-numeric, and zero values (including `00`) fall back to 2 seconds. From 54cf5f6d04294590d7014cb048f5a3922dc48855 Mon Sep 17 00:00:00 2001 From: Vytautas Stankus Date: Wed, 7 Oct 2026 09:43:03 +0300 Subject: [PATCH 2/5] no-mistakes(review): Run drain-ring idle capture in watcher shell, add regression test --- bin/fm-watch.sh | 21 ++++----- tests/fm-wake-queue.test.sh | 85 +++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 77660b6ff5a..b10c95b5bb4 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -876,24 +876,24 @@ secondmate_in_active_turn() { # window_is_busy "$w" "$tail40" } -# First token of the semantic busy classification for : busy, idle, -# unknown, or dead. Capture failure and a missing window are unknown, never -# idle. Empty inbox and a fresh watcher beacon are not consulted. +# Set SECONDMATE_BUSY_CLASS to the first token of the semantic busy +# classification for : busy, idle, unknown, or dead. Capture failure and +# a missing window are unknown, never idle. Empty inbox and a fresh watcher +# beacon are not consulted. Call it directly, never in $(...), so its pane +# capture runs in the watcher's own shell. +SECONDMATE_BUSY_CLASS= secondmate_busy_class() { # local w=$1 task meta tail40 verdict + SECONDMATE_BUSY_CLASS=unknown task=$(window_to_task "$w" "$STATE") meta="$STATE/$task.meta" if [ -z "$w" ] || [ -z "$task" ] || [ ! -f "$meta" ]; then - printf 'unknown' return 0 fi - watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || { - printf 'unknown' - return 0 - } + watcher_capture "$(window_backend "$w")" "$w" 40 "$(window_label "$w")" || return 0 tail40=$WATCHER_CAPTURE verdict=$(fm_busy_classify_meta "$meta" "$task" "$STATE" "$tail40") - printf '%s' "${verdict%% *}" + SECONDMATE_BUSY_CLASS=${verdict%% *} } # 0 iff a child ring is authorized: exact idle, a live agent, and a composer @@ -903,7 +903,8 @@ secondmate_busy_class() { # secondmate_idle_ring_safe() { # local w=$1 backend agent_state cstate [ -n "$w" ] || return 1 - [ "$(secondmate_busy_class "$w")" = idle ] || return 1 + secondmate_busy_class "$w" + [ "$SECONDMATE_BUSY_CLASS" = idle ] || return 1 backend=$(window_backend "$w") agent_state=$(fm_backend_agent_state "$backend" "$w" 2>/dev/null || true) [ "$agent_state" = alive ] || return 1 diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index 40dacd953c7..dc97435ec73 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -914,6 +914,90 @@ test_secondmate_proven_idle_ring_lets_the_child_drain() { pass "a proven-idle leftover row is rung so the child home can drain without a parent alarm" } +# The idle proof that gates a drain ring reads the mate's pane. A TERM that +# lands while that read is blocked must still stop the watcher at once and run +# its cleanup, as it does for every other pane read. +test_term_stops_a_watcher_blocked_in_the_drain_ring_idle_capture() { + local dir state sub fakebin fifo out pid holder i rc orphan + dir=$(make_case secondmate-ring-blocked-capture) + state="$dir/state" + sub="$dir/secondmate" + fakebin="$dir/fakebin" + fifo="$dir/pane.fifo" + out="$dir/watch-blocked.out" + mkdir -p "$sub/state" + printf 'mate\n' > "$sub/.fm-secondmate-home" + printf 'window=firstmate:fm-mate\nkind=secondmate\nharness=claude\nbackend=tmux\nhome=%s\n' \ + "$sub" > "$state/mate.meta" + printf '100\t7\tcheck\trouted\tcheck: routed row\n' > "$sub/state/.wake-queue" + install_secondmate_alive_tmux "$fakebin" + install_secondmate_stall_date "$fakebin" + "$ROOT/bin/fm-busy-event.sh" arm "$state" mate >/dev/null \ + || fail "could not arm the mate's busy contract" + "$ROOT/bin/fm-busy-event.sh" apply "$state" mate idle --current-gen \ + --source claude-hook --event stop >/dev/null \ + || fail "could not mark the mate idle" + + printf '1000\n' > "$dir/now" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_POLL=1 FM_SIGNAL_GRACE=0 \ + FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + secondmate_stall_watch_leg "$dir" "first" progress mate "$(printf '1000\t100-7')" + + # The active-turn read returns, then the idle-proof read blocks on a FIFO + # whose write end the holder keeps open without writing. + mv "$fakebin/tmux" "$fakebin/tmux-alive" + cat > "$fakebin/tmux" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = capture-pane ]; then + n=$(( $(cat "$FM_FAKE_CAPTURE_COUNT" 2>/dev/null || echo 0) + 1 )) + printf '%s\n' "$n" > "$FM_FAKE_CAPTURE_COUNT" + [ "$n" -ge 2 ] || exit 0 + exec cat "$FM_FAKE_TMUX_CAPTURE" +fi +exec "$(dirname "$0")/tmux-alive" "$@" +SH + chmod +x "$fakebin/tmux" + mkfifo "$fifo" + ( exec 3> "$fifo"; : > "$dir/capture-blocked"; exec sleep 30 ) & + holder=$! + + printf '1002\n' > "$dir/now" + touch "$state/.secondmate-liveness-tick" + PATH="$fakebin:$PATH" FM_FAKE_NOW_FILE="$dir/now" FM_HOME="$dir" FM_ROOT_OVERRIDE="$ROOT" \ + FM_STATE_OVERRIDE="$state" FM_FAKE_TMUX_SENT="$dir/sent" \ + FM_FAKE_TMUX_CAPTURE="$fifo" FM_FAKE_CAPTURE_COUNT="$dir/captures" \ + FM_SECONDMATE_WAKE_STALL_SECS=1 FM_SECONDMATE_LIVENESS_SECS=99999999 \ + FM_POLL=1 FM_SIGNAL_GRACE=0 FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 \ + "$WATCH" > "$out" 2>&1 & + pid=$! + i=0 + while [ ! -e "$dir/capture-blocked" ] && [ "$i" -lt 300 ]; do + sleep 0.1 + i=$((i + 1)) + done + if [ ! -e "$dir/capture-blocked" ] || ! is_live_non_zombie "$pid"; then + kill "$holder" 2>/dev/null || true; kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true + fail "the watcher never blocked inside the drain-ring idle capture: $(cat "$out")" + fi + kill "$pid" 2>/dev/null || true + wait_for_exit "$pid" 100 + rc=$? + orphan=$(pgrep -f "cat $fifo" || true) + [ -z "$orphan" ] || pkill -f "cat $fifo" 2>/dev/null || true + kill "$holder" 2>/dev/null || true + wait "$holder" 2>/dev/null || true + [ "$rc" -ne 124 ] || fail "TERM did not stop a watcher blocked in the drain-ring idle capture" + [ -z "$orphan" ] || fail "a watcher stopped in the drain-ring idle capture left that capture running" + [ "$(cat "$dir/captures")" = 2 ] \ + || fail "the watcher did not block in the drain-ring idle capture: $(cat "$dir/captures") pane reads" + [ ! -e "$state/.watch.lock" ] \ + || fail "a watcher stopped in the drain-ring idle capture kept its singleton lock" + [ ! -s "$dir/sent" ] || fail "a watcher stopped before its idle proof still rang the mate" + pass "TERM stops a watcher blocked in the drain-ring idle capture and runs its cleanup" +} + # Busy and unknown panes are never typed into. Busy still defers inside the # active-turn bound. Unknown keeps the parent alarm. Empty inbox is not idle # proof, so the unknown fixture starts with no instruction records. @@ -3432,6 +3516,7 @@ test_secondmate_reprovisioned_queue_starts_a_fresh_interval test_secondmate_active_turn_defers_stall_until_the_turn_ends test_secondmate_long_lived_mate_mid_turn_is_not_a_stall test_secondmate_proven_idle_ring_lets_the_child_drain +test_term_stops_a_watcher_blocked_in_the_drain_ring_idle_capture test_secondmate_busy_and_unknown_panes_are_not_rung test_secondmate_genuine_stall_after_idle_ring_still_alarms test_secondmate_stall_marker_rejects_symlink From 521046c60828aab71e080b2e4d54b513e2787b67 Mon Sep 17 00:00:00 2001 From: Vytautas Stankus Date: Wed, 7 Oct 2026 09:49:15 +0300 Subject: [PATCH 3/5] no-mistakes(document): Document watcher TERM handling for blocked checks and captures --- docs/watcher-continuity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 314a0777bd3..816957c83f2 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -402,7 +402,7 @@ Only the watcher process touches `state/.last-watcher-beat`. No helper process can make a wedged watcher appear healthy. An arm whose own script path sits under a disposable no-mistakes validation checkout (`.no-mistakes/worktrees/`) refuses with the typed failure line before touching any state, because a watcher started there outlives the validation step and keeps writing the real home's state from a checkout about to be deleted. Once per poll the watcher checks that its home, its state directory, and its own code root still exist, and exits with a logged reason when one is gone, scoped to itself alone, so a torn-down temporary home or a discarded checkout never leaves an orphan watcher behind. -The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked pane capture, so both run its EXIT cleanup and stop that capture. +The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked check or pane capture, so both run its EXIT cleanup and stop that read. `watcher_stop_signals` in `bin/fm-watch.sh` owns the signal-handling rationale. The EXIT cleanup bounds its wait for `state/.watcher-down.lock` while persisting recovery state with `FM_WATCHER_CLEANUP_LOCK_BOUND` (default 2 seconds). Only positive decimal integers are accepted, including leading-zero forms such as `08`; empty, non-numeric, and zero values (including `00`) fall back to 2 seconds. @@ -468,6 +468,7 @@ They also prove that a legacy or handoff-phase watcher marker from an absent rep `tests/fm-watch-triage.test.sh` proves TERM stops a watcher blocked inside a poll's pane capture and still releases its lock and records an acknowledgeable stop. It also exercises a single TERM with a live foreign downtime-marker lock holder, retained stale singleton and subsequent arm-style recovery, including decimal `08` and zero `00` cleanup bounds. It checks that a newly appended keyed decision is classified without rereading earlier status bytes, so signal handling can return to the watcher's beacon refresh even when the status history is long. +`tests/fm-wake-queue.test.sh` proves TERM likewise stops a watcher blocked in the drain-ring idle check's pane capture. `tests/fm-watcher-lock.test.sh` covers: From f1ba019ccc1415b75c7a1340cffcbaee62a7cacd Mon Sep 17 00:00:00 2001 From: Vytautas Stankus Date: Wed, 7 Oct 2026 11:00:32 +0300 Subject: [PATCH 4/5] no-mistakes(test): Silence bash 3.2 setpgid race noise from watcher captures --- bin/fm-watch.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index b10c95b5bb4..065500c8ee3 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -2202,8 +2202,11 @@ watcher_capture() { # [expected-label] FM_CAPTURE_OUTPUT=$(mktemp "$STATE/.fm-capture-output.XXXXXX") || return 1 FM_CHECK_SIGNAL_PENDING= trap 'FM_CHECK_SIGNAL_PENDING=1' HUP INT TERM + # The group's stderr is redirected before the fork: bash 3.2 on macOS can + # print a harmless "child setpgid ... Operation not permitted" race from the + # child before the command's own redirections apply. set -m - fm_backend_capture "$@" < /dev/null > "$FM_CAPTURE_OUTPUT" 2>/dev/null & + { fm_backend_capture "$@" < /dev/null > "$FM_CAPTURE_OUTPUT" & } 2>/dev/null FM_ACTIVE_CHECK_PID=$! FM_ACTIVE_CHECK_PGID=$FM_ACTIVE_CHECK_PID set +m From a2751f50bb85652e3c1e86a0c1c812071dc87ca4 Mon Sep 17 00:00:00 2001 From: Vytautas Stankus Date: Wed, 7 Oct 2026 11:58:42 +0300 Subject: [PATCH 5/5] fix(bin): verify the capture group and scope the stop claim to pane reads watcher_capture now confirms its background read leads its own process group, as run_check_capture already does, so watcher_cleanup never relies on a group that set -m failed to create. The comment and continuity doc now say only fm_backend_capture pane reads go through watcher_capture; agent-state and composer-state reads still run inside command substitutions. --- bin/fm-watch.sh | 12 +++++++++--- docs/watcher-continuity.md | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/bin/fm-watch.sh b/bin/fm-watch.sh index 065500c8ee3..16b7845dfb2 100755 --- a/bin/fm-watch.sh +++ b/bin/fm-watch.sh @@ -2145,8 +2145,8 @@ fm_active_check_stop() { # and the signal is consumed, so a stop request could leave this watcher # polling forever while its stopper waits (fixed upstream in bash 5.3). Bash # 3.2 holds HUP and TERM until a running command substitution's child exits, so -# pane captures go through watcher_capture instead. INT keeps its trap because -# bash ignores a direct SIGINT while a child runs. +# fm_backend_capture pane reads go through watcher_capture instead. INT keeps +# its trap because bash ignores a direct SIGINT while a child runs. watcher_stop_signals() { trap - HUP TERM trap 'exit 1' INT @@ -2196,7 +2196,7 @@ fm_capture_output_cleanup() { # so a stop is not held for a blocked read (watcher_stop_signals). The group is # recorded like a check's, so watcher_cleanup stops a read still in flight. watcher_capture() { # [expected-label] - local rc + local rc pgid fm_capture_output_cleanup WATCHER_CAPTURE= FM_CAPTURE_OUTPUT=$(mktemp "$STATE/.fm-capture-output.XXXXXX") || return 1 @@ -2212,6 +2212,12 @@ watcher_capture() { # [expected-label] set +m watcher_stop_signals [ -z "$FM_CHECK_SIGNAL_PENDING" ] || exit 1 + pgid=$(ps -o pgid= -p "$FM_ACTIVE_CHECK_PID" 2>/dev/null | tr -d '[:space:]') + if [ -n "$pgid" ] && [ "$pgid" != "$FM_ACTIVE_CHECK_PGID" ]; then + fm_active_check_stop || true + fm_capture_output_cleanup + return 1 + fi wait "$FM_ACTIVE_CHECK_PID" rc=$? FM_ACTIVE_CHECK_PID= diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 816957c83f2..b82ccf2e712 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -402,7 +402,7 @@ Only the watcher process touches `state/.last-watcher-beat`. No helper process can make a wedged watcher appear healthy. An arm whose own script path sits under a disposable no-mistakes validation checkout (`.no-mistakes/worktrees/`) refuses with the typed failure line before touching any state, because a watcher started there outlives the validation step and keeps writing the real home's state from a checkout about to be deleted. Once per poll the watcher checks that its home, its state directory, and its own code root still exist, and exits with a logged reason when one is gone, scoped to itself alone, so a torn-down temporary home or a discarded checkout never leaves an orphan watcher behind. -The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked check or pane capture, so both run its EXIT cleanup and stop that read. +The watcher uses bash's native fatal handling for HUP and TERM, including during a blocked check or a blocked `fm_backend_capture` pane read, so both run its EXIT cleanup and stop that read. `watcher_stop_signals` in `bin/fm-watch.sh` owns the signal-handling rationale. The EXIT cleanup bounds its wait for `state/.watcher-down.lock` while persisting recovery state with `FM_WATCHER_CLEANUP_LOCK_BOUND` (default 2 seconds). Only positive decimal integers are accepted, including leading-zero forms such as `08`; empty, non-numeric, and zero values (including `00`) fall back to 2 seconds.