Skip to content

fix(bin): keep the watcher beacon truthful and bound every herdr RPC - #2789

Open
Zuschaua wants to merge 9 commits into
kunchenguid:mainfrom
Zuschaua:fm/fm-watcher-stale-beacon
Open

fix(bin): keep the watcher beacon truthful and bound every herdr RPC#2789
Zuschaua wants to merge 9 commits into
kunchenguid:mainfrom
Zuschaua:fm/fm-watcher-stale-beacon

Conversation

@Zuschaua

Copy link
Copy Markdown

Intent

The developer needed a liveness-reporting defect fixed in firstmate's watcher on the herdr backend: the watcher kept absorbing wakes but stopped touching its state/.last-watcher-beat beacon, so guards declared WATCHER DOWN/FAILED and turn-end auto-arms spawned duplicate cycles, causing restart churn without any real wake loss. They asked for the failure path to be causally pinned in bin/fm-watch.sh / bin/backends/herdr.sh (specifically the broken-pipe printf errors around herdr.sh:3052-3053, errexit interactions, and early continue/return after backend errors), and then fixed so a cycle that is alive keeps beating even when a backend read or write fails, with backend pipe errors guarded, bounded-logged, and non-fatal. They also wanted the separately observed herdr read hangs (a 120s+ fm-peek, a stalled gh+teardown chain) investigated rather than assumed related — fixed or bounded if they shared the root cause, otherwise noted as a distinct finding in the PR body — plus machine-sleep resilience so a post-sleep watcher either resumes beating or exits cleanly instead of holding the lock with a stale beat. Constraints: load the firstmate-coding-guidelines skill before editing shared bin/ material and follow repo style (one sentence per line in docs, shellcheck-clean scripts, colocated tests/ regression tests covering beacon-touched-despite-write-failure and loop-survives-EPIPE, no agent co-author, maintainer-verification evidence); update only the doc that owns each changed contract (docs/turnend-guard.md, watcher-continuity.md, script headers); work only in the disposable worktree, never restart or touch any live watcher, never pkill, and never use this home's state/. Definition of done was the full no-mistakes pipeline driven to a PR with CI green, with the developer later insisting explicitly that a green local gate was not delivery and that the pipeline, push, and PR had to be completed.

What Changed

  • bin/fm-watch.sh refreshes state/.last-watcher-beat through a new watcher_beat helper that fires at the top of every cycle and between the bounded backend reads inside a cycle, so a slow backend can no longer age the beacon past FM_GUARD_GRACE while the loop is still absorbing wakes; a beacon the watcher cannot write now ends that cycle instead of leaving a lock-holder whose beat can never resume. The shared loops whose wall time scales with the fleet (fm_pending_reply_tick, signal_crew_provably_working) reach the same beat through the new FM_LIVENESS_BEAT_HOOK / fm_liveness_beat contract in bin/fm-classify-lib.sh, invoked as a command word plus arguments and never evaluated as shell source text.
  • Every herdr control-socket RPC in bin/backends/herdr.sh now runs under FM_BACKEND_HERDR_CLI_TIMEOUT (default 20s, 0 disables) via bin/fm-timeout-lib.sh, with only the backgrounded server launch exempt. The server-start poll cuts short only on consecutive RPC-bound hits — instant cold-socket failures keep it polling — and is additionally capped by a wall-clock budget so no mix of outcomes can multiply the per-RPC bound across the poll; event-FIFO reads are bounded by the reader's budget plus FM_BACKEND_HERDR_EVENT_READ_SLACK and classify a wedged reader's path unusable. The separately reported 120s+ fm-peek and stalled teardown chain share this wedged-server root cause and are bounded by the same change; bin/fm-herdr-ci-cleanup.sh still calls herdr directly and remains outside the bound.
  • fm_run_timed now reports 128+signal for a signal-killed command and 127 for one it cannot exec, so a killed pane read is never mistaken for a successful capture of an empty pane, and the capability probe matches the ~220KB schema with case instead of a grep pipeline that emitted broken-pipe noise under ignored SIGPIPE. New tests/fm-watch-beacon.test.sh plus RPC-bound, poll, reader, and signal-status cases in tests/fm-backend-herdr.test.sh cover the above, with docs/watcher-continuity.md, docs/herdr-backend.md, and docs/configuration.md recording the contracts and the two new knobs.

