Skip to content

fix(bin): give a captain hold the same bounded pause cadence as a declared pause - #2748

Merged
kunchenguid merged 3 commits into
kunchenguid:mainfrom
Inthuson:fm/held-pause-cadence-h5
Aug 21, 2026
Merged

fix(bin): give a captain hold the same bounded pause cadence as a declared pause#2748
kunchenguid merged 3 commits into
kunchenguid:mainfrom
Inthuson:fm/held-pause-cadence-h5

Conversation

@Inthuson

Copy link
Copy Markdown
Contributor

Intent

Give a captain-held task the same bounded pause cadence a paused task already gets, so a finished task waiting on a person is neither escalated as a wedge nor silenced entirely.

Goal and measured cost the captain gave: 11 finished tasks were waiting on a person. Marking them captain-held silenced the 900s inactive-outcome scan, but immediately produced FIVE possible-wedge escalations in one batch, because the 240s wedge detector no longer saw a paused verb. The two verbs are mutually exclusive in practice because every one of these paths reads the LAST status line, so a finished task could not satisfy both suppressors at once.

Named defect: bin/fm-supervise-daemon.sh's stale-persistence recheck gated deferral on the narrow status_is_paused, while bin/fm-inactive-reconcile.sh's reconcile_direct_child_locked suppressed only on captain-held. The fix routes the wedge path through the combined predicate status_is_paused_or_captain_held() in bin/fm-classify-lib.sh, which was written for exactly this case.

Premise correction the captain explicitly asked for: the brief asserted that combined predicate had zero callers, and instructed me to confirm that with a grep across bin/ first and to say so rather than proceed on a stale premise if the situation had moved. It had moved. The predicate already had seven callers in bin/fm-watch.sh, landed in b57c4d6 three commits before HEAD. The defect is real but narrower than briefed: it lived in the daemon, plus two paths the already-migrated watcher wedge path had left behind.

Decisions and tradeoffs:

  • All six daemon call sites were substituted together as one subsystem, not just the one named line. Changing only the stale-persistence recheck would defer the wedge while reconcile_pause_tracking recorded nothing, so the stale marker would persist and the sweep would continue forever: quiet, but never re-surfacing. That is exactly the half-fix the captain warned against.

  • bin/fm-watch.sh's secondmate gate was substituted, and that substitution alone accomplished nothing, which is why pause_state_class was also completed. A captain-held line has no current-state mapping, so crew state reports unknown with no source, crew_absorb_class returns none, and pause_state_class returned none because a mate's endpoint liveness is deliberately never read, leaving agent_alive unset. Every caller therefore cleared pause tracking and silenced the hold. pause_state_class now promotes a declared wait to paused for a secondmate as well as for a confidently dead ordinary crew, bounded by the declared-wait guard already at the top of that function. This was found by writing the test first and watching it fail, not by reading the diff.

  • bin/fm-push-transition-lib.sh's absorb was substituted so a verified captain-held transfer is absorbed like a declared pause rather than fast-escalated, since both name a human the transition would otherwise report and both are already durably recorded.

  • Deliberately kept narrow, each decided explicitly rather than blanket-replaced, per the captain's third requirement. bin/fm-crew-state.sh's map_log_state is a reporting contract, not a wedge path, and conflating the verbs there would erase the distinction that status_key_closing_verb and bin/fm-captain-hold.sh depend on. bin/fm-inactive-reconcile.sh's captain-held guard belongs to a separate subsystem: that scan only reports done and failed, and map_log_state already maps a paused line to paused so a paused task never matches those states and needs no guard, while the captain-held guard exists because a finished task's crew state can still report done from a higher-priority source than the log. bin/fm-classify-lib.sh's own narrow call needs no change because its enclosing status_is_captain_relevant already lists captain-held as non-relevant.

  • BOTH halves are tested, which the captain made a rejection criterion: a captain-held task must earn the same bounded once-per-window recheck as a paused one, AND must not be silenced entirely, because a forgotten hold must not rot invisibly. Six new tests extend the existing colocated suites for these predicates rather than inventing a new harness, each placed next to its paused counterpart. Five of the six fail on a pristine HEAD clone; the sixth is an intentional boundary guard that passes on HEAD by design, asserting that a later resolved line still clears the marker with no escalation.

  • Escalation strings and the classify_stale printf wording were left byte-identical on purpose, to avoid churning assertions pinned by two suites. That is why the diff changes predicates and comments but not those user-visible strings.

Context a reviewer reading only the diff would not have: 10 test-suite failures across the affected suites were reproduced identically on a pristine HEAD clone and are pre-existing, not caused by this change (8 are real-herdr-gated end-to-end tests that no CI lane runs, and fm-backend plus fm-pi-watch-extension fail the same way on HEAD). bin/fm-lint.sh exits 127 only because actionlint is not installed on this machine; ShellCheck itself is clean on all six changed files and no workflow files were touched.

Constraints the captain set: no em-dashes anywhere, including the commit message and the PR body. Never add an agent name as a commit co-author. Never push to the default branch and never merge the PR. A paused workaround line was re-appended as the last status line on those 11 live tasks and must NOT be removed by this work, because it is live supervision state for tasks that are still waiting; the PR body should only note that those lines can be retired once this lands.

What Changed

  • Routed the supervision wedge paths through status_is_paused_or_captain_held() instead of the narrow status_is_paused: every pause and wedge call site in bin/fm-supervise-daemon.sh (classify_stale, reconcile_pause_tracking, migrate_watcher_pause_markers, and both housekeeping loops), the secondmate stale gate in bin/fm-watch.sh, and the absorb in bin/fm-push-transition-lib.sh. A verified captain-held line now defers the wedge escalation, ages a pause marker, and is absorbed on the push fast path, rather than escalating as a possible wedge. bin/fm-classify-lib.sh gained status_is_captain_held() as the verb-level discriminator and now composes the combined predicate from the two.
  • Completed the quiet half so a hold still re-surfaces: pause_state_class in bin/fm-watch.sh promotes an unmapped declared wait to paused for a secondmate as well as for a confidently dead ordinary crew, since a mate's endpoint liveness is deliberately never read and would otherwise leave every caller clearing pause tracking. The bounded recheck now names which human the wait is on, the external dependency for paused: and the captain for a hold, in both the watcher's handle_paused_stale reason and the daemon's re-surface digest. Escalation strings for the existing paused wording were left byte-identical.
  • Added six tests next to their paused counterparts in tests/fm-daemon.test.sh, tests/fm-supervision-events.test.sh, and tests/fm-watch-triage.test.sh, covering both the bounded once-per-window recheck and the not-silenced-entirely requirement, plus a boundary case asserting a later resolved line clears the marker with no escalation. Updated docs/architecture.md, docs/configuration.md, docs/herdr-backend.md, and .agents/skills/afk/SKILL.md to describe the combined declared-wait vocabulary.

Note: the paused: workaround lines currently appended to the live captain-held tasks are untouched by this change and can be retired once this lands.

Risk Assessment

✅ Low: The change is a bounded predicate substitution plus a verb-split in two escalation strings, the fix round's simplification is provably behavior-preserving, both halves of the cadence contract are covered by colocated behavioral tests, docs were corrected, and the one substantive tradeoff (relaxing the 240s wedge bound for any captain-held last line) was explicitly approved by the captain in round 1, leaving only informational nits.

Testing

Ran the three colocated suites for the changed predicates (all pass, including the six new tests), then reproduced the defect on a pristine base extraction where five of the six new tests and both tightened existing assertions fail, confirming a real before/after regression. Because unit passes alone would not show the captain's experience, I also drove the reported incident end-to-end through the real daemon sweep, the real watcher, and the real push-transition absorb on both commits: base fires 11 possible-wedge escalations into the supervisor pane and then goes permanently silent, misreports an ordinary held crew as an external wait, silences a held secondmate entirely, and fast-wakes the captain on a held push, while the target stays quiet inside the recheck window and then re-surfaces a bounded, self-resetting, repeating recheck that names the captain in every path. A separate run of the identical fleet with the paused: verb the captain's 11 live tasks currently carry produced byte-identical output on both trees, so the live workaround lines are untouched. No visual artifact applies: this is a bash CLI supervision toolbelt with no rendered UI, so the end-user surface is the escalation digest text injected into the supervisor pane and the watcher's reason line, both of which are captured verbatim in the transcripts. Also ran the neighbour suites that guard the deliberate choice to keep the two verbs distinct; fm-captain-hold-lifecycle self-skips because the optional tasks-axi tool is not installed on this machine, which is the harness's documented gate behavior and unrelated to the change. Overall result: pass, with the defect demonstrably reproduced before the fix and resolved after it.

Evidence: Away-mode daemon sweep of 11 finished captain-held tasks, base vs target (the reported incident)

BEFORE (base 4d2cb0c), sweep A at 300s idle -> 11 escalations, injected into the captain's pane: stale persisted 300s (possible wedge): fleet:fm-ship-1 ... x11 ... wedge markers still aging: 0 pause-cadence markers tracked: 0 sweep B (window elapsed): 0 escalations -> "supervisor pane receives: NOTHING (buffer empty, the captain hears nothing at all)" sweep D (another window later): 0 escalations. The hold now rots invisibly. AFTER (target 199d6e8), sweep A at 300s idle -> 0 escalations, 11 pause-cadence markers tracked: supervisor pane receives: NOTHING (correct: inside the recheck window) sweep B (window elapsed) -> 11 escalations, injected as ONE digest, 1 Enter press: captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-1 ... x11 ... sweep C (immediate, same window) -> 0 escalations (bounded, not spam) sweep D (one window later) -> 11 escalations again (a forgotten hold keeps re-surfacing)

