Skip to content

fix(bin): auto-acknowledge no-longer-actionable wakes - #70

Merged
ironerumi merged 8 commits into
mainfrom
fm/fork-wake-noise-45-46-48
Oct 3, 2026
Merged

ironerumi merged 8 commits into
mainfrom
fm/fork-wake-noise-45-46-48

Conversation

@ironerumi

Copy link
Copy Markdown
Owner

Intent

The queued fork work was approved to go ahead; this is its second ship. It builds the fork's wake-noise fixes, because upstream shows no sign of merging the tracked fixes (kunchenguid#1692 with PR kunchenguid#2801, and kunchenguid#4228 with PR kunchenguid#4859):

One branch, one PR, closing all three. Background evidence: data/fm-wake-noise-origin-scout/report.md in the firstmate home (sections R1, R2 and the per-issue sections).

What Changed

  • Add Claude Stop ring-time reclassification for qualifying queued turn-end and plain stale wakes whose crews are provably working, using generation-bound drain acknowledgements.
  • Re-arm after successful acknowledgements without a model turn, preserve unsafe or non-qualifying presentations for handling, and enforce a configurable per-cycle cap.
  • Document the contract and add regression coverage for acknowledgement, hand-back, ordering, failure, and continuity behavior.

Risk Assessment

🚨 High: The change leaves required wake-noise behavior opt-in and adapter-specific, and contains a reachable timing bug that defeats immediate re-ring on a new inbox state transition.

Testing

Live disposable tmux labs confirmed qualifying wakes are acknowledged without a model turn, while unproven wakes remain queued; focused auto-ack and Claude auto-arm tests also passed.

  • Live validation: ⚠️ inconclusive - 2 of 3 scenarios driven live against the product
Scenario Result Live Evidence
Queue a turn-end wake after the crew is working again; it is acknowledged without a model turn. ✅ pass live live-wake-autoack.log
Queue a turn-end wake without positive current-work proof; it remains queued and is not acknowledged. ✅ pass live live-wake-autoack-actionable.log
Present unsafe or mixed wake state; the drain hands it back and preserves the queue. ⏸️ untested no The prior payload only recorded a focused executable regression suite using disposable state fixtures and explicitly did not drive this scenario against the live product.
Evidence: Live qualifying wake auto-ack

Source: Live qualifying wake auto-ack

fixture=private tmux lab; endpoint=primary:1; watcher-live=1
/var/folders/l8/tyd57dc53v3938ttkwmw5kgr0000gp/T//fm-lab.CHJc9c/run-live.sh
01M3YXK15WXANA89PGK34FJR5Q  HEAD
❯ /var/folders/l8/tyd57dc53v3938ttkwmw5kgr0000gp/T//fm-lab.CHJc9c/run-live.sh
crew-state-before: state: working · source: pane · harness busy (fm-spawn)
queue-before:
1790995529	1	signal	t1.turn-ended	signal: live turn ended
autoack-rc: 0
autoack-carry-bytes: 0
autoack-stderr-bytes: 0
queue-after-bytes: 0
recovery-marker: acked:handling:82660.1790995529.hhxxUH
01M3YXK15WXANA89PGK34FJR5Q  HEAD
❯
Evidence: Live fail-closed actionable wake

Source: Live fail-closed actionable wake

