Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 28 additions & 15 deletions .pi/extensions/lib/fm-branch-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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/<id>.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 {
Expand Down Expand Up @@ -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);
}
Expand Down
4 changes: 3 additions & 1 deletion bin/fm-wake-drain.sh
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,9 @@ reclaim_stale_branch_grant_locked() {
write_rows_file_locked() { # <target> <source>
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
Expand Down
7 changes: 4 additions & 3 deletions docs/pi-supervision-branch.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -32,9 +32,10 @@ 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).
[`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.
Expand Down Expand Up @@ -62,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

Expand Down
5 changes: 3 additions & 2 deletions docs/verification/runtime-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
43 changes: 43 additions & 0 deletions tests/fm-pi-branch-extension.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<id>.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(
Expand Down
1 change: 1 addition & 0 deletions tests/fm-pi-branch-live-e2e.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
36 changes: 36 additions & 0 deletions tests/fm-wake-queue.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading