fix(bin): absorb routine attended supervision wakes in bash - #74
Merged
Conversation
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
Reduce actual LLM turns spent on routine attended supervision wakes, implementing the captain's 2026-08-24 directive that healthy waiting remain pure code and not silently spend tokens. In attended mode, absorb only recognized nonterminal routine status shapes such as working and resolved echoes when every referenced recorded worker has positive live/busy current-work proof; keep terminal done, needs-decision, blocked, and failed statuses, authenticated check results, stale or wedge suspicion, dead or unreadable endpoints, secondmate status replies, malformed configuration, and unparseable signals as immediate fail-toward-model wakes. Keep one shared status-shape policy in bin/fm-classify-lib.sh for both the watcher and AFK daemon, with a conservative default-on home-local config/attended-routine-status-absorb knob plus exact on/off environment override. An absorbed status append must be recorded before signal suppression, survive crashes, and appear exactly once in the existing UNREAD STATUS section on the next genuine model turn. Absorption must not reset or bypass stale detection, and regression coverage must prove absorption, no-loss presentation, a subsequently quiet worker still wedge-escalates, and terminal statuses still wake. Add a short authoritative docs update and colocated behavioral tests, then ship through no-mistakes without merging. The PR body must report the evidence replay estimate: 16 of the review's defensible 60 primary-wake lower bound, or 26.7%, were routine-only attended signal wakes expected to be absorbed, with the caveat that live endpoint proof is evaluated at event time. The PR body must distinguish existing upstream work from this addition and link it: issue https://github.com/withally/firstmate/issues/2356 already proposes quiet attended supervision via the AFK bash-triage pattern; issue https://github.com/withally/firstmate/issues/2374 and PR https://github.com/withally/firstmate/pull/2877 preserve positive-work evidence for turn-end absorption; issue https://github.com/withally/firstmate/issues/2227 quantifies paid benign watcher turns; PR https://github.com/withally/firstmate/pull/2801 adds bounded context to actionable wakes; current architecture/configuration already provide attended bash classification, signal-grace coalescing, durable presentation cursors, and batched AFK digests. This change adds conservative recognized-shape gating for attended routine status appends plus identity-and-byte-bound delayed presentation receipts, without introducing a second supervisor or classification dialect.
What Changed
bin/fm-classify-lib.shgains one shared attended absorption policy —signal_reason_is_routine_nonterminal(recognized nonterminal shapes only:working,resolved,captain-held, the paused verb,note:, and bare turn-end markers),attended_routine_status_absorb_enabled(default-onconfig/attended-routine-status-absorb, exacton/offviaFM_ATTENDED_ROUTINE_STATUS_ABSORB, any other value or unreadable/symlinked file disables it), andattended_signal_is_absorbable, which additionally requires no captain-relevant status and positive live/busy current-work proof for every referenced crew.bin/fm-watch.shuses that policy in place of its old no-verb triage;bin/fm-supervise-daemon.shreuses the same shape boundary as its syntax gate, escalating unrecognized shapes and treating a signal path teardown already removed as self-handled. Terminaldone/needs-decision/blocked/failed, authenticated checks, secondmate status replies, dead or unreadable endpoints, malformed config, and unparseable signals all still wake immediately, and absorption does not touch stale/wedge timers.state/.status-absorbed-<id>receipt binds the status file's identity and byte endpoint and is written before the watcher advances signal suppression, so a crash replays rather than loses the line. Newstatus_lines_between_offsetsletsscan_unread_surface_snapshotprint the absorbed span exactly once in the existingUNREAD STATUSsection on the next genuine turn; cursor commit retires the receipt only after those bytes print, and a receipt that no longer describes the current file (identity changed, endpoint past EOF, malformed) is dropped and read as absent so it can never suppress the drain's no-loss sections.status_retire_presentation_taskretires the receipt on teardown, andbin/fm-wake-drain.shnow reports a failed status presentation on stderr instead of silently swallowing it.docs/architecture.md,docs/configuration.md,AGENTS.md) describe the recognized-shape policy, the config knob, the receipt, and the state/config files. Tests cover config parsing, absorption plus exactly-once presentation on a later unrelated turn, livenote:absorption, terminal statuses still waking over a live worker, absorb-off surfacing the routine signal, unparseable live status surfacing, daemon escalation on unrecognized shape, vanished-path self-handling, and unverifiable/out-of-range receipts still presenting every section.Evidence replay estimate: 16 of the review's defensible 60 primary-wake lower bound — 26.7% — were routine-only attended signal wakes expected to be absorbed. Caveat: live endpoint proof is evaluated at event time, so the realized rate depends on worker liveness at each wake.
Relation to existing upstream work. This is additive to, not a replacement for, work already in flight: issue #2356 proposes quiet attended supervision via the AFK bash-triage pattern; issue #2374 and PR #2877 preserve positive-work evidence for turn-end absorption; issue #2227 quantifies paid benign watcher turns; PR #2801 adds bounded context to actionable wakes. Current architecture/configuration already provide attended bash classification, signal-grace coalescing, durable presentation cursors, and batched AFK digests. What this change adds on top is conservative recognized-shape gating for attended routine status appends plus identity-and-byte-bound delayed presentation receipts — no second supervisor and no second classification dialect.
Risk Assessment
✅ Low: The absorption gate, the identity-and-byte-bound receipt lifecycle, and the no-loss presentation contract all held under direct tracing — absorbed bytes are recorded before suppression, surfaced exactly once, and not replayed — and every prior-round fix is genuinely present, leaving only informational duplication and error-reporting nits.
Testing
Ran the three targeted suites covering this change (fm-watch-triage, fm-wake-drain-unread-status, fm-daemon) — all 186 assertions pass with no failures — and then drove the real watcher and wake-drain binaries in a scripted end-user scenario to capture a CLI transcript showing a routine live-worker status being absorbed with no LLM turn, its durable receipt on disk, the absorbed line appearing exactly once in UNREAD STATUS on the next genuine drain after the watcher was SIGKILLed, no replay on the following drain, and immediate wakes preserved for terminal
done:, unparseable status text, and the home-local off switch. This change is CLI/supervision-layer only with no rendered UI surface, so the reviewer-visible evidence is the CLI transcript rather than a screenshot.Evidence: Attended routine-absorb end-to-end CLI transcript
Source: Attended routine-absorb end-to-end CLI transcript
=== SCENARIO A: routine 'working:' status from a provably-live worker (attended, absorb default on) === worker wrote: working: compiling step 2 live-work proof: state: working · source: run-step · validating (running) watcher: still blocking -> NO LLM turn spent watcher stdout: '' durable wake queue: '' triage log: [...] absorbed benign signal: .../state/task.status delayed-presentation receipt on disk (survives a crash): .../state/.status-absorbed-task -> 16777234:108892383\t26 (the watcher process was then SIGKILLed - the drain below runs against a crashed watcher) === SCENARIO A cont.: the next GENUINE model turn (unrelated check wake) drains === | 1787582390\t1\tcheck\tunrelated.check.sh\tcheck: unrelated genuine model turn | UNREAD STATUS (new since last drain, not re-printed after this presentation): | task working: compiling step 2 receipt after presentation: removed === SCENARIO A cont.: a second drain must NOT replay the same line === | 1787582390\t1\tcheck\tunrelated.check.sh\tcheck: unrelated genuine model turn === SCENARIO B: terminal 'done:' status, same live worker -> must still wake immediately === watcher: EXITED -> model turn taken (correct) === SCENARIO C: unparseable status line, same live worker -> must still wake === watcher: EXITED -> model turn taken (correct) === SCENARIO D: home-local off switch (config/attended-routine-status-absorb = off) === watcher: EXITED -> model turn taken (absorption disabled, correct)Evidence: Driver script used for the end-to-end transcript
Source: Driver script used for the end-to-end transcript
Evidence: Targeted suite output (fm-watch-triage, fm-wake-drain-unread-status, fm-daemon)
Source: Targeted suite output (fm-watch-triage, fm-wake-drain-unread-status, fm-daemon)
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
bin/fm-classify-lib.sh:1322-note:status lines are no longer absorbable and now cost a turn in BOTH modes, which regresses the very metric this change targets. Concrete trace:state/task.statuslast line isnote: captain said use REST not RPCand the crew is provably working (state: working · source: run-step). Before this change the attended watcher ransignal_reason_is_actionable(false —noteis not captain-relevant and does not match FM_CAPTAIN_RE) thensignal_crew_provably_working(true) and absorbed the wake. Nowsignal_reason_is_routine_nonterminalonly acceptsworking|resolved|captain-held|paused, sonotereturns 1,attended_signal_is_absorbablefails, and the watcher enqueues and wakes the model. In away mode the same verb hits the new early return in bin/fm-supervise-daemon.sh:346 and escalates asunrecognized signal shapewhere it previously classifiedself|routine signal.noteis a first-class informational verb elsewhere in this same library (status_line_is_unread_surfaceat bin/fm-classify-lib.sh:1147 treats it as the canonical UNREAD STATUS shape), so this looks like an omission rather than a deliberate wake, and the intent's wake list (terminal done/needs-decision/blocked/failed, checks, stale, dead endpoints, secondmate replies, malformed config, unparseable signals) does not include notes. Confirm whethernote:should join the routine set (its bytes would still be surfaced once via the receipt path) or intentionally wake.bin/fm-classify-lib.sh:912- An unverifiable absorbed-receipt permanently and silently disables the entire status presentation, including the no-loss surfaces this change is supposed to protect.status_absorbed_receipt_endpointreturns 1 on identity mismatch, malformed content, or an out-of-range endpoint;status_acknowledge_presented_snapshot(line 912) andscan_unread_surface_snapshot(line 1196) both propagate that with|| return 1,print_status_sectionspropagates it toprint_status_presentation, and bin/fm-wake-drain.sh:350 / bin/fm-wake-drain.sh:402 call it as(print_status_presentation) || true. Failure trace: tasktis absorbed (receiptdev:inode\t120written), thenstate/t.statusis replaced out of band with a new inode (id reuse without going throughstatus_retire_presentation_task, a restore/sync, or a manual repair). Every later drain: ack fails at line 912 -> UNREAD STATUS, OPEN DECISIONS and RECORD DIVERGENCE all print nothing, the presentation cursor never commits, and the exit status is swallowed, so a buriedneeds-decisionstops surfacing with no error anywhere. Nothing clears the receipt on this path (commit at line 964 is never reached), so it does not self-heal — unlike the pre-existing analogous case, wherestatus_presentation_cursor_offsetresets to offset 0 on an identity mismatch. Same class:status_lines_between_offsetsreturns 1 for a missing/symlinked status file at line 1211, where the previousstatus_new_lines_since_cursorreturned 0. Suggest distinguishing 'receipt no longer applies to this file' (identity mismatch / endpoint > size -> drop the receipt and continue; the absorbed bytes are gone with the old inode anyway) from a transient read error, and making any remaining hard failure visible rather than swallowed.bin/fm-supervise-daemon.sh:346- The new away-mode gate escalates signals whose referenced files no longer exist.signal_reason_is_routine_nonterminalrequires[ -f "$f" ]for every path, so a queued reason likesignal: state/x.statuswhose task was torn down before the daemon classified it now printsescalate|unrecognized signal shape: state/x.status, whereas the loop below (which starts with[ -e "$f" ] || continue) previously yieldedself|routine signal:and stayed in the digest. The escalation carries no content (the file is gone), so it adds a captain-visible digest entry with nothing to act on. Confirm whether a vanished signal path should escalate or be treated as already-resolved.bin/fm-classify-lib.sh:1210- Dead clamp.absorbedis already clamped to>= offsetat line 1199, andtail_start=$absorbedat line 1209, so[ "$tail_start" -ge "$offset" ] || tail_start=$offsetcan never fire. Dropping it (and usingabsorbeddirectly) removes a branch that reads as if a third invariant were in play.🔧 Fix: absorb live note statuses and fail safe on stale receipts
3 infos still open:
bin/fm-classify-lib.sh:1220- The receipt records only an endpoint, so the "absorbed span" is reconstructed as [presentation-cursor, receipt-endpoint) and printed UNFILTERED. That span can contain bytes that were never absorbed by the watcher, which then appear in UNREAD STATUS even though they belong to another section. Verified live: withstate/t.status=needs-decision [key=k]: pick REST or RPCfollowed byworking: still exploring, and a receipt written at EOF (66), scan_unread_surface_snapshot emits BOTHt needs-decision [key=k]: pick REST or RPCandt working: still exploring, while scan_open_decisions_snapshot emits the same needs-decision ast k needs-decision pick REST or RPC— the decision is rendered twice in one drain, in a section documented as carrying only verbs that "never enter the OPEN DECISIONS fold". Reachable whenever a decision line and a routine line land inside one FM_SIGNAL_GRACE window (only the last line is classified, so the pair is absorbed together). No loss occurs — OPEN DECISIONS still holds the decision until resolved, and surfacing the extra bytes is arguably better than the pre-change silence — so this is duplication/noise, not a defect. A start offset in the receipt would bound the span precisely if the noise matters.bin/fm-classify-lib.sh:1212- The new guard[ -f "$f" ] && [ -r "$f" ] && [ ! -L "$f" ] || continuein scan_unread_surface_snapshot claims to "skip it rather than failing the whole presentation", but it cannot deliver that: print_status_sections (bin/fm-wake-drain.sh:244) calls status_acknowledge_presented_snapshot FIRST, and for any task not in fully_presented that function calls status_presentation_cursor_offset, which hard-fails on a missing/symlinked status file. Verified: with the status file removed after the snapshot, scan_unread_surface_snapshot returns 0 (skips) while status_acknowledge_presented_snapshot returns 1, so print_status_presentation still fails and now prints the new stderr warning. status_commit_presentation_snapshot (line 963) rejects the same condition. The guard only fires for fully_presented tasks, where commit then fails anyway. Either make ack/commit tolerant of a vanished row the same way, or drop the guard and its comment so the code does not read as resilient when it is not.bin/fm-classify-lib.sh:979- status_commit_presentation_snapshot's new receipt-retirement loop runs AFTERmv -f "$tmp" "$state/.status-presentation-cursor", andstatus_absorbed_receipt_endpoint "$f" || return 1/rm -f -- "$receipt" || return 1can return 1 there. If the status file becomes unreadable or the state dir becomes non-writable between the two loops, the cursor is already durably committed but print_status_sections returns 1, so the drain now emitswake drain: status presentation failed; UNREAD STATUS, OPEN DECISIONS and RECORD DIVERGENCE may be incompleteon a presentation that in fact fully succeeded. Practically hard to hit (the first loop validates the same files under the same lock), and the failure direction is safe (the receipt survives and replays), but the reported outcome is wrong. Treating a post-commit receipt-cleanup failure as non-fatal, or moving the cleanup before the mv, would keep the exit status honest.✅ **Test** - passed
✅ No issues found.
bash tests/fm-watch-triage.test.sh(includes newtest_attended_routine_absorb_configuration,test_absorbed_signal_surfaces_on_next_unrelated_model_turn,test_live_note_signal_absorbed_and_presented_once,test_terminal_status_still_wakes_over_a_live_worker,test_attended_absorb_off_surfaces_routine_signal,test_unparseable_live_status_still_surfaces, and the wedge-escalation regression now entering via the absorb path)bash tests/fm-wake-drain-unread-status.test.sh(delayed-presentation receipt no-loss cases: stale identity and out-of-range endpoint)bash tests/fm-daemon.test.sh(away-mode shared shape policy: unparseable signal escalates, retired signal path self-handles)Manual end-to-end driver/tmp/fm-absorb-e2e.sh(copied to evidence) spawning the realbin/fm-watch.shandbin/fm-wake-drain.sh: absorb + no queue entry + on-disk receipt, SIGKILL of the watcher, next genuine drain prints the line once in UNREAD STATUS, second drain does not replay, terminaldone:wakes, unparseable status wakes, home-localconfig/attended-routine-status-absorb=offwakesgit status --porcelainto confirm no transient artifacts left in the worktree✅ **Document** - passed
✅ No issues found.
✅ **Lint** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.