Skip to content

fix(watcher): stop false stale and possible-wedge wakes for healthy crew#897

Open
mattwwatson wants to merge 3 commits into
kunchenguid:mainfrom
mattwwatson:up/watcher-false-wedge
Open

fix(watcher): stop false stale and possible-wedge wakes for healthy crew#897
mattwwatson wants to merge 3 commits into
kunchenguid:mainfrom
mattwwatson:up/watcher-false-wedge

Conversation

@mattwwatson

Copy link
Copy Markdown

Intent

Upstream contribution from the mattwwatson/firstmate fork: stop the watcher waking the supervisor for healthy crew. Anchor declared-pause sightings on the pause marker rather than the pane hash so repaints do not reset or re-escalate a pause, require fresh probe evidence before repeating a possible-wedge escalation (doubling window capped by FM_WEDGE_ESCALATE_MAX_SECS), and make crew_absorb_class distinguish unreadable panes from stopped ones. This addresses the same watcher-flooding pain reported in upstream issue #882. New tests/fm-watch-false-wedge.test.sh passes isolated on the upstream base (9/9 locally, plus fm-watch-triage 34/34); it runs in the portable serial lane unless maintainers prefer shard placement.

What Changed

  • Declared-pause sightings are now anchored on the pause marker content instead of the pane hash, so pane repaints no longer reset the sighting or re-surface an already-reported pause for healthy crew.
  • Repeat possible-wedge escalations now require fresh probe evidence before firing again, with a doubling backoff window capped by the new FM_WEDGE_ESCALATE_MAX_SECS knob (validated as numeric, documented in docs/configuration.md and docs/architecture.md).
  • crew_absorb_class in bin/fm-classify-lib.sh now distinguishes unreadable panes from stopped ones, and a new regression suite tests/fm-watch-false-wedge.test.sh (9 cases, registered in the watcher-wake-lock changed-file family and portable serial lane) plus expanded fm-watch-triage tests cover the changed classification paths.

Risk Assessment

✅ Low: All substantiated round-1 findings were fixed exactly as recommended (test-family registration, backoff-cap validation, key-derivation dedup) with no new issues introduced, and the core watcher rework remains intent-conformant with comprehensive regression tests (9 new false-wedge tests plus the updated triage matrix) covering every fail-safe direction.

Testing

Ran the new fm-watch-false-wedge suite (9/9) and fm-watch-triage (34/34) plus the four adjacent watcher/daemon/wake suites (all green), confirmed both suites sit in the portable-serial lane as stated, and captured product-level before/after transcripts by running the base-commit watcher next to the fixed one: the fix collapses six bare stale wakes for a healthy paused crew into one labeled sighting, stops repeat wedge escalations without fresh probe evidence (doubling window shown in the triage log), and distinguishes unreadable crew-state reads from stopped crews — no failures or regressions found. No visual artifact applies: this is a headless CLI watcher, so its supervisor-facing wake queue and triage-log transcripts are the end-user surface.

Evidence: Symptom A before/after: 6 bare stale wakes on base vs 1 labeled declared-pause wake after the fix
### Symptom A — six polls of a healthy paused crew whose idle pane repaints

== BEFORE (base 82a7943): supervisor wake queue after 6 polls of a healthy PAUSED crew (pane repaints each poll) ==
1784786759	1	stale	test:fm-paused-live	stale: test:fm-paused-live
1784786764	2	stale	test:fm-paused-live	stale: test:fm-paused-live
1784786768	3	stale	test:fm-paused-live	stale: test:fm-paused-live
1784786773	4	stale	test:fm-paused-live	stale: test:fm-paused-live
1784786779	5	stale	test:fm-paused-live	stale: test:fm-paused-live
1784786785	6	stale	test:fm-paused-live	stale: test:fm-paused-live

-- watcher wake output (BEFORE (base 82a7943)) --
stale: test:fm-paused-live
stale: test:fm-paused-live
stale: test:fm-paused-live
stale: test:fm-paused-live
stale: test:fm-paused-live
stale: test:fm-paused-live

-- last triage decisions (BEFORE (base 82a7943)) --