########## BEFORE: base 4d2cb0c ##########
=== base-4d2cb0c: 11 finished tasks marked captain-held, wedge detector armed 300s ago ===
last status line on every task: captain-held [key=ship-1]: tracked by task-decision-ship-1
wedge threshold: 240s    declared-wait recheck window: 900s

--- A. away-mode sweep, 300s idle: past the wedge threshold, inside the recheck window ---
escalations this sweep produced: 11
    stale persisted 300s (possible wedge): fleet:fm-ship-1
    stale persisted 300s (possible wedge): fleet:fm-ship-10
    stale persisted 300s (possible wedge): fleet:fm-ship-11
    stale persisted 300s (possible wedge): fleet:fm-ship-2
    stale persisted 300s (possible wedge): fleet:fm-ship-3
    stale persisted 300s (possible wedge): fleet:fm-ship-4
    stale persisted 300s (possible wedge): fleet:fm-ship-5
    stale persisted 300s (possible wedge): fleet:fm-ship-6
    stale persisted 300s (possible wedge): fleet:fm-ship-7
    stale persisted 300s (possible wedge): fleet:fm-ship-8
    stale persisted 300s (possible wedge): fleet:fm-ship-9
wedge markers still aging: 0   pause-cadence markers tracked: 0
supervisor pane receives (1 submission, 1 Enter press):
    ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (11 event(s)): stale persisted 300s (possible wedge): fleet:fm-ship-1 | 
    stale persisted 300s (possible wedge): fleet:fm-ship-10 | stale persisted 300s (possible wedge): fleet:fm-ship-11 | stale 
    persisted 300s (possible wedge): fleet:fm-ship-2 | stale persisted 300s (possible wedge): fleet:fm-ship-3 | stale persisted 300s 
    (possible wedge): fleet:fm-ship-4 | stale persisted 300s (possible wedge): fleet:fm-ship-5 | stale persisted 300s (possible 
    wedge): fleet:fm-ship-6 | stale persisted 300s (possible wedge): fleet:fm-ship-7 | stale persisted 300s (possible wedge): 
    fleet:fm-ship-8 | stale persisted 300s (possible wedge): fleet:fm-ship-9 (pre-read; re-arm not needed — watcher daemon-managed)

--- B. next sweep after the 900s recheck window elapsed ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 0
supervisor pane receives: NOTHING (buffer empty, the captain hears nothing at all)

--- C. immediate sweep inside the freshly reset window (bounded means zero) ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 0

--- D. one more window later (a forgotten hold must keep re-surfacing) ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 0


########## AFTER: target 199d6e8 ##########
=== target-199d6e8: 11 finished tasks marked captain-held, wedge detector armed 300s ago ===
last status line on every task: captain-held [key=ship-1]: tracked by task-decision-ship-1
wedge threshold: 240s    declared-wait recheck window: 900s

--- A. away-mode sweep, 300s idle: past the wedge threshold, inside the recheck window ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 11
supervisor pane receives: NOTHING (buffer empty, the captain hears nothing at all)

