Conversation
…e status file A status write that left a paused: or captain-held: wait as declared (a repeated identical line, continuation prose) changed the status file signature the re-surface throttle was scoped to, so the watcher re-surfaced the wait at once and reported its age from the write instead of the declaration. Scope the throttle to the latest status event as last_status_line reads it (verb and reason), and anchor the wait's age on when that declaration was first seen, recorded beside it in .paused-since-<key>. A changed verb or reason is a new declaration and still surfaces on first inspection.
This was referenced Sep 18, 2026
Contributor
Author
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
Writing a status line that does not change a declared wait must not reset its resurface cadence.
The defect: writing a status line on a task with a declared wait (
paused:orcaptain-held:) cancels the long resurface cadence and wakes the supervisor immediately, even when the line does not change what is awaited.Measured on 2026-09-14 on two different tasks: a note appended to a paused task's status log produced a
signal:wake for the write, then astale:wake reading "paused 91s" (and "paused 153s" on the second task, predicted in advance), exactly the seconds elapsed since the write; before the write the same pane read "paused 14776s", matching the expected 14400s cadence.A third occurrence on 2026-09-15 re-asked for confirmation 86 seconds after the confirmation line was written, a factor of 167 below the cadence.
The corroborating observation on the
captain-held:branch: two workers produced a stale notice within a minute of writing theircaptain-held:declaration.The cause, read in bin/fm-watch.sh: handle_paused_stale() builds its declaration scope as
declared:$(fm_wake_signal_sig "$statusf"), a file signature that includes size, identity and mtime (status_observed_signature() in bin/fm-classify-lib.sh), and passes it to resurface_absorbed(); when the scope differs from the throttle marker's content, both the age gate and the cadence gate are skipped and the wake is appended immediately.The age shown is also anchored on the status file's mtime, so any write resets it.
The comment above that code says the throttle bounds the DECLARATION rather than the pane hash; the code keys it on the status file instead.
What is wanted:
paused:andcaptain-held:declarations, each with the no-change write (no wake before the cadence) and the real change (immediate wake).Relation to upstream PR #3605: 3605 treats the symptom by moving the age gate out of the scope-matching
if, so a replacement declared wait is absorbed while young.The upstream maintainer classed 3605 as
new-defaultand escalated it to their own captain, because tests/fm-watch-triage.test.sh specifies current behavior (the throttle "must not suppress the new wait's first inspection merely because its timestamp is still young", asserting one wake) and 3605 rewrites that test to assert zero; the underlying concern, citing VISION.md, is that acaptain-held:wait IS a pending decision, and a genuinely new declaration would stay silent until PAUSE_RESURFACE_SECS.Keying the scope on declaration content addresses that objection: a genuinely new declaration changes the key and still surfaces immediately, while a no-change write does not change the key and does not wake.
This change fixes the cause rather than the symptom and is proposed as a replacement for #3605.
What Changed
bin/fm-watch.sh:stale_wait_declarationnow builds the declared-wait throttle scope from the latest status event itself (verb and reason, aslast_status_linereads it) instead of the status file signature (size, identity, mtime).handle_paused_stale,busy_turn_bound_checkandsurface_nonterminal_staleall use it. A status write that leaves apaused:orcaptain-held:wait unchanged, such as a repeated identical declaration or continuation prose with no status verb, no longer resets thePAUSE_RESURFACE_SECScadence or wakes the supervisor early. A new or changed declaration changes the key, so its first inspection still surfaces right away.declared_wait_agerecords when each declaration was first seen in.paused-since-<key>and keeps that time while the declaration stays the same, so a write that doesn't change the wait doesn't restart the age.Age anchor choice: the time the declaration was first seen, taken from the status file mtime at that sighting, lowered only if the file later shows an older mtime. Reason: the age gates the first re-surface and is what the recheck reports, so it must measure the wait, not the latest write; a declaration first seen after later no-change writes anchors on the newest write, which can only delay its first re-surface, never advance it. The record is removed with the rest of the pause state (
clear_pause_stateinfm-watch.sh,clear_pause_trackinginfm-supervise-daemon.sh).docs/architecture.mdnow describes the declaration-keyed throttle and age.tests/fm-watch-triage.test.sh: new tests for bothpaused:andcaptain-held:. Two writes that keep the wait cause no wake before the cadence. After the cadence, exactly one recheck reports the wait's own age. A changed reason surfaces immediately. The same wait declared word for word after the worker has left it (aworking:line in between, or acaptain-held [key=X]re-held after a resolve) also surfaces on first inspection on the live-agent path; those tests passed on the head before they were added, so the review fix round added tests only and no code change.Relation to #3605
This fixes the cause behind #3605 instead of treating the symptom. #3605 moves the age gate so a young replacement wait gets absorbed, which would keep a genuinely new
captain-held:decision silent untilPAUSE_RESURFACE_SECS. That is the maintainer's objection. Here, a new declaration changes the throttle key and still surfaces on first inspection. The existing assertion that a replacement wait's first inspection is not suppressed keeps passing unchanged. Only writes that leave the wait unchanged are absorbed. I propose this PR as a replacement for #3605.Scope of the fix
stale:/ re-surface wakes caused by a status write that leaves a declaredpaused:orcaptain-held:wait unchanged, and the matching age reset. The measured cases ("paused 91s", "paused 153s", and the re-asked confirmation 86s after it was written) were re-writtenpaused:declaration lines, notnote:lines.note:line written under a declared wait still produces a recheck, because the watcher reads the newest status event as ending the wait (the latest-event reader from fix(bin): read the latest status event so buried declarations and open decisions aren't lost #3753). That class is not changed here and is tracked separately. Thesignal:wake for the status write itself also remains.Risk Assessment
✅ Low: The change swaps the declared-wait throttle key and the age anchor from the status-file signature to the latest declaration text, and every clearing path still holds: the live path writes .paused-, so the loop-top reconciliation clears the throttle on a non-wait event; in away mode, the daemon's reconcile_pause_tracking clears .stale- on the status signal; and clear_pause_state and the daemon both drop the new .paused-since record. The fix round added tests only, which honestly exercise the identical re-declaration sequence.
Testing
I ran the new and neighbouring declared-wait tests from tests/fm-watch-triage.test.sh on HEAD (all pass). I ran the new tests against the base commit's watcher, where the no-change-write test fails with the reported immediate "paused 0s" wake. I also drove the real fm-watch.sh step by step for both paused: and captain-held: on HEAD and on base, and saved the wake-queue transcripts to the evidence directory. On HEAD, no-change writes stay silent until the interval runs out, the re-check age follows the declaration, and a changed declaration surfaces at once. I did not exercise the
note:line case or the wedge-escalation path, as recorded decisions and the intent exclude them. There is no UI, so there are no screenshots. Overall result: go.Evidence: Driver script for the real fm-watch.sh declared-wait sequence
Source: Driver script for the real fm-watch.sh declared-wait sequence
Evidence: HEAD transcript: no-change writes absorbed, re-check age anchored on the declaration, changed declaration wakes at once
Source: HEAD transcript: no-change writes absorbed, re-check age anchored on the declaration, changed declaration wakes at once
Evidence: Base transcript: each no-change write wakes immediately with 'paused 1s' / 'captain-held 0s' (reproduces the defect)
Source: Base transcript: each no-change write wakes immediately with 'paused 1s' / 'captain-held 0s' (reproduces the defect)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 1 issue found → auto-fixed ✅
bin/fm-watch.sh:1369- If a worker declares the same wait again word for word after going back to work, the stale path now stays silent until the cadence runs out. The throttle scope is now justdeclared:<latest event text>. The.paused-resurfaced-<key>marker is only cleared by the per-poll check at fm-watch.sh:2440, and that check requires.paused-$keyto exist. Onlyhandle_paused_stalewrites that file. The live-agent path (surface_nonterminal_stale) and the away-mode busy handoff (busy_turn_bound_check, which writes.stale-$key) never create it.Concrete sequence with a live agent: (1)
paused: waiting on CIgoes stale. The first sight wakes and the marker becomesdeclared:paused: waiting on CI. (2) The worker writesworking: fixing the CI failure. Nothing clears the marker, because.paused-$keyis absent. (3) Within PAUSE_RESURFACE_SECS the worker writespaused: waiting on CIagain for a new run. The pane goes stale,stale_wait_throttledmatches the old marker, and the new wait's first stale inspection is absorbed. Before this change the file signature differed, so it surfaced. The same happens for acaptain-held [key=X]: <same reason>held again after a resolve, and in away mode the daemon's one-shot.stale-$keyis not re-fired either. The status write still produces its ownsignal:wake, so the event isn't completely invisible. But the intent requires that "a genuinely new declaration still surfaces immediately", and this is the maintainer's concern on #3605: a captain-held wait is a pending decision.Possible fixes: when the latest event is not a declared wait, clear a
declared:-prefixed.paused-resurfaced-<key>(and adeclared:.stale-<key>) whatever.paused-$keysays. Or accept identical re-declaration as "the same wait". This is a product choice about what counts as a new declaration, so the author should decide.🔧 Fix applied.
✅ Re-checked - no issues remain.
✅ **Test** - passed
✅ No issues found.
bash tests/.nm-targeted.test.sh(temporary runner, since removed) on HEAD: test_absorbed_replacement_wait_does_not_inherit_the_old_throttle, test_absorbed_wait_cadence_survives_a_status_write_that_keeps_the_wait, test_live_declared_wait_churn_honors_the_resurface_throttle, test_live_identical_wait_declared_again_after_leaving_it_surfaces, test_reheld_captain_call_starts_its_own_resurface_window, test_nonterminal_stale_paused_absorbed_then_resurfaced, test_afk_paused_changed_pane_hands_off_plain_stale: all okSame new tests against base 3eb5b633's bin/ viagit archive: test_absorbed_wait_cadence_survives_a_status_write_that_keeps_the_wait fails with a 'paused 0s' stale wake (reproduces the defect); the re-declaration test passes on base too, so it guards against a regressiondrive-declared-wait-cadence.sh <root>runs real fm-watch.sh rounds (declare, ack, repeated identical line, continuation prose, interval elapsed, changed reason) for paused: and captain-held:, on HEAD and on base, printing the wake queue after each step✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.