Skip to content

feat(bin): batch routine supervision wakes across the watcher and Pi primary - #76

Merged
withally merged 17 commits into
mainfrom
fm/fm-secondmate-wake-batching-w1
Aug 25, 2026
Merged

feat(bin): batch routine supervision wakes across the watcher and Pi primary#76
withally merged 17 commits into
mainfrom
fm/fm-secondmate-wake-batching-w1

Conversation

@withally

Copy link
Copy Markdown
Owner

Intent

Implement the wake-churn fixes for secondmate and primary homes from the investigation report: batch all due paused and captain-held pane rechecks into one fleet stale wake while preserving per-pane bookkeeping, bounded safety rechecks, and immediate terminal, wedge, and missing or unreadable endpoint wakes; add a configurable Pi follow-up aggregator using config/wake-batch-seconds with default 60 seconds, deduplicated bounded status-path and endpoint lists, and urgent failed, blocked, needs-decision, lost-lock, and watcher-failure bypass; absorb only unchanged repeat routine status and turn-end signals for already-presented durable pauses and holds while keeping first declaration, changed terminal, missing or unreadable endpoint, and lapsed recheck immediate; deliver one drain and one acknowledgement per batch, compact unchanged OPEN DECISIONS to an unchanged-count marker while preserving exact-once UNREAD STATUS and durable acknowledgement; on Pi session_compact emit only lock and watcher ownership, actionable queue and open decisions, active task identities, and the exact next supervision instruction with no full status tails or unchanged context files; harden Pi fm-send Herdr submit so dropped or unconfirmed submits are detected and boundedly retried rather than silently reported unknown, with live Pi reproduction and regression coverage; and suppress the Firstmate repository session-start digest in a registered crew worktree with the exact one-line crew worktree - digest suppressed. Preserve AGENTS.md section 8 safety boundaries including no blind turn ends, presented-before-ack, and home-scoped repair; keep Claude primary behavior unchanged except the drain and open-decision and compact-recovery requirements; keep Bash 3.2 portability and shellcheck cleanliness; add colocated behavior tests and update docs/supervision-protocols/pi.md, docs/turnend-guard.md, docs/configuration.md, and session-start documentation. Report expected versus measured reductions using the investigation replay. Ship one PR through no-mistakes with CI green, do not merge, and do not perform Herdr lifecycle actions.

What Changed

  • bin/fm-watch.sh now collapses every due paused/captain-held recheck into one bounded fleet stale wake and collects missing or unreadable endpoints from a cycle into one immediate fleet wake, each with its own limit (FM_PAUSED_RESURFACE_BATCH_LIMIT, FM_ENDPOINT_BATCH_LIMIT) and per-pane bookkeeping so an omitted pane stays due; terminal, wedge, and endpoint-loss wakes stay immediate, and bin/fm-classify-lib.sh marks failed:/blocked:/needs-decision: stale wakes urgent. Away-mode daemon triage keeps the per-window identity it parses.
  • .pi/extensions/fm-primary-pi-watch.ts aggregates routine watcher closes over a configurable window (config/wake-batch-seconds, FM_WAKE_BATCH_SECONDS, default 60s) with deduplicated bounded status-path/endpoint lists, one drain and one acknowledgement per batch, urgent-first rendering, urgent-class bypass, and arm-rotation/arm-end flush handling; bin/fm-wake-drain.sh compacts unchanged open decisions to an unchanged-count marker while --compact and --no-presentation-commit drains keep the full block and withhold the UNREAD STATUS cursor.
  • bin/fm-supervision-instructions.sh gains self-contained --next-line/--state-lines output feeding a bounded Pi session_compact recovery digest (ownership, actionable queue, open decisions, task identities, next instruction — no status tails); bin/backends/herdr.sh re-observes inconclusive post-Enter surfaces (FM_BACKEND_HERDR_UNKNOWN_RECHECKS) before reporting a submit unknown; bin/fm-session-start.sh prints only crew worktree - digest suppressed in a registered crew worktree. Docs and colocated behavior tests updated across all four areas.

Risk Assessment

⚠️ Medium: The change is large (2400 lines) and touches shared supervision safety boundaries — wake batching, presentation cursors, away-mode ownership, and compact recovery — but every prior-round defect verifies as genuinely fixed, the new Pi arm-ownership rotation and confirmation-skip trace correctly against bin/fm-watch-arm.sh and the drain's own recovery transition, and the only remaining items are a stale ownership comment and a documented tradeoff.

Testing

Re-ran the targeted session-start suites plus the watcher, Pi-extension, drain, and Herdr suites that own this change's behavior — all pass, and the round-1 hung-git regression is gone: with a wedged git and a 3s bound, session start now returns at 6s with the digest and its loud truncation banner instead of hanging silently to an external 25s kill, and the full session-start file runs in ~4 minutes rather than ~22. End-to-end replays on HEAD still show the intended reductions (6 supervision turns to 2 for a due-paused fleet, several watcher closes to one Pi follow-up with one ack, urgent statuses bypassing the window) and the crew-worktree digest suppression prints its exact one line against a real registered worktree. Evidence transcripts were refreshed in the evidence directory; the worktree is clean of transient artifacts.

Evidence: Session start under a hung git: broken vs fixed

Source: Session start under a hung git: broken vs fixed

=== BROKEN (round-1 target bf53bf0) === exit=124 wall=25s - killed externally, NO output at all: the bound never applied. === FIXED (HEAD cc2dc20) (session-start runtime bound: 3s) === exit=0 wall=6s ... SESSION START ... LOCK ... BOOTSTRAP ... ● STARTUP TRUNCATED - SESSION START HIT ITS 3s RUNTIME BOUND ● It stopped during the "bootstrap" stage, so everything above is COMPLETE only up to that point. ● RECONCILE these stages before acting on anything they would have shown: ● bootstrap wake-queue supervision-instructions read-once fleet-state network-checks context next-step

Repro: a hung/slow git (stuck index.lock, hung network mount) while a primary opens a session.
fm-session-start.sh promises a hard runtime bound and a loud truncation banner.
Round 1 found the crew-worktree predicate ran unbounded BEFORE that bound; cc2dc20 moved it
inside a bounded parent check. Re-run below on the current HEAD.

=== BEFORE (base d982d45) ===
exit=0 wall=3s - digest printed up to LOCK/BOOTSTRAP, truncation banner shown.

