diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index 775b8e3d775..d4d24214e39 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -5,10 +5,11 @@ // actionable wake here (lib/fm-branch-dispatch.ts); the branch handles it with // real tools and reports through the fm_branch_report custom tool, which // writes the durable outcome store FIRST (bin/fm-branch-outcome.sh) and then -// merges an append-only note to main's tail. Main's captain/assistant dialog -// is mirrored into the branch as read-only fm-main-mirror context from Pi's -// before_agent_start prompt and at main's turn_end. Pi-only by construction: this -// file lives in .pi/extensions, so no +// routes it through the freshness boundary before any note reaches main's +// tail. Main's captain/assistant dialog is mirrored into the branch as +// read-only fm-main-mirror context from Pi's before_agent_start prompt and at +// main's turn_end. Pi-only by construction: this file lives in +// .pi/extensions, so no // other harness ever loads it. Supervision is default-on for every task once // this Pi session owns the fleet lock: no captain grant file is required. // Away mode (or a broken branch) keeps today's wake-to-main behavior @@ -134,7 +135,7 @@ const branchCacheKey = `fm-branch-${createHash("sha256").update(fmHome).digest(" const MIRROR_MESSAGE_CAP = 4000; const MERGE_NOTE_BOAT = "⛵"; // Carried inside the captain note's own text because that text is the only -// part of a custom message Pi gives the model (see mergeIntoMain). +// part of a custom message Pi gives the model (see captainOutcomeInput). // // The note still needs to identify itself so main cannot mistake an incoming // outcome for its own earlier answer and silently lose the outcome. Event @@ -150,6 +151,17 @@ const CAPTAIN_OUTCOME_INSTRUCTION = type MirrorItem = { tag: "captain" | "main"; text: string }; type MirrorCursor = { file: string; index: number }; type Verdict = "routine" | "captain"; +// A merge note queued with the task's claim anchor so the claim can be +// re-checked at the single delivery boundary. +type PendingNote = { + generation: number; + seq: string; + task: string; + verdict: Verdict; + summary: string; + silent: boolean; + anchor: string; +}; type LockOwnership = "owned" | "other" | "missing"; const scriptEnv = { @@ -419,6 +431,7 @@ export default function (pi: ExtensionAPI) { // serially by design). let branchChain: Promise = Promise.resolve(); const pendingMirror: MirrorItem[] = []; + const pendingNotes: PendingNote[] = []; const mirrorCollection: MirrorCollectionState = { collectAnchor: null, pendingCursor: null, @@ -593,18 +606,48 @@ export default function (pi: ExtensionAPI) { } } + // The task's claim anchor right now (bin/fm-branch-outcome.sh owns what it + // contains). An empty result means the claim is unverifiable, which never + // reads as stale. + function claimAnchor(task: string): string { + const read = runOutcomeScript(["claim-anchor", "--task", task]); + return read.ok ? read.stdout : ""; + } + + function markRead(expectedGeneration: number, seq: string): boolean { + if (!/^[0-9]+$/.test(seq)) return true; + if (!actingAsOwner(expectedGeneration)) return false; + return runOutcomeScript(["mark-read", "--through", seq]).ok; + } + + function noteContent(task: string, verdict: Verdict, summary: string): string { + return verdict === "captain" ? `${task}: ${summary}` : `${MERGE_NOTE_BOAT} ${task}: ${summary}`; + } + + // Rendered unless it is a captain-facing note (its turn is the outcome) or an + // explicitly silent no-change fleet heartbeat. + function noteDisplay(task: string, verdict: Verdict, silent: boolean): boolean { + return verdict !== "captain" && !(task === "fleet" && silent); + } + + // A captain-relevant outcome whose claim went stale still opens its turn - + // suppressing it could bury a real terminal result - but it is delivered as + // a superseded record with an explicit re-check instruction, so main reports + // the current truth rather than the recorded claim. + function supersededContent(task: string, summary: string): string { + return `${task}: SUPERSEDED - this outcome was recorded earlier and the task's durable record has changed since it was written, so treat its claim as out of date: "${summary}". Re-read the task's current state and report that to the captain instead of this recorded summary.`; + } + // Append-only merge into main. The store row is already durable when this - // runs; the note is a cache of it at main's tail. Delivery modes per the - // design: routine+idle appends now with no turn, routine+busy appends after - // the captain's next prompt, captain-relevant triggers exactly one turn - // (queued as a follow-up while main is busy) - that follow-up turn is - // itself the captain-visible outcome, so the captain-facing note is - // delivered silently (display: false) rather than printed or rendered a - // second time; routine notes stay rendered except an explicitly silent + // runs; the note is a cache of it at main's tail. A routine append explicitly + // disables turn triggering, so it never steers or opens a turn. A captain- + // relevant append uses a follow-up to open exactly one turn without steering; + // that turn is itself the captain-visible outcome, so the captain-facing note + // is delivered silently (display: false) rather than printed or rendered a + // second time. Routine notes stay rendered except an explicitly silent // no-change heartbeat. The read cursor advances once the note is handed to - // Pi; a crash inside Pi's - // own delivery window leaves the outcome durable in the store, where - // main's fm_branch_outcomes tool still reads it on demand. + // Pi; a crash inside Pi's own delivery window leaves the outcome durable in + // the store, where main's fm_branch_outcomes tool still reads it on demand. // // Pi keeps only `content` when it converts a custom message for the model: // customType, display, and details never reach the provider. A captain note @@ -617,49 +660,116 @@ export default function (pi: ExtensionAPI) { // instruction preserves the event-ownership boundary while requiring the // captain-facing response and leaving its wording to main. // + // It takes the finished note body so every captain note is wrapped at the one + // delivery boundary below, a superseded one included: a refreshed outcome is + // no more self-describing than the original it replaces. + // // Encoding shells out, so it can fail on a broken checkout. This file's // failure direction applies: an outcome that cannot be typed is still // delivered, carrying the same instruction as plain text, because an // untyped outcome main can still read beats an outcome the captain never // sees. - function captainOutcomeInput(task: string, summary: string): string { - const body = `${CAPTAIN_OUTCOME_INSTRUCTION}\n\n${task}: ${summary}`; + function captainOutcomeInput(body: string): string { + const text = `${CAPTAIN_OUTCOME_INSTRUCTION}\n\n${body}`; try { - return encodeFirstmateOperationalInput("branch-outcome", body); + return encodeFirstmateOperationalInput("branch-outcome", text); } catch { - return body; + return text; } } - function mergeIntoMain( + function sendNote( expectedGeneration: number, seq: string, task: string, verdict: Verdict, - summary: string, - silent: boolean, + content: string, + display: boolean, ): boolean { if (!actingAsOwner(expectedGeneration)) return false; + const message = { customType: "fm-branch-merge", content, display }; if (verdict === "captain") { - const message = { - customType: "fm-branch-merge", - content: captainOutcomeInput(task, summary), - display: false, - }; pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" }); } else { - const message = { customType: "fm-branch-merge", content: `${MERGE_NOTE_BOAT} ${task}: ${summary}`, display: !(task === "fleet" && silent) }; - if (mainStreaming) { - pi.sendMessage(message, { deliverAs: "nextTurn" }); - } else { - pi.sendMessage(message, {}); - } + pi.sendMessage(message, { triggerTurn: false }); } - if (/^[0-9]+$/.test(seq)) { - if (!actingAsOwner(expectedGeneration)) return false; - return runOutcomeScript(["mark-read", "--through", seq]).ok; + return markRead(expectedGeneration, seq); + } + + // Keep every note HERE with the task's claim anchor until the single + // delivery boundary re-checks it. A note Pi already owns can no longer be + // re-checked before it is rendered. The row stays unread until the note is + // delivered or deliberately dropped as stale, so a session that ends before + // either decision replays it at startup. + function enqueueNote( + expectedGeneration: number, + seq: string, + task: string, + verdict: Verdict, + summary: string, + silent: boolean, + anchor: string, + ): void { + pendingNotes.push({ generation: expectedGeneration, seq, task, verdict, summary, silent, anchor }); + } + + // Delivery boundary: deliver queued notes, re-checking each claim against + // the task's durable record first. A stale routine note is dropped (the + // durable row keeps it, and fm_branch_outcomes still reads it) and a stale + // captain-relevant one is refreshed, narrowing stale exposure to the final + // non-atomic check-to-handoff instant documented by the architecture. + function releasePendingNotes(releaseAllowed: boolean): "released" | "blocked" | "refused" { + while (pendingNotes.length > 0) { + if (!releaseAllowed || mainStreaming) return "blocked"; + const note = pendingNotes[0]; + if (!actingAsOwner(note.generation)) { + // Ownership is gone; the rows are still unread and replay at session start. + pendingNotes.length = 0; + return "refused"; + } + pendingNotes.shift(); + const current = note.anchor ? claimAnchor(note.task) : ""; + const stale = note.anchor !== "" && current !== "" && current !== note.anchor; + if (stale && note.verdict === "routine") { + if (!markRead(note.generation, note.seq)) { + pendingNotes.length = 0; + return "refused"; + } + continue; + } + const body = stale + ? supersededContent(note.task, note.summary) + : noteContent(note.task, note.verdict, note.summary); + const content = note.verdict === "captain" ? captainOutcomeInput(body) : body; + const display = noteDisplay(note.task, note.verdict, note.silent); + if (!sendNote(note.generation, note.seq, note.task, note.verdict, content, display)) { + pendingNotes.length = 0; + return "refused"; + } + // A captain-relevant note just opened main's turn; the rest wait for the + // next idle boundary rather than steering that turn. + if (note.verdict === "captain") return "released"; } - return true; + return "released"; + } + + function mergeIntoMain( + expectedGeneration: number, + seq: string, + task: string, + verdict: Verdict, + summary: string, + silent: boolean, + anchor: string, + ): "delivered" | "held" | "refused" { + if (!actingAsOwner(expectedGeneration)) return "refused"; + enqueueNote(expectedGeneration, seq, task, verdict, summary, silent, anchor); + if (mainStreaming) return "held"; + const released = releasePendingNotes(true); + if (released === "refused") return "refused"; + return pendingNotes.some((note) => note.generation === expectedGeneration && note.seq === seq) + ? "held" + : "delivered"; } function createReportTool(toolGeneration: number): ToolDefinition { @@ -697,7 +807,10 @@ export default function (pi: ExtensionAPI) { }; } const verdict = verdictRaw as Verdict; - const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent)]; + // Captured BEFORE the row is written, so the anchor describes the task + // exactly as the branch just saw it when it judged the claim true. + const anchor = claimAnchor(task); + const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent), "--anchor", anchor]; if (wake) appendArgs.push("--wake", wake); if (!actingAsOwner(toolGeneration)) { return { @@ -714,7 +827,8 @@ export default function (pi: ExtensionAPI) { isError: true, }; } - if (!mergeIntoMain(toolGeneration, appended.stdout, task, verdict, summary, silent)) { + const merged = mergeIntoMain(toolGeneration, appended.stdout, task, verdict, summary, silent, anchor); + if (merged === "refused") { return { content: [{ type: "text", text: `recorded seq ${appended.stdout}, but merge refused after supervision replacement or lock loss` }], details: undefined, @@ -722,7 +836,12 @@ export default function (pi: ExtensionAPI) { }; } return { - content: [{ type: "text", text: `recorded seq ${appended.stdout} and merged [${verdict}] into main` }], + content: [{ + type: "text", + text: merged === "held" + ? `recorded seq ${appended.stdout}; the [${verdict}] note waits for the captain's running turn to end and is re-checked against the task's record before it is merged` + : `recorded seq ${appended.stdout} and merged [${verdict}] into main`, + }], details: undefined, }; }, @@ -1042,11 +1161,10 @@ ${context.command} pi.on?.("agent_start", () => { mainStreaming = true; }); - pi.on?.("agent_end", () => { - mainStreaming = false; - }); - pi.on?.("agent_settled", () => { + pi.on?.("agent_settled", (_event, ctx) => { + if (!ctx.isIdle()) return; mainStreaming = false; + releasePendingNotes(true); }); // before_agent_start stages Pi's authoritative in-flight prompt before @@ -1109,6 +1227,9 @@ ${context.command} shuttingDown = true; generation += 1; pendingMirror.length = 0; + // Held notes were never delivered and their rows are still unread, so the + // replacement session replays them from the durable store instead. + pendingNotes.length = 0; currentMainSession = null; mirrorCollection.collectAnchor = null; mirrorCollection.pendingCursor = null; diff --git a/bin/fm-branch-outcome.sh b/bin/fm-branch-outcome.sh index a505302f05c..3727145472d 100755 --- a/bin/fm-branch-outcome.sh +++ b/bin/fm-branch-outcome.sh @@ -5,40 +5,63 @@ # CONTRACT (this header is the one owner of the store's format). # - Store: $STATE/branch-outcomes.jsonl, strictly APPEND-ONLY. One JSON # object per line: {"seq":N,"epoch":N,"task":"...","wake":"...", -# "verdict":"routine"|"captain","summary":"...","silent":true|false}. -# Legacy rows without `silent` remain valid and are treated as visible. +# "verdict":"routine"|"captain","summary":"...","silent":true|false, +# "anchor":"..."}. +# Legacy rows without `silent` remain valid and are treated as visible; +# legacy rows without `anchor` remain valid and are never treated as stale. # Existing lines are never rewritten, reordered, or deleted by any # subcommand; the read state lives # entirely in the cursor sidecar so marking outcomes read cannot disturb # the log. Retention: the log is small (one line per handled fleet event) # and truncation, if ever needed, is a captain-approved manual act. # - Cursor: $STATE/.branch-outcomes-cursor holds the highest seq handed to -# Pi as an append-only merge note, emitted by the locked session-start +# Pi as an append-only merge note, deliberately dropped as a stale routine +# note at the delivery boundary, emitted by the locked session-start # replay, or silently consumed there because `silent` is true. Records -# above the cursor are "unread": the branch stored them but -# did not reach either handoff. A crash inside Pi's delivery window after +# above the cursor are "unread": the branch stored them but did not reach +# a delivery boundary or replay. A crash inside Pi's delivery window after # cursor advancement does not auto-replay the row; it remains durable and # available through the main session's fm_branch_outcomes tool. # - Every mutation runs under $STATE/.branch-outcomes.lock so the branch # extension and a concurrent session-start replay cannot interleave. -# - The store is written BEFORE the merge note is appended to main -# (store-first durability): nothing about a handled event depends on -# conversation memory. +# - The store is written BEFORE the delivery boundary can append a merge +# note to main (store-first durability): nothing about a handled event +# depends on conversation memory. +# - CLAIM ANCHOR (freshness). An outcome is recorded when its claim is true +# and delivered later, so every delivery re-checks the claim instead of +# trusting the recorded text. `anchor` is the task's claim anchor at +# append time: the durable records that decide whether a task-local claim +# is still current, which are the task metadata's presence and recorded +# `pr=`, plus whether the task's PR merge poll is still armed. Teardown +# and a merged PR both move it; ordinary status appends do not, so a +# routine note is not invalidated by mere progress. `fleet` has no +# task-local claim and anchors to the constant `fleet`. An anchor that +# cannot be computed is empty, which never reads as stale: an +# unverifiable claim is delivered unchanged rather than suppressed, so +# uncertainty never loses a captain-relevant outcome. # # Usage: # fm-branch-outcome.sh append --task --verdict routine|captain \ -# --summary [--wake ] [--silent true|false] +# --summary [--wake ] [--silent true|false] \ +# [--anchor ] # Append one outcome record; prints the assigned seq. +# fm-branch-outcome.sh claim-anchor --task +# Print the task's claim anchor right now. Compare a stored anchor with a +# fresh one to decide whether a recorded outcome is still current. # fm-branch-outcome.sh unread # Print every unread record (raw JSONL). Exit 0 with no output when none. # fm-branch-outcome.sh mark-read --through -# Advance the cursor (never backwards) after handing the records to Pi. +# Advance the cursor (never backwards) after accounting for records at the +# delivery boundary. # fm-branch-outcome.sh list [--recent ] # Print the last n records (default 20), read or not. # fm-branch-outcome.sh startup-replay # Session-start recovery: print visible unread records under a labeled # header into the locked startup digest, skip rows whose `silent` field is -# true, and mark every unread row read. Prints nothing when nothing visible +# true, and mark every unread row read. A row whose stored anchor no longer +# matches its task's current anchor is emitted with "superseded":true added +# so the replay cannot relay a stale claim as current; the stored line is +# not rewritten. Prints nothing when nothing visible # is unread, so a home that never ran the branch stays silent. Run it only # when the session holds the lock (fm-session-start.sh owns the call site). set -eu @@ -46,13 +69,16 @@ set -eu SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=bin/fm-wake-lib.sh . "$SCRIPT_DIR/fm-wake-lib.sh" +# fm_task_id_path_safe: the shared owner of task-id path safety. +# shellcheck source=bin/fm-pr-lib.sh +. "$SCRIPT_DIR/fm-pr-lib.sh" STORE="$STATE/branch-outcomes.jsonl" CURSOR="$STATE/.branch-outcomes-cursor" LOCK="$STATE/.branch-outcomes.lock" usage() { - echo "usage: fm-branch-outcome.sh append --task --verdict routine|captain --summary [--wake ] [--silent true|false] | unread | mark-read --through | list [--recent ] | startup-replay" >&2 + echo "usage: fm-branch-outcome.sh append --task --verdict routine|captain --summary [--wake ] [--silent true|false] [--anchor ] | claim-anchor --task | unread | mark-read --through | list [--recent ] | startup-replay" >&2 exit 2 } @@ -83,10 +109,9 @@ last_seq() { [ -s "$STORE" ] || { printf '0\n'; return 0; } value=$(tail -n 1 "$STORE" 2>/dev/null | jq -er ' select(type == "object") - | select( - keys == ["epoch", "seq", "summary", "task", "verdict", "wake"] - or (keys == ["epoch", "seq", "silent", "summary", "task", "verdict", "wake"] and (.silent | type) == "boolean") - ) + | select((keys - ["anchor", "silent"]) == ["epoch", "seq", "summary", "task", "verdict", "wake"]) + | select((has("silent") | not) or (.silent | type) == "boolean") + | select((has("anchor") | not) or (.anchor | type) == "string") | select((.seq | type) == "number" and .seq >= 1 and .seq == (.seq | floor)) | select((.epoch | type) == "number" and .epoch >= 0 and .epoch == (.epoch | floor)) | select((.task | type) == "string" and (.wake | type) == "string") @@ -96,6 +121,46 @@ last_seq() { printf '%s\n' "$value" } +# The task's claim anchor: the durable records that decide whether a +# task-local outcome is still current (header CONTRACT owns the semantics). +# Fails only for a task id that is not path-safe, so a caller that cannot +# compute one treats the claim as unverifiable rather than stale. +claim_anchor() { # + local id=$1 meta=0 poll=0 pr='' path + if [ "$id" = fleet ]; then + printf 'fleet\n' + return 0 + fi + fm_task_id_path_safe "$id" || return 1 + path="$STATE/$id.meta" + if [ -f "$path" ] && [ ! -L "$path" ]; then + meta=1 + pr=$(sed -n 's/^pr=//p' "$path" | head -n 1 | tr -d '\r') + fi + path="$STATE/$id.pr-poll" + if [ -f "$path" ] && [ ! -L "$path" ]; then + poll=1 + fi + printf 'meta=%s;poll=%s;pr=%s\n' "$meta" "$poll" "$pr" +} + +# Annotate replayed rows whose stored anchor no longer matches the task's +# current one. The stored line is never rewritten; the marker is added only to +# what the replay emits. +annotate_superseded() { # reads rows on stdin + local line task anchor current + while IFS= read -r line; do + [ -n "$line" ] || continue + task=$(printf '%s\n' "$line" | jq -r '.task // ""') + anchor=$(printf '%s\n' "$line" | jq -r '.anchor // ""') + if [ -n "$anchor" ] && current=$(claim_anchor "$task") && [ "$current" != "$anchor" ]; then + printf '%s\n' "$line" | jq -c '. + {superseded: true}' + else + printf '%s\n' "$line" + fi + done +} + record_seq() { # printf '%s\n' "$1" | sed -n 's/^{"seq":\([0-9]*\),.*/\1/p' } @@ -131,6 +196,7 @@ case "$CMD" in SUMMARY='' WAKE='' SILENT=false + ANCHOR='' while [ "$#" -gt 0 ]; do case "$1" in --task) TASK=${2:-}; shift 2 || usage ;; @@ -138,6 +204,7 @@ case "$CMD" in --summary) SUMMARY=${2:-}; shift 2 || usage ;; --wake) WAKE=${2:-}; shift 2 || usage ;; --silent) SILENT=${2:-}; shift 2 || usage ;; + --anchor) ANCHOR=${2:-}; shift 2 || usage ;; *) usage ;; esac done @@ -152,12 +219,21 @@ case "$CMD" in exit 1 fi SEQ=$(( LAST_SEQ + 1 )) - printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s}\n' \ + printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s,"anchor":"%s"}\n' \ "$SEQ" "$(date +%s)" "$(json_escape "$TASK")" "$(json_escape "$WAKE")" \ - "$VERDICT" "$(json_escape "$SUMMARY")" "$SILENT" >> "$STORE" + "$VERDICT" "$(json_escape "$SUMMARY")" "$SILENT" "$(json_escape "$ANCHOR")" >> "$STORE" fm_lock_release "$LOCK" printf '%s\n' "$SEQ" ;; + claim-anchor) + [ "${1:-}" = --task ] || usage + [ "$#" -eq 2 ] || usage + [ -n "${2:-}" ] || usage + claim_anchor "$2" || { + echo "error: task id is not path-safe" >&2 + exit 1 + } + ;; unread) [ "$#" -eq 0 ] || usage fm_lock_acquire_wait "$LOCK" @@ -189,7 +265,7 @@ case "$CMD" in fm_lock_acquire_wait "$LOCK" UNREAD=$(print_unread) if [ -n "$UNREAD" ]; then - VISIBLE=$(printf '%s\n' "$UNREAD" | jq -c 'select(.silent != true)') + VISIBLE=$(printf '%s\n' "$UNREAD" | jq -c 'select(.silent != true)' | annotate_superseded) if [ -n "$VISIBLE" ]; then printf 'BRANCH OUTCOMES (handled by the supervision branch, not yet seen by this session):\n' printf '%s\n' "$VISIBLE" diff --git a/docs/architecture.md b/docs/architecture.md index 2376fb2ce53..0a822e386e8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,7 +79,7 @@ The fleet snapshot and Bearings paths do not consume this additive publication y The script header owns the exact JSON schema. On a Pi primary, supervision is default-on: the watcher extension can hand eligible task-local rows from an ordinary actionable wake, plus selected fleet-wide heartbeat reviews, to a persistent in-process supervision conversation while main-only rows remain on the captain-facing path. -The branch handles those rows, stores the outcome durably, and merges an append-only note back. +The branch handles those rows, stores the outcome durably, and routes it through delivery-time freshness before any note is merged back. A captain-facing outcome instead opens exactly one follow-up turn on the captain's conversation without printing or rendering a separate note. [docs/pi-supervision-branch.md](pi-supervision-branch.md) owns row eligibility and dispatch architecture, while the generated [Pi supervision protocol](supervision-protocols/pi.md) owns MAIN's captain-visible response and merged-event handling; every other harness keeps the wake-to-main path unchanged. diff --git a/docs/configuration.md b/docs/configuration.md index 99e1c1fd608..fb96ad19d3a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,6 +47,7 @@ A captain-facing (verdict `captain`) branch outcome opens exactly one follow-up The branch prompt owns the unconditional explicit-request rule and the distinction between captain-facing, unsolicited routine, and unchanged-review outcomes. The generated [Pi supervision protocol](supervision-protocols/pi.md) owns main's required captain-visible response, event ownership, and conversational treatment for merged outcomes. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is delivered silently with no rendered note, while every other routine outcome still appends a rendered, sailboat-prefixed note. +Delivery-time freshness behavior is owned by [docs/pi-supervision-branch.md](pi-supervision-branch.md). ## Pi supervision branch model and effort (config/supervision-branch-model, config/supervision-branch-effort) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 80964d7ec6a..374adac7bb3 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -6,7 +6,7 @@ The poster is the visual of the idea. 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. +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 routes each durable outcome through delivery-time freshness before any note is merged into 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. Only captain-relevant branch outcomes open a turn on main; the generated [Pi supervision protocol](supervision-protocols/pi.md) requires MAIN to produce the captain-visible response in that turn, while Pi never separately prints or renders a captain-facing merge note. @@ -29,8 +29,8 @@ This feature is Pi-only by construction and changes nothing anywhere else: Every path that cannot reach a working branch falls back to delivering the wake to main - a broken branch degrades to today's behavior, never to a lost wake. - Branch model and effort selection: the same extension registers `/supervision-model`, which picks the branch's model and then its reasoning effort, and applies both at the branch-session creation boundary; [configuration.md](configuration.md#pi-supervision-branch-model-and-effort-configsupervision-branch-model-configsupervision-branch-effort) owns the operator-facing schema and behavior. - Branch system prompt: `bin/fm-branch-prompt.sh`; its header owns the byte-stable-prefix contract (no timestamps, no fleet snapshot, no per-wake content). -- Outcome store: `bin/fm-branch-outcome.sh`; its header owns the append-only format and the read cursor. - Outcomes are written to the store before any note is handed to Pi, and rows that never reach that handoff replay once through the next locked session-start digest. +- Outcome store: `bin/fm-branch-outcome.sh`; its header owns the append-only format, the read cursor, and the claim anchor used for delivery-time freshness (see "Freshness at delivery" below). + Outcomes are written to the store before any note is handed to Pi, and rows left unread because a session ends before delivery replay once through the next locked session-start digest. - 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. @@ -54,9 +54,12 @@ The branch prompt frames mirrored text as context for judgment, never as instruc Stage one is unchanged: the bash watcher absorbs everything provably fine at zero token cost. Stage two is the branch's verdict on each handled event, reported through its `fm_branch_report` tool: `routine` merges without a follow-up turn, while `captain` merges with exactly one follow-up turn. +A note reported after the extension observes `agent_start` is held and delivered only at `agent_settled` after `isIdle()` confirms that retries, compaction, and queued continuations are finished, so its claim can still be re-checked after that work. +A `routine` note explicitly disables turn triggering, so it never steers and never opens a turn, while a `captain` note uses follow-up delivery to open exactly one turn without steering. The generated [Pi supervision protocol](supervision-protocols/pi.md) requires MAIN to produce the captain-visible response in the one follow-up turn a `captain` verdict opens, so its merge note is delivered silently and never printed or rendered in Pi. Because Pi gives the model only a custom message's `content`, that silent note normally carries both a relay instruction and the `branch-outcome` operational kind owned by `bin/fm-operational-input.sh` inside its own text. This self-description lets main distinguish a new supervision outcome from its own earlier captain-facing answer; without it, main can mistake the outcome for that answer and lose the outcome while deciding how to handle it. +A superseded captain outcome is wrapped the same way, because a refreshed outcome is no more self-describing than the original it replaces. The generated [Pi supervision protocol](supervision-protocols/pi.md) owns main's event-ownership and conversational-treatment instructions for merged outcomes. If envelope encoding fails, the captain-facing note degrades to the same runtime instruction as plain text rather than losing the outcome or opening another turn. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is also delivered silently with no rendered note, while every other `routine` outcome stays rendered with its sailboat prefix. @@ -77,6 +80,23 @@ A review that found literally nothing worth reporting uses verdict `routine`, `t 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. +## Freshness at delivery + +An outcome is recorded the moment the branch judges its claim true and delivered later, so the two are not the same instant. +The fleet keeps moving inside the captain's running turn: the PR a note calls review-ready can merge, and the finished task can be torn down, before that turn ends. +A note delivered afterwards would state a claim that has since become false. + +Every delivery therefore re-checks the claim instead of trusting the recorded text, against the task's **claim anchor**: the durable records that decide whether a task-local claim is still current, owned by `bin/fm-branch-outcome.sh`'s header. +A merged PR and a torn-down task both move the anchor; ordinary progress on the task does not, so a routine note is not invalidated by mere activity. +A fleet-wide outcome carries no task-local claim and is never treated as stale. + +Both delivery paths apply it. +The extension queues every note through one delivery boundary and re-checks it immediately before handoff, either synchronously on the immediate path or at an idle-confirmed `agent_settled` event after an observed running turn: a `routine` note found stale is dropped, because it is noise by definition and the durable row still holds it for `fm_branch_outcomes`, while a `captain` note found stale still opens its one turn - suppressing it could bury a real terminal result - carrying an explicit supersession marker that sends main back to the task's current state instead of the recorded claim. +The anchor read and the handoff are not atomic, so an anchor-mutating transition in that instant can still let one already-checked note through. +This accepted residual follows the same confused-agent-grade boundary as the producer/drain residual above rather than claiming adversarial isolation, and it bounds the exposure to that instant instead of the whole captain turn that produced the reported failure. +The locked session-start replay applies the same check to rows that never reached a handoff at all, emitting a stale row with `"superseded":true` added rather than relaying it as current; the stored line is never rewritten. +An anchor that cannot be computed is empty and never reads as stale, so an unverifiable claim is delivered unchanged rather than suppressed. + ## Cost model and the byte-stable prefix The captain accepted the normal provider prompt-caching strategy: a byte-identical branch prefix generated once per firstmate version, the same tool set in the same order on every request, and one shared `prompt_cache_key` per home for all branch sessions (set in a `before_provider_request` hook, and only for providers whose requests already carry that field); main keeps its own per-session key. @@ -91,6 +111,6 @@ What is new is only the attended path: outside away mode, the branch absorbs the ## Verification -Portable regressions: `tests/fm-pi-branch-extension.test.sh` (dispatch, default-on eligibility, main-only classification, requested-versus-unsolicited outcome delivery, pre-turn-end complete-current-request mirroring, fleet-event ownership, main outcome access, eligible-row claim lifecycle, partial pre-drain recheck, fallback, filter, model-visible captain-outcome typing and plain-instruction fallback, cache key, persistence, model pin and searchable picker, effort pin), `tests/fm-branch-supervision.test.sh` (prompt stability, store append-only, leases, guards, non-branch-home invariance), the branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, and main-only-check-class tests in `tests/fm-pi-watch-extension.test.sh`, the recovery test in `tests/fm-session-start.test.sh`, and the per-actor consume regression in `tests/fm-wake-queue.test.sh`. -Live guard: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's custom-message conversion and branch-session surfaces with no user credentials and no provider call; run it after every Pi upgrade and record the dated result in [docs/verification/runtime-backends.md](verification/runtime-backends.md). +Portable regressions: `tests/fm-pi-branch-extension.test.sh` (dispatch, default-on eligibility, main-only classification, requested-versus-unsolicited outcome delivery, pre-turn-end complete-current-request mirroring, fleet-event ownership, main outcome access, eligible-row claim lifecycle, partial pre-drain recheck, fallback, filter, delivery-time freshness, model-visible captain-outcome typing and plain-instruction fallback, cache key, persistence, model pin and searchable picker, effort pin), `tests/fm-branch-supervision.test.sh` (prompt stability, store append-only, claim anchors and superseded replay, leases, guards, non-branch-home invariance), the branch-offer, heartbeat-offer, heartbeat-not-ridden-by-a-check, and main-only-check-class tests in `tests/fm-pi-watch-extension.test.sh`, the recovery test in `tests/fm-session-start.test.sh`, and the per-actor consume regression in `tests/fm-wake-queue.test.sh`. +Live guard: `FM_PI_BRANCH_LIVE_E2E=1 tests/fm-pi-branch-live-e2e.test.sh` exercises the real installed Pi SDK's custom-message conversion, branch-session surfaces, and the refused-turn lifecycle case the held-note release rests on, with no user credentials and no provider call; run it after every Pi upgrade and record the dated result in [docs/verification/runtime-backends.md](verification/runtime-backends.md). The strict typecheck in `tests/fm-pi-primary-types.test.sh` pins the extension against the installed Pi package. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index 2d10a05b590..a4e410cebd0 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -21,6 +21,7 @@ When this session owns supervision and away mode is not active: The supervision branch is default-on (docs/pi-supervision-branch.md): whenever this session owns the fleet lock and away mode is not active, the watcher extension hands eligible task-local rows from ordinary actionable wakes, plus selected fleet-wide heartbeat reviews, to the persistent in-process supervision branch while main-only rows remain queued for this conversation. A no-change heartbeat outcome explicitly reported with `task=fleet` and `silent=true` is delivered silently with no rendered note, while every other routine outcome returns as an appended, rendered note that leads with ⛵ then the dim outcome text. +The delivery-time freshness treatment for a superseded routine or captain outcome is owned by `docs/pi-supervision-branch.md`. A captain-facing outcome instead opens exactly one follow-up turn on this conversation - MAIN must produce its captain-visible response in that turn, and no separate note is printed here. Before MAIN steers, controls lifecycle, or cleans up a task, claim its lease with `bin/fm-lease.sh claim ` and release it afterwards; a refused claim means the branch is acting on that task right now. This conversation still receives every other fleet-wide or unresolvable wake, the branch's wakes when it is unavailable or away mode is active, and every watcher-failure alarm regardless, so the arm and repair contract above is unchanged. diff --git a/docs/verification/runtime-backends.md b/docs/verification/runtime-backends.md index 1fb18af242d..9dd962442ae 100644 --- a/docs/verification/runtime-backends.md +++ b/docs/verification/runtime-backends.md @@ -1018,5 +1018,29 @@ FM_TEST_END 2026-08-29T01:01:01Z tests/fm-pi-branch-live-e2e.test.sh exit=0 dura The focused extension suite also exercised the installed Pi 0.84.4 picker and outcome-renderer consumers; [`calm-mode-feasibility.md`](../calm-mode-feasibility.md#2026-08-28-pi-0844-outcome-renderer-compatibility-verification) owns the version-scoped renderer evidence. +### 2026-08-31 delivery-time freshness re-verification against Pi 0.84.4 + +The live guard and the strict typecheck were rerun after the supervision branch gained its delivery-time freshness boundary. +That change re-checks every merge note immediately before handoff, holds notes reported during an observed main run until an idle-confirmed boundary, and adds a refused-turn lifecycle assertion to the live guard, so the guard's own recorded line changes with it. +The live guard still pointed `PI_CODING_AGENT_DIR` at an empty directory, read no credentials, and made no provider call. +Environment: macOS 15.7.3 arm64, Node v26.5.0, installed `@earendil-works/pi-coding-agent` 0.84.4, signed `pi` CLI 0.84.4. + +```sh +npm exec --yes --package=typescript@5.9.3 -- bash tests/fm-pi-primary-types.test.sh +FM_PI_BRANCH_LIVE_E2E=1 bin/fm-test-run.sh tests/fm-pi-branch-live-e2e.test.sh +``` + +```text +ok - tracked Pi extensions pass strict no-emit typecheck against Pi 0.84.4 +ok - real Pi SDK 0.84.4 accepts the branch session construction, preserves an unpromptable wake, and emits no lifecycle event for a refused turn +ok - real Pi SDK 0.84.4 applies an explicit branch model on create and over a reopened session's recorded model +ok - real Pi SDK 0.84.4 reports its own supported effort levels and applies an explicit branch effort over a reopened session's recorded level +ok - real Pi SDK 0.84.4 delivers a custom message to the provider as user text carrying only content, so the captain outcome's typed envelope is what reaches the model +FM_TEST_END 2026-08-31T18:25:31Z tests/fm-pi-branch-live-e2e.test.sh exit=0 duration_ms=2509 gate_skip=false +``` + +Measured limit of the refused-turn clause on 0.84.4: with no credentials the SDK delivers no extension event at all, so this guard pins the refused-turn case and the fact that the real `DefaultResourceLoader` wires an extension's handlers, not a successfully started turn's own end emission. +The `agent_start` and `agent_settled` event names the held-note release depends on are pinned separately by the strict typecheck above against the installed package; `agent_end` is exercised only as a non-release event by the live guard. + Scope of the earlier evidence: the installed signed `pi` CLI (0.82.0 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 the live guard, picker regression, and strict typecheck 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-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 4189254b941..bc8ec53ad70 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -144,6 +144,60 @@ test_outcome_startup_replay_preserves_silence() { pass "startup replay skips silent outcomes and preserves visible and legacy rows" } +test_claim_anchor_marks_a_superseded_replay() { + local home anchor replay outcome + home="$TMP_ROOT/store-anchor-home" + outcome="$ROOT/bin/fm-branch-outcome.sh" + mkdir -p "$home/state" + + # A live task with an open PR: metadata recorded and the merge poll armed. + printf 'project=%s\nwindow=w\npr=%s\n' "$home/p" 'https://github.com/acme/ledger-lab/pull/7' \ + > "$home/state/ledger-lab.meta" + printf 'github\n' > "$home/state/ledger-lab.pr-poll" + + anchor=$(FM_HOME="$home" "$outcome" claim-anchor --task ledger-lab) || fail "claim-anchor failed" + assert_contains "$anchor" "pr=https://github.com/acme/ledger-lab/pull/7" "anchor lost the recorded PR" + [ "$anchor" = "$(FM_HOME="$home" "$outcome" claim-anchor --task ledger-lab)" ] \ + || fail "claim-anchor is not stable for an unchanged task" + FM_HOME="$home" "$outcome" claim-anchor --task ../escape >/dev/null 2>&1 \ + && fail "claim-anchor accepted a task id that is not path-safe" + + # An ordinary status append is progress, not invalidation. + printf 'working: still going\n' >> "$home/state/ledger-lab.status" + [ "$anchor" = "$(FM_HOME="$home" "$outcome" claim-anchor --task ledger-lab)" ] \ + || fail "an ordinary status append invalidated a task claim" + + FM_HOME="$home" "$outcome" append --task ledger-lab --verdict captain \ + --summary 'PR https://github.com/acme/ledger-lab/pull/7 checks green, ready for review' \ + --anchor "$anchor" >/dev/null || fail "anchored append failed" + FM_HOME="$home" "$outcome" append --task fleet --verdict routine \ + --summary 'fleet review rebased two branches' \ + --anchor "$(FM_HOME="$home" "$outcome" claim-anchor --task fleet)" >/dev/null \ + || fail "fleet anchored append failed" + printf '%s\n' '{"seq":3,"epoch":1,"task":"old-task","wake":"","verdict":"routine","summary":"legacy anchorless row"}' \ + >> "$home/state/branch-outcomes.jsonl" + FM_HOME="$home" "$outcome" append --task ledger-lab --verdict routine \ + --summary 'appended after a legacy tail' --anchor "$anchor" >/dev/null \ + || fail "append refused a valid legacy tail after the anchor field was added" + + # The PR merges and the finished task is torn down before the session that + # would have shown those outcomes ever starts. + rm -f "$home/state/ledger-lab.pr-poll" "$home/state/ledger-lab.meta" + + replay=$(FM_HOME="$home" "$outcome" startup-replay) || fail "startup replay failed" + assert_contains "$replay" '"superseded":true' "replay relayed a stale claim as current" + assert_contains "$replay" "ready for review" "replay dropped a captain-relevant outcome instead of marking it" + [ "$(printf '%s\n' "$replay" | grep -c '"superseded":true')" = 2 ] \ + || fail "replay did not mark every stale row: $replay" + printf '%s\n' "$replay" | grep 'fleet review rebased' | grep -q 'superseded' \ + && fail "a fleet-wide row has no task-local claim and must never be superseded" + printf '%s\n' "$replay" | grep 'legacy anchorless row' | grep -q 'superseded' \ + && fail "a legacy row with no anchor must never be superseded" + assert_contains "$replay" "legacy anchorless row" "replay dropped a legacy row" + [ -z "$(FM_HOME="$home" "$outcome" unread)" ] || fail "replay left rows unread" + pass "a recorded outcome whose task claim moved on replays as superseded, and fleet and legacy rows do not" +} + # --- lease contract ----------------------------------------------------------- test_lease_exclusivity_release_stale_and_sweep() { @@ -539,6 +593,7 @@ test_branch_cannot_force_teardown_or_directly_relaunch() { test_branch_prompt_is_byte_stable_and_above_cache_floor test_outcome_store_is_append_only_with_cursor_reads test_outcome_startup_replay_preserves_silence +test_claim_anchor_marks_a_superseded_replay test_lease_exclusivity_release_stale_and_sweep test_mutating_scripts_refuse_the_other_actors_lease test_main_owned_actions_refuse_the_branch_actor diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index a8816f9317b..c530119e7d2 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -620,7 +620,8 @@ if (untouched !== undefined) throw new Error("cache-key hook rewrote a provider console.log(`CACHE_KEY=${rewriteA.prompt_cache_key}`); // 4. Two-stage filter, stage 2: routine while main is idle appends with no -// turn; routine while main is busy defers to after the captain's next prompt; +// turn; routine while main is busy is held in the extension until the idle +// delivery boundary, so it never reaches the captain's running turn; // captain-relevant appends and triggers exactly one turn. Store rows are // written BEFORE the merge note and marked read after it. const report = session.options.customTools.find((tool) => tool.name === "fm_branch_report"); @@ -628,14 +629,20 @@ const r1 = await report.execute("call-1", { task: "task-9", verdict: "routine", if (r1.isError) throw new Error(`routine report failed: ${JSON.stringify(r1)}`); if (sentToMain.length !== 1) throw new Error("routine report did not merge exactly one note"); if (sentToMain[0].message.customType !== "fm-branch-merge") throw new Error("merge note has the wrong custom type"); -if (sentToMain[0].options.triggerTurn) throw new Error("routine idle merge must not trigger a turn"); +if (sentToMain[0].options.triggerTurn !== false) throw new Error("routine idle merge must explicitly disable turn triggering"); if (sentToMain[0].options.deliverAs) throw new Error("routine idle merge must append immediately"); fire("agent_start", {}); await report.execute("call-2", { task: "task-9", verdict: "routine", summary: "still healthy" }, undefined, undefined, {}); -if (sentToMain[1].options.deliverAs !== "nextTurn" || sentToMain[1].options.triggerTurn) { - throw new Error(`routine busy merge must defer to nextTurn without a turn: ${JSON.stringify(sentToMain[1].options)}`); +// Held in the extension rather than handed to Pi: a note Pi already owns can +// no longer be re-checked before it is rendered. +if (sentToMain.length !== 1) { + throw new Error(`routine busy merge must be held, not handed to Pi mid-turn: ${JSON.stringify(sentToMain.map((sent) => sent.options))}`); } fire("agent_end", {}); +fire("agent_settled", {}, { isIdle: () => true }); +if (sentToMain[1].options.deliverAs || sentToMain[1].options.triggerTurn !== false) { + throw new Error(`a released routine note must explicitly disable turn triggering so it cannot steer or open a turn: ${JSON.stringify(sentToMain[1].options)}`); +} await report.execute("call-3", { task: "task-9", verdict: "captain", summary: "PR https://example.com/pr/9 checks green, ready for review" }, undefined, undefined, {}); if (sentToMain[2].options.triggerTurn !== true || sentToMain[2].options.deliverAs !== "followUp") { throw new Error(`captain merge must trigger exactly one follow-up turn: ${JSON.stringify(sentToMain[2].options)}`); @@ -959,6 +966,8 @@ fire("agent_start", {}, mainCtx); const unsolicited = dispatch("signal: healthy resource result"); if (!unsolicited.accepted) throw new Error("branch did not accept the unsolicited result"); await settle(() => fleetOperations.length === 2, "unsolicited result acknowledgement"); +// Main is mid-turn, so the note is held; the idle boundary is what releases it. +fire("agent_settled", {}, { isIdle: () => true }); if (sentToMain.length !== 1 || sentToMain[0].options.triggerTurn) { throw new Error(`unsolicited healthy result opened a main turn: ${JSON.stringify(sentToMain)}`); } @@ -988,6 +997,7 @@ for (let index = 0; index < requestedPrompts.length; index += 1) { const requested = dispatch("signal: healthy resource result"); if (!requested.accepted) throw new Error(`branch did not accept requested result ${index}`); await settle(() => fleetOperations.length === 4 + (index * 2), `requested result ${index} acknowledgement`); + fire("agent_settled", {}, { isIdle: () => true }); const deliveredRequestMirror = globalThis.__fmSessions[0].ops .filter((op) => op.kind === "custom" && op.message.customType === "fm-main-mirror") .at(-1)?.message.content; @@ -2763,6 +2773,158 @@ EOF pass "an extension rebind re-mirrors undelivered dialog instead of dropping it" } +test_stale_task_claim_is_rechecked_before_delivery() { + local repo home fakebin out status real_bash + repo="$TMP_ROOT/freshness-root" + home="$TMP_ROOT/freshness-home" + fakebin="$home/fakebin" + real_bash=$(command -v bash) + mkdir -p "$home/state" "$home/config" "$fakebin" + install_pi_branch_extension_fixture "$repo" + cat > "$fakebin/bash" <<'SH' +#!/bin/sh +if [ "$1" = "$FM_TEST_OUTCOME_SCRIPT" ] && [ "$2" = append ]; then + task= + previous= + for argument do + if [ "$previous" = --task ]; then task=$argument; fi + previous=$argument + done + "$FM_TEST_REAL_BASH" "$@" + code=$? + if [ "$task" = idle-race-lab ]; then + rm -f "$FM_TEST_STATE/idle-race-lab.meta" "$FM_TEST_STATE/idle-race-lab.pr-poll" + fi + exit "$code" +fi +exec "$FM_TEST_REAL_BASH" "$@" +SH + chmod +x "$fakebin/bash" + PATH="$fakebin:$PATH" PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + FM_TEST_REAL_BASH="$real_bash" FM_TEST_OUTCOME_SCRIPT="$ROOT/bin/fm-branch-outcome.sh" \ + FM_TEST_STATE="$home/state" DRIVER_PRELUDE="$DRIVER_PRELUDE" \ + node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'EOF' +const prelude = process.env.DRIVER_PRELUDE; +await eval(`(async () => { ${prelude}; globalThis.__t = { pi, fire, dispatch, settle, outcomeScript, sentToMain, mainUserMessages, mainTools, renderers, home, realRoot }; })()`); +const { fire, dispatch, settle, outcomeScript, sentToMain, mainTools, home } = globalThis.__t; +import { writeFileSync, rmSync } from "node:fs"; + +writeFileSync(`${home}/state/.lock`, `${process.ppid}\n`); + +// Five live tasks, each with a PR whose merge poll fm-pr-check.sh has armed. +const PR = {}; +for (const id of ["idle-race-lab", "ledger-lab", "steady-lab", "chart-lab", "other-lab"]) { + PR[id] = `https://github.com/acme/${id}/pull/7`; + writeFileSync(`${home}/state/${id}.meta`, `project=${home}/projects/approved\nwindow=w\npr=${PR[id]}\n`); + writeFileSync(`${home}/state/${id}.pr-poll`, `github\n${PR[id]}\ngithub.com\nacme/${id}\n7\n`); +} + +dispatch(`signal: ledger-lab done: PR ${PR["ledger-lab"]} checks green`); +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "branch wake prompt"); +const report = globalThis.__fmSessions[0].options.customTools.find((t) => t.name === "fm_branch_report"); +const contents = () => sentToMain.map((s) => String(s.message.content)); +const record = async (id, verdict, summary) => { + const res = await report.execute(`c-${id}`, { task: id, verdict, summary, wake: `signal: ${id}` }, undefined, undefined, {}); + if (res.isError) throw new Error(`report failed for ${id}: ${JSON.stringify(res)}`); +}; + +// The append wrapper advances this task after the real durable append but +// before append returns, reproducing an idle-path change at the handoff edge. +await record("idle-race-lab", "captain", `PR ${PR["idle-race-lab"]} checks green and is ready for your review`); +const idleRace = contents().filter((c) => c.includes("idle-race-lab")); +if (idleRace.length !== 1 || !/SUPERSEDED/.test(idleRace[0])) { + throw new Error(`an idle-path review-ready claim changed during append was delivered as current: ${JSON.stringify(idleRace)}`); +} +const priorDeliveryCount = sentToMain.length; + +// The captain's turn is running - they told main to merge the very PR the +// branch is about to call review-ready. +fire("agent_start", {}); + +await record("ledger-lab", "captain", `PR ${PR["ledger-lab"]} checks green and is ready for your review`); +await record("steady-lab", "captain", `PR ${PR["steady-lab"]} checks green and is ready for your review`); +await record("chart-lab", "routine", "worker healthy, no action needed"); +await record("fleet", "routine", "fleet review: rebased two branches"); +// Checked at the end, so the delivered-content assertions below report the +// captain-visible symptom first rather than this structural cause. +const reachedPiMidTurn = sentToMain.length - priorDeliveryCount; + +// Inside that same turn the fleet moves on: ledger-lab's PR merges (the +// watcher retires its poll) and the finished task is torn down. chart-lab is +// torn down too. steady-lab and the fleet-wide note are untouched. +for (const id of ["ledger-lab", "chart-lab"]) { + rmSync(`${home}/state/${id}.pr-poll`); + rmSync(`${home}/state/${id}.meta`); +} +rmSync(`${home}/state/other-lab.meta`); // unrelated churn must not touch the fleet note + +// Main settles idle: every held note is delivered now, each re-checked first. +fire("agent_end", {}); +if (sentToMain.length !== priorDeliveryCount) throw new Error("agent_end handed a held note to Pi before the run settled"); +fire("agent_settled", {}, { isIdle: () => true }); + +// The stale review-ready claim is never delivered as if it were still true. +const ledger = contents().filter((c) => c.includes("ledger-lab")); +if (ledger.length !== 1) throw new Error(`expected one ledger-lab note, got ${JSON.stringify(ledger)}`); +if (!/SUPERSEDED/.test(ledger[0])) { + throw new Error(`a merged PR's queued review-ready claim was delivered as current: ${ledger[0]}`); +} +if (!ledger[0].includes("Re-read the task's current state")) { + throw new Error(`superseded note must send main back to the record: ${ledger[0]}`); +} +// ...but a captain-relevant outcome still surfaces, still opening exactly one turn. +const ledgerSent = sentToMain.find((s) => String(s.message.content).includes("ledger-lab")); +if (ledgerSent.options.triggerTurn !== true) { + throw new Error("a superseded captain outcome must still open its turn, not be swallowed"); +} +if (ledgerSent.message.display !== false) throw new Error("captain notes are never rendered"); + +// One idle boundary delivers one captain-relevant note, because that note +// opens main's turn and the rest must not steer it. Each fire() below is a +// separate boundary draining the next held note; they are not redundant. +fire("agent_settled", {}, { isIdle: () => true }); +const steady = contents().filter((c) => c.includes("steady-lab")); +if (steady.length !== 1 || /SUPERSEDED/.test(steady[0])) { + throw new Error(`an unchanged claim must be delivered verbatim: ${JSON.stringify(steady)}`); +} +if (!steady[0].includes("ready for your review")) throw new Error(`current claim lost its outcome: ${steady[0]}`); + +// A stale ROUTINE note is dropped rather than rendered as current... +fire("agent_settled", {}, { isIdle: () => true }); +fire("agent_settled", {}, { isIdle: () => true }); + +if (contents().some((c) => c.includes("chart-lab"))) { + throw new Error(`a stale routine note was rendered: ${JSON.stringify(contents())}`); +} +// ...while the fleet-wide note, which carries no task-local claim, still lands. +const fleet = contents().filter((c) => c.includes("fleet:")); +if (fleet.length !== 1 || /SUPERSEDED/.test(fleet[0])) { + throw new Error(`a fleet-wide note must be unaffected by task churn: ${JSON.stringify(fleet)}`); +} + +// Every held row was accounted for: nothing is left unread, and the dropped +// routine outcome is still readable on demand from the durable store. +if (outcomeScript(["unread"]) !== "") { + throw new Error(`delivered and dropped notes must all be marked read: ${outcomeScript(["unread"])}`); +} +// The structural cause of the symptom above: a note Pi already owns cannot be +// re-checked, so nothing may be handed over while main is still streaming. +if (reachedPiMidTurn !== 0) { + throw new Error(`${reachedPiMidTurn} note(s) reached Pi mid-turn, past any freshness re-check`); +} +const outcomesTool = mainTools.find((t) => t.name === "fm_branch_outcomes"); +const listed = (await outcomesTool.execute("c-list", { recent: 10 }, undefined, undefined, {})).content[0].text; +if (!listed.includes("worker healthy, no action needed")) { + throw new Error(`a dropped routine outcome must survive in the store: ${listed}`); +} +process.exit(0); +EOF + status=$? + out=$(cat "$TMP_ROOT/node-output") + expect_code 0 "$status" "a queued summary must be re-checked against the task record before delivery: $out" + pass "a queued summary whose task claim went stale is refreshed or dropped, never delivered as current" +} + # Direct unit coverage of fm-branch-dispatch.ts's classification, independent # of the Pi SDK stub: every legitimately main-only class (docs/pi-supervision- # branch.md) stays excluded from eligibleSeqs no matter its check-kind key, @@ -3153,6 +3315,7 @@ JS test_outcomes_tool_uses_stock_execution_and_export_consumers test_real_pi_picker_primitives_stay_bounded_and_searchable test_branch_dispatch_two_stage_filter_and_prefix_contract +test_stale_task_claim_is_rechecked_before_delivery test_requested_healthy_outcome_and_unsolicited_routine_outcome_delivery test_captain_outcome_encoding_failure_delivers_plain_instruction test_branch_dispatch_classifies_main_only_rows_and_writes_the_eligible_snapshot diff --git a/tests/fm-pi-branch-live-e2e.test.sh b/tests/fm-pi-branch-live-e2e.test.sh index 098883164ee..15c2c2a019d 100644 --- a/tests/fm-pi-branch-live-e2e.test.sh +++ b/tests/fm-pi-branch-live-e2e.test.sh @@ -17,6 +17,22 @@ # picker offers, Pi's own clamp is what lowers a level a model cannot run, and # an explicit thinking level must beat the level a reopened session recorded. # +# It also pins the vendor-side invariant the delivery-time freshness re-check +# rides on. A note reported while the captain's turn is running is HELD until +# Pi emits agent_settled with an idle context, so the hazard is a run that +# starts without ever settling: that would strand the held note. This guard +# proves, against the real SDK, that the real loader wires an extension's +# handlers and that a prompt Pi refuses to run emits no lifecycle event. +# LIMIT, stated rather than glossed: an isolated agent dir has no credentials +# and no model, so no extension event is deliverable at all here and this guard +# cannot exercise a real run's own agent_settled emission. The event NAMES are pinned +# separately by the strict typecheck against the installed package +# (tests/fm-pi-primary-types.test.sh). +# +# No credentials are read and no provider call leaves the machine: the guard +# points PI_CODING_AGENT_DIR at an empty directory, so model resolution stays +# empty by construction. Run after every Pi upgrade and before trusting +# refreshed per-harness evidence (docs/verification/runtime-backends.md). # No provider call leaves the machine. The branch probe points # PI_CODING_AGENT_DIR at an empty directory, so it reads no credentials and # model resolution stays empty by construction. The precedence probe reads @@ -194,6 +210,66 @@ const pinFallback = mainUserMessages[1].content; if (!pinFallback.includes("openai/no-such-live-model") || !pinFallback.includes("supervision model pin")) { throw new Error(`the real-SDK fallback did not name the unusable pin: ${pinFallback}`); } + +// Held-note release waits for Pi's idle agent_settled event. Drive a prompt +// the real SDK refuses to run and assert it emits no lifecycle event through +// handlers the real loader actually wired. +const sdk = await import(pathToFileURL(`${process.env.PI_PACKAGE_DIR}/dist/index.js`).href); +let factoryWired = 0; +const seen = []; +const probeLoader = new sdk.DefaultResourceLoader({ + cwd: home, + agentDir: sdk.getAgentDir(), + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + systemPrompt: "turn-end probe", + extensionFactories: [ + { + name: "fm-turn-end-probe", + factory: (probePi) => { + factoryWired += 1; + for (const name of ["agent_start", "agent_end", "agent_settled"]) { + probePi.on(name, () => { + seen.push(name); + }); + } + }, + }, + ], +}); +await probeLoader.reload(); +mkdirSync(`${home}/state/probe-session`, { recursive: true }); +const probe = await sdk.createAgentSession({ + cwd: home, + sessionManager: sdk.SessionManager.create(home, `${home}/state/probe-session`), + resourceLoader: probeLoader, + tools: [], + customTools: [], +}); +let promptRefused = false; +try { + await probe.session.prompt("probe turn"); +} catch { + // Expected: the isolated agent dir resolves no model and no credentials. + promptRefused = true; +} +try { + probe.session.dispose(); +} catch { + // Already gone. +} +if (factoryWired !== 1) { + throw new Error(`real resource loader did not wire the extension factory (ran ${factoryWired} times)`); +} +if (!promptRefused) { + throw new Error("credential-free probe prompt was accepted: this guard can no longer prove the refused-turn case"); +} +if (seen.length !== 0) { + throw new Error(`a refused turn emitted unexpected lifecycle events: ${JSON.stringify(seen)}`); +} console.log("LIVE_OK"); process.exit(0); EOF @@ -202,7 +278,8 @@ out=$(cat "$TMP_ROOT/node-output") if [ "$status" -ne 0 ] || [ "$out" != "LIVE_OK" ]; then fail "real-SDK Pi branch guard failed against pi-coding-agent $PI_VERSION: $out" fi -pass "real Pi SDK $PI_VERSION accepts the branch session construction and preserves an unpromptable wake" +pass "real Pi SDK $PI_VERSION accepts the branch session construction, preserves an unpromptable wake, and emits no lifecycle event for a refused turn" + # Second probe: the vendor contract the supervision-branch model pin rests on. # An explicit model must beat the model a reopened session recorded, or a pin