fix(watcher): surface stopped stales once per situation, not per poll or hash tick#735
Open
anxsec wants to merge 8 commits into
Open
fix(watcher): surface stopped stales once per situation, not per poll or hash tick#735anxsec wants to merge 8 commits into
anxsec wants to merge 8 commits into
Conversation
… or hash tick A finished ship task awaiting PR merge (crew state done via run-step) was re-surfaced as stale: on effectively every poll: the hash-keyed .stale-* suppressor never held against a live idle agent's ticking status bar, and a paused: log line superseded by run-step precedence re-entered classification and surfacing on every poll through the status_is_paused re-entry branch. - record each SURFACED stale's situation (branch + last status line) in .stale-surfaced-<key>; an identical situation is absorbed across polls, pane-hash ticks, and watcher restarts - clear that record only on positive working evidence (busy pane, active run), so a crew that resumes and stops again, or writes a new status line, still surfaces; the first stop of any situation always surfaces - cache a superseded paused: verdict against the status file's signature (.paused-none-<key>) so the costly crew-state read and the re-surface no longer repeat every poll - keep wedge timing reserved for provably-working absorbs; an already-surfaced stopped stale is no longer re-nagged as a possible wedge Evidence: 2026-07-11 fix-sync-ssh-m4 merge-wait incident (wake records 31-38 in ~7 minutes); reproduced by four new fm-watch-triage tests that fail against the pre-fix watcher.
…g on streaming busy
5 tasks
Owner
|
Automated reminder: thanks for the PR! This branch currently has a merge conflict with the base branch. When you get a chance, please rebase onto (or merge) the latest base branch, resolve the conflict, and push. After that, checks will re-run and the PR will get looked at again. Noted for firstmate#735 at |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Intent
Fix firstmate watcher defect: a finished ship task awaiting PR merge (crew state done) was re-surfaced as stale on every poll and every pane-hash tick; make it surface once per distinct terminal situation while preserving the absorb-only-when-provably-working, paused-recheck, and herdr false-surface contracts, with a reproducing test in tests/
What Changed
paused:log line is cached against the status file's signature so it isn't re-read and re-surfaced each poll, and surfacing re-arms only on positive working evidence (busy or streaming pane, active run) or a new status line. Already-surfaced stopped stales are also no longer re-nagged by the wedge timer, and a dead 0x1f hex-send path was removed.crew_absorb_classinbin/fm-classify-lib.shgained a distinctunreadableverdict for a failed crew-state read, which still surfaces conservatively but is never cached as a negative verdict — so a transient misread cannot disarm a genuine pause's recheck cadence.tests/fm-watch-triage.test.shgained six regression tests for the once-per-situation behavior plus tmux 3.7 marker/locale fixes, the shellcheck cleanliness test now skips when shellcheck is absent, and the architecture/configuration docs were synced to the new stale-surfacing cadence.Risk Assessment
✅ Low: The change is confined to the watcher's stale-triage state machine, the captain-directed fix for the failed-read caching hazard is correctly implemented with a dedicated regression test, six new tests reproduce the original per-poll re-surfacing incident and the re-arm contracts, and the remaining round-1 concerns were explicitly adjudicated by the user.
Testing
Ran the watcher-triage suite (40/40 pass, including the six new regression tests) plus the other touched test files on top of the green baseline sweep, then reproduced the original merge-wait incident with a scripted supervisor re-arm loop: the base-commit watcher woke the supervisor on all 7 rounds while the fixed watcher woke it exactly twice — once for the first terminal situation and once for a genuinely new status line — absorbing the five idle status-bar hash ticks in between. No UI surface is involved (bash watcher daemon), so evidence is CLI transcripts.
Evidence: Before: base-commit watcher re-surfaces the finished merge-wait crew on every status-bar tick (7 wakes / 7 rounds)
=== BEFORE (base b2bf95f) === tick 11:52 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:53 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:54 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:55 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:56 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:57 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:58 + NEW status (failed:) WOKE SUPERVISOR: stale: demo:fm-merge-wait result: 7 supervisor wake(s)Evidence: After: fixed watcher surfaces once per situation, absorbs hash ticks, re-surfaces on a new status line (2 wakes / 7 rounds)
=== AFTER (fm/watch-pause-done-fix) === tick 11:52 (status-bar minute) WOKE SUPERVISOR: stale: demo:fm-merge-wait tick 11:53 (status-bar minute) watcher stayed quiet (absorbed) tick 11:54 (status-bar minute) watcher stayed quiet (absorbed) tick 11:55 (status-bar minute) watcher stayed quiet (absorbed) tick 11:56 (status-bar minute) watcher stayed quiet (absorbed) tick 11:57 (status-bar minute) watcher stayed quiet (absorbed) tick 11:58 + NEW status (failed:) WOKE SUPERVISOR: stale: demo:fm-merge-wait result: 2 supervisor wake(s) triage log: absorbed stale (terminal situation already surfaced): demo:fm-merge-wait (x5)Evidence: Reproduction script simulating the 2026-07-11 merge-wait incident (runnable against any fm-watch.sh)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-watch.sh:415- The new durable markers state/.stale-surfaced-<key> and state/.paused-none-<key> are window-keyed and never removed at teardown (bin/fm-teardown.sh:1139 removes only $ID.* files) or at spawn. Unlike the old pane-hash suppressors, the situation record is keyed on the last status line, which is likely to repeat across tasks (e.g. 'working: implementing'), so a later task reusing the same id/window can have its first stopped stale silently absorbed by the previous task's leftover record - violating the documented 'first stop of any situation always surfaces' guarantee on the stale path (the turn-end signal path remains as backstop). Clear both markers for the task's window during teardown and/or spawn.bin/fm-watch.sh:1018- crew_absorb_class returns 'none' when the fm-crew-state read transiently fails, and the paused re-entry branch now caches that verdict in .paused-none-<key> against the status-file signature. For a genuinely paused crew on a fully static pane, one transient read failure permanently disables the FM_PAUSE_RESURFACE_SECS recheck cadence (pf and .paused-rechecked are removed by surface_nonterminal_stale, sitf suppresses further surfacing, and heartbeat ignores paused: lines), so the declared external wait can rot invisibly until the crew writes a new status line or the pane changes. Pre-fix the misread self-corrected on the next poll. Consider distinguishing a read failure from an authoritative 'none' verdict before writing the negative cache.bin/fm-watch.sh:939- The terminal-stale branch reimplements the situation-record compare/absorb/record pattern inline (compare at line 939, record at line 950, clear at 935) that surface_nonterminal_stale owns for the 'stopped|' prefix. A small shared helper taking the prefix and the per-branch surface actions would keep the two situation-key formats and their clear/record semantics from drifting apart.bin/fm-watch.sh:913- The afk-mode stale branch still dedups once per pane hash, so a finished merge-wait crew with a ticking status bar continues to enqueue one stale record per hash tick for the away-mode daemon. The daemon batches digests so cost is bounded, and this path is untouched by the change; noting it as a known boundary of the once-per-situation fix.🔧 Fix: never cache a failed crew-state read as superseded-pause verdict
1 info still open:
bin/fm-watch.sh:1055- On the new-hash paused path, anunreadablecrew-state read still falls through*)to clear_pause_tracking, momentarily dropping pause tracking on a transient misread. Unlike the fixed repeat-hash branch, nothing negative is cached: the next stable-stale classification re-reads the crew state and handle_paused_stale re-arms the pause, so the behavior is self-correcting and identical to the pre-changenonehandling. Noted only as a boundary of the no-cache fix.✅ **Test** - passed
✅ No issues found.
command -v tmux >/dev/null || { echo "tmux is required for e2e tests" >&2; exit 1; }; tmux -V; rc=0; for t in tests/*.test.sh; do echo "== $t =="; bash "$t" || rc=1; done; exit "$rc"bash tests/fm-watch-triage.test.sh— all 40 tests pass, including the 6 new regression tests (terminal stale not resurfaced per hash tick, superseded-pause done crew not resurfaced per poll, transient crew-state misread not cached, surfaced stopped stale not wedge-nagged, busy and streaming panes re-arm surfacing)bash tests/fm-bootstrap.test.shandbash tests/fm-cd-pretool-check.test.sh— touched test files passBaseline configured command (fulltests/*.test.shsweep under tmux 3.7) already ran greenManual incident reproduction:merge-wait-demo.shsimulates a done-awaiting-merge crew with a ticking status-bar pane and supervisor re-arm loop, run against the base-commit watcher (7 wakes in 7 rounds — bug reproduced) and the fixed watcher (2 wakes: first surface + new failed: status line; 5 ticks absorbed with triage-log confirmation)✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.