Risk Assessment

✅ Low: Every concern raised across the prior rounds is now fixed with discriminating regression tests, and the remaining worst case for the one function that multiplies the per-RPC bound is roughly 50s against a 300s guard grace, leaving no substantiated outstanding risk despite the change touching every herdr call site.

Testing

I ran the intent-targeted suites (fm-watch-beacon, fm-backend-herdr, fm-watch-triage, fm-session-start) - all green - and then reproduced the reported failure end-to-end rather than relying on unit passes: a real watcher plus real fm-guard.sh against a herdr backend whose every call hangs, once from the base commit and once from the fix. On base the watcher stays ALIVE while its beacon ages past grace and the guard prints the WATCHER DOWN banner and keeps reminding; on the fix the beacon never exceeds 16s of a 20s grace, the guard stays silent for the whole run, and the watcher emits no wake and no stderr. I also showed the operator-facing read hang (fm-peek 150s+ on base, 18s with a typed error on the fix) and the machine-sleep shape (SIGSTOP past grace reads down, SIGCONT resumes the beat and the guard goes silent again). Timings are scaled (grace 20s vs prod 300s, RPC bound 5s vs prod 20s) so a run finishes in a minute while preserving the bound-inside-grace ratio the fix depends on. This is a CLI/state-file change with no rendered UI surface, so the artifacts are CLI transcripts of the actual guard banner and peek behavior rather than screenshots. One caveat: the fixed pipeline-free capability probe is silent and reports capable, but I could not reproduce the base-side "printf: write error: Broken pipe" noise locally (it needs a write still in flight when grep exits), so that artifact confirms the fixed behavior only. The single test failure seen, tests/fm-pending-reply.test.sh's concurrent-resolution case, reproduces 4/4 on an unmodified base tree and is unrelated to the added beat hook. Transient scratch trees were removed and the worktree is clean.

Evidence: Evidence index (what each artifact proves, scaling caveats)
# Watcher liveness beacon under a wedged herdr backend - end-to-end evidence

Base `5b6d0fb` vs fix `bdbfd91`, both driven as real processes (`bin/fm-watch.sh`,
`bin/fm-guard.sh`, `bin/fm-peek.sh`) against a herdr binary that is WEDGED: every
control-socket call hangs (the post-machine-sleep server shape from 2026-08-22).

Timings are scaled so a run finishes in ~1 minute: guard grace 20s (prod default
300s) and RPC bound 5s (prod default 20s). The ratio the fix relies on - per-call
bound and mid-cycle beats well inside grace - is what production runs at too.

| file | what it shows |
| --- | --- |
| `beacon-guard-before.txt` | base: watcher ALIVE the whole time, beacon ages past grace, `fm-guard.sh` prints **WATCHER DOWN - SUPERVISION IS OFF** at t=20s and keeps reminding - the reported false alarm that drove turn-end auto-arms to spawn duplicate cycles |
| `beacon-guard-after.txt` | fix: same wedged backend, beacon never exceeds 16s of the 20s grace, guard stays silent for the whole run, watcher emits no wake and no stderr |
| `sleep-resilience.txt` | machine-sleep shape: watcher SIGSTOPped for 25s (past grace) reads down while frozen, and after SIGCONT resumes beating - guard back to silent within 8s, lock holder unchanged |
| `peek-hang.txt` | operator-facing read: `bin/fm-peek.sh tsk` against the wedged server hangs past 150s on base (killed), and returns in 18s with a typed error on the fix |
| `beacon-guard-demo.sh`, `sleep-resilience-demo.sh`, `peek-hang-demo.sh`, `pipe-noise-demo.sh` | the drivers, re-runnable against any tree: `<script> <tree> <label> <scratch-home>` |

Automated coverage run for this change (all green):
`tests/fm-watch-beacon.test.sh`, `tests/fm-backend-herdr.test.sh`,
`tests/fm-watch-triage.test.sh`, `tests/fm-session-start.test.sh`.
Evidence: BEFORE (base 5b6d0fb): live watcher, stale beacon, guard raises WATCHER DOWN