=== BROKEN (round-1 target bf53bf0) ===
exit=124 wall=25s - killed externally, NO output at all: the bound never applied.

=== FIXED (HEAD cc2dc20) (session-start runtime bound: 3s) ===
exit=0 wall=6s

================================================================================
SESSION START - /tmp/fm-hang.PxYNYb/home
================================================================================

LOCK
--------------------------------------------------------------------------------
lock acquired: harness pid 57225

BOOTSTRAP
--------------------------------------------------------------------------------

●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
●  STARTUP TRUNCATED - SESSION START HIT ITS 3s RUNTIME BOUND
●  It stopped during the "bootstrap" stage, so everything above is COMPLETE
●  only up to that point.
●  RECONCILE these stages before acting on anything they would have shown:
●    bootstrap wake-queue supervision-instructions read-once fleet-state network-checks context next-step
●  Rerun bin/fm-session-start.sh now to finish taking the helm. If it truncates
●  again, raise FM_SESSION_START_TIMEOUT and report the slow stage - a stage that
●  cannot finish inside the bound is a fleet problem, not a reporting detail.
●━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
---
Evidence: Repro script for the hung-git runtime bound

Source: Repro script for the hung-git runtime bound

#!/usr/bin/env bash
set -u
TREE=$1; LABEL=$2
T=$(mktemp -d /tmp/fm-hang.XXXXXX); mkdir -p "$T/home/state" "$T/home/config" "$T/fakebin"
cat > "$T/fakebin/git" <<'SH'
#!/usr/bin/env bash
trap '' TERM
sleep 600
SH
chmod +x "$T/fakebin/git"
start=$(date +%s)
out=$(PATH="$T/fakebin:$PATH" FM_HOME="$T/home" FM_STATE_OVERRIDE="$T/home/state" \
  FM_CONFIG_OVERRIDE="$T/home/config" FM_SESSION_START_TIMEOUT=3 FM_STARTUP_NETWORK_TIMEOUT=2 \
  timeout 25 "$TREE/bin/fm-session-start.sh" 2>&1) ; rc=$?
end=$(date +%s)
printf '%s\n' "=== $LABEL (session-start runtime bound: 3s) ==="
printf 'exit=%s wall=%ss\n' "$rc" "$((end - start))"
printf '%s\n' "$out"
printf '%s\n\n' "---"
rm -rf "$T"
Evidence: Crew worktree digest suppression (real git worktree)

Source: Crew worktree digest suppression (real git worktree)