== AFTER (this change): supervisor wake queue after 6 polls of a healthy PAUSED crew (pane repaints each poll) ==
1784786790	1	stale	test:fm-paused-live	stale: test:fm-paused-live (declared pause, agent still live - surfaced once, then rechecked on the long pause cadence not as a wedge; confirm the wait is real)

-- watcher wake output (AFTER (this change)) --
stale: test:fm-paused-live (declared pause, agent still live - surfaced once, then rechecked on the long pause cadence not as a wedge; confirm the wait is real)

-- last triage decisions (AFTER (this change)) --
[2026-07-23T16:06:31+1000] absorbed stale (paused, awaiting external, age 7206s): test:fm-paused-live
[2026-07-23T16:06:34+1000] absorbed stale (paused, awaiting external, age 7209s): test:fm-paused-live
[2026-07-23T16:06:35+1000] absorbed stale (paused, awaiting external, age 7210s): test:fm-paused-live
[2026-07-23T16:06:36+1000] absorbed stale (paused, awaiting external, age 7211s): test:fm-paused-live
[2026-07-23T16:06:39+1000] absorbed stale (paused, awaiting external, age 7214s): test:fm-paused-live
Evidence: Symptom B before/after: base re-escalates on elapsed time alone; fix absorbs the unchanged repeat with a doubled escalation window
### Symptom B — long quiet single-command step, provably working the whole time

== BEFORE (base 82a7943): quiet-but-working pane, 240s threshold ==
-- round 1 (245s quiet): first escalation must land --
stale: test:fm-suite (idle 246s, possible wedge, escalation 1)
-- round 2 (300s quiet, SAME pane, probe evidence unchanged): --
stale: test:fm-suite (idle 301s, possible wedge, escalation 2)
-- triage decisions --
[2026-07-23T16:06:41+1000] absorbed non-terminal stale (provably working): test:fm-suite
-- wake queue --
1784786803	1	stale	test:fm-suite	stale: test:fm-suite (idle 246s, possible wedge, escalation 1)
1784786804	2	stale	test:fm-suite	stale: test:fm-suite (idle 301s, possible wedge, escalation 2)

== AFTER (this change): quiet-but-working pane, 240s threshold ==
-- round 1 (245s quiet): first escalation must land --
stale: test:fm-suite (idle 246s, possible wedge, escalation 1)
-- round 2 (300s quiet, SAME pane, probe evidence unchanged): --
(no wake — absorbed)
-- triage decisions --
[2026-07-23T16:06:46+1000] absorbed non-terminal stale (provably working): test:fm-suite
[2026-07-23T16:06:50+1000] absorbed non-terminal stale (unchanged working verdict, quiet 300s, next escalation window 480s): test:fm-suite
-- wake queue --
1784786808	1	stale	test:fm-suite	stale: test:fm-suite (idle 246s, possible wedge, escalation 1)
Evidence: crew_absorb_class before/after: failed read now classified 'unreadable', not folded into 'none'

reader exits non-zero (worktree unreadable): BEFORE (base 82a7943): none AFTER (this change) : unreadable reader returns 'state: parked' (crew really stopped): BEFORE (base 82a7943): none AFTER (this change) : none

### crew_absorb_class: a FAILED crew-state read vs a genuinely STOPPED crew

reader exits non-zero (worktree unreadable):
  BEFORE (base 82a7943): none
  AFTER  (this change) : unreadable

reader returns 'state: parked' (crew really stopped):
  BEFORE (base 82a7943): none
  AFTER  (this change) : none