=== BEFORE (base 5b6d0fb): watcher against a wedged herdr backend === t= 5s watcher=ALIVE beacon-age=5s guard: (silent) supervision healthy t= 15s watcher=ALIVE beacon-age=15s guard: (silent) supervision healthy t= 20s watcher=ALIVE beacon-age=21s guard: WATCHER DOWN banner | ● WATCHER DOWN - SUPERVISION IS OFF | ● 1 task(s) in flight, but no watcher has a fresh beacon (last beat: 20s ago, grace 20s). t= 30s watcher=ALIVE beacon-age=31s guard: watcher still down reminder t= 60s watcher=ALIVE beacon-age=62s guard: watcher still down reminder

=== BEFORE (base 5b6d0fb): watcher from /var/folders/7_/czjdt35s5p1czs3j9wxfg01w0000gn/T//fm-beacon-demo.8fkUvG/base-tree against a wedged herdr backend ===
watcher pid 40367 started (SIGPIPE ignored, as under a Node arm chain)
t=  5s  watcher=ALIVE  beacon-age=5s       guard: (silent) supervision healthy
t= 10s  watcher=ALIVE  beacon-age=10s      guard: (silent) supervision healthy
t= 15s  watcher=ALIVE  beacon-age=15s      guard: (silent) supervision healthy
t= 20s  watcher=ALIVE  beacon-age=21s      guard: WATCHER DOWN banner
        | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
        | ●  WATCHER DOWN - SUPERVISION IS OFF
        | ●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: 20s ago, grace 20s).
        | ●  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.
        | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
t= 25s  watcher=ALIVE  beacon-age=26s      guard: watcher still down reminder
t= 30s  watcher=ALIVE  beacon-age=31s      guard: watcher still down reminder
t= 35s  watcher=ALIVE  beacon-age=36s      guard: watcher still down reminder
t= 40s  watcher=ALIVE  beacon-age=41s      guard: watcher still down reminder
t= 45s  watcher=ALIVE  beacon-age=47s      guard: watcher still down reminder
t= 50s  watcher=ALIVE  beacon-age=52s      guard: watcher still down reminder
t= 55s  watcher=ALIVE  beacon-age=57s      guard: watcher still down reminder
t= 60s  watcher=ALIVE  beacon-age=62s      guard: watcher still down reminder
=== BEFORE (base 5b6d0fb): done ===
Evidence: AFTER (fix bdbfd91): same wedged backend, beacon keeps advancing, guard silent

=== AFTER (fix bdbfd91): watcher against a wedged herdr backend === t= 15s watcher=ALIVE beacon-age=14s guard: (silent) supervision healthy t= 20s watcher=ALIVE beacon-age=0s guard: (silent) supervision healthy t= 35s watcher=ALIVE beacon-age=16s guard: (silent) supervision healthy t= 40s watcher=ALIVE beacon-age=3s guard: (silent) supervision healthy t= 60s watcher=ALIVE beacon-age=5s guard: (silent) supervision healthy === AFTER (fix bdbfd91): done === (watcher stdout 0 bytes: no spurious wake)

=== AFTER (fix bdbfd91): watcher from /var/folders/7_/czjdt35s5p1czs3j9wxfg01w0000gn/T//fm-beacon-demo.8fkUvG/head-tree against a wedged herdr backend ===
watcher pid 78125 started (SIGPIPE ignored, as under a Node arm chain)
t=  5s  watcher=ALIVE  beacon-age=4s       guard: (silent) supervision healthy
t= 10s  watcher=ALIVE  beacon-age=9s       guard: (silent) supervision healthy
t= 15s  watcher=ALIVE  beacon-age=14s      guard: (silent) supervision healthy
t= 20s  watcher=ALIVE  beacon-age=0s       guard: (silent) supervision healthy
t= 25s  watcher=ALIVE  beacon-age=6s       guard: (silent) supervision healthy
t= 30s  watcher=ALIVE  beacon-age=11s      guard: (silent) supervision healthy
t= 35s  watcher=ALIVE  beacon-age=16s      guard: (silent) supervision healthy
t= 40s  watcher=ALIVE  beacon-age=3s       guard: (silent) supervision healthy
t= 45s  watcher=ALIVE  beacon-age=8s       guard: (silent) supervision healthy
t= 50s  watcher=ALIVE  beacon-age=14s      guard: (silent) supervision healthy
t= 55s  watcher=ALIVE  beacon-age=0s       guard: (silent) supervision healthy
t= 60s  watcher=ALIVE  beacon-age=5s       guard: (silent) supervision healthy
=== AFTER (fix bdbfd91): done ===
Evidence: Machine-sleep shape: frozen past grace, then resumes beating after SIGCONT