fixture=private tmux lab; endpoint=primary:1; watcher-live=1
/var/folders/l8/tyd57dc53v3938ttkwmw5kgr0000gp/T//fm-lab.TMPbmj/run-live.sh
01M3YXK15WXANA89PGK34FJR5Q  HEAD
❯ /var/folders/l8/tyd57dc53v3938ttkwmw5kgr0000gp/T//fm-lab.TMPbmj/run-live.sh
crew-state-before: state: unknown · source: none · no current-state source avail
able
queue-before-bytes: 58
autoack-rc: 1
autoack-carry-bytes: 0
autoack-stderr-bytes: 0
queue-after-bytes: 58
ack-marker-present: yes
01M3YXK15WXANA89PGK34FJR5Q  HEAD
❯
- Outcome: ⚠️ 1 warning across 1 run (9m15s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 5 issues (2 errors, 3 warnings)
  • 🚨 bin/fm-wake-autoack.sh:71 - The required behavior is not enabled by default: this helper exits unless config/wake-autoack exists, so eligible turn-ended or plain-stale wakes still consume a model turn in normal homes. Even when enabled, the changed documentation limits the seam to Claude and leaves Pi, omp, OpenCode, and the away daemon unchanged (docs/configuration.md:604-605,608,619), contradicting the required behavior that ack-only wakes must not consume turns and non-actionable watcher wakes must not be surfaced. This is an unapproved containment/scope reduction; confirm that policy or enforce classification at the shared wake-delivery boundary.
  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.

🔧 Fix applied.
2 errors still open:

  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.
  • 🚨 bin/fm-claude-stop-autoarm.sh:448 - The required criterion is “an ack-only wake must not consume a model turn,” but after 25 successful auto-ack cycles (AUTOACK_MAX, lines 399-401) this condition stops calling auto-ack and falls through to the rewake banner. A 26th qualifying turn-ended or plain-stale wake therefore consumes a Claude turn; docs/configuration.md:616 explicitly documents this behavior. Confirm that this deliberate cap is authorized; otherwise remove it or prevent it from ringing an ack-only queue.

🔧 Fix applied.
3 issues (1 error, 2 warnings) still open:

  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.
  • ⚠️ bin/fm-task-inbox-lib.sh:510 - Round 2's in-flight marker write suppresses all failures and returns success. If the state directory is temporarily unwritable after a ring, a busy handling turn leaves no .inflight; the next poll can re-ring or escalate while that turn is still active. The callers also ignore this result at bin/fm-watch.sh:552 and bin/fm-watch.sh:582. Propagate the write failure through the existing watcher error path.
  • ⚠️ bin/fm-wake-autoack.sh:136 - Simplification: the new verbatim durable .wake-autoack.log copy and FM_WAKE_AUTOACK_LOG_LINES retention option (line 139) are not required by the three stated wake-noise criteria; they add persistent duplicated wake data and log-rotation behavior. Remove this component and its related banner/docs/tests unless an audit trail is explicitly authorized.

🔧 Fix applied.
4 issues (2 errors, 2 warnings) still open:

  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.
  • ⚠️ bin/fm-task-inbox-lib.sh:510 - Round 2's in-flight marker write suppresses all failures and returns success. If the state directory is temporarily unwritable after a ring, a busy handling turn leaves no .inflight; the next poll can re-ring or escalate while that turn is still active. The callers also ignore this result at bin/fm-watch.sh:552 and bin/fm-watch.sh:582. Propagate the write failure through the existing watcher error path.
  • 🚨 bin/fm-task-inbox-lib.sh:451 - The Round 3 transition fix only detects a changed .ring-state. If record A was escalated for a dead/missing pane, .escalated is written without .ring-state; after A is handled and fresh record B arrives before the next poll, lines 451-468 retain the old marker and age-gate B, delaying its first ring by the grace period instead of ringing on the state transition. Treat a marker naming a former oldest record as a transition and clear it before the age gate; the added test at tests/fm-task-inbox-inflight.test.sh:87 covers only the .ring-state path.
  • ⚠️ bin/fm-watch.sh:529 - The Round 3 fix introduced an unwritable-.inflight stale path that queues a wake but never records .escalated. With the concrete failure fixture used at tests/fm-task-inbox-inflight.test.sh:164, every subsequent busy poll repeats the stale wake and grows the durable queue. The same missing deduplication remains in the sibling .ring-state failure path at bin/fm-watch.sh:611-614. After queuing the bookkeeping failure, mark the current record escalated through the shared marker-owning path so it surfaces once.

🔧 Fix applied.
6 issues (3 errors, 3 warnings) still open:

  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.
  • ⚠️ bin/fm-task-inbox-lib.sh:510 - Round 2's in-flight marker write suppresses all failures and returns success. If the state directory is temporarily unwritable after a ring, a busy handling turn leaves no .inflight; the next poll can re-ring or escalate while that turn is still active. The callers also ignore this result at bin/fm-watch.sh:552 and bin/fm-watch.sh:582. Propagate the write failure through the existing watcher error path.
  • 🚨 bin/fm-task-inbox-lib.sh:451 - The Round 3 transition fix only detects a changed .ring-state. If record A was escalated for a dead/missing pane, .escalated is written without .ring-state; after A is handled and fresh record B arrives before the next poll, lines 451-468 retain the old marker and age-gate B, delaying its first ring by the grace period instead of ringing on the state transition. Treat a marker naming a former oldest record as a transition and clear it before the age gate; the added test at tests/fm-task-inbox-inflight.test.sh:87 covers only the .ring-state path.
  • ⚠️ bin/fm-watch.sh:529 - The Round 3 fix introduced an unwritable-.inflight stale path that queues a wake but never records .escalated. With the concrete failure fixture used at tests/fm-task-inbox-inflight.test.sh:164, every subsequent busy poll repeats the stale wake and grows the durable queue. The same missing deduplication remains in the sibling .ring-state failure path at bin/fm-watch.sh:611-614. After queuing the bookkeeping failure, mark the current record escalated through the shared marker-owning path so it surfaces once.
  • 🚨 bin/fm-claude-stop-autoarm.sh:573 - The hand-back path truncates AUTOACK_CARRY to 4000 bytes even though the drain has already committed unread status presentation. If a qualifying wake has over 4KB of status or decision text, the next model turn receives only a prefix and cannot reproduce the committed lines, silently losing actionable data. Preserve the complete carry or make truncation replayable; this contradicts the verbatim/no-loss contract in docs/configuration.md:246-247.
  • ⚠️ bin/fm-wake-autoack.sh:83 - The auto-ack classifier scans every queue row and computes max_seq globally, but it runs as the main actor while fm-wake-drain.sh excludes live branch-granted rows. For example, a main working turn-end at seq 1 plus a branch-granted working turn-end at seq 2 makes the main drain return ack-through 1; the global max of 2 forces a needless model wake. A non-ack-class branch row similarly blocks main auto-ack. Restrict classification and cutoff calculation to the main actor's unreserved rows.

🔧 Fix applied.
5 issues (2 errors, 3 warnings) still open:

  • 🚨 bin/fm-task-inbox-lib.sh:444 - A fresh new oldest record is returned as quiet before the new-oldest transition is detected at lines 454-461. For example, after record 1 has rung and gained an in-flight sighting, moving it to handled and writing fresh record 2 causes the next watcher poll to wait one grace interval instead of ringing immediately, violating the state-transition requirement. The added test backdates record 2 at tests/fm-task-inbox-inflight.test.sh:91, so it does not cover this real sequence. Detect and reset the transition before the age gate and ring immediately for it.
  • ⚠️ bin/fm-task-inbox-lib.sh:510 - Round 2's in-flight marker write suppresses all failures and returns success. If the state directory is temporarily unwritable after a ring, a busy handling turn leaves no .inflight; the next poll can re-ring or escalate while that turn is still active. The callers also ignore this result at bin/fm-watch.sh:552 and bin/fm-watch.sh:582. Propagate the write failure through the existing watcher error path.
  • 🚨 bin/fm-task-inbox-lib.sh:451 - The Round 3 transition fix only detects a changed .ring-state. If record A was escalated for a dead/missing pane, .escalated is written without .ring-state; after A is handled and fresh record B arrives before the next poll, lines 451-468 retain the old marker and age-gate B, delaying its first ring by the grace period instead of ringing on the state transition. Treat a marker naming a former oldest record as a transition and clear it before the age gate; the added test at tests/fm-task-inbox-inflight.test.sh:87 covers only the .ring-state path.
  • ⚠️ bin/fm-watch.sh:529 - The Round 3 fix introduced an unwritable-.inflight stale path that queues a wake but never records .escalated. With the concrete failure fixture used at tests/fm-task-inbox-inflight.test.sh:164, every subsequent busy poll repeats the stale wake and grows the durable queue. The same missing deduplication remains in the sibling .ring-state failure path at bin/fm-watch.sh:611-614. After queuing the bookkeeping failure, mark the current record escalated through the shared marker-owning path so it surfaces once.
  • ⚠️ bin/fm-wake-autoack.sh:83 - The auto-ack classifier scans every queue row and computes max_seq globally, but it runs as the main actor while fm-wake-drain.sh excludes live branch-granted rows. For example, a main working turn-end at seq 1 plus a branch-granted working turn-end at seq 2 makes the main drain return ack-through 1; the global max of 2 forces a needless model wake. A non-ack-class branch row similarly blocks main auto-ack. Restrict classification and cutoff calculation to the main actor's unreserved rows.
⚠️ **Test** - 1 warning
  • ⚠️ live validation verdict: inconclusive (2 of 3 scenarios were driven live against the product); untested: Present unsafe or mixed wake state; the drain hands it back and preserves the queue.
  • Live validation: ⚠️ inconclusive - 2 of 3 scenarios driven live against the product
Scenario Result Live Evidence
Queue a turn-end wake after the crew is working again; it is acknowledged without a model turn. ✅ pass live live-wake-autoack.log
Queue a turn-end wake without positive current-work proof; it remains queued and is not acknowledged. ✅ pass live live-wake-autoack-actionable.log
Present unsafe or mixed wake state; the drain hands it back and preserves the queue. ⏸️ untested no The prior payload only recorded a focused executable regression suite using disposable state fixtures and explicitly did not drive this scenario against the live product.
  • bash tests/fm-wake-autoack.test.sh
  • bash tests/fm-claude-stop-autoarm.test.sh
  • Live private tmux-lab qualifying wake acknowledgment
  • Live private tmux-lab fail-closed actionable wake check
✅ **Document** - passed

✅ No issues found.

🔧 **Lint** - 1 issue found → auto-fixed ✅
  • ⚠️ linter found issues (exit code 1)

🔧 Fix applied.
✅ Re-checked - no issues remain.

✅ **Push** - passed

✅ No issues found.

…x re-ring from in-flight handling

Adds bin/fm-wake-autoack.sh behind config/wake-autoack: when an arm closes
with an actionable wake, the Claude Stop hook re-asks crew_is_provably_working
for turn-end and plain stale rows and, only when every queued row is cleared,
runs the drain's own generation-bound acknowledgement and re-arms instead of
ringing. Anything else rings as before; a presentation the drain consumed is
carried into the banner and every acknowledged row is logged.

The steering-inbox ladder records busy sightings after a ring and paces the
next ring or escalation from the latest sighting, so a handling turn in flight
no longer draws a re-ring at the first idle poll.

Closes #45, closes #46, closes #48
@ironerumi

Copy link
Copy Markdown
Owner Author

Scope record for this PR

Issues closed here: #45 and #46 only.
Issue #48 (re-ring on a state transition) is not part of this PR.
Its inbox-ladder change was removed during validation and moves to a separate redesign.
#48 must stay open; the branch's first commit message still carries a closes #48 line from before that removal and should not close it.

Premise check against the post-sync HEAD

Issue Already covered Built here
#45 The away daemon already acknowledges deterministically (bin/fm-supervise-daemon.sh handle_durable_wakes). The attended Claude Stop hook had no equivalent. bin/fm-wake-autoack.sh plus a small seam in bin/fm-claude-stop-autoarm.sh runs the drain's own generation-bound acknowledgement and re-arms instead of ringing.
#46 The watcher already classifies each row when it surfaces it, and config/turnend-churn-absorb absorbs benign turn-ends. Re-classification at ring time: a queued turn-end or plain first-sight stale row whose crew is provably working again is acknowledged. Status, needs-decision, check, heartbeat, and every enriched stale form always ring.

Behavior notes

  • The auto-ack has no opt-in or opt-out switch (hard cutover); actionable wakes still ring.
  • A presentation the drain consumed but could not safely acknowledge is carried whole into the rewake banner.
  • The 25-consecutive-acknowledgement cap is a runaway-loop backstop; at the cap the banner leads with the cap being hit, the count acknowledged, and the repeating wake sources.
  • The keep-warm self-wake is not a queued wake and is untouched.

Scope boundaries

  • Only the Claude Stop hook has the seam. The Pi, omp, and OpenCode adapters are out of scope for this PR: this fleet runs Claude primaries and secondmates only, and the away daemon already acknowledges deterministically.
  • Accepted for this PR: with a live Pi supervision-branch grant, a branch-granted row can make the auto-ack cutoff disagree with the drain's, so the script hands back and one extra model turn is spent. It never loses a wake.

Test evidence

The pipeline's live validation verdict was inconclusive for one scenario: an unsafe or mixed wake state being handed back with the queue preserved.
That safety case is proven deterministically by tests/fm-wake-autoack.test.sh (unread-status hand-back, a row arriving between classification and drain, a failed acknowledgement, mixed queues, status and needs-decision rows) and the Stop-hook seam tests in tests/fm-claude-stop-autoarm.test.sh (hand-back carried into the banner, bounded acknowledgements, a superseded owner never acknowledging).
The test step was approved on that basis; the run records it as an exception with no operator reason supplied.

@ironerumi
ironerumi merged commit 170cb5c into main Oct 3, 2026
19 checks passed
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