--- B. next sweep after the 900s recheck window elapsed ---
escalations this sweep produced: 11
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-1
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-10
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-11
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-2
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-3
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-4
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-5
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-6
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-7
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-8
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-9
wedge markers still aging: 0   pause-cadence markers tracked: 11
supervisor pane receives (1 submission, 1 Enter press):
    ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (11 event(s)): captain-held 1002s (awaiting the captain, answer the held 
    decision or release the hold): fleet:fm-ship-1 | captain-held 1002s (awaiting the captain, answer the held decision or release 
    the hold): fleet:fm-ship-10 | captain-held 1002s (awaiting the captain, answer the held decision or release the hold): 
    fleet:fm-ship-11 | captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-2 | 
    captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-3 | captain-held 1002s 
    (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-4 | captain-held 1002s (awaiting the captain, 
    answer the held decision or release the hold): fleet:fm-ship-5 | captain-held 1002s (awaiting the captain, answer the held 
    decision or release the hold): fleet:fm-ship-6 | captain-held 1002s (awaiting the captain, answer the held decision or release 
    the hold): fleet:fm-ship-7 | captain-held 1002s (awaiting the captain, answer the held decision or release the hold): 
    fleet:fm-ship-8 | captain-held 1002s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-9 
    (pre-read; re-arm not needed — watcher daemon-managed)

--- C. immediate sweep inside the freshly reset window (bounded means zero) ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 11

--- D. one more window later (a forgotten hold must keep re-surfacing) ---
escalations this sweep produced: 11
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-1
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-10
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-11
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-2
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-3
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-4
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-5
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-6
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-7
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-8
    captain-held 1008s (awaiting the captain, answer the held decision or release the hold): fleet:fm-ship-9
wedge markers still aging: 0   pause-cadence markers tracked: 11
Evidence: Normal-mode watcher reason line for a held crew and a held secondmate, base vs target

BEFORE (base 4d2cb0c) ordinary crew, agent exited, last line captain-held: stale: test:fm-held (paused 501s, awaiting external - declared pause, ...; confirm the wait still holds) secondmate, last line captain-held: (the watcher printed nothing: the hold was silenced) queued wake record: (empty queue: nothing for the captain to read) AFTER (target 199d6e8) ordinary crew: stale: test:fm-held (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold) secondmate: stale: test:fm-secondmate-hold (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)

########## BEFORE: base 4d2cb0c ##########
=== base-4d2cb0c ===

--- 1. finished ordinary crew, agent exited, last line captain-held ---
watcher reason line printed to the captain:
    stale: test:fm-held (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
queued wake record the next drain presents:
    kind=stale key=test:fm-held
    payload=stale: test:fm-held (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
watcher did not exit within the bound (no re-surface)

--- 2. secondmate, endpoint liveness never read, last line captain-held ---
watcher reason line printed to the captain:
    (the watcher printed nothing: the hold was silenced)
queued wake record the next drain presents:
    (empty queue: nothing for the captain to read)


########## AFTER: target 199d6e8 ##########
=== target-199d6e8 ===

--- 1. finished ordinary crew, agent exited, last line captain-held ---
watcher reason line printed to the captain:
    stale: test:fm-held (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)
queued wake record the next drain presents:
    kind=stale key=test:fm-held
    payload=stale: test:fm-held (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)

--- 2. secondmate, endpoint liveness never read, last line captain-held ---
watcher reason line printed to the captain:
    stale: test:fm-secondmate-hold (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)
queued wake record the next drain presents:
    kind=stale key=test:fm-secondmate-hold
    payload=stale: test:fm-secondmate-hold (captain-held 501s, awaiting the captain - verified hold transfer, rechecked on a long cadence not a wedge; answer the held decision or release the hold)
Evidence: Push-transition absorb for a verified captain-held transfer, base vs target

BEFORE (base 4d2cb0c) wake queue: kind=stale key=default:wG:pQ payload=stale: default:wG:pQ (herdr: agent blocked - waiting on human, escalated immediately, not via wedge timer) supervisor wakes fired: 1 triage log: (empty) AFTER (target 199d6e8) wake queue: (empty) supervisor wakes fired: 0 triage log: absorbed push blocked (declared wait, awaiting external or captain): default:wG:pQ

########## BEFORE: base 4d2cb0c ##########
=== base-4d2cb0c ===
last status line: captain-held [key=route]: tracked by task-decision-route

wake queue (an entry here means the captain is interrupted right now):
    kind=stale key=default:wG:pQ
    payload=stale: default:wG:pQ (herdr: agent blocked - waiting on human, escalated immediately, not via wedge timer)
supervisor wakes fired: 1

triage log (an entry here means it was absorbed for the bounded cadence):
    (empty)


########## AFTER: target 199d6e8 ##########
=== target-199d6e8 ===
last status line: captain-held [key=route]: tracked by task-decision-route

wake queue (an entry here means the captain is interrupted right now):
    (empty)
supervisor wakes fired: 0

triage log (an entry here means it was absorbed for the bounded cadence):
    [2026-08-21T16:35:38+0000] absorbed push blocked (declared wait, awaiting external or captain): default:wG:pQ
Evidence: Regression proof: each added or tightened test run against the pristine base commit

base 4d2cb0c :: fm-daemon :: test_stale_captain_held_classifies_pause not ok - captain-held transfer did not classify as pause: self|transient stale (sess:fm-held-w9h): captain-held [key=route]: ... base :: fm-daemon :: test_housekeeping_captain_held_resurfaces_and_resets not ok - a captain hold was silenced entirely instead of re-surfacing as a captain-owned recheck: base :: fm-daemon :: test_housekeeping_captain_held_stale_marker_transitions_to_pause not ok - a captain hold did not move its stale marker to pause tracking base :: fm-daemon :: test_housekeeping_captain_held_resolved_cleared ok (intended boundary guard: passes on base by design) base :: fm-watch-triage :: test_status_is_paused_classifier not ok - captain-held verb not recognized (status_is_captain_held: command not found) base :: fm-watch-triage :: test_secondmate_captain_held_resurfaces_in_normal_mode not ok - watcher did not re-surface a captain-held secondmate base :: fm-watch-triage :: test_exited_declared_pause_is_bounded_but_live_gate_surfaces not ok - captain-held dead-agent pane surfaced as a stopped crew instead of a captain-owned recheck base :: fm-supervision-events (whole script) not ok - a captain-held crew must NOT be fast-escalated

Regression proof: each behavioral test this change adds or tightens, run against a
pristine extraction of the base commit 4d2cb0c with only the test files taken from
the target commit 199d6e8. "not ok" here is the reported defect reproducing.

--- base 4d2cb0c :: tests/fm-daemon.test.sh :: test_stale_captain_held_classifies_pause
    not ok - captain-held transfer did not classify as pause: self|transient stale (sess:fm-held-w9h): captain-held [key=route]: tracked by task-decision-route

--- base 4d2cb0c :: tests/fm-daemon.test.sh :: test_housekeeping_captain_held_resurfaces_and_resets
    not ok - a captain hold was silenced entirely instead of re-surfacing as a captain-owned recheck: 

--- base 4d2cb0c :: tests/fm-daemon.test.sh :: test_housekeeping_captain_held_stale_marker_transitions_to_pause
    not ok - a captain hold did not move its stale marker to pause tracking

--- base 4d2cb0c :: tests/fm-daemon.test.sh :: test_housekeeping_captain_held_resolved_cleared
    ok - housekeeping clears the pause marker once a captain hold is answered

--- base 4d2cb0c :: tests/fm-daemon.test.sh :: test_housekeeping_paused_resurfaces_and_resets
    ok - housekeeping re-surfaces a stale declared pause on the long cadence and resets its window

--- base 4d2cb0c :: tests/fm-watch-triage.test.sh :: test_status_is_paused_classifier
    ./tests/zz-iso.test.sh: line 343: status_is_captain_held: command not found
    not ok - captain-held verb not recognized

--- base 4d2cb0c :: tests/fm-watch-triage.test.sh :: test_secondmate_captain_held_resurfaces_in_normal_mode
    not ok - watcher did not re-surface a captain-held secondmate

--- base 4d2cb0c :: tests/fm-watch-triage.test.sh :: test_exited_declared_pause_is_bounded_but_live_gate_surfaces
    not ok - captain-held dead-agent pane surfaced as a stopped crew instead of a captain-owned recheck: 1787330254	1	stale	test:fm-held	stale: test:fm-held (paused 501s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)

--- base 4d2cb0c :: tests/fm-watch-triage.test.sh :: test_secondmate_paused_resurfaces_in_normal_mode
    ok - a declared paused secondmate re-surfaces on the bounded normal-mode cadence

--- base 4d2cb0c :: tests/fm-supervision-events.test.sh (linear script, stops at the first failure)
    ok - handle_push_transition: a blocked crew enqueues a stale wake naming its window and wakes the supervisor
    ok - handle_push_transition: enqueue failure cannot commit the Herdr dedupe marker
    ok - handle_push_transition: a declared-pause crew is absorbed (no fast wake), left to the poll loop's long cadence
    not ok - a captain-held crew must NOT be fast-escalated: 1787330255	1	stale	default:wG:pQ	stale: default:wG:pQ (herdr: agent blocked - waiting on human, escalated immediately, not via wedge timer)
Evidence: Live paused: workaround lines produce identical captain-visible output on both trees

diff of the same 11-task fleet sweep with verb=paused, base 4d2cb0c vs target 199d6e8 (normalized only for wall-clock ages): no differences. The paused path still emits "paused <age>s (awaiting external, recheck whether the wait still holds)" and never names the captain.

# Regression guard: the paused: workaround the captain re-appended to 11 live tasks
# Same 11-task fleet, same real daemon sweep, verb = paused instead of captain-held.
# Transcripts normalized only for wall-clock ages, then diffed between trees.

no differences: base 4d2cb0c and target 199d6e8 produce identical captain-visible output for the paused verb

=== target-tree transcript (paused verb) ===
=== target: 11 finished tasks declaring paused, wedge detector armed 300s ago ===
last status line on every task: paused: awaiting the upstream release tracked by task-decision-ship-1
wedge threshold: 240s    declared-wait recheck window: 900s

--- A. away-mode sweep, 300s idle: past the wedge threshold, inside the recheck window ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 11
supervisor pane receives: NOTHING (buffer empty, the captain hears nothing at all)

--- B. next sweep after the 900s recheck window elapsed ---
escalations this sweep produced: 11
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-1
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-10
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-11
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-2
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-3
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-4
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-5
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-6
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-7
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-8
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-9
wedge markers still aging: 0   pause-cadence markers tracked: 11
supervisor pane receives (1 submission, 1 Enter press):
    ⁣FIRSTMATE_OP: v1 away-supervisor: Supervisor escalate (11 event(s)): paused 1002s (awaiting external, recheck whether the wait 
    still holds): fleet:fm-ship-1 | paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-10 | paused 
    1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-11 | paused 1002s (awaiting external, recheck 
    whether the wait still holds): fleet:fm-ship-2 | paused 1002s (awaiting external, recheck whether the wait still holds): 
    fleet:fm-ship-3 | paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-4 | paused 1002s 
    (awaiting external, recheck whether the wait still holds): fleet:fm-ship-5 | paused 1002s (awaiting external, recheck whether the 
    wait still holds): fleet:fm-ship-6 | paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-7 | 
    paused 1002s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-8 | paused 1002s (awaiting external, 
    recheck whether the wait still holds): fleet:fm-ship-9 (pre-read; re-arm not needed — watcher daemon-managed)

--- C. immediate sweep inside the freshly reset window (bounded means zero) ---
escalations this sweep produced: 0
    (nothing surfaced to the captain)
wedge markers still aging: 0   pause-cadence markers tracked: 11

--- D. one more window later (a forgotten hold must keep re-surfacing) ---
escalations this sweep produced: 11
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-1
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-10
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-11
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-2
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-3
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-4
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-5
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-6
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-7
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-8
    paused 1008s (awaiting external, recheck whether the wait still holds): fleet:fm-ship-9
wedge markers still aging: 0   pause-cadence markers tracked: 11
Evidence: Targeted suite run on the target commit (three colocated suites, exit 0)

Source: Targeted suite run on the target commit (three colocated suites, exit 0) (local file: /tmp/no-mistakes-evidence/01M0JF1DWJ6CG6JHZYP8VC2QG8/targeted-suites.log)

ok - a captain-held transfer classifies as pause, not as a wedge candidate
ok - housekeeping re-surfaces a forgotten captain hold on the long cadence and resets its window
ok - housekeeping clears the pause marker once a captain hold is answered
ok - housekeeping moves a captain hold's existing stale marker to pause before wedge escalation
ok - handle_push_transition: a captain-held crew is absorbed (no fast wake), left to the poll loop's long cadence
ok - status_is_paused: only the leading paused verb matches, paused is not captain-relevant, and the two declared-wait verbs stay separable
ok - a captain-held secondmate re-surfaces on the bounded normal-mode cadence
FM_TEST_SUMMARY total=3 failed=0 skipped_gate=0 duration_ms=149136
Evidence: Neighbour suites guarding the kept-narrow verb distinction

FM_TEST_END tests/fm-inactive-reconcile.test.sh exit=0 gate_skip=false FM_TEST_END tests/fm-captain-hold-lifecycle.test.sh exit=0 gate_skip=true (skip: tasks-axi not found) FM_TEST_END tests/fm-crew-state.test.sh exit=0 gate_skip=false FM_TEST_SUMMARY total=3 failed=0 skipped_gate=1

FM_TEST_BEGIN 2026-08-21T16:36:00Z tests/fm-inactive-reconcile.test.sh family=watcher-wake-lock expected_gate_skip=none
actionable: inactive terminal outcome awaiting captain presentation: child=child state=done pr=https://example.test/owner/repo/pull/1
ok - main direct terminal presentation has a durable receipt
ok - secondmate reports its own inactive terminal child
actionable: inactive terminal outcome needs parent report: child=child state=failed
ok - relative local parent homes fail closed
ok - invalid secondmate markers block routing and surface the obligation
ok - remote parent-replies mirror input is durable and idempotent
ok - reused task ids retain per-incarnation terminal receipts
ok - legacy metadata rewrites preserve terminal receipt identity
ok - relaunch cannot replace metadata during terminal snapshot
actionable: inactive terminal outcome awaiting captain presentation: child=child state=done pr=https://example.test/owner/repo/pull/1
ok - terminal reconciliation ignores heartbeat backoff state
actionable: inactive terminal outcome awaiting captain presentation: child=child state=done pr=https://example.test/owner/repo/pull/1
ok - scan marker replaces a symlink without overwriting its target
ok - nonterminal and captain-held workers remain outside inactive terminal reporting
ok - watcher hook wakes for terminal loss and preserves idle secondmate exemption
actionable: inactive terminal outcome awaiting captain presentation: child=b state=done pr=https://example.test/owner/repo/pull/1
ok - stalled state reads are bounded without starving later children
ok - aggregate scan budget includes durable wake operations
actionable: inactive terminal outcome needs parent report: child=child state=failed
ok - notice recovery remains idempotent across queue acknowledgement
actionable: inactive terminal outcome awaiting captain presentation: child=child state=done pr=https://example.test/owner/repo/pull/1
ok - reconciliation makes zero forge or PR API calls
all inactive reconciliation tests passed
FM_TEST_END 2026-08-21T16:36:40Z tests/fm-inactive-reconcile.test.sh exit=0 duration_ms=40498 gate_skip=false
FM_TEST_BEGIN 2026-08-21T16:36:40Z tests/fm-captain-hold-lifecycle.test.sh family=pure-contract-unit expected_gate_skip=none
skip: tasks-axi not found
FM_TEST_END 2026-08-21T16:36:40Z tests/fm-captain-hold-lifecycle.test.sh exit=0 duration_ms=50 gate_skip=true
FM_TEST_BEGIN 2026-08-21T16:36:40Z tests/fm-crew-state.test.sh family=pure-contract-unit expected_gate_skip=none
ok - active run-step is authoritative
ok - stale needs-decision over active run is superseded
ok - stale blocked over active run is superseded
ok - genuine parked run is not flagged superseded
ok - scalar gate parked run is not flagged superseded
ok - gate block parked run is not flagged superseded
ok - ci-ready status log beats monitoring run
ok - ci-monitoring run with checks already green surfaces done
ok - top-level ci status uses ci log green marker
ok - terminal no-checks ci-monitor marker surfaces done
ok - base-advance rearm after green stays working
ok - pending no-checks ci-monitor marker stays working
ok - ci-monitoring run with checks not yet green stays working
ok - a fresh issue after an earlier green reading is not masked
ok - stale checks-green status log does not mask CI relapse
ok - ci fixing is not overridden by an earlier green marker
ok - top-level fixing is not overridden by a stale ci running row
ok - top-level fixing is not overridden by a stale done log
ok - terminal passed run is authoritative
ok - terminal failed run is authoritative
ok - cross-branch run is attributed via the real runs list
ok - cross-branch attribution picks the branch's most recent row
ok - coarse run does not probe another branch's ci log
ok - another branch's run is ignored, falls back
ok - no run + a busy semantic record reads working, attributed to its source
ok - a converted adapter never reads working from rendered footer text
ok - grok still reads working through its isolated rendered-tail fallback
ok - herdr's native busy verdict reads working with no record present
ok - a mid-tool-call crew stays working because its record outranks herdr's generation state
ok - an idle record with idle agent_status stays not-busy (no regression for a human-blocked agent)
ok - no run + idle pane uses the status-log verb
ok - no run + idle pane parses keyed status syntax
ok - no run + idle pane on a paused: status reports state: paused with its reason
ok - no run + idle pane honors the configured paused verb
ok - a trailing resolved: event does not corrupt state render (idle stays idle)
ok - dead window ignores stale status log
ok - closed pane still reports a terminal run-step
ok - closed pane still reports an active run-step
ok - no timeout command uses perl bound
ok - scout skips the run lookup
ok - torn-down worktree is handled gracefully
ok - fm-crew-state remote: alive endpoint falls through to the routed status log
ok - fm-crew-state remote: an idle alive endpoint reads alive, never gone or dead
ok - fm-crew-state remote: an unreachable host reads unknown-remote, never gone or dead
ok - fm-crew-state remote: the remote host's own dead verdict is reported truthfully
ok - missing meta is handled gracefully
ok - crew_is_provably_working absorbs a validating crew found only via the runs-list fallback
ok - crew_is_provably_working still surfaces a genuinely stopped crew (safety property preserved)
ok - usage error exits 2
ok - historical same-branch rewritten head is not attributed as current
ok - active run with valid descendant fix head remains current
ok - local work advanced past run head invalidates attribution
ok - missing run head falls back instead of matching by branch
all fm-crew-state tests passed
FM_TEST_END 2026-08-21T16:36:47Z tests/fm-crew-state.test.sh exit=0 duration_ms=6405 gate_skip=false
FM_TEST_SUMMARY total=3 failed=0 skipped_gate=1 duration_ms=47070
FM_TEST_SUMMARY_FAMILY family=pure-contract-unit count=2 duration_ms=6455 failed=0
FM_TEST_SUMMARY_FAMILY family=watcher-wake-lock count=1 duration_ms=40498 failed=0
FM_TEST_SLOWEST rank=1 script=tests/fm-inactive-reconcile.test.sh duration_ms=40498
FM_TEST_SLOWEST rank=2 script=tests/fm-crew-state.test.sh duration_ms=6405
FM_TEST_SLOWEST rank=3 script=tests/fm-captain-hold-lifecycle.test.sh duration_ms=50
Evidence: Reproduction script: fleet-scale away-mode sweep (runnable against either checkout)
#!/usr/bin/env bash
# repro-captain-held-fleet.sh <repo-root> <label>
#
# Reproduces the captain's reported incident at fleet scale against a real
# firstmate checkout: 11 FINISHED tasks whose last status line is a verified
# captain-held transfer, each already wedge-aged past the 240s wedge detector,
# swept by the real bin/fm-supervise-daemon.sh housekeeping loop over a real
# state dir and a shimmed backend.
#
# Prints the captain-visible surface at each phase:
#   A  the away-mode sweep that fired possible-wedge escalations in one batch,
#      plus the digest that lands in the captain's supervisor pane
#   B  the same fleet once the bounded recheck window has elapsed, plus its digest
#   C  an immediate second sweep inside the freshly reset window (is it bounded?)
#   D  a third window later (does a forgotten hold keep re-surfacing, or rot?)
set -u

REPO=$1
LABEL=$2
# Optional third arg: which declared-wait verb the tasks carry. "captain-held" is
# the incident under test; "paused" re-runs the identical fleet through the verb
# the captain's 11 live tasks currently carry as a workaround, so the two trees'
# captain-visible output for that path can be diffed for drift.
VERB=${3:-captain-held}

# shellcheck source=/dev/null
. "$REPO/tests/wake-helpers.sh"
# shellcheck source=/dev/null
. "$REPO/bin/fm-supervise-daemon.sh"

TMP_ROOT=$(fm_test_tmproot "fm-held-fleet-$LABEL")
HELD=11
WEDGE_SECS=240
PAUSE_SECS=900

dir=$(make_supercase held-fleet)
state="$dir/state"; fakebin="$dir/fakebin"
crew_pane="$dir/crew-pane.txt"; super_pane="$dir/super-pane.txt"
printf 'ship-11 $ \n' > "$crew_pane"

now=$(date +%s)
i=1
while [ "$i" -le "$HELD" ]; do
  task="ship-$i"
  win="fleet:fm-$task"
  printf 'window=%s\nbackend=tmux\nkind=ship\nharness=claude\n' "$win" > "$state/$task.meta"
  # A FINISHED task: it reported done, then the captain marked it held because
  # the remaining work is a decision only a person can make.
  {
    printf 'done: PR https://example.test/pr/9%02d ready for review\n' "$i"
    if [ "$VERB" = paused ]; then
      printf 'paused: awaiting the upstream release tracked by task-decision-%s\n' "$task"
    else
      printf 'captain-held [key=%s]: tracked by task-decision-%s\n' "$task" "$task"
    fi
  } > "$state/$task.status"
  # The 240s wedge detector has already armed on every one of them.
  echo $(( now - 300 )) > "$state/.subsuper-stale-$task"
  i=$(( i + 1 ))
done

PREV=0

sweep() {
  PATH="$fakebin:$PATH" FM_FAKE_TMUX_CAPTURE="$crew_pane" FM_FAKE_TMUX_PANE_ALIVE=1 \
    FM_STATE_OVERRIDE="$state" FM_STALE_ESCALATE_SECS="$WEDGE_SECS" \
    FM_PAUSE_RESURFACE_SECS="$PAUSE_SECS" FM_ESCALATE_BATCH_SECS=99999 \
    housekeeping "$state"
}

report() {  # <phase-title>
  local total new
  if [ -s "$state/.subsuper-escalations" ]; then
    total=$(wc -l < "$state/.subsuper-escalations" | tr -d ' ')
  else
    total=0
  fi
  new=$(( total - PREV ))
  printf '\n--- %s ---\n' "$1"
  printf 'escalations this sweep produced: %s\n' "$new"
  if [ "$new" -gt 0 ]; then
    tail -n "$new" "$state/.subsuper-escalations" | sed 's/^/    /'
  else
    printf '    (nothing surfaced to the captain)\n'
  fi
  printf 'wedge markers still aging: %s   pause-cadence markers tracked: %s\n' \
    "$(find "$state" -maxdepth 1 -name '.subsuper-stale-*' | wc -l | tr -d ' ')" \
    "$(find "$state" -maxdepth 1 -name '.subsuper-paused-*' | wc -l | tr -d ' ')"
  PREV=$total
}

flush_to_pane() {  # what the captain actually reads in the supervisor pane
  local sent="$dir/sent.log"
  printf '\xe2\x9d\xaf\n' > "$super_pane"
  : > "$sent"
  afk_enter "$state" >/dev/null 2>&1 || true
  if PATH="$fakebin:$PATH" FM_FAKE_TMUX_PANE_ALIVE=1 FM_FAKE_TMUX_SENT="$sent" \
       FM_FAKE_TMUX_CAPTURE="$super_pane" FM_STATE_OVERRIDE="$state" \
       FM_SUPERVISOR_BACKEND=tmux escalate_flush "$state"; then
    if [ -s "$sent" ]; then
      printf 'supervisor pane receives (1 submission, %s Enter press):\n' \
        "$(grep -c '^\[ENTER\]$' "$sent")"
      grep -v '^\[ENTER\]$' "$sent" | fold -s -w 130 | sed 's/^/    /'
    else
      printf 'supervisor pane receives: NOTHING (buffer empty, the captain hears nothing at all)\n'
    fi
  else
    printf 'flush failed\n'
  fi
  PREV=0
}

elapse_window() {  # push cadence tracking past PAUSE_SECS
  local m
  for m in "$state"/.subsuper-paused-*; do
    [ -e "$m" ] || continue
    echo $(( now - (PAUSE_SECS + 100) )) > "$m"
  done
}

printf '=== %s: %s finished tasks declaring %s, wedge detector armed 300s ago ===\n' \
  "$LABEL" "$HELD" "$VERB"
printf 'last status line on every task: %s\n' "$(last_status_line "$state/ship-1.status")"
printf 'wedge threshold: %ss    declared-wait recheck window: %ss\n' "$WEDGE_SECS" "$PAUSE_SECS"

sweep
report "A. away-mode sweep, 300s idle: past the wedge threshold, inside the recheck window"
flush_to_pane

elapse_window
sweep
report "B. next sweep after the ${PAUSE_SECS}s recheck window elapsed"
flush_to_pane

sweep
report "C. immediate sweep inside the freshly reset window (bounded means zero)"

elapse_window
sweep
report "D. one more window later (a forgotten hold must keep re-surfacing)"
- Evidence: Reproduction script: normal-mode watcher recheck for a held crew and a held secondmate (local file: /tmp/no-mistakes-evidence/01M0JF1DWJ6CG6JHZYP8VC2QG8/repro-watcher-held-recheck.sh)
Evidence: Reproduction script: push-transition absorb for a verified captain-held transfer
#!/usr/bin/env bash
# repro-push-transition-absorb.sh <repo-root> <label>
#
# The third changed subsystem: bin/fm-push-transition-lib.sh's absorb, driven
# through the real handle_push_transition over a real state dir. A backend push
# reports a ship blocked while its last status line is a verified captain-held
# transfer. Prints the captain-visible outcome: the wake queue (an immediate
# escalation the captain gets woken for) and the triage log (a silent absorb the
# poll loop's bounded cadence will re-surface).
set -u

REPO=$1
LABEL=$2

# shellcheck source=/dev/null
. "$REPO/tests/lib.sh"

TMP=$(fm_test_tmproot "fm-push-absorb")
STATE_DIR="$TMP/state"
mkdir -p "$STATE_DIR"
export FM_STATE_OVERRIDE="$STATE_DIR"
export FM_ROOT_OVERRIDE="$REPO"
# shellcheck source=/dev/null
. "$REPO/bin/fm-watch.sh"

WAKE_LOG="$TMP/wakes"; : > "$WAKE_LOG"
wake() { printf '%s\n' "$1" >> "$WAKE_LOG"; return 0; }
sleep() { :; }

printf '=== %s ===\n' "$LABEL"
fm_write_meta "$STATE_DIR/ship-hold.meta" "window=default:wG:pQ" "backend=herdr" "kind=ship"
printf 'done: PR https://example.test/pr/901 ready for review\ncaptain-held [key=route]: tracked by task-decision-route\n' \
  > "$STATE_DIR/ship-hold.status"
printf 'last status line: %s\n' "$(last_status_line "$STATE_DIR/ship-hold.status")"

handle_push_transition herdr default "$(fm_transition_record wG:pQ wG "" blocked claude)"

printf '\nwake queue (an entry here means the captain is interrupted right now):\n'
if [ -s "$STATE_DIR/.wake-queue" ]; then
  awk -F '\t' '{ printf "    kind=%s key=%s\n    payload=%s\n", $3, $4, $5 }' "$STATE_DIR/.wake-queue"
else
  printf '    (empty)\n'
fi
printf 'supervisor wakes fired: %s\n' "$(wc -l < "$WAKE_LOG" | tr -d ' ')"
printf '\ntriage log (an entry here means it was absorbed for the bounded cadence):\n'
if [ -s "$STATE_DIR/.watch-triage.log" ]; then
  sed 's/^/    /' "$STATE_DIR/.watch-triage.log"
else
  printf '    (empty)\n'
fi

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 3 infos
  • ⚠️ bin/fm-supervise-daemon.sh:375 - Away-mode wedge detection now relaxes from FM_STALE_ESCALATE_SECS (240s) to FM_PAUSE_RESURFACE_SECS (3600s) for ANY task whose last status line is captain-held, including a crew that is still working. Unlike paused:, which the crew (or firstmate steering it) writes to declare its OWN idleness, the captain-held [key=...] line is bookkeeping appended by bin/fm-captain-hold.sh complete about a decision: origin_open_decisions (bin/fm-captain-hold.sh:291-296) skips only a done/failed last line for a non-secondmate, so a working: origin is the normal case for a live task. Concrete sequence: firstmate runs complete &lt;origin&gt; on a task whose last line is working:, so captain-held [key=x]: tracked by ... becomes the last line; the crew then wedges on a hung foreground call with a frozen pane; classify_stale now returns pause, handle_wake records .subsuper-paused-&lt;key&gt; instead of .subsuper-stale-&lt;key&gt;, housekeeping (2) defers at line 1011, and the wedge is reported only after 3600s and labeled "awaiting external" rather than "stale persisted 240s (possible wedge)". The same premise drives bin/fm-push-transition-lib.sh:137, where a herdr -&gt;blocked edge (per bin/fm-transition-lib.sh:73-79, precisely a permission/trust dialog, interactive menu, or wedged prompt, NOT the captain hold) is now absorbed. Normal mode has behaved this way since b57c4d6 so this is supervisor parity rather than a new hole, and the hourly recheck still fires, which is why this is a decision to confirm rather than a defect: is relaxing the wedge bound for a live crew that merely carries a hold transfer intended, or should the daemon's captain-held deferral additionally require that the crew show no positive working evidence (leaving paused: unconditional)?
  • ⚠️ bin/fm-supervise-daemon.sh:1056 - The hourly recheck the captain actually reads is paused ${age}s (awaiting external, recheck whether the wait still holds): $win, and the normal-mode equivalent at bin/fm-watch.sh:432 is stale: $win (paused ${age}s, awaiting external - declared pause, ...). For the 11 captain-held tasks this change targets, the wait is on the CAPTAIN, not on an external dependency, so the digest points the one person who can unblock the work away from the fact that they are the blocker. The intent authorizes this explicitly ("Escalation strings and the classify_stale printf wording were left byte-identical on purpose, to avoid churning assertions pinned by two suites"), so this is disclosed containment, not an oversight. Raising it only so the wording tradeoff is the captain's call: a branch on status_is_paused vs the captain-held verb at these two emit points would name the right human, at the cost of touching the assertions in tests/fm-daemon.test.sh and tests/fm-watch-triage.test.sh that pin "awaiting external".
  • ⚠️ docs/configuration.md:592 - The FM_PAUSE_RESURFACE_SECS entry ends "the away-mode daemon uses the same setting for declared external waits", which this change makes incomplete: the daemon now applies the same cadence to a verified captain-held transfer as well (bin/fm-supervise-daemon.sh:1043-1057). The sibling FM_BUSY_TURN_MAX_SECS line directly above already says "a declared external wait or verified captain-held transfer", so the two entries now disagree. Relatedly, docs/architecture.md:30 still asserts "the secondmate idle-endpoint exemption is unchanged", but bin/fm-watch.sh:1127 now admits a captain-held mate to the pane-stale path and pause_state_class promotes it to the bounded cadence. AGENTS.md:336 puts keeping documentation accurate inside the current task, so this belongs with the change rather than a follow-up.
  • ℹ️ bin/fm-watch.sh:519 - The new disjunct is unreachable as written: control flow can only arrive at line 519 as either (kind == secondmate, with agent_alive never assigned) or (kind != secondmate and agent_alive == dead), because the block at 504-511 returns none for every non-secondmate whose agent is not confidently dead. So [ &#34;${agent_alive:-unknown}&#34; = dead ] || [ &#34;$(window_kind &#34;$win&#34;)&#34; = secondmate ] is always true whenever class = none, and the whole condition reduces to [ &#34;$class&#34; = none ]. The second window_kind &#34;$win&#34; also costs a full second scan of $STATE/*.meta (via fm_backend_meta_for_window) on every secondmate stale poll, on top of the one at line 504. Hoisting kind=$(window_kind &#34;$win&#34;) into a local once at the top of the function and reusing it at 487, 504, and 520 removes that scan and makes the reduction visible; the explanatory comment above it carries the intent either way. Behavior-preserving.
  • ℹ️ bin/fm-inactive-reconcile.sh:343 - Informational, on an untouched file and outside this change's scope. The commit message justifies leaving this guard captain-held-only with "a declared pause needs no such guard: the scan only reports done or failed, and nothing else reaches its record path". That is not quite exact: bin/fm-crew-state.sh's run-step path is authoritative over map_log_state, so a task whose last line is paused: but whose no-mistakes run reports a terminal outcome emits state: done (bin/fm-crew-state.sh:495-497, 522-524) and does reach the record path at line 353. The reachable symptom is an inactive-outcome presentation for a paused task, which is a different failure from the possible-wedge escalations this change fixes, and it predates the change; no action needed here. Noting it only so the stated justification is not later read as proof that the paused side needs no guard.

🔧 Fix: name the captain in a held task's bounded recheck
3 infos still open:

  • ℹ️ bin/fm-watch.sh:437 - handle_paused_stale now re-reads the last status line itself (grep + tail + a command substitution) on every stale poll for a paused or held window, even though the caller already holds that exact line: last is read once at bin/fm-watch.sh:1130 and never reassigned in the loop, and lines 464, 1249 and 1294 each re-read the same file inline. The function's own header states it "must be cheap: it NEVER re-reads crew state", so a fourth identical read per poll works against the stated contract, and it opens a small window where the wording is chosen from a newer line than the one whose classification authorized the absorb (harmless today, since both possible outcomes are already correct wordings). Passing the already-read line in as a parameter and reusing $last at the four call sites removes the read and ties the wording to the line the gate actually saw. Behavior-preserving.
  • ℹ️ bin/fm-supervise-daemon.sh:382 - classify_stale still distills a captain-held stale as pause|paused (awaiting external), rechecked on a long cadence: &lt;line&gt;, so the away-mode daemon's own log names an external dependency for a wait that is on the captain, which is now inconsistent with the digest wording the fix round introduced 675 lines below. This is log-only and not a captain-facing regression: handle_wake's pause action only passes distilled to log, never to escalate_add, and no other consumer reads that decision text (the daemon test matches the pause| prefix alone). The intent explicitly kept this printf byte-identical, and the captain's round-1 instruction named only the two emit points, so no action is needed; recording it so this log line is not later mistaken for the digest the captain reads.
  • ℹ️ bin/fm-supervise-daemon.sh:1032 - The "which human the wait names" rationale is now stated in full three times: bin/fm-classify-lib.sh:150-154 (the new status_is_captain_held header, the natural owner since that file owns the verb vocabulary), bin/fm-watch.sh:421-425, and this housekeeping comment. .agents/skills/firstmate-coding-guidelines/SKILL.md's one-owner rule allows a one-line reinforcement at a risk point but not a second full restatement, precisely because the copies drift when only one is edited. The second half of the fm-watch.sh comment (the pause-tracking-only fallback keeping the external-wait wording) is genuinely local and should stay; the duplicated first sentence there and this three-line daemon addition can shrink to a cross-reference to the predicate that owns it. Comments only, no behavior change.
✅ **Test** - passed

✅ No issues found.

  • bin/fm-test-run.sh tests/fm-daemon.test.sh tests/fm-supervision-events.test.sh tests/fm-watch-triage.test.sh (exit 0; includes all six new tests)
  • bin/fm-test-run.sh tests/fm-inactive-reconcile.test.sh tests/fm-captain-hold-lifecycle.test.sh tests/fm-crew-state.test.sh - neighbour suites guarding the deliberate decision NOT to conflate the two verbs (fm-captain-hold-lifecycle self-skips: skip: tasks-axi not found)
  • Regression proof on a pristine git archive 4d2cb0c extraction with only the target test files overlaid, each test isolated: test_stale_captain_held_classifies_pause, test_housekeeping_captain_held_resurfaces_and_resets, test_housekeeping_captain_held_stale_marker_transitions_to_pause, test_secondmate_captain_held_resurfaces_in_normal_mode, test_status_is_paused_classifier, test_exited_declared_pause_is_bounded_but_live_gate_surfaces all fail before the fix; test_housekeeping_captain_held_resolved_cleared and test_housekeeping_paused_resurfaces_and_resets pass before it
  • tests/fm-supervision-events.test.sh run whole against the base tree: fails at a captain-held crew must NOT be fast-escalated with the actual fast-escalated wake record
  • Manual end-to-end away-mode reproduction: 11 finished tasks whose last status line is captain-held, wedge markers aged 300s past a 240s threshold, swept by the real housekeeping from bin/fm-supervise-daemon.sh over a real state dir, then flushed through the real escalate_flush/inject_msg to capture the digest typed into the supervisor pane, across four phases (inside window / window elapsed / immediate re-sweep / one more window later), run against both base and target
  • Manual end-to-end normal-mode reproduction: real bin/fm-watch.sh run against a held ordinary crew with a dead agent and against a held secondmate, capturing the watcher reason line and the queued wake record on both base and target
  • Manual end-to-end push-transition reproduction: real handle_push_transition on a held ship reporting blocked, capturing the wake queue, supervisor wake count, and triage log on both base and target
  • Live-state regression guard: the same 11-task fleet re-run with the paused: verb on both trees, transcripts normalized for wall-clock ages and diffed (no differences)
  • Intent constraint checks: no em-dashes in added diff lines or in either commit message, no agent co-author trailer, diff confined to bin/, docs/, tests/
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 warning
  • ⚠️ linter found issues (exit code 127)
✅ **Push** - passed

✅ No issues found.

…lared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.
@Inthuson

Copy link
Copy Markdown
Contributor Author

Operational note for whoever lands this.

Eleven finished tasks currently carry a paused: line re-appended as their last status line. That was a deliberate workaround for exactly the defect this PR fixes: once these tasks were marked captain-held:, the 900s inactive-outcome scan went quiet but the 240s wedge detector no longer saw a paused verb, so five of them escalated as possible wedges in one batch. The paused: line bought the bounded cadence back by hand.

Those lines are live supervision state and are intentionally untouched by this change. Once this lands, they can be retired: captain-held: earns the bounded once-per-window recheck on its own, and the recheck now names the captain rather than an external dependency, so a held task neither escalates as a wedge nor rots invisibly.

Inthuson's firstmate

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Corrective — a verified captain-held last line was escalating as a 240s possible-wedge (and then going silent) instead of sharing the bounded FM_PAUSE_RESURFACE_SECS cadence a paused: already gets. Rechecks now name the captain, not an external wait. No enable flag; this restores the intended hold contract.

VISION: aligns (an escalation exists for a decision only a human can make; a forgotten hold must not rot invisibly; wedge noise is not news).

Security: no.

CI: all checks green, including Require no-mistakes (SUCCESS). mergeable CLEAN, ahead 3 / behind 0.

Overlap (do not land together): semantic conflict with #2749 on pause_state_class (opposite cheap-path liveness policy) and with #2750 on handle_paused_stale plus bin/fm-classify-lib.sh / bin/fm-supervise-daemon.sh housekeeping. Same-file neighbors: #2738, #2496, #2598, #2419.

This is not waiting on the author and is not a captain-decision hold. Isolated merge-eligible YES. Coordinator must not land it with #2750 or #2749. The author's operational note about live paused: workaround lines on eleven held tasks is real supervision state; retire those lines only after this lands.

@kunchenguid kunchenguid left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Speaking as Kun's firstmate:

Corrective. A verified captain-held task now gets the same bounded pause cadence as a declared pause, so finished work waiting on the captain is neither wedge-escalated nor silenced. The daemon, push-transition, and leftover watcher paths now use status_is_paused_or_captain_held; rechecks name which human the wait is on.

VISION: aligns (honest under load, looking-away durability, scripts own mechanics, token efficiency). Not a new default grant — it completes the existing combined predicate the watcher already had.

Overlap: file-level with open #2749 and conflicting #2750 on watch/daemon/classify. Those stay open; they are different defects.

CI green including no-mistakes. On current main (ahead 3 / behind 0). Landing this one.

@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate:

Corrective. A verified captain-held task now gets the same bounded pause cadence as a declared pause, so finished work waiting on the captain is neither wedge-escalated nor silenced. The daemon, push-transition, and leftover watcher paths now use status_is_paused_or_captain_held; rechecks name which human the wait is on.

VISION: aligns (honest under load, looking-away durability, scripts own mechanics, token efficiency). Not a new default grant — it completes the existing combined predicate the watcher already had.

Overlap: file-level with open #2749 and conflicting #2750 on watch/daemon/classify. Those stay open; they are different defects.

CI green including no-mistakes. On current main (ahead 3 / behind 0). Landing this one.

@kunchenguid
kunchenguid merged commit 3d125ad into kunchenguid:main Aug 21, 2026
13 checks passed
@kunchenguid

Copy link
Copy Markdown
Owner

Speaking as Kun's firstmate: this is merged. Thank you @Inthuson — really appreciate you taking the time on this.

withally added a commit to withally/firstmate that referenced this pull request Aug 22, 2026
* fix(bearings): restore decision options and add close controls (kunchenguid#2707)

* fix(bearings): always show decision options and a close/drop control

Freeform-only Captain's Call cards hid the option buttons the board was designed around, and there was no way to drop a stale hold without inventing an answer. Require selectable options, keep freeform as a supplement, and route the reserved __drop__ answer through decline so the hold leaves Captain's Call.

* no-mistakes(review): Fix drop closure and decision-only option validation

* no-mistakes(review): Preserve answerability for non-decision cards

* no-mistakes(document): Clarify decision drop documentation

* ci: require no-mistakes pipeline step attestation (kunchenguid#2710)

Signature-only PRs can hide skipped review, test, or document steps. Fail unless no-mistakes >= 1.46.0 attests those three steps completed.

* feat: collapse decisions into tasks held for the captain (kunchenguid#2728)

* feat(captain-hold): collapse the decisions concept into tasks held for the captain

A decision is no longer a separate type: it is an ordinary backlog task held
for the captain, identified by its task id. bin/fm-captain-hold.sh owns the
surviving behaviors - guarded hold creation, the recorded-answer close
(answer/answers with a release mode for captain-gated work), the source
bindings, and the investigation completion gate - and bin/fm-decision-hold.sh
becomes a one-release compatibility shim over it.

The fleet snapshot now parses hold-until and computes captain_actionable as
queued + captain-held + unblocked + due, independent of row kind, plus a
presentation-only deferred_marker for prose-deferred rows. Bearings renders
every due captain-held task in Captain's Call, date-deferred holds as dated
Charted Next gates, suppresses prose-deferred rows from default views with an
omitted disclosure, and excludes from Recently Landed anything that closed
while still held for the captain.

Legacy compatibility: pre-collapse <origin>-decision-<key> rows are already
plain task ids and keep working; short keys in recorded metadata, concrete
origin bindings, chat --resolve-key fallbacks, and old resolution records all
resolve in place.

* no-mistakes(review): Fix captain answer replay and body preservation

* no-mistakes(review): Fix captain hold idempotency and legacy replay

* no-mistakes(review): Validate card close modes and compatibility routing

* no-mistakes(review): Enforce release replay mode matching

* no-mistakes(review): Prevent duplicate decision cards and released replay mismatches

* no-mistakes(review): Preserve answer columns and legacy resolve replays

* no-mistakes(document): Document strict replay and legacy compatibility

* no-mistakes(lint): Quote done literals to satisfy ShellCheck

* no-mistakes: apply CI fixes

* fix(rebase): keep collapsed captain hold board semantics

* fix: bound recovery announcements and preserve supervision (kunchenguid#2733)

* fix(watch): announce recovery once per generation and keep successors supervising

A lost Pi/OpenCode handling handshake re-announced the same recovery
generation on every cycle and spent the successor's first ~55s blind, so
a real crew event could be ignored and then dropped. Record the
announcement in the durable marker, confirm the handshake before the
follow-up without swallowing failure, and enter the poll loop immediately.

* no-mistakes(review): Tighten recovery event timing regression

* no-mistakes(document): Document recovery-loop supervision guarantees

* fix(bin): surface captain-call record divergence (kunchenguid#2744)

* fix(bin): signal a captain call resolved in the log but still held

A captain call has two records and closing one has never closed the
other: a `resolved [key=...]` line closes the status-log fold, while the
backlog task held for the captain closes only through
`fm-captain-hold.sh answer`. Answering on the status side alone left no
trace of the disagreement - the fold went quiet, the durable record kept
saying the captain owed an answer, and nothing warned. The defect was
never the separation; it was the silence.

Add `fm-captain-hold.sh diverged`, a read-only report of that
contradiction, and print it from `fm-wake-drain.sh` as a bounded RECORD
DIVERGENCE section beside OPEN DECISIONS on every drain. It flags one
condition: a task still open and still carrying the captain-hold
annotations whose key was closed on the status side by the resolve verb,
under the collapsed identity or the legacy derived one.

It closes nothing, ever. A captain call closed wrongly leaves review
entirely, which is worse than the noise, so both reconciliation
directions stay human-owned and the printed hint names both - a
resolution is not proof the captain ruled, since a call can dissolve on a
false premise or turn out to have been a question of fact.

Three states are deliberately not divergence: a `captain-held` close is
the verified transfer `complete` writes, a still-open keyed decision
belongs to the OPEN DECISIONS fold, and a captain call with no routed
work item is legitimate rather than incomplete, so routed work is no part
of the test.

`fm-classify-lib.sh` gains `status_key_closing_verb`, which reports how
the status side currently reads one key by replaying the existing
`_fm_decision_fold_line` rule rather than re-deriving it, so the two
closing verbs stay distinguishable in one place. The per-wake cost is one
`tasks-axi list`, one key scan per status log, and the precise per-key
fold only for a key that already names a still-open task; the call is
hard-bounded so a slow backlog tool can never delay wake presentation.

* fix(document): Correct divergence lifecycle documentation

* fix(document): Neutralize divergence lifecycle prose

* fix(bin): re-arm after an abandoned auto-arm claim and defer a wedge escalation while a worktree is written (kunchenguid#2524)

* fix(watch): re-arm supervision after an abandoned auto-arm claim

A Claude auto-arm cycle that armed, delivered one rewake, and exited left
its single-flight lock behind. Both Stop-event participants then deferred
to that lock forever, because its recorded pid was still live: the
turn-end guard read it as recovery under way and allowed the stop, and the
next Stop firing treated it as another owner and declined to arm. On
2026-08-14 a home with two tasks in flight lost supervision for about 40
minutes with no watcher process and no watcher lock, its beacon frozen at
the one delivery, and both crewmates' finished reports sat in the durable
queue until an operator drained it by hand.

Abandonment is now proven from the epoch ledger instead of inferred from
pid liveness. A lock whose holder pid matches the ledger's own owner_pid
while the recorded outcome is anything other than arming has already
finished its decision, so that claim is reclaimed under the lock's steal
mutex, stops counting as recovery ownership in the guard, and is cleared
by the guard's terminal check rather than deferred to. A failed clear
re-blocks instead of allowing a blind stop, and an arming entry stays in
flight however old it is, because its owner foregrounds the arm for the
whole watcher cycle.

Issue kunchenguid#2251's PR kunchenguid#2263 does not cover this failure. It is closed and
unmerged, lives entirely in bin/fm-watch-arm.sh, and retires the stalled
watcher and matching stale watcher lock of an arm that is currently
running. Here no arm and no watcher were running and no watcher lock
existed, so it has nothing to retire and the home stays blind.

tests/fm-claude-stop-autoarm.test.sh covers the reclaim, the still-arming
and unnamed-owner cases that must keep the gate closed, and the failed
clear. tests/fm-turnend-guard.test.sh covers the guard side of the same
boundary. Both fail without this change.

* fix(watch): defer a wedge escalation while the task worktree is written

The wedge detector had two inputs, rendered pane quietness and the run
step, and neither can see a crew that is writing source, then tests, then
documentation behind a static pane. On 2026-08-14 one crewmate produced
eight consecutive possible-wedge escalations in a single afternoon, three
of them demanding deep inspection, while it was demonstrably working and
then committed. Every one of them cost a supervision turn to disprove by
hand.

Add write activity inside the crew's own recorded worktree as a third
liveness input. crew_worktree_written_since compares the worktree against
the caller's existing idle-window timer file, so -newer needs no clock
arithmetic, no temp file, and no portable mtime write. The probe runs only
inside the branch that was about to escalate, which bounds it to one
pruned, depth-bounded walk per window per FM_STALE_ESCALATE_SECS and
leaves the per-poll stale sweep exactly as cheap as before.

Positive evidence defers rather than cancels. The idle timer restarts so
the next window probes again, the escalation counter is neither advanced
nor reset so a later genuine wedge keeps the demand-deep-inspection
history it earned, and a .writing-since marker ages the whole deferral
chain so the pane still re-surfaces once per FM_PAUSE_RESURFACE_SECS,
through the same throttle shape a declared pause already uses, labeled as
a recheck rather than a wedge. This can only reduce false positives: every
absence of evidence, including no recorded worktree, a torn-down worktree,
a missing anchor, and a failed walk, falls through to the unchanged
escalation schedule, so a crew that writes nothing still escalates on the
existing timetable.

What the signal cannot see, by design or by construction:

- CPU burn with no writes, such as a long compaction, is invisible. That
  case keeps the old behavior exactly.
- A commit-only phase writes only .git, which is pruned first so that
  firstmate's own read-only git commands against the worktree can never
  make the probe self-fulfilling.
- Writes under the pruned generated trees, or deeper than
  FM_WORKTREE_WRITE_MAXDEPTH, do not count.
- The probe cannot attribute a write to the crew, so a background build or
  another process touching the tree looks the same. The hourly re-surface
  is what bounds that, and a churny file cannot buy silence.
- The away-mode daemon's own escalation path is deliberately untouched.

tests/fm-watch-triage.test.sh covers the classifier including the .git
prune, both halves of the live case on one fixture (quiet plus writing
defers, quiet plus silent still escalates and counts), and the bounded
re-surface. All three fail without this change.

* no-mistakes(review): prove autoarm claims by identity; skip mate-home write probe

* no-mistakes(document): document away-mode wedge boundary and probe filesystem limit

* no-mistakes(document): qualify turn-end recovery condition for abandoned auto-arm claims

* fix(watch): keep a write deferral scoped to its own idle window

Two consistency gaps in the worktree write probe, both found while reviewing
the wedge-deferral change on this branch.

A write deferral is a bounded chain: its .writing-since marker ages the whole
chain so a churning worktree still re-surfaces once per resurface window. That
is only sound while the chain belongs to the current quiet stretch, so every
path that restarts the idle-window timer has to drop it too. Two did not: the
corrupt-timer repair in wedge_timer_check, and both first-sight branches for a
captain-relevant status. A chain left over from an earlier quiet stretch made
the first deferral of the new window re-surface immediately instead of after a
full fresh window.

FM_WORKTREE_WRITE_PRUNE is a skip list, so clearing it reads as "skip nothing"
and is the obvious way to widen the probe to the whole depth-bounded tree.
Instead an empty list reported no evidence at all, quietly costing the wedge
detector its third liveness input on a home that meant to widen the walk. An
empty list now widens the walk, and the header says so.

Neither change alters when a stall that writes nothing escalates.

Regressions in tests/fm-watch-triage.test.sh cover all three paths and each
one fails on the pre-fix code.

* no-mistakes(review): honor an empty write-prune, bound the probe, share window_key

* no-mistakes(document): align probe knob count and guard regression-coverage ownership

* no-mistakes(lint): silence deliberate single-quote SC2016 in write-prune env test

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause (kunchenguid#2748)

* fix(bin): give a captain hold the same bounded pause cadence as a declared pause

Two supervisors read a finished task's last status line and disagreed about which
declarations mean an idle endpoint is expected. bin/fm-inactive-reconcile.sh
suppresses its inactive-outcome scan only on `captain-held`, while the away-mode
daemon's wedge path gated deferral on `paused` alone. Both read the LAST line, so
the two verbs are mutually exclusive and no finished task waiting on a person
could satisfy both at once. Marking 11 such tasks `captain-held:` silenced the
900s outcome scan and immediately produced five possible-wedge escalations in one
batch, because the 240s wedge detector no longer saw a pause verb.

fm-classify-lib.sh's status_is_paused_or_captain_held already owns the combined
question, and bin/fm-watch.sh's ordinary-crew wedge path already asked it. This
extends that same answer to the paths still asking the narrower one:

- bin/fm-supervise-daemon.sh, all six sites, which form one subsystem and have to
  move together. classify_stale returns the pause action, reconcile_pause_tracking
  and migrate_watcher_pause_markers record and migrate the marker, and
  housekeeping defers the wedge and then re-surfaces the recheck. Changing only
  the stale-persistence gate would defer the escalation while
  reconcile_pause_tracking recorded nothing, so the wedge marker would persist and
  the sweep would `continue` past it forever: quiet, but never re-surfacing.
- bin/fm-watch.sh's secondmate stale gate, whose downstream owner
  pause_state_class already treats both declarations identically.
- bin/fm-push-transition-lib.sh's absorb, where either declaration already names
  the human the transition would report and the wait is already durably recorded.

Quieting alone would be half a fix, so the bounded re-surface had to reach a hold
too. A hold has no current-state mapping, unlike `paused`, so authoritative crew
state reports it as unknown and pause_state_class received `none`. An ordinary
crew recovers pause classification from that state through confirmed agent death,
which proves no live decision gate is being silenced. A secondmate's endpoint
liveness is deliberately never read there, because an idle mate is healthy by
design, so that confirmation is unavailable by construction and cannot be
required: without recovering the classification for a mate, every caller silenced
a held mate outright and its hold would rot invisibly. That promotion is bounded
by the declared-wait guard at the top of the function, so it can only reclassify a
task that already declared a wait and shows no positive working evidence.

Two narrow `status_is_paused` calls are deliberately left alone.
bin/fm-crew-state.sh's map_log_state is a current-state reporting contract, not a
wedge path; reporting a hold as `paused` would erase the distinction
status_key_closing_verb and fm-captain-hold.sh depend on, where a `captain-held`
close is a verified durable transfer and a `resolved` close claims outright
settlement. fm-classify-lib.sh's call inside status_is_captain_relevant needs no
change because that function's own case list already returns non-relevant for
`captain-held`.

bin/fm-inactive-reconcile.sh keeps its `captain-held` suppression as it is. Its
guard exists because a finished task's crew state still reports done from a
higher-priority source than the log, and a declared pause needs no such guard: the
scan only reports done or failed, and nothing else reaches its record path.
Widening it would change a separate subsystem's reporting contract, which this
defect does not require.

Coverage extends the existing colocated patterns for these predicates and asserts
both halves. tests/fm-daemon.test.sh covers the classification, the wedge marker
converting to pause tracking with no escalation, the bounded re-surface with its
window reset, and the boundary case where an answered hold stops claiming the
cadence. tests/fm-watch-triage.test.sh covers a held secondmate re-surfacing on
the same bounded cadence without being labeled a wedge.
tests/fm-supervision-events.test.sh covers the absorbed push transition. Every one
of these fails on the pre-fix code except the answered-hold boundary case, which
is there to pin that the quieting was not widened too far.

The `paused:` workaround appended to those 11 tasks is live supervision state and
is untouched here. It can be retired once this lands.

* no-mistakes(review): name the captain in a held task's bounded recheck

* no-mistakes(document): extend declared-wait supervision docs to captain-held holds

* fix(bin): make lint prerequisites and harness tests reliable (kunchenguid#2758)

* fix(lint): name the installer when ShellCheck or actionlint is missing

A missing actionlint exited 127 like a bare command-not-found. Fail with
exit 1 and point at the pinned installer, matching the missing-ShellCheck
path, without weakening the version pin.

* test: isolate kimi and muse detection from inherited Cursor markers

Harness detection checks CURSOR_AGENT before ancestry, so these
markerless-adapter cases failed when the suite itself ran under Cursor.
Clear the verified markers the same way the secondmate harness tests already do.

* no-mistakes(document): Document Muse Cursor marker cleanup

* feat(bin): report watched tooling updates that are available or installed but inert (kunchenguid#2684)

* feat(checks): report tool updates that are available or installed but inert

Firstmate had no way to notice that tooling this home depends on needs an
update, and no way at all to notice the worse case: an update that installed
correctly and then did nothing.

That second case is why this exists. A tool that self-installs into
~/.local/bin while a version manager keeps its own older copy earlier on PATH
looks completely up to date to anything that asks only "is a newer version
published". On 2026-08-20 a Herdr update landed at 0.8.2 while an older 0.8.0
copy stayed earlier on PATH, so every Herdr command failed on a protocol
mismatch and firstmate could not read its own fleet.

bin/fm-tool-update-check.sh reports the two conditions separately:

  <tool> update available      a newer version exists at the update source.
  <tool> update not in effect  a newer copy is installed on this host, but
                               PATH still resolves an older one.

PATH skew is measured, never inferred. Every executable copy of a watched
command on PATH is asked for its own version and those answers are compared,
so one lookup cannot hide the skew, and a directory name is never read as a
version because a version manager's "latest" directory can hold an older
build. A copy that will not report a version is a check failure, not a pass.

The watched tools live in local, gitignored config/watched-tools.json, so
adding a tool is a config edit rather than a code change, and the file is
never propagated to another home. Update sources cover both shapes: a local
clone's commit distance from its remote branch, and a command's own version
and update announcement, including a tool like no-mistakes that prints its
version on one command and announces a new release on another.

The check prints one line when something needs attention and prints nothing
otherwise, so it rides the existing watcher state-check contract with its
trust binding instead of introducing a schedule of its own, and
state/.tool-updates keeps the same pending update from being reported on
every poll.

The check only reports. It never installs, updates, reorders PATH, touches a
version manager, or fetches into a watched repository; every git probe is
read-only.

Tests cover the skew case as a regression, and it was verified by mutation:
removing the skew report, or stopping after the first PATH hit as a single
lookup would, each make that test fail.

* no-mistakes(review): fix tool update check probe reporting, budget, and shim write

* no-mistakes(review): keep sweeps alive on broken patterns and oversized budgets

* no-mistakes(review): roll back failed arm, widen budget clamp, bound repo probe

* no-mistakes(review): guard git probes at the budget, record uncut findings

* no-mistakes(document): fix stale watched-tool report-record wording in docs and header

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

* no-mistakes: apply CI fixes

The behavior shard's watch-triage suite failed on the new worktree-write wedge
tests. Those five tests are the only ones in the file that do not use its
standard waits. They give a fixed 3 second liveness budget to the one poll that
now spawns the bounded worktree walk, and 4 seconds to an escalating watcher
where every other test in the file gives 10. On a loaded runner that poll
outlives the fixed budget, so the round is reaped before the deferral it asserts
on is recorded, and the test reports a lost deferral instead of the deferral
under test. Wait for a completed poll cycle through the file's own
wait_poll_cycle, which is what its header documents this hazard for, and use the
file's standard 100 tick exit budget.

Verified against a load that reproduces the failure: 11 of 12 runs failed
before, 8 of 8 pass after. Verified by mutation too, so the waits still prove
the behavior: removing the write deferral, and keeping a finished deferral chain
across an idle-timer repair, each still fail their test.

* fix: decouple ask-user decisions from yolo (kunchenguid#2764)

* fix: treat yolo as merge authority only, not ask-user finding authority

Yolo on/off was documented as also deciding no-mistakes ask-user findings, which hid firstmate's duty to judge unambiguous-toward-design findings itself. Keep every safety boundary; this is a contract clarification, not a relaxation.

* no-mistakes(document): Clarify yolo documentation ownership and merge posture

* fix(spawn): restore filesystem identity guard

---------

Co-authored-by: Kun Chen <3233006+kunchenguid@users.noreply.github.com>
Co-authored-by: Mickaël Rémond <mremond@process-one.net>
Co-authored-by: Inthuson <iaminthuson@gmail.com>
Co-authored-by: Inthuson <inthuson@amazon.com>
digbycampbell pushed a commit to digbycampbell/firstmate that referenced this pull request Aug 22, 2026
Takes the 10 genuinely-missing upstream commits through kunchenguid#2764: the
captain-hold rework (kunchenguid#2707/kunchenguid#2728/kunchenguid#2744/kunchenguid#2748), watcher robustness
(kunchenguid#2524/kunchenguid#2733), ask-user/yolo decouple (kunchenguid#2764), tool-update watch (kunchenguid#2684),
and CI/lint (kunchenguid#2710/kunchenguid#2758). Stops at 52d20f1 per the divergence
assessment: voice (fbe37e9) is deliberately skipped, and Relay
follow-up preservation (dc0172c) cannot be merged without it since
fbe37e9 is its ancestor.

Resolution stance: fork deviations preserved throughout - the slim
AGENTS.md (upstream hunks hand-ported, including the 'gh-axi for all
GitHub operations' wording), the jq argv-limit staged-file fix in
fm-fleet-snapshot.sh/fm-bearings-snapshot.sh (upstream's kunchenguid#2728
hold-until/captain_actionable hunks threaded onto it), worktree-claim
and git-identity hooks, the brief's verification and isolation clauses,
and the fork's PR-#15 polling fixes in fm-pi-watch-extension.test.sh.
decision-hold-lifecycle ripples hand-ported to captain-hold-lifecycle.
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.

2 participants