running normally watcher=ALIVE beacon-age=7s lock-holder=84842 guard: (silent) supervision healthy >>> machine sleep: SIGSTOP the watcher for 25s (grace is 20s) frozen, past grace watcher=ALIVE beacon-age=33s lock-holder=84842 guard: WATCHER DOWN banner >>> wake: SIGCONT 8s after wake watcher=ALIVE beacon-age=7s lock-holder=84842 guard: (silent) supervision healthy 24s after wake watcher=ALIVE beacon-age=5s lock-holder=84842 guard: (silent) supervision healthy

watcher pid 84842 started against a wedged herdr backend
running normally           watcher=ALIVE beacon-age=7s      lock-holder=84842  guard: (silent) supervision healthy
>>> machine sleep: SIGSTOP the watcher for 25s (grace is 20s)
frozen, past grace         watcher=ALIVE beacon-age=33s     lock-holder=84842  guard: WATCHER DOWN banner
>>> wake: SIGCONT
8s after wake              watcher=ALIVE beacon-age=7s      lock-holder=84842  guard: (silent) supervision healthy
16s after wake             watcher=ALIVE beacon-age=15s     lock-holder=84842  guard: (silent) supervision healthy
24s after wake             watcher=ALIVE beacon-age=5s      lock-holder=84842  guard: (silent) supervision healthy
Evidence: Operator read path: fm-peek hangs 150s+ on base, bounded to 18s with a typed error on the fix

=== BEFORE (base 5b6d0fb): bin/fm-peek.sh tsk against a wedged herdr server === RESULT: STILL HANGING after 150s - killed (this is the reported symptom) === AFTER (fix bdbfd91): bin/fm-peek.sh tsk against a wedged herdr server === RESULT: returned after 18s with exit 1 | error: herdr server for session 'sess' did not report running within its 10s poll budget (gave up after 12s)

=== BEFORE (base 5b6d0fb): bin/fm-peek.sh tsk against a wedged herdr server ===
RESULT: STILL HANGING after 150s - killed (this is the reported symptom)
operator sees on stderr:
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  | ●  WATCHER DOWN - SUPERVISION IS OFF
  | ●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 20s).
  | ●  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.
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

=== AFTER (fix bdbfd91): bin/fm-peek.sh tsk against a wedged herdr server ===
RESULT: returned after 18s with exit 1
operator sees on stderr:
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  | ●  WATCHER DOWN - SUPERVISION IS OFF
  | ●  1 task(s) in flight, but no watcher has a fresh beacon (last beat: never, grace 20s).
  | ●  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.
  | ●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  | error: herdr server for session 'sess' did not report running within its 10s poll budget (gave up after 12s)
Evidence: Re-runnable demo drivers (beacon/guard, machine sleep, peek hang, pipe noise)
#!/usr/bin/env bash
# End-to-end reproduction of the 2026-08-22 incident and its fix.
#
# A real bin/fm-watch.sh runs against a herdr-backed task window whose herdr
# binary is WEDGED (every control-socket call hangs, the post-machine-sleep
# server shape). The watcher process stays alive and keeps absorbing its cycle.
# Every 5s a real bin/fm-guard.sh - the surface an operator/agent actually sees
# - is run against that home and its verdict recorded.
#
# Scaled down so one run finishes in ~90s: guard grace 20s (prod default 300s)
# and RPC bound 5s (prod default 20s). The ratio the fix depends on - per-call
# bound and mid-cycle beats well inside grace - is preserved.
set -u
TREE=$1; LABEL=$2; HOME_DIR=$3; SAMPLES=${4:-12}
STATE="$HOME_DIR/state"; FAKEBIN="$HOME_DIR/fakebin"
mkdir -p "$STATE" "$FAKEBIN"

cat > "$FAKEBIN/herdr" <<'SH'
#!/usr/bin/env bash
exec sleep 60
SH
chmod +x "$FAKEBIN/herdr"

printf 'window=sess:wG:pQ\nbackend=herdr\nkind=ship\n' > "$STATE/tsk.meta"

