fix(afk): deliver away-mode escalations through Telegram - #5
Merged
Conversation
* Add internal status skill * no-mistakes(document): register /status skill in documentation-audiences inventory * no-mistakes(lint): replace grep|wc -l with grep -c in status skill test * test: silence literal status skill patterns * Refactor bearings default to chat-only --------- Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
…tate, duplicated delivery dir, digest loss on restart
… timing, full-unit merge
…eferred vs failed reclaim
…eferred vs failed reclaim
… fix blocker timing
…k, temp cleanup glob
…re consumed rename
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
The developer's goal was to fix a real delivery defect in Firstmate's away mode: while the captain is away, escalations the daemon surfaces (decisions, blockers, review-ready notices) were not reaching them on Telegram. They required the fix to extend the existing single Telegram relay owner rather than adding a second bot, poller, sender, or credential store; preserve all approval boundaries so a Telegram message never authorizes merges or privileged actions; deliver each escalation exactly once with no duplication between Telegram and chat; keep delivery status observable and durable without leaking tokens, chat IDs, or message contents; and fall back safely with a visible failure instead of claiming delivery. Validation was to run through the no-mistakes pipeline, driven gate by gate without --yes, with a PR opened against morris2spears/firstmate main after rebasing there, and no merging. Across repeated review rounds the developer refused narrow patches for the recurring duplicate-delivery and evidence-loss themes, first approving a single-owner ledger transaction that atomically snapshots/restores/retires buffer, digest, receipt, and rollback evidence, then on round 5 directing that the mechanism be replaced entirely: immutable versioned batch directories with one atomic active-batch pointer switch performed only before daemon start, with no in-place merging or adoption into live paths.
What Changed
fm-supervise-daemon.sh(decisions, blockers, review-ready, stale/wedge and pause re-surface notices) are now relayed to Telegram through the existing singlefm-tg-lib.shrelay owner, with in-session chat delivery deduplicated against a persisted confirmed-prefix receipt so each line is delivered exactly once and an unconfirmed send fails visibly instead of being reported as delivered.bin/fm-away-ledger-lib.shholds the away batch state as an immutable versioned store: each lifecycle transition stages a complete successor version, validates it, and switches a singleactivepointer atomically under an owner lock, refusing to activate while a live daemon holds the lock;fm-afk-launch.sh,fm-afk-start.shandfm-afk-return.shpublish a version before clearing live artifacts, fold retained versions into the return catch-up, and retire them only once the catch-up gate is acknowledged.README.md,docs/configuration.md,docs/architecture.md,docs/scripts.md,AGENTS.md,.agents/skills/) for the away-mode delivery state and env seams, and new/extended shell test suites (fm-tg-mode,fm-afk-launch,fm-afk-return,fm-daemon,fm-bearings-skill) covering the version store, pointer switch, exactly-once delivery and failure classes.Risk Assessment
✅ Low: The round-7 finding is fixed at exactly the recommended owner boundary - a validated active-version identity that the store can vouch for when the ledger payload cannot be parsed - in 38 lines with a regression test that reproduces the reported sequence, and I could not substantiate any new reachable defect: the
v-prefix cannot collide with a minted ledger id, the read-side match still refuses a receipt naming a different batch, an absent or dangling pointer fails closed with an honest log, and the unlocked pointer read can only degrade to that same honest refusal.Testing
I ran the targeted away-mode/Telegram suites (fm-tg-mode's 136 cases, fm-afk-launch, fm-afk-return, fm-daemon, fm-bearings-skill - all pass) and then drove the feature end-to-end the way a captain experiences it: away flag on, three escalations (decision, blocker, review-ready) buffered, flushed through the real ledger owner and version store with only the outbound phone client and the captain-pane inject replaced by recorders. The phone received one message carrying all three events plus the approval-boundary notice; captain chat received only a text-free receipt naming the private digest, not the alert body; with the phone client removed the next escalation produced a visible "Telegram delivery unavailable; the captain was not contacted there" chat fallback and no send; after restoring the client only the new line went to the phone, and counted per-event occurrences show each escalation reached exactly one surface exactly once. Durable delivery status files contained only result class plus epoch (no text, token, or chat id), away dirs were 0700 and digests 0600, the store held immutable v.* versions behind one active pointer, and the real bin/fm-afk-return.sh catch-up listed every away escalation exactly once before retiring the store to empty. This change is shell scripts, skill markdown, and docs with no rendered UI surface, so the end-user surfaces are the Telegram message text and captain-chat text, which I captured verbatim instead of as screenshots. The only failure seen was one pre-existing fm-bearings-snapshot case that fails identically at the base commit and exercises code this change does not touch; the worktree was left clean.
Evidence: Away-mode Telegram escalation end-to-end transcript
### 3. batch flushes -> phone receives it === PHONE MESSAGE === Firstmate needs you: needs-decision: approve the privileged prod cutover for fm-task-3 | blocked: fm-task-7 needs a rotated deploy credential | review-ready: fm-task-9 has a green PR awaiting the captain Notice only. This message does not approve a merge, privileged change, destructive action, or security-sensitive action. ### 4. what the captain sees in session (no duplicate of the alert body) Telegram accepted away-mode alert 1785404629-ab351e0865099728-a0-0-6b7a8d517884deb0 (3 event(s)). Reconcile the durable fleet records now using the private away digests state/tg-away-digest/1785404629-ab351e0865099728-*.items; do not repeat this alert in captain chat. ### 5/6. durable evidence -rw------- tg-away-delivery/<id>.status accepted 1785404630 every delivery status is exactly <result class> <epoch> - no text, no token, no chat id drwx------ tg-away-delivery / tg-away-digest / tg-away-versions ### 7. phone client GONE -> visible failure, no false claim Supervisor escalate (1 event(s)): blocked: fm-task-11 lost its upstream API key (Telegram delivery unavailable; the captain was not contacted there. Use the existing in-session fallback.) phone messages so far: 1 ### 8. exactly-once accounting times "lost its upstream API key" reached the phone: 0 / captain chat: 1 times "privileged prod cutover" reached the phone: 1 / captain chat: 0 ### 9. immutable versioned batch store + single active pointer active / active.applied / v.1785404632-50b5d5c948badb9b active pointer -> v.1785404632-50b5d5c948badb9b ### 11. real return gate: bin/fm-afk-return.sh begin exit: 0; away flag now: cleared catch-up escalation: blocked: fm-task-15 is waiting on a captain decision that never flushed catch-up away-telegram: needs-decision: approve the privileged prod cutover for fm-task-3 catch-up away-telegram: blocked: fm-task-7 needs a rotated deploy credential catch-up away-telegram: review-ready: fm-task-9 has a green PR awaiting the captain catch-up away-telegram: needs-decision: approve the staging rollback for fm-task-12 fm-afk-return: catch-up clear; ordinary captain work may proceed each away escalation appears exactly once: fm-task-3:1 fm-task-7:1 fm-task-9:1 fm-task-11:0 (already delivered in chat) fm-task-12:1 fm-task-15:1 version store after the return retirement: 0 entriesEvidence: Exact bytes the phone client received
Evidence: Exact messages injected into captain chat
Telegram accepted away-mode alert <id> (3 event(s)). Reconcile the durable fleet records now using the private away digests state/tg-away-digest/<batch>-*.items; do not repeat this alert in captain chat. Supervisor escalate (1 event(s)): blocked: fm-task-11 lost its upstream API key (Telegram delivery unavailable; the captain was not contacted there. Use the existing in-session fallback.) Telegram accepted away-mode alert <id2> (1 event(s)). Reconcile ... do not repeat this alert in captain chat.Evidence: Reproducible end-to-end evidence script
Pipeline
Updates from git push no-mistakes
✅ **intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
🔧 **Review** - 5 issues found → auto-fixed (7) ✅
bin/fm-afk-launch.sh:534- fm_afk_launch_start calls fm_afk_launch_reclaim_backups with group adoption enabled (no digests-only flag) after the launch has succeeded, and success means fm_afk_launch_wait_ready has already looped until daemon_lock_held_by_live_daemon returned 0 - the daemon is live. away_ledger_reclaim_backups (bin/fm-away-ledger-lib.sh:469-545) then adopts a leftover backup's buffer/sidecar/wedge group straight into the live paths whenever those three are absent, which is precisely the state a freshly started session is in after fm_afk_clear_stale_artifacts. That is the hazard the digests-only mode was added for: the lib comment states the daemon is the sole writer of the buffer and its sidecar with no shared lock, so adoption races escalate_add. Failing sequence: a prior transaction left an orphaned backup (away_ledger_restore reported failure, fm_afk_launch_restore_backup kept it); a newstartsucceeds; reclaim checks the three live paths as absent; the daemon escalates in that window, writing state/.subsuper-escalations plus a fresh ledger via escalate_add; reclaim's mktemp+mv then overwrites both, so the just-escalated captain-relevant line reaches neither Telegram nor captain chat and a retired batch's counts are re-installed live. The rollback branch is safe (no daemon), so the fix is at the shared boundary of this call: pass digests-only=1 whenever the transaction succeeded (daemon live), or perform group reclaim before the daemon is launched - never after it.bin/fm-afk-return.sh:181- return_reconcile calls away_ledger_reclaim_backups with group adoption enabled unconditionally, but the precedingfm-afk-launch.sh stopmay have failed (that path only sets lifecycle_ok=0 and appends evidence, it does not return). With the daemon still alive, adoption into the live buffer/sidecar/wedge paths hits the same unlocked-writer race as the launch path: the daemon's concurrently appended escalation line is discarded by the adoption mv. Gate group adoption on the stop having actually succeeded (pass digests-only=1 when lifecycle_ok is 0), so only digest text is merged while a daemon may still be writing.bin/fm-afk-return.sh:188- return_reconcile appends the ENTIRE live escalation buffer asescalationevidence and then appends every digest item asaway-telegramevidence, so lines already counted in the ledger'saccountedappear twice in the catch-up gate. Reachable whenever an accepted+digested send's in-session receipt injection failed (inject_msg returns non-zero, escalate_flush preserves the buffer with accounted == n) and the away session then ends: the same escalation is presented to Firstmate as two separate catch-up items to act on. away_ledger_fold_retained_backups deliberately reads each backup's own accounted count and prints only lines past it, and the lib comment at bin/fm-away-ledger-lib.sh:578-582 asserts a caller-side dedupe "as it does at return" that does not exist here. Fix at the same boundary the backup path already uses: read away_ledger_read against the live buffer and emit only lines pastaccounted, falling back to the whole buffer when the record isunknown.bin/fm-away-ledger-lib.sh:469- The recorded intent for the final round was to replace this recovery mechanism entirely with immutable versioned batch directories and one atomic active-batch pointer switch performed only before daemon start, with no in-place merging or adoption into live paths. The delivered code still does per-file in-place digest merging into the live digest directory and per-artifact adoption of buffer/sidecar/wedge into live paths (away_ledger_reclaim_backups), and it is invoked after daemon start on the launch success path. If that directive is authoritative, this is the mechanism it asked to retire rather than harden further; if the directive was superseded, the surviving in-place design should be stated as the accepted approach. Author decision, not a mechanical fix.bin/fm-tg-followup.sh:165- fmtg_send_stdin returns 127 both for "no runnable client" (its own guard) and transparently when the client itself exits 127, so a client that exits 127 for its own reasons is reported as "phone-inbox tg client not runnable at <path>". The daemon avoids this by calling fmtg_client_runnable out-of-band; this caller could do the same (or have fmtg_send_stdin use a distinct out-of-band signal) so the diagnostic cannot be forged by the client's own status. Diagnostic wording only - no behavioral impact, both branches exit 1.🔧 Fix: replace away batch reclaim with immutable versioned store
4 warnings still open:
bin/fm-away-ledger-lib.sh:666- away_ledger_unaccounted_lines resolves the accounted count with away_ledger_read, and away_ledger_read MIGRATES IN PLACE: on any legacy record shape (bare epoch, 5-field, 6-field, or an id without a nonce) it calls away_ledger_write, which mktemps and mv's over "<buf>.since". away_ledger_fold_versions calls it with "$vdir/escalations", so the return catch-up's documented read-only fold writes into a published immutable version at $vdir/escalations.since. Concrete path: an away session running the pre-ledger daemon writes a bare-epoch sidecar; the daemon is upgraded mid-session (a scenario this branch explicitly supports and documents); the next entry publishes a version carrying that legacy sidecar; at return, away_ledger_fold_versions <state> escalations mutates that version. Two consequences: the store's stated immutability invariant ("Read-only: nothing is retired, renamed, or marked here") is false, and if the migration write fails (read-only dir, full disk) away_ledger_read returnsunknown, unaccounted_lines falls back to the WHOLE version buffer, and lines that version's own digests already carry are appended underescalationwhile the same text is appended underaway-telegram- the cross-kind duplication this round fixed. Fix at the shared boundary: give the owner a non-mutating record parse (read/normalise without the away_ledger_write migration) and use it for every version-directory read; keep the migrating read for live paths only. tests/fm-tg-mode.test.sh:1049 exercises fold_versions with a current 7-field sidecar and only asserts .complete still exists, so it cannot catch this.bin/fm-afk-return.sh:235- clear_delivery_artifacts is now the acknowledgement boundary and returns non-zero when away_ledger_retire_batch or away_ledger_versions_retire_all fails, but return_reconcile calls it bare (rc discarded) and afterrm -f "$GATE"at line 234. Failing sequence: a version directory cannot be removed (permissions, or a file inside it held); versions_retire_all returns 1; the warning goes to stderr but the gate is already gone and return_reconcile returns 0, so the catch-up is reported acknowledged while retained versions survive. The next away session's catch-up folds those same versions again and presents already-acknowledged escalations as fresh items to act on - exactly the duplicate-presentation the store is meant to prevent. Retire before closing the gate, or keep the gate open (lifecycle blocker) and propagate the non-zero status when retirement fails.bin/fm-afk-start.sh:157- The non-prepared branch of fm_afk_start_main calls fm_afk_clear_stale_artifacts, which retires the live buffer, sidecar and wedge marker via away_ledger_retire_batch WITHOUT first publishing an immutable version, and it never runs away_ledger_version_gc / away_ledger_version_apply_pending. On this documented direct-start path (the script header's "direct, non-prepared start") a crashed prior away session's un-flushed escalation lines are deleted with no surviving copy, so nothing reaches the return catch-up - the same evidence-loss class the version store was introduced to close, which the launcher path avoids by publishing first (bin/fm-afk-launch.sh:518-530). Additionally, an interrupted pointer switch never converges when the daemon is started outside the launcher, since apply_pending is only invoked by fm_afk_launch_versions_prepare. Fix: in the non-prepared branch, publish a version (and sweep/replay) before clearing, so every away entry - launcher or not - retains the prior unit until the catch-up acknowledges it.bin/fm-away-ledger-lib.sh:374- The authoritative instruction stated that during daemon operation every ledger transition creates a successor complete immutable directory and switches atomically under the owner lock. As delivered, versions are published only at lifecycle transactions (away entry, crash rollback); the daemon's own transitions still write the live artifacts in place - away_ledger_write mv's the sidecar over itself, escalate_add appends to the live buffer, away_ledger_digest_record publishes into the live digest directory. The safety property the clause protects is instead enforced two other ways: the daemon is the sole writer of the live unit, and away_ledger_version_activate / away_ledger_version_apply_pending refuse (rc 3) while away_ledger_daemon_live sees the lock held, so no version content can land under a running daemon. No cross-pairing or lost-append path remains reachable that I can substantiate. Reporting it because the clause is explicit and unimplemented: either add per-transition successor versions, or confirm that in-place daemon writes plus the refuse-while-live guard are the accepted design and update the store's own doc block to say so.🔧 Fix: immutable version reads, locked store, gated retirement
6 issues (3 warnings, 3 infos) still open:
bin/fm-away-ledger-lib.sh:618- _away_ledger_version_publish_locked now returns 1 whenever the staged record parses asunknown, and that failure propagates all the way out: away_ledger_entry_capture returns 1, so fm_afk_launch_start / fm_afk_launch_start_native abort with "could not capture the pre-launch away batch" (bin/fm-afk-launch.sh:520,580) and a direct start refuses at bin/fm-afk-start.sh:167. Nothing clears the offending record, so away mode cannot be entered again until someone manually deletes state/.subsuper-escalations.since. Reachable state: a zero-byte or truncated sidecar - the pre-branch daemon wrote it with a plain_now > "${buf}.since"redirect, so a crash or a full disk mid-write leaves an empty file, and away_ledger_parse reportsunknownfor it while[ -e "$staging/escalations.since" ]is still true. Any record shape outside the 1/5/6/7-field set (a downgrade after a future field is added) does the same. The refusal is also unnecessary:unknownis already designed to fail closed, and away_ledger_unaccounted_lines falls back to the whole buffer, so publishing the unit with the record left as-is (or omitting only the sidecar slot from the version) preserves every owed line and lets the fold over-report instead of wedging the lifecycle. Treat an unparseable staged record as "nothing to normalise" rather than a publication failure.bin/fm-afk-return.sh:229- clear_delivery_artifacts (which calls away_ledger_retire_batch, deleting the live buffer, sidecar, wedge marker and digest directory) now runs BEFORE write_gate at line 234. If write_gate then fails - its mktemp or mv cannot complete, e.g. an unwritable or full state dir - return_reconcile returns 1 with the gate still holding only write_pending_seed's preserved evidence, while this pass's freshly folded escalation and digest lines have already been deleted from disk and exist nowhere durable. Before this round retirement ran only after the gate had closed cleanly, so a write_gate failure left the live unit intact for the next pass to re-fold. Fix the ordering while keeping the new gating: write the gate with this pass's evidence first, then attempt retirement, then remove the gate on success (appending the lifecycle blocker and re-writing the gate when retirement fails), so nothing is deleted before its evidence is durable.bin/fm-away-ledger-lib.sh:525- away_ledger_lock_acquire reclaims a stale lock with a barerm -rf -- "$lock"after observing a dead pid (line 525, and the same shape for an incomplete lock at line 518). Two acquirers that both read the same dead pid both remove it: A removes, loops, and its mkdir succeeds; B - still in the iteration where it decided to reclaim - then removes A's freshly created lock and its own mkdir succeeds too, so both believe they hold the store. Mutual exclusion is what stops a publish from interleaving with a materialise; with it broken, _away_ledger_version_publish_locked can copy the buffer, a concurrent _away_ledger_version_apply_locked can mv both live artifacts, and the published version ends up with a buffer paired with a foreign sidecar - the cross-pairing this store exists to make impossible. Preconditions are narrow (a holder that died mid-transaction plus two concurrent entry paths), but this repo already owns the hardened protocol: fm_lock_try_acquire in bin/fm-wake-lib.sh takes a<lock>.steallock before removing a stale primary and adds a mid-acquire freshness grace. Adopt that reclaim protocol here - factoring the primitive into the dependency-free bin/fm-lock-lib.sh keeps fm-away-ledger-lib.sh out of fm-wake-lib.sh, which fm-afk-return.sh's read-onlyguardpath deliberately does not source.bin/fm-away-ledger-lib.sh:735- _away_ledger_version_apply_locked returns 1 whenactivenames a version that is missing or not provably complete, and away_ledger_entry_capture turns that rc 1 into a refused away entry on every path. Nothing clears such a pointer: away_ledger_version_gc sweeps incomplete version directories but never inspects the pointers, so a version lost out of band (a partial away_ledger_version_retire where the pointer rm failed, or an operator deletion) wedges away mode permanently. Cheap hardening: have version_gc drop anactive/active.appliedpointer that names a non-existent or incomplete version - the content it named is already gone, so clearing the pointer is the only forward path.bin/fm-afk-start.sh:167- The new capture refusal returns 1 at line 168, but state/.afk was already written at line 140, so a refused direct start leaves away mode flagged with no daemon supervising: afk_active is true for the daemon-less system and the captain's next message hits the return gate. The escalation buffer itself is preserved (the intended property, and what tests/fm-daemon.test.sh asserts), so nothing is lost - only the flag is inconsistent. Clearing state/.afk on this refusal when this invocation was the one that wrote it would make the failure fully honest.bin/fm-away-ledger-lib.sh:416- The round-2 instruction asked that every daemon transition (append, reservation, acceptance, digest, accounted acknowledgement, retry state, wedge evidence) acquire the owner lock, build a complete successor version, validate it, and switch the pointer, with no live artifact writes remaining. The delivered code keeps those transitions as in-place atomic writes to the live unit and instead documents that choice at lines 416-425 as the accepted design, with the rationale that the daemon is the sole writer for the whole session and the refuse-while-live guard means no version content can land under it. I could not substantiate a reachable cross-pairing or lost-append path under the current code, so I am not treating it as a defect - flagging it only so the deviation from an explicit instruction is a decision you make rather than one that passes silently.🔧 Fix: version every away ledger transition under owner lock
5 issues (1 error, 3 warnings, 1 info) still open:
bin/fm-supervise-daemon.sh:659- escalate_add now returns 1 when away_ledger_append cannot commit a successor version, and its log line claims "The heartbeat scan and wake queue re-derive it." Neither call site honours that. At line 1597 the wake handler ignores the rc and unconditionally runsstale_marker_remove(removing the persistence marker housekeeping would re-escalate from) andmark_escalated_seenat line 1601, which writes state/.subsuper-seen-status-<task> with the very status line that was never buffered - and the heartbeat catch-all at line 1441-1443 skips any status whose seen marker already equalslast. The catch-all scan repeats the same mistake at line 1444: escalate_add's rc is ignored and mark_status_seen runs anyway. Result: on a refused append the captain-relevant escalation exists in no buffer, no version and no digest, is permanently suppressed from re-derivation, and reaches neither Telegram nor captain chat - only a line in the daemon's own log the away captain cannot see. This is newly reachable because the append is now a full transaction (10s owner-lock timeout, stage copy, publish validation, materialise) rather than a>>redirect - and the lock can legitimately be busy, e.g. while the launcher's fm_afk_launch_versions_sweep holds it or during the shutdown reentrancy stall below. Fix at the two call sites: only remove the stale marker and record the seen marker when escalate_add succeeded, so the next scan re-derives the escalation.bin/fm-supervise-daemon.sh:1023- escalate_flush now ends withaway_ledger_truncate "$buf" || return 1, so a truncate transaction that fails AFTER inject_msg confirmed the submit is reported to callers as a flush failure. The ledger and buffer still say the batch is undelivered, so the next flush (housekeeping's retry at line 1338/1342, or the max-defer path) re-injects the identical digest into captain chat - a duplicate in-session delivery of an escalation the captain already received - and housekeeping additionally treats the flush as wedged and fires inject_wedge_alarm. Reachable whenever the transaction fails at its tail: a 10s owner-lock timeout, a materialise copy failure, or the finalactive.appliedpointer write failing after the projection already succeeded (_away_ledger_transact_locked returns 1 there even though the truncation landed). Distinguish the two outcomes: retry the truncation under the lock (or persist the confirmed-inject fact in the same version) so a post-inject bookkeeping failure never re-delivers the same batch; only a genuinely unsubmitted inject should return non-zero.bin/fm-away-ledger-lib.sh:623- away_ledger_lock_acquire is not reentrant, and the daemon installstrap cleanup TERM INT(bin/fm-supervise-daemon.sh:1800) whose cleanup calls escalate_flush at line 1787 - which now performs ledger transactions (reserve/confirm/digest via telegram_away_deliver, then truncate). A TERM arriving while a transaction holds state/tg-away-versions/.owner.lock runs the trap with that lock still held by this same process: the recorded pid is alive, so _away_ledger_lock_steal deliberately refuses to reclaim it, and every shutdown transaction spins the full 200 x 0.05s (~10s) before failing. The stop path in fm-afk-launch.sh SIGTERMs the daemon precisely so this flush runs, and the daemon documents a ~1s shutdown, so the observable effects are a shutdown that stalls for tens of seconds (potentially escalated to SIGKILL by the stop timeout) and a final flush that delivers nothing in session or to the phone. The buffered lines survive in the live unit and its version for the return fold, so this is a liveness and lost-final-flush problem rather than evidence loss. Fix: make the owner lock reentrant for the holding pid with depth tracking (so a nested release does not drop the outer transaction's lock), or block TERM/INT for the duration of a transaction and let the trap run between transitions.bin/fm-away-ledger-lib.sh:879- _away_ledger_transact_locked readspreviousfrom the active pointer at line 867 and, after applying the successor, deletes it at line 879, justified by "the successor was built from it and is a complete superset". That holds only when the live unit is actually the predecessor's projection, but _away_ledger_stage_build copies the LIVE unit, not $dir/$previous. Divergence is reachable: if away_ledger_version_materialise fails partway (it removes the live buffer, sidecar and wedge first, then copies each back), transact returns non-zero withactive= the new version andactive.appliedstill stale, and nothing replays it - away_ledger_version_apply_pending only runs at an away entry, never inside a live session. The next escalate_add then sees an empty/absent buffer, mints a fresh batch via away_ledger_open, stages from that diverged live unit, publishes a successor that does NOT contain the earlier lines, and deletes the predecessor version that did - the only remaining copy if the entry-capture version has since been retired. Cheap guard that makes the stated invariant true: only retirepreviouswhenactive.appliedequalledpreviousat the start of the transaction (the live unit was provably that version's projection); otherwise leave it retained for the return fold, which already deduplicates identical lines.bin/fm-away-ledger-lib.sh:475- away_ledger_wedge_record creates state/.subsuper-inject-wedged.stage.XXXXXX and removes it on its own paths, but a crash between mktemp and the rm leaks it. away_ledger_retire_working_records enumerates the temp patterns to sweep (.since., .apply., .staging., .tg-away-digest.apply.) and does not include .subsuper-inject-wedged.stage.*, so the leak survives every lifecycle boundary. One-line addition to the same sweep list.🔧 Fix: honour refused appends, unique mints, reentrant store lock
4 issues (1 error, 3 warnings) still open:
bin/fm-supervise-daemon.sh:1408- The housekeeping wedge path still ignores escalate_add's return code and removes the suppressor unconditionally:escalate_add "$state" "stale persisted ${age}s (possible wedge): $win"is followed bystale_marker_remove "$win" "$state"on the next line, regardless of rc. This is the exact contract round 4 established and fixed at the other two sites (handle_wake at line 1620 and the catch-all scan at line 1465) - a refused append must leave suppressors intact - and this sibling was missed. Failing sequence: the append transaction refuses (owner-lock timeout against a concurrent fm_afk_launch_versions_sweep / gc, a materialise failure, or a full disk), escalate_add logs and returns 1 with the line in no buffer, no version and no digest; stale_marker_remove then deletes state/.subsuper-stale-<key>, so the aged wedge that was about to be escalated is discarded. Recovery is only partial and delayed: the marker is recreated by a later stale wake (stale_marker_record at line 1669) with a FRESH epoch, so the pane must idle another full FM_STALE_ESCALATE_SECS before it can escalate again, and if the pane produces no further stale wake the possible-wedge escalation is gone for good - the 8.5h silent-wedge class this daemon exists to prevent. Fix at the same boundary as the two fixed sites:if escalate_add ...; then stale_marker_remove ...; fi, with an ERROR log on the else branch.bin/fm-supervise-daemon.sh:1442- The pause re-surface path has the same shape: escalate_add's rc is ignored and_now > "$marker"on line 1443 resets the pause marker's epoch anyway. On a refused append (same triggers as above) the recheck escalation is buffered nowhere, yet the marker is re-armed, so the forgotten pause cannot re-surface for another full FM_PAUSE_RESURFACE_SECS window - the exact rot the block's own comment says must not happen. Less severe than the stale path because the marker survives and the pause does eventually re-surface, but the suppressor-only-on-success rule should hold here too: only reset the marker when escalate_add succeeded, and leave it aged so the next housekeeping tick retries immediately.bin/fm-supervise-daemon.sh:965- The new in-session dedupe guard matches on an EXACT line count: away_ledger_chat_confirmed compares the recorded<batch-id> <lines> confirmedagainst the buffer's current n. A batch only ever grows, so any append between the confirmed submit and a successful truncation defeats the guard. Failing sequence: escalate_flush injects and inject_msg confirms, chat_mark recordsconfirmed n=3(line 1039), away_ledger_truncate fails (owner-lock timeout, materialise copy failure, or the trailing active.applied pointer write) and escalate_flush returns 1; before housekeeping's retry a wake arrives and escalate_add appends line 4; the next flush sees n=4, chat_confirmed returns false, and chat_body (tail -n +$((accounted + 1))) carries all four lines back into inject_msg - re-delivering lines 1-3 that the captain already has in chat, which is the duplication the round-4 fix was accepted to eliminate. Fix at the same boundary: treat the confirmed record as a delivered PREFIX rather than an equality check - when the recorded batch id matches and recorded lines <= n, skip the firstrecordedlines when building chat_body (and skip the inject entirely when recorded == n), instead of re-injecting the whole buffer.bin/fm-afk-start.sh:177- fm_afk_clear_stale_artifacts now delegates to away_ledger_retire_batch, which returns non-zero when the version-store owner lock cannot be acquired (a 10s timeout against a concurrent launcher sweep or a stale lock) or when a live artifact cannot be removed. The call at line 177 is unguarded, and this script runs underset -eu(line 33) with fm_afk_start_main invoked unguarded at line 187, so that non-zero status now aborts the script beforeexec "$FM_AFK_DAEMON". Before this change the function was a barerm -f ... 2>/dev/nulland always returned 0, so the start always proceeded. Result: state/.afk has already been written at line 139, the unit has been partially retired, no daemon is started, and nothing is printed - away mode is flagged with no supervisor and the failure is silent. Note the capture-failure branch immediately above (lines 168-176) already does the honest thing: it prints a diagnostic and rolls back a flag this invocation wrote. Give the retire call the same treatment - capture its status, print the diagnostic, roll back the flag when had_afk is 0, and return 1 explicitly - rather than letting errexit end the run mid-way.🔧 Fix: gate suppressors on committed appends, confirmed chat prefix
2 issues (1 error, 1 info) still open:
bin/fm-supervise-daemon.sh:1051- The new confirmed-prefix guard is defeated by this function's own pre-submit write. Line 1051 runsaway_ledger_chat_mark "$state" "$batch_id" "$n" attemptingbefore every inject, and _away_ledger_mut_chat writes the chat slot with a single>redirect (bin/fm-away-ledger-lib.sh:250), so the record<id> <prefix> confirmedis replaced by<id> <n> attempting. away_ledger_chat_delivered only answers for phaseconfirmed, so the prefix drops to 0. Failing sequence: buffer has 3 lines; flush injects them; chat_mark recordsconfirmed 3; away_ledger_truncate fails (owner-lock timeout, materialise failure, or the trailing active.applied write) and flush returns 1. A wake appends line 4. Next flush correctly computes chat_start=3 and builds a message containing only line 4 - then line 1051 clobbers the confirmed record withattempting 4, and inject_msg fails (a wedged pane, the documented condition inject_wedge_alarm exists for). The next flush sees chat_delivered=0, chat_start falls back toaccounted, and lines 1-3 the captain already has are re-injected into captain chat - the exact duplication the round-5 fix was accepted to eliminate, reachable through a sibling state transition in the same function. The new regression test (tests/fm-tg-mode.test.sh:153) only exercises a successful inject, so it cannot catch this. Fix at the shared owner boundary rather than at this call site: make the chat record's confirmed count monotonic -away_ledger_chat_mark/_away_ledger_mut_chat must carry the existing confirmed count forward when writing anattemptingrecord for the same batch id (or holdconfirmedandattemptingas separate fields), and away_ledger_chat_delivered must read the confirmed field regardless of the current phase. That also covers a crash between the attempting write and the submit.bin/fm-supervise-daemon.sh:1030- When the ledger record parses asunknown, escalate_flush forces batch_id tonone, so the second away_ledger_chat_delivered call cannot match the chat record's real batch id and returns 0; chat_start collapses toaccounted(0) and the unknown-status message re-injects the whole buffer, repeating a prefix captain chat already received. Precondition is a sidecar corrupted or truncated after a confirmed submit. This is the deliberate fail-open direction (an unreadable ledger over-reports to chat rather than dropping a captain-relevant line), so it is low severity - but state/.subsuper-chat-delivery names its own batch id and is independent of the sidecar, so reading the id from the chat record itself would preserve the prefix without weakening the fail-open behaviour.🔧 Fix: monotonic confirmed chat prefix, independent attempt range
2 issues (1 warning, 1 info) still open:
bin/fm-away-ledger-lib.sh:300- The confirmed-prefix receipt cannot be created at all when the ledger is unreadable and no receipt exists yet. escalate_flush forces batch_id=none on anunknownrecord (bin/fm-supervise-daemon.sh:980); away_ledger_chat_mark_attempt (line 300) and away_ledger_chat_mark_confirmed (line 318) resolve the identity via _away_ledger_chat_effective_id, which falls back to the RECORDED id when the requested one isnone- and with no receipt on disk that is alsonone, which the following case arm rejects with return 1. Failing sequence: a batch's sidecar is corrupted or truncated (a full disk, a crash mid-write); the flush logs WARN for the unrecorded attempt, injects successfully, then logs the ERROR at bin/fm-supervise-daemon.sh:1057 because the confirmation cannot be recorded either; if away_ledger_truncate also fails - the same root cause typically produces both - the next flush finds no receipt, chat_start collapses to 0, and every line already injected is re-delivered into captain chat. The round-6 instruction explicitly asked for a batch identity/chat receipt that stays recoverable when the ledger payload is malformed; as delivered that holds only when a receipt already exists (the case tests/fm-tg-mode.test.sh:1441 covers), never for a batch that first goes unreadable. Fix at the same owner boundary: when neither the caller nor the receipt supplies a usable id, let the receipt mint and carry its own identity (away_ledger_mint_id) rather than refusing - a later flush passingnonealready adopts whichever id the receipt names, so a single write makes the prefix hold. Two faults are needed to reach the duplication, so this is a follow-up rather than a blocker.bin/fm-away-ledger-lib.sh:293- The new attempt-from/attempt-to fields are written on every flush and parsed back by _away_ledger_chat_parse, but nothing consumes them for a decision. The round-6 instruction wanted an uncertain submit to prevent retyping the attempted range; inject_msg is boolean (delivered or not), so there is no uncertain in-session outcome to gate on and a refused inject correctly retries the same suffix. Keeping the fields is defensible - separating them fromconfirmedis precisely what makes the prefix monotonic, and they make a crash between attempt and submit visible in the version - so this is informational only, no action needed.🔧 Fix: open chat receipts from validated active version identity
✅ Re-checked - no issues remain.
tests/fm-bearings-snapshot.test.sh- tests/fm-bearings-snapshot.test.sh fails one case ("restoring the SSHHIP child did not clear only its narrow warning") in this environment. I verified it fails identically with the base commit's bin/ and base test file, and the case only exercises bin/fm-fleet-snapshot.sh which this change does not touch, so it is pre-existing and unrelated to the away-mode Telegram work. Flagging it so the author decides whether to chase it separately; no fix attempted here since it is out of this change's scope.bash tests/fm-tg-mode.test.sh- 136 away/Telegram-mode cases including versioned batch store, pointer switch, exactly-once delivery, failure classesbash tests/fm-afk-launch.test.shbash tests/fm-afk-return.test.shbash tests/fm-daemon.test.shbash tests/fm-bearings-skill.test.shbash tests/fm-bearings-snapshot.test.sh(1 pre-existing failure; re-ran 3x, then re-ran with base-commit bin/ and base test file to confirm it predates this change)Manual end-to-end away session viaROOT=$PWD OUT=<evidence> bash away-telegram-e2e.sh: away flag set, 3 escalations buffered,escalate_flushdelivering through a recordingtgclient, missing-client failure round, client-restored round, then realbin/fm-afk-return.sh begincatch-up and version-store retirementVerifiedgit status --porcelainclean after restoring the temporary base-commit checkout used for the pre-existing-failure comparison✅ **Document** - passed
✅ No issues found.
✅ **Push** - passed
✅ No issues found.