Before, an unreadable pane and a stopped crew collapsed into the same class, so
a transient read failure was treated as evidence the crew stopped. After, the
watcher can tell 'no information' apart from 'stopped'.

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 4 issues found → auto-fixed ✅
  • ⚠️ bin/fm-test-run.sh:136 - The new regression test tests/fm-watch-false-wedge.test.sh is not registered in family_for_basename() in bin/fm-test-run.sh, so it falls into the 'unclassified' family. The changed-file map routes bin/fm-watch* and bin/fm-classify-lib.sh to the 'watcher-wake-lock' family only, so future fm-test-run.sh --changed runs after edits to the exact files this test guards will silently skip it (it only runs via --all or the portable-serial lane). Add fm-watch-false-wedge.test.sh to the watcher-wake-lock family list (bin/fm-test-run.sh:136 area) so watcher changes re-run their regression cover.
  • ⚠️ bin/fm-watch.sh:158 - WEDGE_ESCALATE_MAX_SECS is not validated as numeric, while WEDGE_UNREADABLE_SURFACE_COUNT one line below gets a case guard. If FM_WEDGE_ESCALATE_MAX_SECS is set empty or non-numeric, [ "$secs" -gt "$WEDGE_ESCALATE_MAX_SECS" ] in wedge_escalate_interval errors and evaluates false, silently removing the cap: the repeat-escalation backoff then doubles unchecked up to STALE_ESCALATE_SECS*2^12 (~11 days with defaults), effectively silencing repeat wedge escalations — the missed-alarm mode this change elsewhere guards against. Apply the same numeric-fallback case guard used for WEDGE_UNREADABLE_SURFACE_COUNT.
  • ℹ️ bin/fm-watch.sh:1092 - The main loop still derives the per-window marker key with the inline triple substitution (key=${w//:/}; key=${key////}; key=${key//./_}) at lines 1092-1094 even though this commit introduces state_key() as the documented single owner of that derivation and converts every other site (including line 1104 a few lines below, which recomputes the same key via state_key). Folding this last site into state_key() would complete the dedup and remove the double computation.
  • ℹ️ bin/fm-watch.sh:557 - Upgrade transition note: pre-existing .paused-resurfaced-* markers written by the old code contain an epoch timestamp, while pause_declared_class now compares the marker content against the current pause line. On the first watcher run after upgrade, every already-surfaced live-agent pause will mismatch and be surfaced once more before re-anchoring on the pause instance. One extra labeled wake per paused window, self-healing; acceptable, but worth knowing when validating the rollout.

🔧 Fix: register wedge test in changed map, guard cap, dedup key
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • bash tests/fm-watch-false-wedge.test.sh (9/9 ok, exit 0)
  • bash tests/fm-watch-triage.test.sh (34/34 ok, exit 0)
  • bin/fm-test-run.sh --list --lane portable-serial confirms both suites are registered in the portable serial lane
  • bash tests/fm-watch-checkpoint.test.sh, tests/fm-watcher-lock.test.sh, tests/fm-daemon.test.sh, tests/fm-wake-queue.test.sh (4/27/97/12 ok, all exit 0) covering the other changed watcher/daemon paths
  • Manual e2e before/after: ran base-commit (82a7943) and fixed fm-watch.sh against a simulated healthy paused crew with a repainting pane for 6 rounds, capturing wake queues and triage logs (symptom A evidence)
  • Manual e2e before/after: primed a quiet provably-working stale pane past FM_STALE_ESCALATE_SECS and re-ran with unchanged probe evidence, showing base re-escalates while the fix absorbs with a doubled next-escalation window (symptom B evidence)
  • Manual before/after of crew_absorb_class with a failing crew-state reader vs a parked crew, base lib vs fixed lib
  • Verified FM_WEDGE_ESCALATE_MAX_SECS is documented in docs/configuration.md and wired in bin/fm-watch.sh
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

The watcher could wake the supervisor for crew that were healthy: a
declared pause was re-sighted by pane hash, so any pane repaint reset
or re-escalated it, and repeat possible-wedge escalations fired on a
timer without any new evidence. Anchor pause sightings on the declared
pause marker instead of the pane hash, require fresh evidence (a probe
re-read) before re-escalating a wedge, cap the doubling escalation
window with FM_WEDGE_ESCALATE_MAX_SECS, and teach crew_absorb_class to
distinguish an unreadable pane from a stopped one so each is classified
and absorbed correctly.
@mattwwatson
mattwwatson force-pushed the up/watcher-false-wedge branch from 315bff3 to 4175bf8 Compare July 23, 2026 21:11
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.

1 participant