echo "=== $LABEL: watcher from $TREE against a wedged herdr backend ==="
PATH="$FAKEBIN:$PATH" FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" \
  FM_ROOT_OVERRIDE="$TREE" FM_POLL=1 FM_SIGNAL_GRACE=1 \
  FM_CHECK_INTERVAL=999999 FM_HEARTBEAT=999999 FM_BACKEND_HERDR_EVENTS_FORCE=0 \
  FM_BACKEND_HERDR_CLI_TIMEOUT=5 \
  bash -c 'trap "" PIPE; exec "$1"' _ "$TREE/bin/fm-watch.sh" \
  > "$HOME_DIR/watcher.out" 2> "$HOME_DIR/watcher.err" &
WPID=$!
echo "watcher pid $WPID started (SIGPIPE ignored, as under a Node arm chain)"

age_of() {
  local f=$1 m now
  [ -f "$f" ] || { printf 'absent'; return; }
  if [ "$(uname)" = Darwin ]; then m=$(stat -f %m "$f"); else m=$(stat -c %Y "$f"); fi
  now=$(date +%s); printf '%ss' "$((now - m))"
}

i=0
while [ "$i" -lt "$SAMPLES" ]; do
  sleep 5
  i=$((i + 1))
  alive=dead; kill -0 "$WPID" 2>/dev/null && alive=ALIVE
  guard=$(FM_HOME="$HOME_DIR" FM_STATE_OVERRIDE="$STATE" FM_ROOT_OVERRIDE="$TREE" \
    FM_SUPERVISION_MODEL=persistent FM_GUARD_GRACE=20 \
    "$TREE/bin/fm-guard.sh" 2>&1)
  verdict='(silent) supervision healthy'
  case "$guard" in
    *'WATCHER DOWN'*) verdict='WATCHER DOWN banner' ;;
    *'watcher still down'*) verdict='watcher still down reminder' ;;
    '') verdict='(silent) supervision healthy' ;;
    *) verdict="other: $(printf '%s' "$guard" | head -1)" ;;
  esac
  printf 't=%3ds  watcher=%s  beacon-age=%-7s  guard: %s\n' \
    "$((i * 5))" "$alive" "$(age_of "$STATE/.last-watcher-beat")" "$verdict"
  case "$guard" in *'WATCHER DOWN'*)
    printf '%s\n' "$guard" | sed 's/^/        | /'
  ;; esac
done

kill "$WPID" 2>/dev/null || true
wait "$WPID" 2>/dev/null || true
echo "=== $LABEL: done ==="
echo
Evidence: Capability probe under ignored SIGPIPE (fixed tree silent and capable; base noise not reproducible locally)
=== BEFORE (base 5b6d0fb): fm_backend_herdr_events_capable with SIGPIPE ignored ===
capability verdict: rc=0 (0 = event push usable)
stderr the operator sees: (empty)

=== AFTER (fix bdbfd91): fm_backend_herdr_events_capable with SIGPIPE ignored ===
capability verdict: rc=0 (0 = event push usable)
stderr the operator sees: (empty)


