From e7e46ffd077186f7460db24ee61ac8e017f1a332 Mon Sep 17 00:00:00 2001 From: "francesco.quarta" Date: Wed, 26 Aug 2026 12:24:21 +0200 Subject: [PATCH 1/2] supervision(branch): torn-down-task wake rows are main-only, not corruption A wake row can legitimately outlive its task's state files: the watcher queues a signal/stale row while the task still exists, the branch accepts the wake, and teardown deletes state/.meta before the serialized branchChain reaches that wake. scopeForUnreadWake classified any unmapped signal/stale row as queue corruption (UNSAFE), poisoning the whole scan and producing a false 'Supervision branch unavailable, falling back to main: the unread wake queue could not be read safely' wake (2026-08-26 m365-pi-scout incident). Legible-but-unmapped rows now behave exactly like the existing check-kind class: excluded from the branch's eligible set, left queued for main, non-vetoing in ordinary scans, and still a heartbeat veto. Structural corruption (unknown kind, malformed line, non-numeric seq) still vetoes the whole scan. The classification contract stays owned by fm-branch-dispatch.ts's header; pi-supervision-branch.md documents the boundary. Also: bin/fm-wake-drain.sh no longer abandons an empty claim/consume scratch tmp file in state/ (the .main-eligible-rows.tmp.* and .wake-rows.consume.* litter), and the live Pi branch e2e fixture copies lib/fm-calm-visibility.ts its 0.84.x imports require, refreshing the dated runtime-backends.md evidence for pi-coding-agent 0.84.3. Regression: tests/fm-pi-branch-extension.test.sh (unmapped-row classification, mixed-queue eligibility, heartbeat veto) and tests/fm-wake-queue.test.sh (no scratch tmp left behind). --- .pi/extensions/lib/fm-branch-dispatch.ts | 43 +++++++++++++++--------- bin/fm-wake-drain.sh | 4 ++- docs/pi-supervision-branch.md | 3 +- docs/verification/runtime-backends.md | 5 +-- tests/fm-pi-branch-extension.test.sh | 43 ++++++++++++++++++++++++ tests/fm-pi-branch-live-e2e.test.sh | 1 + tests/fm-wake-queue.test.sh | 36 ++++++++++++++++++++ 7 files changed, 116 insertions(+), 19 deletions(-) diff --git a/.pi/extensions/lib/fm-branch-dispatch.ts b/.pi/extensions/lib/fm-branch-dispatch.ts index c893022db60..f3e4519b2ed 100644 --- a/.pi/extensions/lib/fm-branch-dispatch.ts +++ b/.pi/extensions/lib/fm-branch-dispatch.ts @@ -35,13 +35,13 @@ export interface UnreadWakeScope { /** * True only when this scan itself is untrustworthy: the queue or its * metadata could not be read, a line fails the structural tab-field check, - * an unresolvable signal/stale row was found, or - for a heartbeat review - * only - a main-owned row sits anywhere in the unread queue. False whenever - * the scan completed cleanly and simply found nothing (or nothing further) - * eligible for the branch right now: status "unsafe" with corrupted false - * is the ordinary "ordinary main-only content, nothing here for the - * branch" case, not a fault, and callers should treat it as ordinary - * absence rather than escalating. + * an unknown wake kind was found, or - for a heartbeat review only - a + * main-owned row (check-kind or legible-but-unmapped) sits anywhere in the + * unread queue. False whenever the scan completed cleanly and simply found + * nothing (or nothing further) eligible for the branch right now: status + * "unsafe" with corrupted false is the ordinary "ordinary main-only + * content, nothing here for the branch" case, not a fault, and callers + * should treat it as ordinary absence rather than escalating. */ corrupted: boolean; } @@ -63,13 +63,18 @@ const UNSAFE_SCOPE: UnreadWakeScope = { status: "unsafe", eligible: false, proje // heartbeat=false is the changed half of this contract. A check-kind row - // merge-confirmation polls, Relay mentions, credential/auth failures, and // every other legitimately main-only class - no longer vetoes the whole scan; -// it is simply excluded from eligibleSeqs and left for main. An unresolvable -// signal/stale row (unmapped project) still vetoes the whole scan exactly as -// before, because that is a data/metadata problem this function cannot safely -// reason past, not an ordinary main-only event. A row this repo's -// fm_wake_append could never have produced (an unknown kind, or a line that -// fails the structural tab-field check) also still vetoes the whole scan - -// that is queue corruption, not an everyday mixed queue. +// it is simply excluded from eligibleSeqs and left for main. A legible +// signal/stale row whose task metadata no longer exists (an unmapped project) +// is the same ordinary main-only class, NOT corruption: a wake row can +// legitimately outlive its task's state files when teardown deletes +// state/.meta while the wake is still queued or already accepted by the +// branch, so such a row is excluded from eligibleSeqs and left for main just +// like a check-kind row. Only a row this repo's fm_wake_append could never +// have produced (an unknown kind, or a line that fails the structural +// tab-field check) still vetoes the whole scan - that is queue corruption, +// not an everyday mixed queue. A heartbeat review keeps its own all-or-nothing +// rule for EVERY main-owned row (check-kind or unmapped), because a heartbeat +// needs the whole fleet's context and only main can consume those rows. export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWakeScope { let queue = ""; try { @@ -128,7 +133,15 @@ export function scopeForUnreadWake(state: string, heartbeat: boolean): UnreadWak // ordinary main-only row. return UNSAFE_SCOPE; } - if (!project) return UNSAFE_SCOPE; + if (!project) { + // A legible signal/stale row whose task metadata is gone (teardown raced + // the queued wake) is main-only, exactly like a check-kind row: only + // main can consume it, so a heartbeat review defers to main, and an + // ordinary scan simply leaves it queued for main without vetoing the + // rows that ARE resolvable. + if (heartbeat) return UNSAFE_SCOPE; + continue; + } projects.add(project); eligibleSeqs.push(seq); } diff --git a/bin/fm-wake-drain.sh b/bin/fm-wake-drain.sh index 33f8cec9195..0e909b7a52c 100755 --- a/bin/fm-wake-drain.sh +++ b/bin/fm-wake-drain.sh @@ -91,7 +91,9 @@ reclaim_stale_branch_grant_locked() { write_rows_file_locked() { # local target=$1 source=$2 if [ ! -s "$source" ]; then - rm -f -- "$target" + # The source is always this caller's owned scratch file (DRAIN_TMP), and an + # empty claim/consume set must not abandon it in state/. + rm -f -- "$target" "$source" return fi chmod 0600 "$source" || return 1 diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 789ff069a2f..a46e2873a88 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -8,7 +8,7 @@ This document stays the owner and the contract. Fleet supervision on the Pi primary harness runs on a second, persistent conversation - the supervision branch - inside the same `pi` process as the captain's chat. Supervision is default-on: once a Pi primary session owns this home's fleet lock, the branch handles eligible task-local rows from ordinary actionable wakes plus heartbeat scans that the cheap bash-level scan flags as possibly captain-relevant, then merges each outcome back by appending a short note to the captain conversation's tail. Ordinary main-only rows remain on main even when eligible task-local rows share their queue. -An unresolvable row makes the scan unsafe and returns the whole wake to main, and every watcher-failure alarm also stays on main. +A structurally unreadable row (unknown kind, malformed line) makes the scan unsafe and returns the whole wake to main, while a legible row whose task metadata is gone (a wake that outlived its task's teardown) counts as ordinary main-only content and is left queued for main without vetoing the rest; every watcher-failure alarm also stays on main. Only captain-relevant branch outcomes open a turn on main - that follow-up turn is itself the captain-visible outcome, so Pi never separately prints or renders a captain-facing merge note. The design source is the captain-approved forked-supervision architecture board, a captain-private fleet record (a self-contained HTML explainer with the measured cache and judgment evidence); this document records the shape it landed as, and the delivering PR cites the board artifact itself. @@ -35,6 +35,7 @@ This feature is Pi-only by construction and changes nothing anywhere else: A fleet-wide heartbeat is separately eligible only when the unread queue contains heartbeat rows and resolvable task-local rows (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. The branch recomputes eligibility immediately before prompting the branch to drain and publishes the exact eligible row set to `state/.branch-eligible-rows` through `writeEligibleRowsSnapshot`. A newly-arrived main-owned row observed at that recheck no longer defers the whole queue to main: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own later drain. + A legible signal/stale row whose task metadata no longer exists at that recheck (teardown raced the queued wake) is the same main-only class - excluded, never a veto, left for main - exactly like a check-kind row, while any structurally unreadable row (unknown kind, malformed line) still defers the whole queue to main (classification owner: `.pi/extensions/lib/fm-branch-dispatch.ts` header, and see the heartbeat rule in "Heartbeat routing" below). [`watcher-continuity.md`](watcher-continuity.md#per-actor-acknowledgement) owns the consume-side guarantee that neither actor can present or acknowledge the other's claim. Heartbeat keeps its own unchanged all-or-nothing recheck: one main-owned row anywhere in the unread queue still defers the whole review to main, because a heartbeat needs the whole fleet's context. A producer can still append a row in the instant between that final check and drain startup; this accepted residual follows the confused-agent-grade boundary above rather than claiming adversarial queue isolation. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 0e918ad5c46..3b529d353c1 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -947,11 +947,12 @@ FM_HARNESS_LIVENESS_DRIFT=1 bin/fm-test-run.sh tests/fm-harness-liveness-drift-l The supervision-branch extension (`.pi/extensions/fm-branch-supervision.ts`, [docs/pi-supervision-branch.md](../pi-supervision-branch.md)) builds its persistent second session through the Pi SDK surface: `createAgentSession`, `DefaultResourceLoader` with `extensionFactories`, `SessionManager`, `createBashToolDefinition` with a `spawnHook`, `sendCustomMessage`, and the `before_provider_request` hook. -Evidence produced 2026-08-23 on macOS 26.5.0 arm64, Node v24.14.1: +Evidence produced 2026-08-23 on macOS 26.5.0 arm64, Node v24.14.1; refreshed 2026-08-26 on the same host, Node v26.5.0: - Real-SDK guard: `FM_PI_BRANCH_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-pi-branch-live-e2e.test.sh` against the globally installed `@earendil-works/pi-coding-agent` 0.80.10 printed `ok - real Pi SDK 0.80.10 accepts the branch session construction and preserves an unpromptable wake`. The guard reads no credentials and makes no provider call: an isolated empty `PI_CODING_AGENT_DIR` leaves model resolution empty, so the branch's first prompt fails fast and must prove the fallback that returns the wake to main. -- Strict typecheck: `tests/fm-pi-primary-types.test.sh` printed `ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.80.10` with the branch extension and dispatch lib included. + Refreshed 2026-08-26 against `@earendil-works/pi-coding-agent` 0.84.3: printed `ok - real Pi SDK 0.84.3 accepts the branch session construction and preserves an unpromptable wake` (the fixture now copies `lib/fm-calm-visibility.ts` into the scratch repo, which the 0.84.x imports require). +- Strict typecheck: `tests/fm-pi-primary-types.test.sh` printed `ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.84.3` with the branch extension and dispatch lib included. Scope of this evidence: the installed signed `pi` CLI (0.84.1 at verification time) is a compiled binary whose bundled SDK is not importable from Node, so the importable npm package is the only surface the guard and the typecheck can pin. The extension executes inside the signed CLI's own runtime, so a CLI upgrade can drift ahead of the pinned npm surface; refresh this record after every Pi upgrade by re-running both commands above (point `FM_PI_PACKAGE_DIR` at a matching npm install when one exists) and by watching the branch's own fallback line - every branch failure degrades to the pre-branch wake-to-main path by construction, which `tests/fm-pi-branch-extension.test.sh` holds with a broken generator and the live guard holds with the real SDK. diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 340c9a48781..b99bcf5a7b6 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -1396,6 +1396,49 @@ if (!truncated.corrupted || truncated.eligible || truncated.eligibleSeqs.length throw new Error(`a four-field queue row was not classified as corruption: ${JSON.stringify(truncated)}`); } +// A legible but UNMAPPED signal row (a wake that outlived its task's +// teardown: state/.meta deleted while the row was still queued) is +// ordinary main-only content, not corruption: excluded from eligibleSeqs, +// left for main, and never allowed to veto rows that DO resolve. Only a +// structurally unreadable row is corruption (above). This is the 2026-08-26 +// m365-pi-scout wake-loss reproduction: the branch's chained re-scan found a +// stale row whose task had just been torn down and wrongly fell back to main +// with "the unread wake queue could not be read safely". +writeFileSync( + `${state}/.wake-queue`, + "1\t1\tsignal\ttorn-down-task.status\tsignal: torn-down-task.status\n", +); +const unmapped = scopeForUnreadWake(state, false); +if (unmapped.eligible || unmapped.corrupted || unmapped.eligibleSeqs.length !== 0) { + throw new Error(`an unmapped signal row must read as ordinary main-only, not corruption: ${JSON.stringify(unmapped)}`); +} + +// A mixed queue: the unmapped row (seq 1) never vetoes the resolvable +// task-local rows (seq 2, 3) - the torn-down-leftover reproduction. +writeFileSync( + `${state}/.wake-queue`, + [ + "1\t1\tstale\torphan-window\tstale: orphan-window", + "1\t2\tsignal\ttask-a.status\tsignal: task-a.status", + "1\t3\tstale\tfm-window\tstale: fm-window", + ].join("\n"), +); +const unmappedMixed = scopeForUnreadWake(state, false); +if (!unmappedMixed.eligible || unmappedMixed.corrupted) { + throw new Error(`unmapped row must not veto resolvable task-local rows: ${JSON.stringify(unmappedMixed)}`); +} +if (unmappedMixed.eligibleSeqs.slice().sort().join(",") !== "2,3") { + throw new Error(`eligibleSeqs must exclude the unmapped row and keep the resolvable ones: ${JSON.stringify(unmappedMixed)}`); +} + +// heartbeat keeps its all-or-nothing rule for an unmapped row exactly as for +// a check-kind row: a main-owned row anywhere in the unread queue defers the +// whole review to main. +const unmappedHeartbeat = scopeForUnreadWake(state, true); +if (unmappedHeartbeat.eligible || !unmappedHeartbeat.corrupted) { + throw new Error(`an unmapped row must still veto a heartbeat review: ${JSON.stringify(unmappedHeartbeat)}`); +} + // A mixed queue: the main-only row (seq 1) never vetoes the task-local rows // (seq 2, 3) - the reproduction from the task. writeFileSync( diff --git a/tests/fm-pi-branch-live-e2e.test.sh b/tests/fm-pi-branch-live-e2e.test.sh index 5e2c49c89fa..82bb613fb8b 100644 --- a/tests/fm-pi-branch-live-e2e.test.sh +++ b/tests/fm-pi-branch-live-e2e.test.sh @@ -37,6 +37,7 @@ mkdir -p "$repo/.pi/extensions/lib" "$repo/node_modules/@earendil-works" \ "$home/state" "$home/config" "$agentdir" cp "$ROOT/.pi/extensions/fm-branch-supervision.ts" "$repo/.pi/extensions/fm-branch-supervision.ts" cp "$ROOT/.pi/extensions/lib/fm-branch-dispatch.ts" "$repo/.pi/extensions/lib/fm-branch-dispatch.ts" +cp "$ROOT/.pi/extensions/lib/fm-calm-visibility.ts" "$repo/.pi/extensions/lib/fm-calm-visibility.ts" cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" mkdir -p "$repo/bin" cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" diff --git a/tests/fm-wake-queue.test.sh b/tests/fm-wake-queue.test.sh index b37485c0803..bef8e45fba9 100755 --- a/tests/fm-wake-queue.test.sh +++ b/tests/fm-wake-queue.test.sh @@ -708,6 +708,41 @@ test_main_drain_excludes_rows_already_granted_to_branch() { pass "main drain and acknowledgement exclude an active branch grant" } +test_empty_actor_claims_leave_no_scratch_tmp_behind() { + local dir state out err n + dir=$(make_case empty-claims) + state="$dir/state" + + # A main drain whose claim set is empty (every row is branch-granted) and a + # branch ack whose consume set is empty (every granted row is at or below the + # cutoff) must not abandon their scratch tmp files in state/ - the pre-fix + # leak left .main-eligible-rows.tmp.* and .wake-rows.consume.* litter behind. + append_wake "$state" signal "task-a.status" "signal: task-a" || fail "signal append failed" + append_wake "$state" stale "fm-window" "stale: fm-window" || fail "stale append failed" + FM_STATE_OVERRIDE="$state" "$GRANT" activate "$$" empty-claims || fail "branch owner activation failed" + FM_STATE_OVERRIDE="$state" "$GRANT" publish empty-claims 1 2 || fail "branch grant publication failed" + + out="$dir/main.out" + err="$dir/main.err" + FM_STATE_OVERRIDE="$state" "$DRAIN" > "$out" 2> "$err" || fail "main drain failed: $(cat "$err")" + [ -s "$out" ] && fail "main drain presented branch-granted rows: $(cat "$out")" + + out="$dir/branch.out" + err="$dir/branch.err" + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" > "$out" 2> "$err" \ + || fail "branch drain failed: $(cat "$err")" + sequence=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through \([0-9][0-9]*\) --recovery-generation [A-Za-z0-9._-][A-Za-z0-9._-]*$/\1/p' "$err") + generation=$(sed -n 's/^WAKE_ACK_REQUIRED:.*--ack-through [0-9][0-9]* --recovery-generation \([A-Za-z0-9._-][A-Za-z0-9._-]*\)$/\1/p' "$err") + [ -n "$sequence" ] && [ -n "$generation" ] || fail "branch drain omitted its acknowledgement boundary" + FM_STATE_OVERRIDE="$state" FM_SUPERVISION_ACTOR=branch "$DRAIN" --ack-through "$sequence" --recovery-generation "$generation" \ + || fail "branch ack failed" + + n=$(find "$state" -maxdepth 1 \( -name '.main-eligible-rows.tmp.*' -o -name '.wake-rows.consume.*' \) | wc -l | tr -d ' ') + [ "$n" -eq 0 ] || fail "empty actor claims left scratch temp files in state/: $(find "$state" -maxdepth 1 \( -name '.main-eligible-rows.tmp.*' -o -name '.wake-rows.consume.*' \) | tr '\n' ' ')" + + pass "empty actor claims and acks leave no scratch temp files behind" +} + test_branch_grant_refuses_rows_already_claimed_by_main() { local dir state rc dir=$(make_case branch-refuses-main-claim) @@ -1218,6 +1253,7 @@ test_enrichment_preserves_all_unread_lines_and_status_file_failures test_slow_annotation_does_not_block_append_and_deleted_file_fails_open test_branch_actor_scoped_ack_never_swallows_a_main_owned_row test_main_drain_excludes_rows_already_granted_to_branch +test_empty_actor_claims_leave_no_scratch_tmp_behind test_branch_grant_refuses_rows_already_claimed_by_main test_actor_filter_precedes_same_key_deduplication test_main_reclaims_a_grant_whose_branch_owner_exited From b784758b46544b1cd4ca4fbd74784c390c2e1838 Mon Sep 17 00:00:00 2001 From: "francesco.quarta" Date: Wed, 26 Aug 2026 12:47:23 +0200 Subject: [PATCH 2/2] no-mistakes(document): Align stale unresolvable-wake wording in supervision-branch doc --- docs/pi-supervision-branch.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index a46e2873a88..13a3f1650c0 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -32,7 +32,7 @@ This feature is Pi-only by construction and changes nothing anywhere else: - Consistency: `bin/fm-lease-lib.sh` owns the per-task lease contract, the main-only role partition, and the deliberate CONFUSED-AGENT-GRADE threat model these guards target (captain-decided; adversarial-grade separation is out of scope and tracked as follow-up design work); `bin/fm-lease.sh` is the command surface. The guards are wired into `fm-send.sh`, `fm-control.sh`, and `fm-teardown.sh` (overlap, lease-checked, with claim serialization retained through the mutation) and `fm-pr-merge.sh`, `fm-merge-local.sh`, and `fm-spawn.sh` (main-owned, branch refused; a relaunch through `fm-control` stays branch-legal recovery). - Autonomy: supervision is default-on for every task once a Pi primary session owns the fleet lock (docs/configuration.md "Pi supervision branch"); no captain grant file is required. - A fleet-wide heartbeat is separately eligible only when the unread queue contains heartbeat rows and resolvable task-local rows (see "Heartbeat routing" below); every other fleet-wide or unresolvable wake, and every watcher-failure alarm, stays on main. + A fleet-wide heartbeat is separately eligible only when the unread queue contains heartbeat rows and resolvable task-local rows (see "Heartbeat routing" below); every other fleet-wide wake, every structurally unreadable wake, and every watcher-failure alarm stays on main. The branch recomputes eligibility immediately before prompting the branch to drain and publishes the exact eligible row set to `state/.branch-eligible-rows` through `writeEligibleRowsSnapshot`. A newly-arrived main-owned row observed at that recheck no longer defers the whole queue to main: it is excluded from the eligible set, so whatever else is currently eligible still reaches the branch, and the main-owned row stays queued for main's own later drain. A legible signal/stale row whose task metadata no longer exists at that recheck (teardown raced the queued wake) is the same main-only class - excluded, never a veto, left for main - exactly like a check-kind row, while any structurally unreadable row (unknown kind, malformed line) still defers the whole queue to main (classification owner: `.pi/extensions/lib/fm-branch-dispatch.ts` header, and see the heartbeat rule in "Heartbeat routing" below). @@ -63,7 +63,7 @@ Only a scan already flagged as possibly captain-relevant emits the bare `heartbe The branch runs its normal operating procedure for the wake (`bin/fm-branch-prompt.sh` "Handling a wake") and performs the deeper fleet review that main previously performed. A review that found literally nothing worth reporting uses verdict `routine`, `task=fleet`, and `silent=true` so it has no rendered note, while a fleet-wide routine action omits `silent` and keeps its rendered sailboat note. Only a captain-worthy finding reports verdict `captain` and opens a main turn. -Every other fleet-wide or unresolvable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path. +Every other fleet-wide wake, every structurally unreadable wake - including watcher-failure alarms, which are never offered to the branch - keeps today's wake-to-main path. ## Cost model and the byte-stable prefix