From 3ede1c078a0a1707e1a815d3443e5c548e89118b Mon Sep 17 00:00:00 2001 From: peterOC26 Date: Mon, 21 Sep 2026 10:56:51 +0200 Subject: [PATCH 1/3] fix: hand pending stage continuations to main supervision --- .pi/extensions/fm-branch-supervision.ts | 30 ++++++++--- bin/fm-branch-outcome.sh | 32 +++++++++--- bin/fm-branch-prompt.sh | 5 ++ docs/pi-supervision-branch.md | 3 ++ docs/supervision-protocols/pi.md | 5 +- docs/verification/supervision.md | 28 ++++++++++ tests/fm-branch-supervision.test.sh | 25 +++++++++ tests/fm-pi-branch-extension.test.sh | 68 ++++++++++++++++++++++++- 8 files changed, 178 insertions(+), 18 deletions(-) diff --git a/.pi/extensions/fm-branch-supervision.ts b/.pi/extensions/fm-branch-supervision.ts index a56d064ca6f..02413568abd 100644 --- a/.pi/extensions/fm-branch-supervision.ts +++ b/.pi/extensions/fm-branch-supervision.ts @@ -197,8 +197,9 @@ const AWAY_POSTURE_TAIL = const PROCESSING_INSTRUCTION = "This is a supervision processing request delivered automatically by the supervision branch. " + "It was not typed by the captain. " + - "The outcomes below are already stored durably and already shown to the captain as anchor entries in this transcript; each fleet event is already handled, so do not re-drain, re-run, or acknowledge the wake. " + + "The outcomes below are already stored durably and already shown to the captain as anchor entries in this transcript; the originating wake is already handled, so do not re-drain, re-run, or acknowledge that wake. This does not complete a pending workflow continuation. " + "Process each outcome now as firstmate: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed. " + + "For a continuation handoff, reconcile current task/stage state and existing authority, then start the authorized spawn, steer, or handoff before acknowledging; if already started, do not duplicate it. Record the actual blocker, decision, or evidence that no further action is needed when you cannot advance. Waiting for firstmate to act is not an external pause. " + "When every outcome below is processed, call fm_branch_processed with through={N} exactly once. " + "Until that call the outcomes stay open and are presented again; an answer that does not make that call never counts as processing."; type MirrorItem = { tag: "captain" | "main"; text: string }; @@ -211,6 +212,7 @@ type OutcomeRow = { verdict: Verdict; summary: string; silent: boolean; + continuation?: string; }; type VisibleOutcomeRecord = OutcomeRow & { version: 1 }; type ProviderRecovery = { @@ -492,7 +494,11 @@ function parseOutcomeRow(value: unknown): OutcomeRow | null { if (row.silent !== undefined && typeof row.silent !== "boolean") return null; const silent = row.silent === true; if (silent && (row.task !== "fleet" || row.verdict !== "routine")) return null; - return { seq: row.seq, task: row.task, verdict: row.verdict, summary: row.summary, silent }; + if (row.continuation !== undefined && (typeof row.continuation !== "string" || !row.continuation.trim() || row.verdict !== "captain" || silent)) return null; + return { + seq: row.seq, task: row.task, verdict: row.verdict, summary: row.summary, silent, + ...(row.continuation !== undefined ? { continuation: row.continuation as string } : {}), + }; } function parseVisibleOutcomeRecord(value: unknown): VisibleOutcomeRecord | null { @@ -506,7 +512,8 @@ function sameOutcome(left: OutcomeRow, right: OutcomeRow): boolean { left.task === right.task && left.verdict === right.verdict && left.summary === right.summary && - left.silent === right.silent; + left.silent === right.silent && + left.continuation === right.continuation; } // Volatile mirror-collection state. Instance-scoped and cleared at the @@ -1031,7 +1038,7 @@ export default function (pi: ExtensionAPI) { // beats an outcome that is never processed. async function processingRequestInput(rows: OutcomeRow[]): Promise { const through = rows[rows.length - 1].seq; - const listed = rows.map((row) => `[seq ${row.seq}] ${row.task}: ${row.summary}`).join("\n"); + const listed = rows.map((row) => `[seq ${row.seq}] ${row.task}: ${row.summary}${row.continuation ? `\nMAIN continuation handoff: ${row.continuation}` : ""}`).join("\n"); const body = `${PROCESSING_INSTRUCTION.replace("{N}", String(through))}\n\n${listed}`; try { return await encodeFirstmateOperationalInputWith(runCommandAsync, "branch-outcome", body); @@ -1186,6 +1193,9 @@ export default function (pi: ExtensionAPI) { description: "One or two sentences in captain outcome language; include the full https:// PR URL when a PR is involved", }), + continuation: Type.Optional(Type.String({ + description: "Pending MAIN action: completed stage/artifact, next spawn/steer/handoff, and existing authorization from the brief or accepted plan. Forces captain routing; omit when no MAIN action remains. Never infer authority from waiting prose.", + })), wake: Type.Optional(Type.String({ description: "The wake reason line this outcome answers" })), silent: Type.Optional(Type.Boolean({ description: "True only when a fleet-wide heartbeat review found literally nothing worth reporting; omit or use false whenever any action was taken or any routine result is worth a note", @@ -1196,20 +1206,26 @@ export default function (pi: ExtensionAPI) { const verdictRaw = String((params as { verdict: unknown }).verdict || ""); const summary = String((params as { summary: unknown }).summary || "").trim(); const wake = String((params as { wake?: unknown }).wake ?? "").trim(); + const continuationRaw = (params as { continuation?: unknown }).continuation; + if (continuationRaw !== undefined && (typeof continuationRaw !== "string" || !continuationRaw.trim())) { + return { content: [{ type: "text", text: "invalid continuation: a nonempty handoff is required" }], details: undefined, isError: true }; + } + const continuation = typeof continuationRaw === "string" ? continuationRaw.trim() : undefined; const silent = (params as { silent?: unknown }).silent === true; - if (!task || !summary || (verdictRaw !== "routine" && verdictRaw !== "captain") || (silent && (task !== "fleet" || verdictRaw !== "routine"))) { + if (!task || !summary || (verdictRaw !== "routine" && verdictRaw !== "captain") || (silent && (task !== "fleet" || verdictRaw !== "routine" || continuation !== undefined))) { return { content: [{ type: "text", text: "invalid report: task, verdict (routine|captain), and summary are required" }], details: undefined, isError: true, }; } - const verdict = verdictRaw as Verdict; + const verdict: Verdict = continuation ? "captain" : verdictRaw as Verdict; const scopeRefusal = wakeScopeRefusal(task); if (scopeRefusal) { return { content: [{ type: "text", text: scopeRefusal }], details: undefined, isError: true }; } const appendArgs = ["append", "--task", task, "--verdict", verdict, "--summary", summary, "--silent", String(silent)]; + if (continuation) appendArgs.push("--continuation", continuation); if (wake) appendArgs.push("--wake", wake); // Ownership, the durable append, and the delivery it authorizes are // ONE unit of the delivery queue: store-before-visible-delivery and @@ -2222,7 +2238,7 @@ ${context.command} name: "fm_branch_processed", label: "Acknowledge processed supervision outcomes", description: - "Acknowledge that every captain-facing supervision outcome up to a sequence number has been processed by this conversation. Call it exactly once after handling a supervision processing request, with through set to the highest sequence that request listed; an outcome that is not acknowledged is presented again.", + "Acknowledge that every captain-facing supervision outcome up to a sequence number has been processed by this conversation. Call it exactly once after handling a supervision processing request, with through set to the highest sequence that request listed, after performing any authorized continuation or recording its actual blocker/decision/no-action evidence; an outcome that is not acknowledged is presented again.", promptSnippet: "Acknowledge processed captain-facing supervision outcomes by sequence.", parameters: Type.Object({ through: Type.Number({ description: "The highest outcome sequence number this conversation has processed" }), diff --git a/bin/fm-branch-outcome.sh b/bin/fm-branch-outcome.sh index 491be2a7c6e..a4692e95f29 100755 --- a/bin/fm-branch-outcome.sh +++ b/bin/fm-branch-outcome.sh @@ -6,8 +6,11 @@ # - 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, -# "statusEndpoint":N,"statusIdent":"..."}. Legacy rows without `silent` -# or status provenance remain valid and are treated as visible. +# "statusEndpoint":N,"statusIdent":"...","continuation":"..."}. +# Optional continuation records the completed stage, next action, and its +# existing authority for MAIN. It forces verdict captain and cannot be +# silent; it is a durable handoff, never executable prose or new authority. +# Legacy rows without `silent`, continuation, or status provenance remain valid and are treated as visible. # Every read and append validates the complete log as a gap-free sequence; # malformed, duplicate, or reordered rows fail closed. # Existing lines are never rewritten, reordered, or deleted by any @@ -59,7 +62,7 @@ # # Usage: # fm-branch-outcome.sh append --task --verdict routine|captain \ -# --summary [--wake ] [--silent true|false] +# --summary [--wake ] [--silent true|false] [--continuation ] # Append one outcome record; prints the assigned seq. # fm-branch-outcome.sh unread # Print every unread record (raw JSONL). Exit 0 with no output when none. @@ -107,7 +110,7 @@ OUTCOME_INDEX_MAX_BYTES=512 OUTCOME_INDEX_READY="$STATE/.branch-outcome-index-ready" usage() { - echo "usage: fm-branch-outcome.sh append --task --verdict routine|captain --summary [--wake ] [--silent true|false] | unread | mark-read --through | unprocessed | mark-processed --through | processed-init [--held-lock] | list [--recent ] | startup-replay" >&2 + echo "usage: fm-branch-outcome.sh append --task --verdict routine|captain --summary [--wake ] [--silent true|false] [--continuation ] | unread | mark-read --through | unprocessed | mark-processed --through | processed-init [--held-lock] | list [--recent ] | startup-replay" >&2 exit 2 } @@ -179,7 +182,10 @@ last_seq() { jq -Rse ' def valid: type == "object" - and ( + and (if has("continuation") then + ((.continuation | type) == "string" and (.continuation | test("\\S")) and .verdict == "captain" and .silent != true) + else true end) + and (del(.continuation) | ( keys == ["epoch", "seq", "summary", "task", "verdict", "wake"] or (keys == ["epoch", "seq", "silent", "summary", "task", "verdict", "wake"] and (.silent | type) == "boolean") or ( @@ -188,7 +194,7 @@ last_seq() { and ((.statusEndpoint | type) == "number" and .statusEndpoint >= 0 and .statusEndpoint <= 9007199254740991 and .statusEndpoint == (.statusEndpoint | floor)) and ((.statusIdent | type) == "string" and (.statusIdent | test("[\\t\\n]") | not)) ) - ) + )) and ((.seq | type) == "number" and .seq >= 1 and .seq <= 9007199254740991 and .seq == (.seq | floor)) and ((.epoch | type) == "number" and .epoch >= 0 and .epoch == (.epoch | floor)) and ((.task | type) == "string" and (.wake | type) == "string") @@ -427,10 +433,12 @@ case "$CMD" in SUMMARY='' WAKE='' SILENT=false + CONTINUATION='' while [ "$#" -gt 0 ]; do case "$1" in --task) TASK=${2:-}; shift 2 || usage ;; --verdict) VERDICT=${2:-}; shift 2 || usage ;; + --continuation) CONTINUATION=${2:-}; [ -n "$CONTINUATION" ] || usage; shift 2 || usage ;; --summary) SUMMARY=${2:-}; shift 2 || usage ;; --wake) WAKE=${2:-}; shift 2 || usage ;; --silent) SILENT=${2:-}; shift 2 || usage ;; @@ -442,6 +450,10 @@ case "$CMD" in [ -n "$SUMMARY" ] || usage case "$VERDICT" in routine|captain) ;; *) usage ;; esac case "$SILENT" in true|false) ;; *) usage ;; esac + if [ -n "$CONTINUATION" ]; then + [[ "$CONTINUATION" =~ [^[:space:]] ]] || usage + VERDICT=captain + fi if [ "$SILENT" = true ] && { [ "$TASK" != fleet ] || [ "$VERDICT" != routine ]; }; then echo "error: silent outcomes must be routine fleet outcomes" >&2 exit 2 @@ -460,10 +472,14 @@ case "$CMD" in SEQ=$(( LAST_SEQ + 1 )) capture_status_position "$TASK" rm -f -- "$OUTCOME_INDEX_READY" || { fm_lock_release "$LOCK"; exit 1; } - printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s,"statusEndpoint":%s,"statusIdent":"%s"}\n' \ + CONTINUATION_FIELD='' + if [ -n "$CONTINUATION" ]; then + CONTINUATION_FIELD=",\"continuation\":\"$(json_escape "$CONTINUATION")\"" + fi + printf '{"seq":%s,"epoch":%s,"task":"%s","wake":"%s","verdict":"%s","summary":"%s","silent":%s,"statusEndpoint":%s,"statusIdent":"%s"%s}\n' \ "$SEQ" "$(date +%s)" "$(json_escape "$TASK")" "$(json_escape "$WAKE")" \ "$VERDICT" "$(json_escape "$SUMMARY")" "$SILENT" "$CAPTURED_STATUS_ENDPOINT" \ - "$(json_escape "$CAPTURED_STATUS_IDENT")" >> "$STORE" + "$(json_escape "$CAPTURED_STATUS_IDENT")" "$CONTINUATION_FIELD" >> "$STORE" # A task with neither a live meta nor a status log is retired: the branch # reports the teardown it just performed, and writing the index here would # recreate the footprint teardown removed. The outcome itself is still diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index acf213ccff2..24caeb8e0d4 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -65,6 +65,11 @@ For anything it tells you to escalate, or any failure that survives the playbook Report verdict captain for the finished result of work the captain requested, even when that result is healthy. A start or still-working update on requested work that brings no new artifact, finding, or decision is verdict routine. +A completed stage awaiting firstmate's next authorized action is an active continuation, even if the worker calls it paused or waiting. +Reconcile the brief, accepted plan, and current task state: perform an authorized action within your role, or set fm_branch_report.continuation to hand MAIN the completed stage/artifact, the next spawn/steer/handoff, and the existing authority for it. +The tool forces that handoff to verdict captain; an attended branch must use it when continuation needs a new worker because spawning remains MAIN-only. +Before handing off a repeated wake, check whether that stage already advanced or has an unprocessed handoff in bin/fm-branch-outcome.sh unprocessed; do not request the same continuation again. +Never derive authorization from arbitrary waiting prose; uncertain authority is a captain decision, and an actual external wait remains routine. Also report verdict captain for: - work ready for review - include the PR's full https:// URL when the task's ready status or `pr=` metadata holds one, otherwise only the identifier you actually have; - a decision only the captain can make, including every ask-user finding from a validation gate; diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 97b354a56dc..f0871affae6 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -122,6 +122,9 @@ A presentation already pending its run boundary is not resent or widened; once t The first two presentations of a given sequence set open a turn of their own; after that the request rides the captain's next prompt so an ignored request cannot become an unbounded loop of empty turns, while changed sequence membership and a session replacement each start that budget over. Routine outcomes never enter this path and stay turn-free. A home upgraded with outcomes already delivered treats those rows as processed once, at the first reconciliation that finds no processed marker, so its history is not re-presented. +A typed continuation handoff from `fm_branch_report` is stored with the outcome and forces captain routing even if the caller supplies routine; it survives session replacement as part of the same sequence-bound processing request. +The branch prompt owns when to hand off a completed stage, and the [Pi supervision protocol](supervision-protocols/pi.md) owns MAIN's continuation duty; the store header owns the optional field format. +This does not interpret waiting prose, launch a worker automatically, grant branch spawn authority, or replay handled history. The generated [Pi supervision protocol](supervision-protocols/pi.md) owns event ownership for merged outcomes and main's acknowledgement duty, while deterministic entry delivery owns captain visibility. 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. The branch prompt's "Verdict: routine or captain" section owns the verdict criteria, including how requested work's finished results and its mere progress updates are classified; unsolicited routine outcomes remain routine sailboat notes, unchanged fleet reviews remain silent, and doubt escalates. diff --git a/docs/supervision-protocols/pi.md b/docs/supervision-protocols/pi.md index f9142b7f755..8a6978e7047 100644 --- a/docs/supervision-protocols/pi.md +++ b/docs/supervision-protocols/pi.md @@ -24,14 +24,15 @@ While the away-posture record `state/.afk-contract` exists the branch takes ever Decision-owned signal and stale routing, including whole-batch precedence and the independent heartbeat exception, is owned by [docs/pi-supervision-branch.md](../pi-supervision-branch.md#components-and-their-owners). 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. A captain-facing outcome instead appears as one exact, sequence-keyed visible transcript entry, and while attended then arrives in this conversation as one hidden supervision processing request listing each `[seq N] task: summary` it covers; outcomes recorded while away wait for that request until the record is archived. -That request is the one turn in which MAIN processes the outcome: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, or record that no further action is needed, then call the `fm_branch_processed` tool with the highest sequence the request listed, exactly once. +That request is the one turn in which MAIN processes the outcome: give the captain a visible response where one is due, answer or escalate a decision, act on a blocker or failure, start any already-authorized continuation after reconciling current stage state, or record the actual blocker/decision/no-action evidence, then call the `fm_branch_processed` tool with the highest sequence the request listed, exactly once. Only that call closes the outcome; an unrelated, empty, or paraphrased answer leaves it open, and the current unprocessed sequence set is presented again at the next run boundary and at session start until it is acknowledged. The persisted entry is already the captain-visible record, so MAIN must not re-emit it verbatim merely because it appeared; this prevents repetition but does not replace any captain-facing outcome response required by `AGENTS.md` section 9. Regression example - keep verbatim and never condense away: `[seq 41] claude-mod: implementation complete, ready for review` requires relaying a captain-facing outcome response, not just `Captain, shipshape.`. A merge ask with no URL that leans on the dim anchor violates `AGENTS.md` section 9. 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, and every watcher-failure alarm regardless of posture, so the arm and repair contract above is unchanged. -Treat the merged fleet event as already handled for fleet operations: MAIN must not re-drain, re-run, or acknowledge it. +Treat the originating wake as already handled: MAIN must not re-drain, re-run, or acknowledge it. +A pending continuation handed off under the branch prompt's verdict rules still needs MAIN to act before acknowledging the outcome; reconcile existing actions before spawning or steering so retries do not duplicate work. Separately, MAIN applies judgment about whether and how to surface, summarize, reference, or incorporate a merged sailboat outcome in the captain conversation; event ownership does not decide the conversational treatment. Read the durable outcome store with the fm_branch_outcomes tool when the captain asks what happened. diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index 6e5198fa2ab..be5af036752 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -572,3 +572,31 @@ Observed output: ``` The safe command-channel contract is covered without a notification by `tests/fm-daemon.test.sh`: the summary reaches both `$1` and stdin, every channel is process-group bounded, and a failed channel falls through. + +## Completed-stage continuation handoff + +Verified on 2026-09-21 with Node v22.23.2 and Pi 0.85.1 installed; the portable extension fixture uses a stub SDK with the real outcome-store scripts. +The current routing contract is in [Pi supervision branch](../pi-supervision-branch.md), with MAIN's action duty in the generated [Pi protocol](../supervision-protocols/pi.md). + +Commands: + +```sh +bin/fm-test-run.sh tests/fm-pi-branch-extension.test.sh +bin/fm-test-run.sh tests/fm-branch-supervision.test.sh tests/fm-supervision-instructions.test.sh +``` + +Relevant exact output: + +```text +ok - completed plan stage durably hands the authorized next scout to MAIN without a human prompt +FM_TEST_SUMMARY total=1 failed=0 skipped_gate=0 duration_ms=121314 +ok - continuation store forces MAIN routing and rejects silent or malformed handoffs +FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=34170 +``` + +The regression asserts a durable typed handoff containing the completed artifact, next scout, and existing authority, and its delivery to an automatic MAIN turn across session replacement. +It also checks that identical waiting prose without a handoff stays routine and that a closed handoff is not replayed. +It proves the handoff boundary, not a live model's subsequent spawn decision. +Pi and Pi-signed share this extension and protocol; the protocol suite exercises every supported primary-harness rendering path. +The other harness protocols do not host this in-process branch; their shared outcome-store startup reader remains covered by the store suite's captain barrier and legacy-row cases. +No runtime backend lifecycle API participates in continuation storage or routing, so tmux, Herdr, zellij, Orca, and cmux require no lifecycle change for this guarantee. diff --git a/tests/fm-branch-supervision.test.sh b/tests/fm-branch-supervision.test.sh index 3a19310026e..426f175ebf0 100644 --- a/tests/fm-branch-supervision.test.sh +++ b/tests/fm-branch-supervision.test.sh @@ -62,6 +62,29 @@ test_branch_prompt_is_byte_stable_and_above_cache_floor() { # --- append-only outcome store ------------------------------------------------ +test_continuation_store_routes_and_validates_handoffs() { + local home row out status + home="$TMP_ROOT/continuation-store" + mkdir -p "$home/state" + row=$(FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" append --task plan --verdict routine \ + --summary 'Waiting for the next look' --continuation 'Revision 4 committed; spawn round 4 under accepted review plan') \ + || fail "continuation append failed" + [ "$row" = 1 ] || fail "continuation sequence missing" + row=$(FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" unread) || fail "continuation unread failed" + printf '%s\n' "$row" | jq -e '.verdict == "captain" and (.continuation | contains("spawn round 4"))' >/dev/null \ + || fail "store did not persist actionable MAIN handoff" + out=$(FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" append --task fleet --verdict routine \ + --summary waiting --silent true --continuation 'Spawn next authorized scout' 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "store accepted silent continuation" + # Reject corrupt typed handoffs rather than dropping their action on read. + printf '%s\n' "$row" | jq -c '.continuation = 42' > "$home/state/branch-outcomes.jsonl" + out=$(FM_HOME="$home" "$ROOT/bin/fm-branch-outcome.sh" unread 2>&1) + status=$? + [ "$status" -ne 0 ] || fail "store accepted malformed continuation: $out" + pass "continuation store forces MAIN routing and rejects silent or malformed handoffs" +} + test_outcome_store_is_append_only_with_cursor_reads() { local home store snapshot seq1 seq2 unread replay out status home="$TMP_ROOT/store-home" @@ -1117,3 +1140,5 @@ test_branch_cannot_force_teardown_or_directly_relaunch test_away_record_relocates_main_owned_actions_to_the_branch test_away_branch_spawn_requires_queued_dispatchable_work test_away_spend_cap_is_rechecked_under_the_task_set_lock + +test_continuation_store_routes_and_validates_handoffs diff --git a/tests/fm-pi-branch-extension.test.sh b/tests/fm-pi-branch-extension.test.sh index 06ccf8eb93c..c489b34676d 100644 --- a/tests/fm-pi-branch-extension.test.sh +++ b/tests/fm-pi-branch-extension.test.sh @@ -924,7 +924,7 @@ EOF *) fail "the processing request body lost its self-description or the outcome itself: $body" ;; esac case "$body" in - *"do not re-drain, re-run, or acknowledge the wake."*"call fm_branch_processed with through=3 exactly once."*"never counts as processing."*) ;; + *"do not re-drain, re-run, or acknowledge that wake."*"call fm_branch_processed with through=3 exactly once."*"never counts as processing."*) ;; *) fail "the processing request body lost the event-ownership boundary or the sequence-bound acknowledgement duty: $body" ;; esac if ./bin/fm-operational-input.sh kind < "$home/state/delivered-routine-note" >/dev/null 2>&1; then @@ -1239,6 +1239,70 @@ EOF pass "captain outcomes are exact and exactly once across crash, reload, busy main, compaction, and an unrelated assistant response" } +test_completed_stage_hands_continuation_to_main() { + local repo home status + repo="$TMP_ROOT/continuation-root" + home="$TMP_ROOT/continuation-home" + mkdir -p "$home/state" "$home/config" + install_pi_branch_extension_fixture "$repo" + PLUGIN="$repo/.pi/extensions/fm-branch-supervision.ts" FM_HOME="$home" FM_ROOT_OVERRIDE="$ROOT" \ + DRIVER_PRELUDE="$DRIVER_PRELUDE" node --input-type=module > "$TMP_ROOT/node-output" 2>&1 <<'JS' +await eval(`(async () => { ${process.env.DRIVER_PRELUDE}; globalThis.__t = { fire, dispatch, settle, sentToMain, mainTools, outcomeScript, defaultSessionCtx }; })()`); +const { fire, dispatch, settle, sentToMain, mainTools, outcomeScript, defaultSessionCtx } = globalThis.__t; +const requests = () => sentToMain.filter((sent) => sent.message.customType === "fm-branch-process"); +await fire("session_start", {}, defaultSessionCtx); +let finishPrompt; +globalThis.__fmOnBranchPrompt = () => new Promise((resolve) => { finishPrompt = resolve; }); +const offered = dispatch("signal: completed plan revision"); +if (!offered.accepted) throw new Error("completed-stage wake refused"); +await settle(() => (globalThis.__fmPrompts ?? []).length === 1, "completed-stage branch prompt"); +const report = globalThis.__fmSessions[0].options.customTools.find((tool) => tool.name === "fm_branch_report"); +const summary = "The speech-before-audio plan was rewritten again and is waiting for the next plan look. Live site is untouched."; +// Counterfactual: identical waiting prose alone cannot grant or trigger action. +const ordinary = await report.execute("ordinary", { task: "branch-driver", verdict: "routine", summary }); +if (ordinary.isError || requests().length) throw new Error("waiting prose was mechanically promoted"); +const continuation = "Plan revision 4 committed 58a6cb7 closes H4; spawn the opposite-family plan-review round 4 scout as required by the accepted plan-review workflow in the task brief."; +const invalid = await report.execute("empty-handoff", { task: "branch-driver", verdict: "routine", summary, continuation: " " }); +if (!invalid.isError) throw new Error("empty handoff accepted"); +const handed = await report.execute("handoff", { task: "branch-driver", verdict: "routine", summary, continuation }); +if (handed.isError) throw new Error(`handoff failed: ${JSON.stringify(handed)}`); +// Assert the action handoff itself, not just a rendered outcome or cursor: +// the real append-only store must retain the completed artifact, next spawn, +// and authority as a typed pending continuation with a concrete sequence. +const row = JSON.parse(outcomeScript(["unprocessed"])); +if (row.continuation !== continuation || row.verdict !== "captain" || row.task !== "branch-driver") { + throw new Error(`no durable actionable handoff: ${JSON.stringify(row)}`); +} +if (requests().length !== 1 || requests()[0].options.triggerTurn !== true || + !requests()[0].message.content.includes(`MAIN continuation handoff: ${continuation}`)) { + throw new Error("pending spawn was not handed to an automatic MAIN turn"); +} +finishPrompt(); +await offered.settlement; +globalThis.__fmOnBranchPrompt = undefined; +await fire("session_shutdown", {}); +await fire("session_start", {}, defaultSessionCtx); +if (JSON.parse(outcomeScript(["unprocessed"])).continuation !== continuation || + !requests().at(-1).message.content.includes(`MAIN continuation handoff: ${continuation}`)) { + throw new Error("session replacement lost the actionable continuation"); +} +// Closing the handoff follows the same existing sequence-bound contract. +const processed = mainTools.find((tool) => tool.name === "fm_branch_processed"); +const ack = await processed.execute("handoff-complete", { through: row.seq }); +if (ack.isError) throw new Error(`handoff acknowledgement failed: ${JSON.stringify(ack)}`); +const count = requests().length; +await fire("agent_start", {}); +await fire("agent_end", {}); +await fire("agent_settled", {}); +if (requests().length !== count || outcomeScript(["unprocessed"]).trim()) throw new Error("closed handoff replayed"); +await fire("session_shutdown", {}); +console.log("ok"); +JS + status=$? + [ "$status" -eq 0 ] || fail "completed-stage continuation handoff failed: $(cat "$TMP_ROOT/node-output")" + pass "completed plan stage durably hands the authorized next scout to MAIN without a human prompt" +} + test_captain_outcome_processing_turn_is_sequence_keyed_and_re_presented() { local repo home out status repo="$TMP_ROOT/processing-turn-root" @@ -5313,3 +5377,5 @@ test_delivery_keeps_the_event_loop_live_and_ordered test_session_replacement_during_delivery_neither_loses_nor_duplicates test_store_failure_during_delivery_neither_loses_nor_duplicates test_mark_read_failure_keeps_routine_redelivery_and_captain_deduplication + +test_completed_stage_hands_continuation_to_main From 94f14aa2c07a69fdfbdf6779a1a9b22bf57c5d0f Mon Sep 17 00:00:00 2001 From: peterOC26 Date: Mon, 21 Sep 2026 11:29:53 +0200 Subject: [PATCH 2/3] docs: distinguish live CLI and stub continuation evidence --- docs/verification/supervision.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/verification/supervision.md b/docs/verification/supervision.md index be5af036752..6229a8ee0e5 100644 --- a/docs/verification/supervision.md +++ b/docs/verification/supervision.md @@ -597,6 +597,9 @@ FM_TEST_SUMMARY total=2 failed=0 skipped_gate=0 duration_ms=34170 The regression asserts a durable typed handoff containing the completed artifact, next scout, and existing authority, and its delivery to an automatic MAIN turn across session replacement. It also checks that identical waiting prose without a handoff stays routine and that a closed handoff is not replayed. It proves the handoff boundary, not a live model's subsequent spawn decision. +For validation evidence, classify the real outcome-store CLI and the stub-SDK extension checks separately. +The CLI exercises live executable behavior, while the SDK fixture is regression-only evidence; it cannot support a live-pass verdict for automatic MAIN delivery or subsequent model-driven continuation. +When a scenario requires live model execution and the isolated environment has no authenticated model, record that scenario as untested with the missing prerequisite rather than pass, and retain the completed CLI results. Pi and Pi-signed share this extension and protocol; the protocol suite exercises every supported primary-harness rendering path. The other harness protocols do not host this in-process branch; their shared outcome-store startup reader remains covered by the store suite's captain barrier and legacy-row cases. No runtime backend lifecycle API participates in continuation storage or routing, so tmux, Herdr, zellij, Orca, and cmux require no lifecycle change for this guarantee. From 6d0ce3491c2db212c098bcef8db9f16851ff354d Mon Sep 17 00:00:00 2001 From: peterOC26 Date: Mon, 21 Sep 2026 11:38:03 +0200 Subject: [PATCH 3/3] no-mistakes(document): Replace stale outcome field list with schema pointer --- docs/pi-supervision-branch.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index f0871affae6..84a58b6a36c 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -109,7 +109,7 @@ 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` keeps the existing custom-message path without a follow-up turn, while `captain` appends a versioned `fm-branch-visible-outcome` custom session entry. -The captain entry contains the store sequence, task, verdict, exact summary, and silent flag, and its renderer presents the exact task and summary with an anchor prefix. +The captain entry follows `VisibleOutcomeRecord` in [the branch extension](../.pi/extensions/fm-branch-supervision.ts); its renderer presents the exact task and summary with an anchor prefix. Pi custom session entries persist in the transcript but do not enter model context, so a stale compaction summary, an unrelated assistant response, prompt caching, or model instruction noncompliance cannot acknowledge or rewrite the outcome. The store sequence is the idempotency key: reload after entry persistence but before cursor advancement finds the matching entry, avoids a duplicate, and advances the cursor; conflicting content for one sequence fails closed. Reconciliation runs at session start when that generation already owns the fleet lock and at the first post-lock `turn_end`, so a cold start that acquires the lock through the startup digest still delivers stored captain outcomes without waiting for another wake.