NOTE: this probe exercises the capability read the fix rewrote (pipeline-free
`case` matching instead of `printf | grep -Fq`). The fixed tree is silent and
still reports the event path capable. The BASE tree did not emit the reported
"printf: write error: Broken pipe" here either: that leak needs the producer to
still be writing when grep exits on its first match, which this local
single-write fake did not hit even at a 4MB schema. So this artifact confirms
the fixed behavior only; it is not a before/after reproduction.
- Outcome: ⚠️ 1 info across 1 run (25m51s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/backends/herdr.sh:1525 - The new consecutive-failure bail in fm_backend_herdr_server_ensure treats any non-zero herdr status --json the same, but its stated rationale ("each such failure already costs a full RPC bound") only holds for a bound-hit (rc 124). A status that fails instantly - the shape a cold control socket produces while the just-launched server is still binding - costs nothing, so three of them plus two 0.5s sleeps abort the ensure after roughly one second, where the pre-change loop polled the full seq 1 20 / 10s budget. Failing sequence: herdr server is backgrounded, the socket is not listening yet, the first three poll RPCs return connect-refused non-zero, server_ensure returns 1, and every fm_backend_herdr_target_ready consumer on that cycle (capture, agent_alive, spawn/container_ensure) fails even though the server comes up 2s later. Gate the counter on the bound actually being hit (rc 124) or bail on elapsed wall time instead of a raw failure count, which is what the comment's own reasoning describes.
  • ℹ️ bin/fm-classify-lib.sh:55 - fm_liveness_beat runs eval &#34;$FM_LIVENESS_BEAT_HOOK&#34;, evaluating an environment-derived string as shell code in every consumer of fm-classify-lib.sh and fm-pending-reply-lib.sh (fm-crew-state.sh, fm-brief.sh, fm-inactive-reconcile.sh, fm-fleet-snapshot.sh, fm-captain-hold.sh, ...), none of which previously evaluated env content. Direct invocation - $FM_LIVENESS_BEAT_HOOK unquoted - keeps the documented "a command" contract including word-split arguments, without the eval. It also hardens the watcher-continuity.md claim that no helper process can freshen the beacon: if a hook name is already marked for export in the inherited environment, fm-watch.sh's reassignment inherits that export attribute and children would evaluate it rather than treat it as an unknown name.
  • ℹ️ bin/backends/herdr.sh:3456 - The EOF-versus-timeout discriminator uses kill -0 &#34;$reader_pid&#34; immediately after a failed read. A child that has just exited closes its FIFO write end before the parent is notified, so a still-unreaped zombie answers kill -0 successfully and a clean full-budget wait (reader exit 0, contract rc 1) would be reported as rc 2 "event path unusable", which makes the caller sleep and count toward the runtime-disable threshold. I probed this 40 times with both a subshell and an external python writer on this host and it never fired - Bash's SIGCHLD reaping consistently wins the race - so this is a residual hazard rather than an observed defect, and the existing clean-timeout test does not exercise it because its fake reader exits long before the drain loop. A sentinel line from the reader, or comparing elapsed time against read_bound, would remove the dependence on reap timing.
  • ℹ️ bin/fm-herdr-ci-cleanup.sh:39 - bin/fm-herdr-ci-cleanup.sh calls herdr session list/stop/delete directly rather than through the adapter, so it is the one remaining herdr caller the new FM_BACKEND_HERDR_CLI_TIMEOUT bound does not cover; a wedged server hangs it exactly as it hung fm-peek before this change. It is off the watcher path and outside the stated scope of this fix, so noting it rather than treating it as a gap in the delivered work.

🔧 Fix: gate server-ensure bail on RPC bound hits, drop hook eval
1 warning still open:

  • ⚠️ bin/backends/herdr.sh:1539 - Narrowing the bail to rc 124 (correct) removed the only thing capping this poll's total wall time, because bound_hits resets to 0 on every non-124 outcome - including a successful status that answers running:false. Concrete shape, and the realistic one for a server still binding: a herdr whose status alternates between a fast running:false and a bound hit never accumulates three consecutive 124s, so the loop runs all 20 attempts; roughly 13 of them burn the full FM_BACKEND_HERDR_CLI_TIMEOUT (20s default) = 260s, plus 20 x 0.5s of sleeps = 270s, plus up to 20s for the pre-loop status call = about 290s. Two consequences. (1) The error message at this line still asserts "did not report running within 10s" after what can be a near-five-minute wait, so the operator-facing diagnostic is now false. (2) This whole span sits inside a single gap between two watcher_beat calls in fm-watch.sh's per-window loop (watcher_beat -> window_kind -> fm_backend_capture -> target_ready -> server_ensure -> || continue), leaving about 10s of headroom against the FM_GUARD_GRACE=300s that this entire change exists to stay inside; raising FM_BACKEND_HERDR_CLI_TIMEOUT above ~28s or lowering FM_GUARD_GRACE re-opens the exact stale-beacon failure. It also means a partially responsive server can still block fm-peek and teardown chains for ~290s, versus the 120s+ hang the change set out to bound. server_ensure is the single place that multiplies the per-RPC bound, so capping the whole poll on elapsed wall time (bail once cumulative elapsed exceeds a budget, independent of which failures occurred) is the earliest shared boundary that makes the per-iteration bound hold for any failure mix - and it makes the message at this line honest again. This is not the count-versus-rc124 question already settled in the prior round; it is the missing ceiling on the loop as a whole.

🔧 Fix: cap server-ensure poll with a wall-clock budget
1 info still open:

  • ℹ️ bin/backends/herdr.sh:1541 - With the wall-clock deadline in place, the consecutive-bound-hit bail can only fire when FM_BACKEND_HERDR_CLI_TIMEOUT is below roughly 4.75s. Reaching bound_hits -ge 3 requires attempts 1 and 2 to pass the deadline check, i.e. 2t + 0.5 < 10; at the stock default of 20s the very first bound hit already puts elapsed at 20 >= the 10s deadline, so the loop breaks and the generic "poll budget" message is what an operator sees - the "hit the 20s RPC bound 3 times in a row" message is unreachable in the default configuration. The outcome is identical and correct either way (return 1, bounded well inside the grace), so this is defense in depth rather than a defect, and the covering test pins the timeout at 1s precisely so the bail stays exercised. Worth knowing when reading docs/herdr-backend.md's "three sides" sentence: the consecutive-bound-hit side describes a path that only manifests under a small configured bound, not at stock settings. Noting the layering, not asking for a change - the bail was added deliberately in an earlier round and removing it would be churn.
⚠️ **Test** - 1 info
  • ℹ️ tests/fm-pending-reply.test.sh:487 - tests/fm-pending-reply.test.sh:487 test_concurrent_resolution_closes_escalation_once is flaky under load: it failed once in three HEAD runs and 4 of 4 runs on an unmodified base-5b6d0fb tree, so it is a pre-existing race in the test's 8-way concurrent fm_pending_reply_try_resolve fan-out, not a regression from the fm_liveness_beat call added to fm_pending_reply_tick. Flagging it only so a red CI run on this suite is not misattributed to this change.
  • bin/fm-test-run.sh tests/fm-watch-beacon.test.sh - 7/7 pass (beacon advances under failing and hanging backends, unwritable beacon ends the cycle, FM_LIVENESS_BEAT_HOOK per-record/per-task, word-split not evaluated, unset hook is a no-op)
  • bin/fm-test-run.sh tests/fm-backend-herdr.test.sh - pass, including the new RPC-bound, signal-status (137/127), pipe-noise-free capability probe, hung-reader, and three server-ensure poll cases
  • bin/fm-test-run.sh tests/fm-watch-triage.test.sh - pass (always-on triage plus the beacon-stays-fresh case)
  • bin/fm-test-run.sh tests/fm-session-start.test.sh - pass (covers the fm_run_timed signal/exec status contract changed in bin/fm-timeout-lib.sh)
  • bin/fm-test-run.sh tests/fm-pending-reply.test.sh x3 on HEAD (1 intermittent failure) and x4 on a base-5b6d0fb tree (4 failures) to classify test_concurrent_resolution_closes_escalation_once as a pre-existing flake
  • Manual E2E: beacon-guard-demo.sh drives a real bin/fm-watch.sh with SIGPIPE ignored against a herdr fake whose every call hangs, sampling bin/fm-guard.sh every 5s - run once from a base 5b6d0fb tree and once from the fix bdbfd91 tree
  • Manual E2E: sleep-resilience-demo.sh freezes the fixed watcher with SIGSTOP for 25s (past the 20s grace) and resumes it with SIGCONT, sampling guard verdict, beacon age, and lock holder
  • Manual E2E: peek-hang-demo.sh runs bin/fm-peek.sh tsk against the wedged herdr fake on both trees, wall-clock timed
  • Manual probe: pipe-noise-demo.sh runs fm_backend_herdr_events_capable under trap &#34;&#34; PIPE with a large fake schema on both trees
⚠️ **Document** - 1 info
  • ℹ️ AGENTS.md:131 - Judgment call, deliberately left unedited: AGENTS.md:131 ("touched every poll") and bin/fm-supervision-lib.sh:8 ("touched every poll cycle") still describe the beacon only as a per-cycle touch, while the watcher now also refreshes it mid-cycle and writes a cycle counter as content. Both statements remain true as written, and the beacon contract's owner (docs/watcher-continuity.md, plus the watcher_beat header comment for the mtime-versus-content split) states the full behavior, so expanding either pointer would duplicate the owner rather than fix a wrong fact.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

…rdr backend

A wedged herdr server (seen after a machine-sleep window) accepts
connections but responds after minutes or never. Every herdr
control-socket RPC was unbounded, so the watcher's per-window reads
stretched one supervision cycle far past FM_GUARD_GRACE while the loop
was still absorbing wakes correctly; the beacon, touched only at the
top of each cycle, went stale, and every guard declared a live watcher
down, triggering arm churn. The same unbounded calls hung fm-peek and
teardown chains through the capture path's server-ensure poll.

- Bound every herdr RPC with FM_BACKEND_HERDR_CLI_TIMEOUT (default 20s,
  timeout/gtimeout/perl-alarm); only the long-lived backgrounded server
  launch bypasses the bound.
- Bail out of the server-ensure poll after three consecutive failed
  status reads instead of multiplying the bound by the whole poll budget.
- Bound the event-wait FIFO reads to the reader budget plus slack and
  classify a reader wedged past it unusable (rc 2), discriminating
  timeout from EOF by reader liveness (Bash 3.2 returns 1 for both);
  escalate a stopped reader's kill to KILL before reaping.
- Refresh the beacon between backend reads (per window, per check, after
  the signal grace); the cycle counter in the beacon's content still
  advances only at cycle top so cycle-boundary consumers stay exact.
  Exit the cycle when the beacon cannot be written so no lock-holder
  survives with a beat that can never resume.
- Replace the capability probe's printf-into-grep schema checks with
  pipeline-free case matches, removing the broken-pipe noise seen in arm
  outputs under ignored SIGPIPE.

The watcher graph runs without errexit, so the printf EPIPE failures
were never fatal; the beacon staleness came entirely from cycle
elongation by unbounded RPCs.
@greptile-apps

greptile-apps Bot commented Aug 22, 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(ci): raise the no-mistakes f..." | Re-trigger Greptile

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Corrective for the #2786 class: a living watcher must keep beating, and a Herdr RPC must be bounded so a hang cannot hold the lock with a stale beacon. Aligns (never hide a failure behind a living lock; restart is a non-event).

Not merge-eligible yet: no Actions checks on this HEAD besides Greptile. Fork CI must run including no-mistakes. Overlap with watch/classify and #2786.

Waiting on CI — not waiting on the captain.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Corrective watcher/herdr honesty fix. Relates to #2786 (watcher stays alive but the beacon goes stale while the lock is held): the watcher now refreshes state/.last-watcher-beat between bounded backend work, and every herdr control-socket RPC sits under FM_BACKEND_HERDR_CLI_TIMEOUT (default 20s) except the long-lived server launch.

VISION (per rule):

  • One captain, one interface — aligns. A live watcher reported dead was a hidden failure; the beacon is now truthful, and broken-pipe schema noise is gone.
  • Authority is explicit — aligns. The timeout is a fail-closed bound on existing RPCs, not a new grant. 0 still disables it.
  • Scripts own the mechanics — aligns. Bounds, beacon ownership, and the liveness hook are scripts. FM_LIVENESS_BEAT_HOOK is invoked as a command word (not eval).
  • A restart is a non-event — aligns. An unwritable beacon ends the cycle so the arm chain can replace the lock-holder; an evicted watcher cannot freshen a successor's beacon.
  • Delegation with a spine — aligns. Guard/arm decide replacement from durable mtime, not recollection.
  • The fleet outlives any vendor — aligns. Timeout policy is herdr-local; mechanics live in shared fm-timeout-lib.sh.
  • Scope — aligns. Supervision command layer.

Security: no. The beat hook is word-split, not evaluated as shell, and is not exported.

Overlap: bin/fm-watch.sh also moves in the pause-cadence set and #2776/#2781/#2784. bin/backends/herdr.sh also moves in hold-pair #2637 and #2730/#2655 (fm-timeout-lib.sh too). Function-level watcher_beat / herdr RPC bound look unique. Do not land on top of #2637 without a rebase.

CI: first-time fork workflows approved after diff review (CI 32565306990, Require no-mistakes 32565306997). Greptile is green. Portable CI / no-mistakes have not finished. Body has the git push no-mistakes signature but no no-mistakes-pipeline-attestation:v1 block — the check will refuse until that structured attestation is in the body for this HEAD.

Ahead 8, behind 0, mergeable, mergeStateStatus UNSTABLE.

Waiting on the author for attestation and green no-mistakes — not waiting on the captain.

Merge-eligible: NO. Captain-flag NOW: NO.

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