--- registered crew worktree --- crew worktree - digest suppressed --- unregistered worktree (no state/*.meta pointing at it): digest still runs --- === SESSION START - /tmp/fm-crew.ikps0e/home === LOCK lock acquired: harness pid 57225

--- registered crew worktree ---
crew worktree - digest suppressed
--- unregistered worktree (no state/*.meta pointing at it): digest still runs ---

================================================================================
SESSION START - /tmp/fm-crew.ikps0e/home
================================================================================

LOCK
--------------------------------------------------------------------------------
lock acquired: harness pid 57225
Evidence: Crew worktree suppression demo script

Source: Crew worktree suppression demo script

#!/usr/bin/env bash
# End-user check: a Firstmate session opened from a REGISTERED crew worktree of a
# repository must print exactly one line instead of the primary digest.
set -u
TREE=${1:?usage: crew-worktree-suppression.sh <tree-root>}
T=$(mktemp -d /tmp/fm-crew.XXXXXX)
git init -q "$T/primary"; git -C "$T/primary" commit -q --allow-empty -m init
mkdir -p "$T/primary/state" "$T/home/state" "$T/home/config"
git -C "$T/primary" worktree add -q "$T/wt-task1" -b task1
printf 'worktree=%s\n' "$T/wt-task1" > "$T/primary/state/task1.meta"
echo "--- registered crew worktree ---"
FM_HOME="$T/home" FM_STATE_OVERRIDE="$T/home/state" FM_CONFIG_OVERRIDE="$T/home/config" \
  FM_ROOT_OVERRIDE="$T/wt-task1" timeout 60 "$TREE/bin/fm-session-start.sh" 2>&1 | head -20
echo "--- unregistered worktree (no state/*.meta pointing at it): digest still runs ---"
git -C "$T/primary" worktree add -q "$T/wt-other" -b other
FM_HOME="$T/home" FM_STATE_OVERRIDE="$T/home/state" FM_CONFIG_OVERRIDE="$T/home/config" \
  FM_ROOT_OVERRIDE="$T/wt-other" timeout 60 "$TREE/bin/fm-session-start.sh" 2>&1 | head -8
rm -rf "$T"
Evidence: Wake-churn replay: 6 supervision turns to 2

Source: Wake-churn replay: 6 supervision turns to 2

RESULT HEAD (cc2dc20): supervision turns spent = 2; stale wake records presented = 2 (turn 2 delivers: "stale: paused fleet recheck (5 due): fleet:crew-2 ...; fleet:crew-6 ..." - every pane still named individually inside the single batched reason)

=== BEFORE (base d982d45): fleet of 6 crew panes, all declared pauses due for recheck ===
-- supervision turn 1 -- watcher exited with:
     stale: fleet:crew-1
   supervisor drain shows:
     1787653677	1	stale	fleet:crew-1	stale: fleet:crew-1
-- supervision turn 2 -- watcher exited with:
     stale: fleet:crew-2 (paused 504s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653679	2	stale	fleet:crew-2	stale: fleet:crew-2 (paused 504s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 3 -- watcher exited with:
     stale: fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653681	3	stale	fleet:crew-3	stale: fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 4 -- watcher exited with:
     stale: fleet:crew-4 (paused 509s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653684	4	stale	fleet:crew-4	stale: fleet:crew-4 (paused 509s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 5 -- watcher exited with:
     stale: fleet:crew-5 (paused 512s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653687	5	stale	fleet:crew-5	stale: fleet:crew-5 (paused 512s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 6 -- watcher exited with:
     stale: fleet:crew-6 (paused 515s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653690	6	stale	fleet:crew-6	stale: fleet:crew-6 (paused 515s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- round 7: watcher reached its heartbeat with nothing left to deliver; fleet reconciled
RESULT BEFORE (base d982d45): supervision turns spent = 6; stale wake records presented = 6

=== AFTER (target bf53bf0): fleet of 6 crew panes, all declared pauses due for recheck ===
-- supervision turn 1 -- watcher exited with:
     stale: fleet:crew-1
   supervisor drain shows:
     1787653736	1	stale	fleet:crew-1	stale: fleet:crew-1
-- supervision turn 2 -- watcher exited with:
     stale: paused fleet recheck (5 due): fleet:crew-2 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-4 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-5 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-6 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787653741	2	stale	paused-fleet	stale: paused fleet recheck (5 due): fleet:crew-2 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-4 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-5 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-6 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- round 3: watcher reached its heartbeat with nothing left to deliver; fleet reconciled
RESULT AFTER (target bf53bf0): supervision turns spent = 2; stale wake records presented = 2


### Report-shaped fleet (~8 independently paused endpoints, investigation report line 74)

=== BEFORE (base d982d45): fleet of 9 crew panes, all declared pauses due for recheck ===
-- supervision turn 1 -- watcher exited with:
     stale: fleet:crew-1
   supervisor drain shows:
     1787654367	1	stale	fleet:crew-1	stale: fleet:crew-1
-- supervision turn 2 -- watcher exited with:
     stale: fleet:crew-2 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654369	2	stale	fleet:crew-2	stale: fleet:crew-2 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 3 -- watcher exited with:
     stale: fleet:crew-3 (paused 510s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654372	3	stale	fleet:crew-3	stale: fleet:crew-3 (paused 510s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 4 -- watcher exited with:
     stale: fleet:crew-4 (paused 512s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654374	4	stale	fleet:crew-4	stale: fleet:crew-4 (paused 512s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 5 -- watcher exited with:
     stale: fleet:crew-5 (paused 515s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654377	5	stale	fleet:crew-5	stale: fleet:crew-5 (paused 515s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 6 -- watcher exited with:
     stale: fleet:crew-6 (paused 519s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654381	6	stale	fleet:crew-6	stale: fleet:crew-6 (paused 519s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 7 -- watcher exited with:
     stale: fleet:crew-7 (paused 523s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654385	7	stale	fleet:crew-7	stale: fleet:crew-7 (paused 523s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 8 -- watcher exited with:
     stale: fleet:crew-8 (paused 527s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654389	8	stale	fleet:crew-8	stale: fleet:crew-8 (paused 527s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- supervision turn 9 -- watcher exited with:
     stale: fleet:crew-9 (paused 532s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654394	9	stale	fleet:crew-9	stale: fleet:crew-9 (paused 532s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- round 10: watcher reached its heartbeat with nothing left to deliver; fleet reconciled
RESULT BEFORE (base d982d45): supervision turns spent = 9; stale wake records presented = 9

=== AFTER (target bf53bf0): fleet of 9 crew panes, all declared pauses due for recheck ===
-- supervision turn 1 -- watcher exited with:
     stale: fleet:crew-1
   supervisor drain shows:
     1787654440	1	stale	fleet:crew-1	stale: fleet:crew-1
-- supervision turn 2 -- watcher exited with:
     stale: paused fleet recheck (8 due): fleet:crew-2 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-4 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-5 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-6 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-7 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-8 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-9 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
   supervisor drain shows:
     1787654445	2	stale	paused-fleet	stale: paused fleet recheck (8 due): fleet:crew-2 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-3 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-4 (paused 506s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-5 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-6 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-7 (paused 507s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-8 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds); fleet:crew-9 (paused 508s, awaiting external - declared pause, rechecked on a long cadence not a wedge; confirm the wait still holds)
-- round 3: watcher reached its heartbeat with nothing left to deliver; fleet reconciled
RESULT AFTER (target bf53bf0): supervision turns spent = 2; stale wake records presented = 2
Evidence: Pi follow-up aggregator demo

Source: Pi follow-up aggregator demo

SCENARIO A - routine wakes only: 4 watcher closes -> one follow-up at t+4.2s, "batched 3 watcher wakes" (duplicate status path deduplicated), 1 acknowledgement SCENARIO B - same wakes plus one blocked [key=deploy-window]: delivered at t+1.6s, urgent bypasses the configured window

captain's config/wake-batch-seconds = 3   (production default: 60)

================================================================
SCENARIO A - routine wakes only (working:/done:/paused stale): held for the 3s window, deduplicated, delivered once
================================================================
primary arms its watcher; the watcher closes several times within ~1s

--- Pi primary received a follow-up at t+3.8s ---
⁣FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: batched 3 watcher wakes:
- signal: $FM_HOME/state/alpha.status
- stale: w1:crew-bravo (paused awaiting upstream release)
- signal: $FM_HOME/state/bravo.status

Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.

watcher closes seen: 4; follow-ups delivered to the primary: see above; delivery acknowledgements sent back to the watcher: 1

================================================================
SCENARIO B - same routine wakes plus one blocked [key=deploy-window] status: urgent bypasses the window
================================================================
primary arms its watcher; the watcher closes several times within ~1s

--- Pi primary received a follow-up at t+1.5s ---
⁣FIRSTMATE_OP: v1 watcher: FIRSTMATE WATCHER WAKE: batched 4 watcher wakes:
- signal: $FM_HOME/state/charlie.status
- signal: $FM_HOME/state/alpha.status
- stale: w1:crew-bravo (paused awaiting upstream release)
- signal: $FM_HOME/state/bravo.status

Run bin/fm-wake-drain.sh first and handle the queued wake. Watcher continuity is extension-owned.

watcher closes seen: 5; follow-ups delivered to the primary: see above; delivery acknowledgements sent back to the watcher: 1
Evidence: Evidence index

Source: Evidence index

# Wake-churn batching - end-to-end evidence

All transcripts were produced by driving the real production scripts
(`bin/fm-watch.sh`, `bin/fm-wake-drain.sh`, `bin/fm-session-start.sh`,
`bin/backends/herdr.sh`, `.pi/extensions/fm-primary-pi-watch.ts`) against
throwaway FM homes.
Each `*.sh` here regenerates its `*.txt` neighbour.

## 1. Watcher fleet batching - `wake-churn-replay.txt`

A fleet whose declared pauses all come due in the same watcher cycle.
Every watcher exit is one supervision turn for the primary; the replay drains
and acknowledges like a primary would, then re-arms, until nothing is left.

| fleet | supervision turns BEFORE (d982d45) | AFTER (bf53bf0) |
|-------|------------------------------------|-----------------|
| 6 panes | 6 | 2 |
| 9 panes | 9 | 2 |

The single remaining non-batched turn is the live pane's own first stale
surface, which stays immediate by design.
Paused-recheck turns alone collapse 8 -> 1 on the report-shaped fleet: an 87.5%
reduction, inside the investigation report's expected 85-90% (report line 350).
Each pane is still named individually inside the one batched reason, so per-pane
bookkeeping is preserved.

## 2. Pi follow-up aggregator - `pi-wake-aggregator-demo.txt`

`config/wake-batch-seconds` set to 3 (production default 60).
Scenario A: four routine watcher closes, one duplicate status path, delivered as
one `FIRSTMATE WATCHER WAKE: batched 3 watcher wakes` follow-up at t+3.8s with
the duplicate deduplicated and exactly one delivery acknowledgement.
Scenario B: the same closes plus one `blocked [key=deploy-window]` status is
delivered at t+1.5s - the urgent class bypasses the configured window.

## 3. Drain and compact recovery - `drain-and-recovery-demo.txt`

First drain of a session prints OPEN DECISIONS in full; the next drain in the
same session, with an unchanged decision set, collapses to
`OPEN DECISIONS: unchanged, 1 open`; `fm-session-start.sh --compact` re-presents
them in full and emits only lock/watcher ownership, the actionable queue and
open decisions, active task identities, and the exact next supervision
instruction - with its `WAKE_ACK_REQUIRED` command on stdout and no status
tails or context files.
A session started inside a registered crew worktree prints exactly
`crew worktree - digest suppressed`.

## 4. Herdr submit hardening - `herdr-submit-repro.txt`

A steer whose post-Enter surface is momentarily unreadable:
BEFORE reports `unknown` after one Enter (delivery silently unconfirmed);
AFTER rechecks boundedly, retries the submit once, and confirms delivery.

## 5. Runtime bound under a hung git - `session-start-hung-git-repro.txt`

Round 1 found a regression: the registered-crew-worktree check ran `git rev-parse`
BEFORE the runtime-bound wrapper, so with a hung `git` session start produced NO
digest and never returned inside its bound (killed at 25s, exit 124).
Fixed in cc2dc20 - the predicate now runs under its own bounded parent check.
Re-measured on HEAD: exit 0 at 6s wall with a 3s bound, digest printed up to
BOOTSTRAP followed by the full STARTUP TRUNCATED banner naming the stages to
reconcile. `tests/fm-session-start.test.sh` passes end to end again (~4 min,
down from ~22 min while the hang was present).

## 6. Crew worktree suppression - `crew-worktree-suppression.txt`

A session opened from a worktree registered in the primary's `state/*.meta`
prints exactly `crew worktree - digest suppressed`; an unregistered linked
worktree still gets the full digest.
- Outcome: 🔧 1 issue found → auto-fixed ✅ across 2 runs (1h40m12s)

Pipeline

Updates from git push no-mistakes

... (7 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

⚠️ **Review** - 2 infos

🔧 Fix: fix(drain): keep compact open decisions and mate status wakes full
2 issues (1 error, 1 warning) still open:

  • 🚨 bin/fm-session-start.sh:654 - The compact-recovery digest runs &#34;$SCRIPT_DIR/fm-wake-drain.sh&#34; --compact || true without redirecting stderr, unlike the ordinary digest at bin/fm-session-start.sh:795 which uses DRAIN_OUT=$(&#34;$SCRIPT_DIR/fm-wake-drain.sh&#34; 2&gt;&amp;1). WAKE_ACK_REQUIRED: after handling completes run bin/fm-wake-drain.sh --ack-through &lt;N&gt; --recovery-generation &lt;G&gt; is printed on stderr only (bin/fm-wake-drain.sh:405 and :452), and the Pi compact route discards stderr entirely: runSessionstartHook spawns fm-sessionstart-run.sh with stdio: [&#34;ignore&#34;, &#34;pipe&#34;, &#34;ignore&#34;] (.pi/extensions/fm-primary-turnend-guard.ts:105). Concrete sequence: Pi emits session_compact -> injectSessionstart("compact") -> fm-sessionstart-run.sh routes to fm-session-start.sh --compact -> the compact drain truncates .wake-queue, prints RAW_ROWS on stdout, and drives fm_recovery_marker_begin_handling so the episode is now pending:handling:G; the ack line is written to stderr and thrown away. The recovering session receives the wakes but is never told to acknowledge them, so the recovery episode stays unacknowledged and the intent's required "one drain and one acknowledgement per batch" / "durable acknowledgement" property is broken on exactly the new path. The same redirection gap also swallows the drain's own diagnostics ("compact OPEN DECISIONS presentation failed", "decision recovery could not begin handling safely"). Fix: capture the compact drain with 2&gt;&amp;1 the way the ordinary digest already does, so the ack command and the drain's error text reach the recovering session.
  • ⚠️ bin/fm-wake-drain.sh:167 - The unchanged-open-decisions collapse is gated only on the persisted $STATE/.open-decisions-presentation marker, which is written by commit_open_decisions_presentation (:210) and never cleared by anything except a drain that finds zero open decisions. Only the --compact caller passes force. But a brand-new session and a /clear re-emit destroy the session context just as compaction does, and both run the ordinary drain: fm-session-start.sh:795 DRAIN_OUT=$(&#34;$SCRIPT_DIR/fm-wake-drain.sh&#34; 2&gt;&amp;1) on the full-startup path and on --reemit. Concrete sequence: session S1 drains and presents task1 [key=api-shape] needs-decision: pick REST or RPC in full, committing the marker; S1 quits (or the captain runs /clear); session S2 starts, takes the lock, and its startup digest's WAKE QUEUE section prints only OPEN DECISIONS: unchanged, 1 open - no task, no key, no note, and no bin/fm-send.sh &lt;task&gt; --resolve-key &lt;key&gt; instruction - because the decision set has not changed. S2 has none of S1's context, so the collapse's own stated premise ("the full block is already in this session's context") is false, and this is the same failure the compact fix corrected one caller at a time. The status-tail section is not a substitute: it prints only the last STATUS_TAIL (5) lines per task, so a decision buried under later routine appends - the case tests/fm-wake-drain-open-decisions.test.sh:test_buried_decision_still_surfaces exists for - never appears. The comment at bin/fm-session-start.sh:772-773 still claims "The drain's separate OPEN DECISIONS section remains actionable even when that queue is empty (AGENTS.md sections 3 and 8)", which is no longer true across a process boundary. Fix at the shared boundary: bind the marker to the presenting session (store the session/lock owner identity alongside $digest $count and force the full block when it differs), or pass force from the startup and --reemit digest paths as well.

🔧 Fix: fix(session-start): carry compact wake ack and re-present open decisions
2 issues (1 warning, 1 info) still open:

  • ⚠️ bin/fm-session-start.sh:672 - The compact-recovery digest replaces the --reemit digest for compact on every harness, but it never reports away mode or X mode. The --reemit path it replaces always emitted the full supervision block (bin/fm-session-start.sh:808-832), which prints - Away mode: active; load /afk and keep normal harness supervision paused while the daemon owns the watcher. and the X-mode source &lt;x-mode.env&gt; line. The compact block instead calls fm-supervision-instructions.sh --harness &lt;h&gt; --next-line, and --next-line accepts no --afk/--x-mode and ordinary_wake_line (bin/fm-supervision-instructions.sh:167-191) has no away-mode branch, so the output is identical whether or not state/.afk exists. Concrete sequence: away mode is active (daemon owns the watcher, state/.afk present), the Pi primary compacts, fm-sessionstart-run.sh routes compact to fm-session-start.sh --compact; nothing in LOCK AND WATCHER OWNERSHIP (fm-lock.sh has no afk awareness) or in the drain (fm-guard.sh only emits its afk-aware repair line when the watcher is DOWN) mentions away mode, and NEXT SUPERVISION INSTRUCTION tells the recovering session the Pi extension already owns watcher continuity. The recovering session is handed an attended-supervision instruction while the daemon owns triage, losing exactly the away-mode boundary the intent requires preserving ("Preserve AGENTS.md section 8 safety boundaries"). The same gap hides an active X mode's source $CONFIG/x-mode.env requirement. Confirm whether the compact digest is meant to carry away/X mode state (e.g. print the two state lines, or teach --next-line the afk wording) or whether omitting them is deliberate.
  • ℹ️ docs/configuration.md:148 - docs/configuration.md still says the forced full OPEN DECISIONS block belongs to "A compact-recovery drain (bin/fm-wake-drain.sh --compact)", but the round-6 change generalized it: --session-recovery (bin/fm-wake-drain.sh:50-67) now sets the same force, and bin/fm-session-start.sh:798 passes it on the full-startup and --reemit digest paths. As written the docs lead an operator to expect OPEN DECISIONS: unchanged, N open from a new session's or a /clear re-emit's digest, which no longer happens. The intent requires updating docs/configuration.md; extend that paragraph (and the neighboring --compact sentence) to cover every session-recovery drain.

🔧 Fix: fix(session-start): report away and X mode in compact digest
1 error still open:

  • 🚨 bin/fm-wake-drain.sh:183 - The new unchanged-OPEN-DECISIONS collapse is committed by a caller that never presents the block, so the away-mode return handover degrades to a bare count. bin/fm-supervise-daemon.sh:1320 runs fm-wake-drain.sh &gt; &#34;$out&#34; 2&gt; &#34;$err&#34; and consumes $out strictly as TSV wake rows (case &#34;$epoch&#34; in &#39;&#39;|*[!0-9]*) continue), then rm -f's it: the OPEN DECISIONS: lines are discarded, but print_status_sections still reaches commit_open_decisions_presentation (bin/fm-wake-drain.sh:304) and writes $STATE/.open-decisions-presentation = &lt;digest&gt; &lt;count&gt;. Concrete sequence: away mode active; a crew appends needs-decision [key=api-shape]: pick REST or RPC; the watcher wakes, the daemon drains, and the marker is committed while no human sees the block; the captain returns and bin/fm-afk-return.sh:156 runs &#34;$SCRIPT_DIR/fm-wake-drain.sh&#34; 2&gt; &#34;$drain_err&#34; with no --session-recovery, so line 183's [ &#34;$prior&#34; = &#34;$digest $count&#34; ] holds and the returning captain's evidence (append_evidence wake "$drained") reads only OPEN DECISIONS: unchanged, 1 open - no task, no key, no bin/fm-send.sh &lt;task&gt; --resolve-key &lt;key&gt; instruction. fm-afk-return.sh's scan_open_blockers (:58) is not a second surface: it matches only [ &#34;$verb&#34; = blocked ], so a needs-decision has none. Before this change every drain reprinted the full block, so the away-return handover always carried it, and the intent's Preserve AGENTS.md section 8 safety boundaries covers exactly this handover. Fix at the shared boundary: the collapse's premise is already in this session&#39;s context, which is false for a machine consumer and for a captain returning from away mode - do not commit the presentation marker on the daemon's non-presenting drain (or gate the commit on a presenting caller), and pass --session-recovery from bin/fm-afk-return.sh:156.

🔧 Fix: fix(drain): never spend open-decisions collapse on unread drains
3 issues (1 warning, 2 infos) still open:

  • ⚠️ bin/fm-wake-drain.sh:314 - The new --no-presentation-commit boundary is half-applied. Its own comment states the invariant generally - "A caller that consumes this drain's rows mechanically and discards the presented text has shown it to nobody, so it must not spend that record" (bin/fm-wake-drain.sh:40-45) - but the flag is only read by commit_open_decisions_presentation (:237). print_status_sections still calls status_acknowledge_presented_snapshot (:311) and status_commit_presentation_snapshot (:314) unconditionally on the very same drain. bin/fm-supervise-daemon.sh:1320 runs that drain and consumes $out strictly as TSV wake rows (case &#34;$epoch&#34; in &#39;&#39;|*[!0-9]*) continue), then rm -f's it, so every UNREAD STATUS line is discarded while its presentation cursor is advanced. Concrete sequence: away mode active; crew task1 appends note: upstream contract changed, see thread (routine informational, not a decision so the open-decisions guard does not cover it); the watcher wakes, the daemon drains, the UNREAD STATUS block is generated and thrown away, and status_commit_presentation_snapshot advances task1's cursor past those bytes; handle_wake only distills the wake reason, not the status bytes; the captain returns and bin/fm-afk-return.sh:156 drains again - the line is already recorded as presented and never appears. The intent requires "preserving exact-once UNREAD STATUS", and exact-once means presented exactly once to a reader, not consumed by a machine. Same fix shape as the open-decisions one: gate status_commit_presentation_snapshot (and its acknowledge sibling) on PRESENTATION_COMMIT too, so the daemon's non-presenting drain leaves the unread span for whoever reads it next. Flagging as ask-user rather than auto-fix because it changes what a returning captain is shown and the daemon may be intended to own that distillation.
  • ℹ️ .pi/extensions/fm-primary-pi-watch.ts:283 - wakeIsUrgent classifies a wake either by regex over the message or by resolving *.status paths found in the message and reading their last line. The watcher's terminal-stale wake reason carries neither: bin/fm-watch.sh:1456 and :1471 emit the bare string stale: &lt;window&gt; (no verb, no status path). So a crew whose last status is failed: ... and whose pane then goes stale produces stale: test:fm-x, which queueWake (:364) treats as routine and holds for the full wake-batch window (default 60s) instead of taking the urgent bypass the intent requires for "urgent failed, blocked, needs-decision, lost-lock, and watcher-failure". The primary delivery path is covered - the same failure normally arrives first as signal: &lt;state&gt;/&lt;task&gt;.status, which wakeIsUrgent does read and does flush immediately - so the impact is bounded to a <=60s delay on the secondary stale path. Confirm whether that is deliberate, or whether the extension should map stale: &lt;window&gt; back to its task (via state/*.meta, the way the watcher does) before classifying, or the watcher should carry the terminal verb in the stale reason.
  • ℹ️ bin/fm-wake-drain.sh:150 - open_decisions_digest introduces git hash-object --stdin as the only git dependency in the drain's presentation path; every other hashing site in this repo (bin/fm-backlog-receive.sh:33, bin/fm-backlog-handoff.sh:86, bin/fm-backend-hometag-lib.sh:46) uses a shasum/sha256sum fallback pair. git hash-object --stdin does work outside a repository (verified), so the only failure is git being absent from PATH - but then digest=$(...) || return 1 at :191 aborts print_open_decisions_section, which aborts print_status_sections, so OPEN DECISIONS and RECORD DIVERGENCE are never printed AND status_commit_presentation_snapshot (:314) never runs, leaving the same unread status replayed on every subsequent drain. Low probability, disproportionate blast radius; use the repo's existing shasum/sha256sum fallback so a missing git degrades to a full re-present rather than suppressing the whole section.

🔧 Fix: fix(drain): withhold unread cursor and mark urgent stale wakes
2 infos still open:

  • ℹ️ bin/fm-supervision-instructions.sh:230 - --next-line strips only the - Ordinary wake: prefix and prints the harness line verbatim, but three of those lines end in a forward reference to content the compact digest deliberately omits: codex says "take the next foreground bin/fm-watch-checkpoint.sh checkpoint as directed below", grok says "as directed below", and the unknown fallback says "follow the continuation in the harness protocol below". The compact digest ends at NEXT SUPERVISION INSTRUCTION with no protocol snippet after it (bin/fm-session-start.sh:678-684), so a recovering session on those harnesses is pointed at instructions that are not present. Reachable for a codex-exec compact hook payload and, more commonly, whenever fm-harness.sh cannot detect the harness during the compact hook and falls back to unknown. The claude and pi lines are self-contained and read correctly. Either give --next-line self-contained wording for those cases or have the compact digest name where the protocol lives.
  • ℹ️ .pi/extensions/fm-primary-pi-watch.ts:110 - wakeBatchSeconds is resolved once at module load (const wakeBatchSeconds = configuredWakeBatchSeconds()), so editing config/wake-batch-seconds has no effect until Pi is restarted. docs/configuration.md's new "Pi watcher wake batching" section says only "Write one positive integer number of seconds to gitignored config/wake-batch-seconds to change it for that home", which leads an operator to expect the running session to pick it up. The neighbouring FM_* knobs are process-env by nature so the same caveat is implicit there, but a config file is not. Add the restart requirement to that paragraph.

🔧 Fix: fix(supervision): make every harness next line self-contained
3 issues (1 warning, 2 infos) still open:

  • ⚠️ bin/fm-supervision-instructions.sh:206 - ordinary_wake_line was rewritten so every harness line now begins drain and handle this wake with bin/fm-wake-drain.sh, then run the exact --ack-through command it printed. That line is printed unconditionally by the full digest at bin/fm-supervision-instructions.sh:255, including on the read-only path, two lines below - Lock: read-only; do not drain, arm, spawn, steer, merge, or repair fleet state here. Verified: bin/fm-supervision-instructions.sh --harness pi --read-only 1 emits both lines back to back. Before this change codex/pi/opencode/grok ordinary lines contained no drain instruction at all (the Pi extension already owns watcher continuity; do not arm another cycle.), and claude/cursor said only drain and handle the wake without naming the command or the acknowledgement, so this is a new and much more actionable contradiction on four harnesses. bin/fm-wake-drain.sh has no lock-ownership gate of its own: a read-only session that follows the instruction truncates $FM_WAKE_QUEUE, advances the UNREAD STATUS cursor and the open-decisions presentation marker, consuming the lock holder's durable wakes - the AGENTS.md section 8 boundary the intent requires preserving. Fix: suppress or reword the drain clause when READ_ONLY=1 (the --next-line compact path already routes read-only sessions to repair_line, which handles it correctly).
  • ℹ️ bin/fm-session-start.sh:658 - The compact-recovery digest prints the ACTIONABLE QUEUE AND OPEN DECISIONS header and then whatever fm-wake-drain.sh --compact emits. With an empty queue and no open decisions the drain is completely silent, so the section is a bare header. Verified by running the compact digest against a fresh home: the header is followed immediately by ACTIVE TASK IDENTITIES. The full digest already handles this at bin/fm-session-start.sh:805-809 with an explicit (no queued wakes) fallback. For a digest whose entire purpose is a self-contained handover to a session that just lost its context, an empty section is indistinguishable from a drain that failed or was suppressed. Mirror the (no queued wakes) fallback: capture the drain output and print a positive nothing-pending line when it is empty.
  • ℹ️ .pi/extensions/fm-primary-pi-watch.ts:326 - confirmHandlingDeliveryWithRetry moved out of deliverActionableWake and into flushWakeBatch, so it now runs up to wakeBatchSeconds (default 60s) after the wake was produced instead of synchronously with it. bin/fm-watch-arm.sh:404 requires fm_pid_alive &#34;$handling_watcher_pid&#34; before fm_recovery_marker_begin_handling, and snapshot() only rescues a stale pid while owner.child is non-null. Concrete sequence: a routine actionable wake queues recovery {G,B} and starts the 60s timer; within that window B's arm child closes non-actionably, releaseChild() sets owner.child = null, and scheduleRetry either waits out its backoff or bails (lock lost / retryLimit exceeded) leaving owner.child null; the flush then confirms against the dead pid B, gets a non-zero status, and pushes watcher: FAILED - handling delivery confirmation was rejected (...) into the batch as an urgent detail even though nothing about the recovery episode is actually wrong (the drain's own fm_recovery_marker_begin_handling at bin/fm-wake-drain.sh:421 still covers the real transition). Bounded to a misleading extra line during an already-degraded window, but it did not exist before batching. Consider capturing the confirmation at queue time, or skipping it when the recorded arm is gone.

🔧 Fix: fix(pi): flush a wake batch when its arm ends unreplaced
3 issues (2 warnings, 1 info) still open:

  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:390 - The new arm-end flush is disabled for any batch that spans more than one arm - i.e. exactly the aggregating case wake batching exists for. owner.wakeBatchArm is set only when the batch is EMPTY (queueWake:390) and is never re-pointed at a successor, while an actionable close deliberately does not flush. Concrete sequence: arm A closes actionably -> successor B started -> deliverActionableWake queues W1 while owner.child===B, so wakeBatchArm=B; 10s later B closes actionably -> successor C started -> W2 queued, batch non-empty so wakeBatchArm STAYS B (already dead, and B will never emit another close); C then closes non-actionably -> the close handler calls flushWakeBatchOnArmEnd(owner, C), the guard owner.wakeBatchArm !== armChild is true, and it returns without flushing. The batch waits out the full wakeBatchSeconds window under a broken chain and then confirms against C's dead watcher pid - the precise failure the round-11 instruction ("A batch never outlives its originating watcher arm unconfirmed") required removing. After the first rotation the ownership guard is permanently unsatisfiable for that batch. Fix: hand ownership to the replacement on an actionable rotation (set wakeBatchArm to the new owner.child when a non-empty batch survives restoreAfterActionableClose), or track the batch against the CURRENT arm rather than the opening one. The round-11 test only exercises a single-wake batch, so it passes with this hole open.
  • ⚠️ .pi/extensions/fm-primary-pi-watch.ts:330 - When the arm-end flush does fire, its handling confirmation is guaranteed to be rejected and appended as a spurious urgent watcher: FAILED line. bin/fm-watch-arm.sh:548-568 prints watcher: started pid=$child ... recovery-generation=... and then wait &#34;$child&#34;, so the arm child process only exits AFTER its watcher pid has exited; the extension's close event therefore fires when recovery.watcherPid is already dead. flushWakeBatchOnArmEnd runs after releaseChild() (owner.child===null), so confirmHandlingDeliveryWithRetry's snapshot() falls back to the stale recovery, bin/fm-watch-arm.sh --handling-delivered fails its fm_pid_alive &#34;$handling_watcher_pid&#34; gate at :404, and flushWakeBatch:331-332 pushes watcher: FAILED - handling delivery confirmation was rejected (...) into the delivered batch even though the recovery episode is fine (the drain's own fm_recovery_marker_begin_handling still covers it). Concrete sequence: arm A closes actionably -> successor B -> W1 queued with recovery{G,B}, wakeBatchArm=B -> B closes non-actionably 5s later -> flush -> confirm(dead B) -> rejected -> the supervisor is told the watcher failed for a reason that did not happen. Pre-batching, the confirmation ran synchronously while B was alive and succeeded. The round-11 test's fake arm script exits 0 unconditionally for --handling-delivered, so it cannot detect this. Fix: skip the confirmation (and its failure detail) when the recorded watcher pid is no longer alive - there is nothing left to confirm at that point.
  • ℹ️ bin/fm-session-start.sh:680 - The compact-recovery digest passes --afk to fm-supervision-instructions.sh --state-lines but not to the --next-line call two sections later, and ordinary_wake_line has no away-mode branch (repair_line does). Under away mode the digest therefore prints - Away mode: active; load /afk and keep normal harness supervision paused while the daemon owns the watcher. and then, as the NEXT SUPERVISION INSTRUCTION, drain and handle this wake with bin/fm-wake-drain.sh, then run the exact --ack-through command it printed; .... The recovering session is handed an attended drain-and-acknowledge order while bin/fm-supervise-daemon.sh owns triage and drains the same queue. Confirm whether --next-line should take --afk and route to the away-mode wording (as repair_line already does), or whether the state line alone is considered sufficient.

🔧 Fix: fix(pi): rotate batch arm ownership and skip ended-arm confirms
2 infos still open:

  • ℹ️ bin/fm-watch.sh:294 - The window_key header claims to be the single owner of the per-window marker-file format and enumerates every family (".hash-, .count-, .stale-, .stale-since-, .wedge-escalations-, .paused-, .writing-"), warning that a second copy is how a future change silently orphans a window's markers. The change adds a new family, .endpoint-missing-<key>, which is not listed there and is also not cleared by clear_pause_tracking (bin/fm-watch.sh:615) alongside .stale-/.stale-since-/.wedge-escalations-. Add it to that enumeration so the stated owner stays complete.
  • ℹ️ .pi/extensions/fm-primary-pi-watch.ts:313 - The arm-end flush skips confirmHandlingDeliveryWithRetry unconditionally rather than on !pidAlive(recovery.watcherPid). The header comment justifies it with "an arm process only closes AFTER its own watcher pid has exited", which holds for both bin/fm-watch-arm.sh paths (started+wait and attach_and_wait) but not for an arm killed out-of-band while its watcher survives. Verified this is not a durability loss: bin/fm-wake-drain.sh:461 performs fm_recovery_marker_begin_handling on the drain the batch orders, so the recovery episode still transitions. Noting the tradeoff only; a pidAlive guard would make the code match its stated premise exactly.
🔧 **Test** - 1 issue found → auto-fixed ✅
  • 🚨 bin/fm-session-start.sh:267 - bin/fm-session-start.sh:267 runs fm_root_is_registered_crew_worktree (which shells out to git rev-parse) BEFORE the "--- 0. runtime bound ---" wrapper, in both the parent and the timed child. With a slow or hung git (stuck index.lock, hung network mount, unresponsive filesystem) session start now emits NO digest at all and never returns within its own runtime bound: measured exit=124 after an external 25s kill with FM_SESSION_START_TIMEOUT=3, versus the base commit which truncated loudly at 3s and still printed LOCK/BOOTSTRAP output. The repository's existing test tests/fm-session-start.test.sh::test_runtime_bound_truncates_loudly_and_exits_zero (which installs a hanging git) fails deterministically on the target commit and passes on the base commit; it also stretched the suite from seconds to ~22 minutes. Fix: move the crew-worktree suppression check inside the runtime-bound child (or bound/timeout the git calls in fm_root_is_registered_crew_worktree) so the digest's hard bound and truncation contract still hold. Repro transcript: /Users/ivan/.no-mistakes/evidence/01M0VT8T0CHZ154T3SVWEQETHM/session-start-hung-git-repro.txt
  • bin/fm-test-run.sh tests/fm-watch-triage.test.sh tests/fm-pi-watch-extension.test.sh tests/fm-wake-drain-open-decisions.test.sh tests/fm-wake-drain-open-decisions-cursor.test.sh tests/fm-wake-drain-unread-status.test.sh tests/fm-supervision-instructions.test.sh tests/fm-session-start.test.sh tests/fm-sessionstart-nudge.test.sh tests/fm-backend-herdr.test.sh — all green except tests/fm-session-start.test.sh (exit=1)
  • tests/fm-session-start.test.sh::test_runtime_bound_truncates_loudly_and_exits_zero isolated on the target tree (hangs) and on a git archive d982d45 baseline tree (passes in seconds)
  • Watcher replay driving the real bin/fm-watch.sh over synthetic 6- and 9-pane paused fleets on base vs target, draining and acknowledging each wake like a primary: wake-churn-replay.sh &lt;tree&gt; &lt;label&gt; &lt;fleet&gt;
  • Pi aggregator demo loading the real .pi/extensions/fm-primary-pi-watch.ts with config/wake-batch-seconds=3: routine-only batch vs urgent blocked [key=...] bypass, dedup of repeated status paths, single delivery acknowledgement
  • Real bin/fm-wake-drain.sh twice in one session (full OPEN DECISIONS then unchanged-count marker) and bin/fm-session-start.sh --compact after a queued wake
  • Real bin/fm-session-start.sh invoked with FM_ROOT_OVERRIDE pointing at a git worktree registered as worktree= in a primary's state meta (crew-worktree suppression line)
  • Herdr submit reproduction driving the real fm_backend_herdr_send_text_submit with a transiently unreadable post-Enter surface, base vs target
  • bin/fm-session-start.sh under a hung git with FM_SESSION_START_TIMEOUT=3, base vs target

🔧 Fix: bound crew-worktree check inside session-start runtime bound
✅ Re-checked - no issues remain.

  • bash tests/fm-session-start.test.sh — 50 assertions pass in 3m54s, including the pure-Bash watchdog bounds session start, kills its hung grandchild, and emits the truncation contract (the test that failed deterministically in round 1)
  • bash tests/fm-sessionstart-nudge.test.sh — 22 assertions pass, including session-start hooks suppress the primary digest in a registered crew worktree
  • bash tests/fm-watch-triage.test.sh (82 ok), bash tests/fm-pi-watch-extension.test.sh (40 ok), bash tests/fm-wake-drain-open-decisions.test.sh (13 ok), bash tests/fm-wake-drain-unread-status.test.sh (15 ok), bash tests/fm-backend-herdr.test.sh (182 ok)
  • Manual repro on HEAD with a git stub that ignores TERM and sleeps 600s, FM_SESSION_START_TIMEOUT=3, wrapped in timeout 25: exit 0 at 6s wall, digest through BOOTSTRAP plus the STARTUP TRUNCATED banner (round-1 target: exit 124, no output)
  • Manual live crew-worktree check: real git init + git worktree add, worktree recorded in the primary's state/task1.meta, driving the real bin/fm-session-start.sh — prints exactly crew worktree - digest suppressed; an unregistered linked worktree still runs the full digest
  • Manual replay wake-churn-replay.sh &lt;tree&gt; &#39;HEAD (cc2dc20)&#39; 6 driving the real bin/fm-watch.sh + bin/fm-wake-drain.sh — 6 supervision turns collapse to 2
  • Manual pi-wake-aggregator-demo.sh driving the real .pi/extensions/fm-primary-pi-watch.ts — 4-5 watcher closes become one batched follow-up with one acknowledgement at t+4.2s; a blocked [key=deploy-window] status bypasses the window at t+1.6s
🔧 **Document** - 2 issues found → auto-fixed ✅
  • 🚨 bin/fm-session-start.sh:298 - Repository lint fails on this branch independently of documentation: bin/fm-lint.sh exits 1 on shellcheck SC2016 at bin/fm-session-start.sh:298 (the single-quoted . &#34;$1/fm-primary-scope-lib.sh&#34;; fm_root_is_registered_crew_worktree &#34;$2&#34; bash -c body, introduced by cc2dc20). The intent requires shellcheck cleanliness. Fixing it means editing an executable file, which is outside this documentation phase.
  • ℹ️ docs/configuration.md:148 - Judgment call, left as-is: this change put the OPEN DECISIONS unchanged-collapse and --no-presentation-commit cursor semantics in docs/configuration.md, while docs/architecture.md already owned the drain fold/presentation contract. I treated configuration.md as the owner and reduced architecture.md to a one-line pointer rather than migrating the prose; a later consolidation could move that mechanism rationale into architecture.md and leave configuration.md owning only the knobs.

🔧 Fix: silence intentional SC2016 in session-start crew check
✅ Re-checked - no issues remain.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@withally
withally merged commit b0638f6 into main Aug 25, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant