From b4c2776e76ffb063db4df6782afb5729ebea55a3 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 00:41:44 +0200 Subject: [PATCH 01/15] fix(pi): defer watcher wake delivery while a captain turn is active Reproduced against the installed @earendil-works/pi-coding-agent SDK: AgentSession.prototype.prompt() has no atomic check-and-set between reading isStreaming and committing to a new run in _runAgentPrompt(), so a watcher wake fired from fm-primary-pi-watch.ts's sendWake at any uncoordinated moment could race a concurrently-submitted captain message. Both prompt() calls fall through to a concurrent run against the same session, leaving one turn's tool call (typically the bin/fm-wake-drain.sh run the wake instructs) as the final visible transcript entry with no synthesized reply - the reported "yellow tool block, session stuck" symptom that correlated with a watcher stale wake in both reproductions. sendWake now tracks each generation's own before_agent_start/ agent_settled window and defers delivery until the turn genuinely settles when it fires mid-turn, closing the previously fully unguarded window down to the same narrow idle-vs-idle residual fm-primary-turnend-guard.ts's own agent_settled-gated followUp already accepts. docs/watcher-continuity.md documents the mechanism and its accepted residual. tests/fm-pi-watch-extension.test.sh adds test_pi_wake_delivery_defers_while_a_captain_turn_is_active, verified to fail against the pre-fix extension and pass against the fix. --- .pi/extensions/fm-primary-pi-watch.ts | 83 +++++++++++++++++++++-- docs/watcher-continuity.md | 10 +++ tests/fm-pi-watch-extension.test.sh | 98 +++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 6 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index a1b5249b844..f681678aa4e 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -59,6 +59,12 @@ type SessionGeneration = { retryFailures: number; restoring: boolean; seq: number; + // Tracks this generation's own turn activity so an actionable watcher close + // (which can land at any real wall-clock moment, fully decoupled from Pi's + // prompt lifecycle) never calls pi.sendUserMessage while a turn is already + // known to be in flight. See sendWake below for why this exists. + busy: boolean; + pendingWake: string | null; }; function refreshWatchToolShell( @@ -199,6 +205,8 @@ function createGeneration(): SessionGeneration { retryFailures: 0, restoring: false, seq: 0, + busy: false, + pendingWake: null, }; } @@ -243,7 +251,59 @@ export default function (pi: ExtensionAPI) { !calmPresentation.stockExportRendering && !calmTranscriptClassIsVisible(itemClass); - async function sendWake( + // pi.sendUserMessage always triggers a turn (agent-session.js prompt()): + // deliverAs only changes queueing while the agent is already streaming, but + // an idle session falls straight into starting a brand-new run. That run + // has no atomic check-and-set against a concurrently-submitted captain + // message: prompt() reads isStreaming, then awaits several extension hooks + // (emitInput, checkCompaction, checkAuth, emitBeforeAgentStart) BEFORE it + // commits by setting _isAgentRunActive = true, so two prompt() calls that + // both observe "idle" can both fall through to a concurrent run against the + // same session state - one turn's tool call (typically the + // bin/fm-wake-drain.sh run this wake instructs) is left dangling with no + // synthesized reply while the other's continuation silently wins, which is + // the "stuck yellow tool block, no further answer" failure this closes. + // The arm child's close event (this file's only caller of sendWake) fires + // whenever the watcher happens to exit, fully decoupled from Pi's own turn + // lifecycle - unlike fm-primary-turnend-guard.ts's agent_settled-only + // followUp, nothing here previously refused to fire mid-turn. Deferring + // delivery until the current turn has genuinely settled closes that + // dominant, previously fully unguarded window down to the same narrow + // idle-vs-idle residual the turnend-guard extension already accepts: a + // captain message typed in the exact same tick a settled session delivers + // its own queued wake can still race, because Pi's extension API exposes no + // hook that fires atomically with prompt()'s internal isStreaming check. + // That residual is a Pi SDK gap, not something firstmate's own tracked code + // can close outside a full submission-serializing mutex, which is out of + // scope here given its own regression risk. + function sendWake( + owner: SessionGeneration, + message: string, + ): void { + if (!generationIsLive(owner)) return; + if (owner.busy) { + owner.pendingWake = message; + return; + } + void deliverWakeNow(owner, message).catch(() => { + // Pi owns delivery errors; continuity restoration never waits on prompting. + }); + } + + // Called from the agent_settled handler once a deferred wake's turn has + // genuinely finished. Re-checks owner.busy because a settled turn's own + // queued continuation (steer/followUp delivered during that same turn, + // drained before agent_settled fires) never flips busy back on here - only + // a fresh before_agent_start does - so this is safe to call unconditionally + // on every settle. + function flushPendingWake(owner: SessionGeneration): void { + const message = owner.pendingWake; + if (message === null) return; + owner.pendingWake = null; + sendWake(owner, message); + } + + async function deliverWakeNow( owner: SessionGeneration, message: string, ): Promise { @@ -330,18 +390,16 @@ export default function (pi: ExtensionAPI) { if (!pidAlive(watcherPid)) { await retireArm(owner.child); } - await sendWake(owner, `${message}\n\n${confirmed.detail}`); + sendWake(owner, `${message}\n\n${confirmed.detail}`); return; } } if (!repairFailed && offerWakeToBranch(message)) return; - await sendWake(owner, message); + sendWake(owner, message); } function surfaceFailure(owner: SessionGeneration, message: string): void { - void sendWake(owner, message).catch(() => { - // Pi owns delivery errors; continuity restoration never waits on prompting. - }); + sendWake(owner, message); } function retryDelay(attempt: number): number { @@ -570,6 +628,19 @@ export default function (pi: ExtensionAPI) { stopGeneration(generation); }); + // Brackets this generation's own isStreaming window (agent-session.js sets + // _isAgentRunActive at the same before_agent_start/agent_settled + // boundaries) so sendWake can tell whether a turn - the captain's own or an + // earlier deferred wake's - is genuinely in flight before ever calling + // pi.sendUserMessage. See sendWake's comment for the race this closes. + pi.on?.("before_agent_start", () => { + generation.busy = true; + }); + pi.on?.("agent_settled", () => { + generation.busy = false; + flushPendingWake(generation); + }); + pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index be43542f2ab..78a741e6407 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -45,6 +45,16 @@ No adapter starts a replacement with shell `&`. The turn-end guard remains the final backstop rather than the normal continuity mechanism and cooperates with the auto-arm in its `--claude` mode. +## Pi wake delivery timing + +`pi.sendUserMessage(..., {deliverAs: "followUp"})` always triggers a turn: `deliverAs` only changes queueing while the agent is already streaming, but an idle session falls straight into starting a brand-new run with no atomic check-and-set against a concurrently-submitted captain message. +Pi's `prompt()` reads its streaming state, then awaits several extension hooks (`input`, compaction and auth checks, `before_agent_start`) before it actually commits to a new run, so two calls that both observe "idle" can both fall through to a concurrent run against the same session state. +The watcher arm child's close event that triggers `fm-primary-pi-watch.ts`'s `sendWake` is fully decoupled from Pi's own turn lifecycle - it can fire at any wall-clock moment, including squarely mid-turn - so nothing there previously refused to call `sendUserMessage` while a captain turn (or an earlier wake's handling turn) was already active. +The extension now tracks each generation's own busy window from `before_agent_start` to `agent_settled` (the same boundary `agent-session.js` uses for its internal streaming flag) and defers `sendUserMessage` until the turn genuinely settles when it fires mid-turn, delivering the most recently queued wake exactly once on settle. +This closes the dominant, previously fully unguarded window down to the same narrow idle-vs-idle residual `fm-primary-turnend-guard.ts`'s own `agent_settled`-only followUp already accepts: a captain message typed in the exact same tick a settled session delivers its own queued wake can still race, because Pi's extension API exposes no hook that fires atomically with `prompt()`'s internal streaming check. +That residual is a Pi SDK gap, not something firstmate's own tracked code can close without a full submission-serializing mutex, which carries its own regression risk and is out of scope here. +`tests/fm-pi-watch-extension.test.sh`'s `test_pi_wake_delivery_defers_while_a_captain_turn_is_active` covers the deferral and flush-on-settle contract. + ## Recovery episode acknowledgement A recovery episode is one generation of `state/.watcher-down`, and it is retired only by the generation-bound acknowledgement the drain prints as `WAKE_ACK_REQUIRED`. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index c7b708d0e86..0bc914c1ce4 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -423,6 +423,103 @@ EOF pass "Pi actionable close starts one successor before wake delivery settles" } +test_pi_wake_delivery_defers_while_a_captain_turn_is_active() { + local repo home plugin log stop out status + repo="$TMP_ROOT/pi-wake-defer-root" + home="$TMP_ROOT/pi-wake-defer-home" + log="$TMP_ROOT/pi-wake-defer.log" + stop="$TMP_ROOT/pi-wake-defer.stop" + mkdir -p "$repo/bin" "$home/state" "$home/config" + install_pi_watch_extension_fixture "$repo" + plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" + cat > "$repo/bin/fm-watch-arm.sh" <<'SH' +#!/usr/bin/env bash +if [ "${1:-}" = --handling-delivered ]; then + printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}" + exit 0 +fi +printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}" +count=$(grep -c '^arm=' "$FM_ARM_LOG") +if [ "$count" -eq 1 ]; then + printf 'watcher: started pid=%s (beacon fresh)\n' "$$" + printf 'stale: synthetic actionable close\n' + exit 0 +fi +printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" +trap 'exit 0' TERM INT +while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done +SH + chmod +x "$repo/bin/fm-watch-arm.sh" + out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF' +import { writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +// Regression coverage for the captain-input hang: a watcher stale wake used +// to call pi.sendUserMessage(..., {deliverAs: "followUp"}) the instant the +// watcher's arm child closed, with zero regard for whether a turn (the +// captain's own, or an earlier wake's handling turn) was already active. +// Because Pi's own prompt() has no atomic check-and-set against a +// concurrently-submitted message while idle, that unconditional mid-turn +// delivery could race a real captain message and leave one turn's tool call +// as the final visible transcript entry with no synthesized reply. The fix +// defers delivery until agent_settled fires while a turn is marked busy. +const handlers = {}; +let tool = null; +const sendCalls = []; +const pi = { + on(event, handler) { + handlers[event] = handler; + }, + registerCommand() {}, + registerTool(candidate) { + if (candidate.name === "fm_watch_arm_pi") tool = candidate; + }, + sendUserMessage: async (message) => { + sendCalls.push(message); + }, +}; +writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +if (!handlers.before_agent_start || !handlers.agent_settled) { + throw new Error("extension did not register before_agent_start/agent_settled handlers"); +} + +// Simulate: a captain turn is already in flight when the watcher's stale +// wake arrives. +handlers.before_agent_start(); + +await tool.execute("tool-call-wake-defer", {}, undefined, undefined, {}); + +// Give the actionable close, successor establishment, and handling +// confirmation handshake time to run to completion. The wake must stay +// queued - not delivered - for as long as the turn is marked busy. +await new Promise((resolve) => setTimeout(resolve, 1500)); +if (sendCalls.length !== 0) { + throw new Error(`wake delivered while a captain turn was still active: ${sendCalls.join(" | ")}`); +} + +// The turn settles: the deferred wake must be delivered now, exactly once. +handlers.agent_settled(); +for (let i = 0; i < 250 && sendCalls.length === 0; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 20)); +} +if (sendCalls.length !== 1) { + throw new Error(`expected exactly one deferred wake delivery after settle, got ${sendCalls.length}: ${sendCalls.join(" | ")}`); +} +if (!sendCalls[0].includes("FIRSTMATE WATCHER WAKE")) { + throw new Error(`deferred delivery missing wake content: ${sendCalls[0]}`); +} +writeFileSync(process.env.FM_STOP_FILE, "stop\n"); +process.exit(0); +EOF +) + status=$? + expect_code 0 "$status" "Pi wake delivery must defer while a captain turn is active and flush on settle" + [ -z "$out" ] || fail "Pi wake-defer test printed output: $out" + pass "Pi wake delivery defers while a captain turn is active and flushes on settle" +} + test_pi_branch_offer_owns_actionable_wake() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-branch-offer-root" @@ -2809,6 +2906,7 @@ test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop test_pi_scheduled_retry_call_is_owned_noop test_pi_actionable_close_starts_single_successor_before_delivery +test_pi_wake_delivery_defers_while_a_captain_turn_is_active test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check From 9dc5928c17b22ae3338a0160fc0cd3e43ae9ea5c Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:14:25 +0200 Subject: [PATCH 02/15] Revert "fix(pi): defer watcher wake delivery while a captain turn is active" This reverts commit b4c2776e76ffb063db4df6782afb5729ebea55a3. --- .pi/extensions/fm-primary-pi-watch.ts | 83 ++--------------------- docs/watcher-continuity.md | 10 --- tests/fm-pi-watch-extension.test.sh | 98 --------------------------- 3 files changed, 6 insertions(+), 185 deletions(-) diff --git a/.pi/extensions/fm-primary-pi-watch.ts b/.pi/extensions/fm-primary-pi-watch.ts index f681678aa4e..a1b5249b844 100644 --- a/.pi/extensions/fm-primary-pi-watch.ts +++ b/.pi/extensions/fm-primary-pi-watch.ts @@ -59,12 +59,6 @@ type SessionGeneration = { retryFailures: number; restoring: boolean; seq: number; - // Tracks this generation's own turn activity so an actionable watcher close - // (which can land at any real wall-clock moment, fully decoupled from Pi's - // prompt lifecycle) never calls pi.sendUserMessage while a turn is already - // known to be in flight. See sendWake below for why this exists. - busy: boolean; - pendingWake: string | null; }; function refreshWatchToolShell( @@ -205,8 +199,6 @@ function createGeneration(): SessionGeneration { retryFailures: 0, restoring: false, seq: 0, - busy: false, - pendingWake: null, }; } @@ -251,59 +243,7 @@ export default function (pi: ExtensionAPI) { !calmPresentation.stockExportRendering && !calmTranscriptClassIsVisible(itemClass); - // pi.sendUserMessage always triggers a turn (agent-session.js prompt()): - // deliverAs only changes queueing while the agent is already streaming, but - // an idle session falls straight into starting a brand-new run. That run - // has no atomic check-and-set against a concurrently-submitted captain - // message: prompt() reads isStreaming, then awaits several extension hooks - // (emitInput, checkCompaction, checkAuth, emitBeforeAgentStart) BEFORE it - // commits by setting _isAgentRunActive = true, so two prompt() calls that - // both observe "idle" can both fall through to a concurrent run against the - // same session state - one turn's tool call (typically the - // bin/fm-wake-drain.sh run this wake instructs) is left dangling with no - // synthesized reply while the other's continuation silently wins, which is - // the "stuck yellow tool block, no further answer" failure this closes. - // The arm child's close event (this file's only caller of sendWake) fires - // whenever the watcher happens to exit, fully decoupled from Pi's own turn - // lifecycle - unlike fm-primary-turnend-guard.ts's agent_settled-only - // followUp, nothing here previously refused to fire mid-turn. Deferring - // delivery until the current turn has genuinely settled closes that - // dominant, previously fully unguarded window down to the same narrow - // idle-vs-idle residual the turnend-guard extension already accepts: a - // captain message typed in the exact same tick a settled session delivers - // its own queued wake can still race, because Pi's extension API exposes no - // hook that fires atomically with prompt()'s internal isStreaming check. - // That residual is a Pi SDK gap, not something firstmate's own tracked code - // can close outside a full submission-serializing mutex, which is out of - // scope here given its own regression risk. - function sendWake( - owner: SessionGeneration, - message: string, - ): void { - if (!generationIsLive(owner)) return; - if (owner.busy) { - owner.pendingWake = message; - return; - } - void deliverWakeNow(owner, message).catch(() => { - // Pi owns delivery errors; continuity restoration never waits on prompting. - }); - } - - // Called from the agent_settled handler once a deferred wake's turn has - // genuinely finished. Re-checks owner.busy because a settled turn's own - // queued continuation (steer/followUp delivered during that same turn, - // drained before agent_settled fires) never flips busy back on here - only - // a fresh before_agent_start does - so this is safe to call unconditionally - // on every settle. - function flushPendingWake(owner: SessionGeneration): void { - const message = owner.pendingWake; - if (message === null) return; - owner.pendingWake = null; - sendWake(owner, message); - } - - async function deliverWakeNow( + async function sendWake( owner: SessionGeneration, message: string, ): Promise { @@ -390,16 +330,18 @@ export default function (pi: ExtensionAPI) { if (!pidAlive(watcherPid)) { await retireArm(owner.child); } - sendWake(owner, `${message}\n\n${confirmed.detail}`); + await sendWake(owner, `${message}\n\n${confirmed.detail}`); return; } } if (!repairFailed && offerWakeToBranch(message)) return; - sendWake(owner, message); + await sendWake(owner, message); } function surfaceFailure(owner: SessionGeneration, message: string): void { - sendWake(owner, message); + void sendWake(owner, message).catch(() => { + // Pi owns delivery errors; continuity restoration never waits on prompting. + }); } function retryDelay(attempt: number): number { @@ -628,19 +570,6 @@ export default function (pi: ExtensionAPI) { stopGeneration(generation); }); - // Brackets this generation's own isStreaming window (agent-session.js sets - // _isAgentRunActive at the same before_agent_start/agent_settled - // boundaries) so sendWake can tell whether a turn - the captain's own or an - // earlier deferred wake's - is genuinely in flight before ever calling - // pi.sendUserMessage. See sendWake's comment for the race this closes. - pi.on?.("before_agent_start", () => { - generation.busy = true; - }); - pi.on?.("agent_settled", () => { - generation.busy = false; - flushPendingWake(generation); - }); - pi.registerCommand?.("fm-watch-arm-pi", { description: "Arm firstmate watcher supervision through the Pi extension instead of foreground bash.", handler: async (_args, ctx) => { diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 78a741e6407..be43542f2ab 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -45,16 +45,6 @@ No adapter starts a replacement with shell `&`. The turn-end guard remains the final backstop rather than the normal continuity mechanism and cooperates with the auto-arm in its `--claude` mode. -## Pi wake delivery timing - -`pi.sendUserMessage(..., {deliverAs: "followUp"})` always triggers a turn: `deliverAs` only changes queueing while the agent is already streaming, but an idle session falls straight into starting a brand-new run with no atomic check-and-set against a concurrently-submitted captain message. -Pi's `prompt()` reads its streaming state, then awaits several extension hooks (`input`, compaction and auth checks, `before_agent_start`) before it actually commits to a new run, so two calls that both observe "idle" can both fall through to a concurrent run against the same session state. -The watcher arm child's close event that triggers `fm-primary-pi-watch.ts`'s `sendWake` is fully decoupled from Pi's own turn lifecycle - it can fire at any wall-clock moment, including squarely mid-turn - so nothing there previously refused to call `sendUserMessage` while a captain turn (or an earlier wake's handling turn) was already active. -The extension now tracks each generation's own busy window from `before_agent_start` to `agent_settled` (the same boundary `agent-session.js` uses for its internal streaming flag) and defers `sendUserMessage` until the turn genuinely settles when it fires mid-turn, delivering the most recently queued wake exactly once on settle. -This closes the dominant, previously fully unguarded window down to the same narrow idle-vs-idle residual `fm-primary-turnend-guard.ts`'s own `agent_settled`-only followUp already accepts: a captain message typed in the exact same tick a settled session delivers its own queued wake can still race, because Pi's extension API exposes no hook that fires atomically with `prompt()`'s internal streaming check. -That residual is a Pi SDK gap, not something firstmate's own tracked code can close without a full submission-serializing mutex, which carries its own regression risk and is out of scope here. -`tests/fm-pi-watch-extension.test.sh`'s `test_pi_wake_delivery_defers_while_a_captain_turn_is_active` covers the deferral and flush-on-settle contract. - ## Recovery episode acknowledgement A recovery episode is one generation of `state/.watcher-down`, and it is retired only by the generation-bound acknowledgement the drain prints as `WAKE_ACK_REQUIRED`. diff --git a/tests/fm-pi-watch-extension.test.sh b/tests/fm-pi-watch-extension.test.sh index 0bc914c1ce4..c7b708d0e86 100755 --- a/tests/fm-pi-watch-extension.test.sh +++ b/tests/fm-pi-watch-extension.test.sh @@ -423,103 +423,6 @@ EOF pass "Pi actionable close starts one successor before wake delivery settles" } -test_pi_wake_delivery_defers_while_a_captain_turn_is_active() { - local repo home plugin log stop out status - repo="$TMP_ROOT/pi-wake-defer-root" - home="$TMP_ROOT/pi-wake-defer-home" - log="$TMP_ROOT/pi-wake-defer.log" - stop="$TMP_ROOT/pi-wake-defer.stop" - mkdir -p "$repo/bin" "$home/state" "$home/config" - install_pi_watch_extension_fixture "$repo" - plugin="$repo/.pi/extensions/fm-primary-pi-watch.ts" - cat > "$repo/bin/fm-watch-arm.sh" <<'SH' -#!/usr/bin/env bash -if [ "${1:-}" = --handling-delivered ]; then - printf 'confirmed generation=%s watcher=%s\n' "$2" "$4" >> "${FM_ARM_LOG:?}" - exit 0 -fi -printf 'arm=%s predecessor=%s\n' "$$" "${FM_WATCH_PREDECESSOR_ARM_PID:-none}" >> "${FM_ARM_LOG:?}" -count=$(grep -c '^arm=' "$FM_ARM_LOG") -if [ "$count" -eq 1 ]; then - printf 'watcher: started pid=%s (beacon fresh)\n' "$$" - printf 'stale: synthetic actionable close\n' - exit 0 -fi -printf 'watcher: started pid=%s (beacon fresh) recovery-generation=fixture-generation\n' "$$" -trap 'exit 0' TERM INT -while [ ! -e "$FM_STOP_FILE" ]; do sleep 0.02; done -SH - chmod +x "$repo/bin/fm-watch-arm.sh" - out=$(PLUGIN="$plugin" FM_HOME="$home" FM_ROOT_OVERRIDE="$repo" FM_ARM_LOG="$log" FM_STOP_FILE="$stop" node --input-type=module 2>&1 <<'EOF' -import { writeFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; - -// Regression coverage for the captain-input hang: a watcher stale wake used -// to call pi.sendUserMessage(..., {deliverAs: "followUp"}) the instant the -// watcher's arm child closed, with zero regard for whether a turn (the -// captain's own, or an earlier wake's handling turn) was already active. -// Because Pi's own prompt() has no atomic check-and-set against a -// concurrently-submitted message while idle, that unconditional mid-turn -// delivery could race a real captain message and leave one turn's tool call -// as the final visible transcript entry with no synthesized reply. The fix -// defers delivery until agent_settled fires while a turn is marked busy. -const handlers = {}; -let tool = null; -const sendCalls = []; -const pi = { - on(event, handler) { - handlers[event] = handler; - }, - registerCommand() {}, - registerTool(candidate) { - if (candidate.name === "fm_watch_arm_pi") tool = candidate; - }, - sendUserMessage: async (message) => { - sendCalls.push(message); - }, -}; -writeFileSync(`${process.env.FM_HOME}/state/.lock`, `${process.pid}\n`); -const mod = await import(pathToFileURL(process.env.PLUGIN).href); -mod.default(pi); -if (!handlers.before_agent_start || !handlers.agent_settled) { - throw new Error("extension did not register before_agent_start/agent_settled handlers"); -} - -// Simulate: a captain turn is already in flight when the watcher's stale -// wake arrives. -handlers.before_agent_start(); - -await tool.execute("tool-call-wake-defer", {}, undefined, undefined, {}); - -// Give the actionable close, successor establishment, and handling -// confirmation handshake time to run to completion. The wake must stay -// queued - not delivered - for as long as the turn is marked busy. -await new Promise((resolve) => setTimeout(resolve, 1500)); -if (sendCalls.length !== 0) { - throw new Error(`wake delivered while a captain turn was still active: ${sendCalls.join(" | ")}`); -} - -// The turn settles: the deferred wake must be delivered now, exactly once. -handlers.agent_settled(); -for (let i = 0; i < 250 && sendCalls.length === 0; i += 1) { - await new Promise((resolve) => setTimeout(resolve, 20)); -} -if (sendCalls.length !== 1) { - throw new Error(`expected exactly one deferred wake delivery after settle, got ${sendCalls.length}: ${sendCalls.join(" | ")}`); -} -if (!sendCalls[0].includes("FIRSTMATE WATCHER WAKE")) { - throw new Error(`deferred delivery missing wake content: ${sendCalls[0]}`); -} -writeFileSync(process.env.FM_STOP_FILE, "stop\n"); -process.exit(0); -EOF -) - status=$? - expect_code 0 "$status" "Pi wake delivery must defer while a captain turn is active and flush on settle" - [ -z "$out" ] || fail "Pi wake-defer test printed output: $out" - pass "Pi wake delivery defers while a captain turn is active and flushes on settle" -} - test_pi_branch_offer_owns_actionable_wake() { local repo home plugin log stop out status repo="$TMP_ROOT/pi-branch-offer-root" @@ -2906,7 +2809,6 @@ test_pi_tool_returns_agent_tool_result test_pi_redundant_tool_call_is_owned_noop test_pi_scheduled_retry_call_is_owned_noop test_pi_actionable_close_starts_single_successor_before_delivery -test_pi_wake_delivery_defers_while_a_captain_turn_is_active test_pi_branch_offer_owns_actionable_wake test_pi_branch_offer_flags_heartbeat test_pi_heartbeat_is_not_ridden_into_main_by_a_co_present_check From 8546818b526301a03fb16c3861a1fb8afdc1c4eb Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:23:48 +0200 Subject: [PATCH 03/15] fix(pi): recover from a turn that settles without a reply Captain decision: pivot from preventing the underlying Pi SDK race to detecting and recovering from its visible symptom, after review found the prior before_agent_start/agent_settled busy-gate (reverted here) does not actually close the reproduced idle-vs-idle race - that gate only starts protecting after prompt()'s isStreaming check has already run, so both racing calls still slip past it exactly as before. Root cause (docs/verification/pi-watch-extension-reply-recovery.md): AgentSession.prototype.prompt() has no atomic check-and-set between reading isStreaming and committing to a new run, so a captain message and a watcher wake delivered through fm-primary-pi-watch.ts's sendWake can both observe "idle" and both fall through to a concurrent _runAgentPrompt run against the same session - leaving a dangling tool call or an unanswered message as the final visible transcript entry. Closing that race with certainty needs a full submission-serializing mutex on Pi's earliest input hook; rejected as disproportionate new risk for a rare race. fm-primary-turnend-guard.ts's agent_settled handler now also checks, once its existing supervision guard is clean, whether the last conversational message-type session entry is an assistant reply with genuine text. If not - a dangling tool call, or a message with no reply at all - it sends one recovery follow-up instructing the model to finish any unresolved tool call and answer the pending message without repeating an earlier answer. A guardFollowupActive-style latch absorbs the settle that follow-up itself produces, and a bounded per-generation attempt counter (3) stops the loop after repeated distinct failures with one loud, once-only notice instead of retrying forever; a healthy settle resets both. Only one follow-up ever fires per settle, since reply recovery only runs once the pre-existing supervision guard found nothing to say. tests/fm-turnend-guard.test.sh adds reply-recovery coverage: the dangling-tool-call and fully-unanswered detection cases, the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, and the session-start digest exclusion. docs/watcher-continuity.md documents the mechanism under "Turn-settle reply recovery"; the verification record captures the reproduction against the real installed SDK and the rejected mutex alternative. --- .pi/extensions/fm-primary-turnend-guard.ts | 119 ++++++- .../pi-agent-session-toctou-repro.mjs | 107 ++++++ .../pi-watch-extension-reply-recovery.md | 53 +++ docs/watcher-continuity.md | 17 + tests/fm-turnend-guard.test.sh | 309 ++++++++++++++++++ 5 files changed, 597 insertions(+), 8 deletions(-) create mode 100644 docs/verification/pi-agent-session-toctou-repro.mjs create mode 100644 docs/verification/pi-watch-extension-reply-recovery.md diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index cad464a8191..01cd1b05f8a 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext, SessionEntry, SessionMessageEntry } from "@earendil-works/pi-coding-agent"; import { classifyFirstmateCurrentOperationalText, encodeFirstmateOperationalInput, @@ -11,6 +11,24 @@ import { let guardFollowupActive = false; +// Turn-settle reply recovery (docs/watcher-continuity.md "Turn-settle reply +// recovery" is the authoritative contract). Pi's own AgentSession.prompt() +// has no atomic check-and-set between reading isStreaming and committing to +// a new run (agent-session.js), so two prompt() calls that both observe +// "idle" - a captain message and a watcher wake delivered through +// pi.sendUserMessage - can both fall through to a concurrent run against the +// same session. That race is a Pi SDK gap firstmate cannot close from +// extension code without a full submission-serializing mutex of its own +// (rejected as disproportionate risk for a rare race). Detecting and +// recovering from its visible symptom - a turn that settles without ever +// producing a synthesized reply to the last conversational message - closes +// the actual acceptance criteria (no lost captain input, no silent hang) +// without needing to prevent the race itself. +let orphanedReplyFollowupActive = false; +let orphanedReplyAttempts = 0; +const ORPHANED_REPLY_ATTEMPT_LIMIT = 3; +let orphanedReplyExhaustedNotified = false; + type LockOwnership = "owned" | "missing" | "other"; const extensionFile = fileURLToPath(import.meta.url); @@ -438,6 +456,45 @@ async function claimSessionstartMessage( return sessionstartMessage(generation, result); } +function isSessionMessageEntry(entry: SessionEntry): entry is SessionMessageEntry { + return entry.type === "message"; +} + +function messageHasVisibleText(message: unknown): boolean { + const content = (message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) return false; + return content.some((part) => { + const candidate = part as { type?: unknown; text?: unknown }; + return candidate?.type === "text" && typeof candidate.text === "string" && candidate.text.trim().length > 0; + }); +} + +// Detects the reproduced race's visible signature: the settled turn's last +// conversational message-type entry (skipping non-message entries such as +// the session-start digest, which carries no reply expectation) is not an +// assistant message with genuine text content. That covers a dangling tool +// call with no follow-up reply, and a user or watcher-wake message that +// never received any answer at all. A healthy run only ever settles on a +// final assistant text reply (or an error/aborted assistant message, which +// this also flags - visible to the model, but still worth one recovery +// nudge since nothing confirms the captain ever saw it either). +function lastConversationalTurnUnanswered(ctx: ExtensionContext): boolean { + let entries: SessionEntry[]; + try { + entries = ctx.sessionManager.getEntries(); + } catch { + return false; + } + for (let i = entries.length - 1; i >= 0; i -= 1) { + const entry = entries[i]; + if (!isSessionMessageEntry(entry)) continue; + const role = (entry.message as { role?: unknown } | undefined)?.role; + if (role === "assistant") return !messageHasVisibleText(entry.message); + return true; + } + return false; +} + function runGuard(): Promise<{ code: number; stderr: string }> { return new Promise((resolveResult) => { const child = spawn(`${root}/bin/fm-turnend-guard.sh`, { @@ -574,26 +631,72 @@ export default function (pi: ExtensionAPI) { return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; }); - pi.on("agent_settled", async () => { + pi.on("agent_settled", async (_event, ctx) => { if (guardFollowupActive) { guardFollowupActive = false; return; } const result = await runGuard(); - if (result.code !== 2) return; + if (result.code === 2) { + guardFollowupActive = true; + try { + const content = encodeFirstmateOperationalInput( + "turn-end-guard", + "TURN WOULD END BLIND - supervision is off. " + + "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + + result.stderr, + ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); + } catch { + guardFollowupActive = false; + } + return; + } + + // Only one follow-up ever fires per settle: the supervision guard above + // takes priority, and reply recovery below runs only once it is clean. + if (orphanedReplyFollowupActive) { + orphanedReplyFollowupActive = false; + return; + } + + if (!lastConversationalTurnUnanswered(ctx)) { + orphanedReplyAttempts = 0; + orphanedReplyExhaustedNotified = false; + return; + } + + if (orphanedReplyAttempts >= ORPHANED_REPLY_ATTEMPT_LIMIT) { + if (orphanedReplyExhaustedNotified) return; + orphanedReplyExhaustedNotified = true; + try { + const content = encodeFirstmateOperationalInput( + "turn-end-guard", + `TURN ENDED WITHOUT A REPLY - automatic recovery gave up after ${ORPHANED_REPLY_ATTEMPT_LIMIT} attempts. ` + + "The last message in this conversation still has no visible assistant answer. " + + "Check the transcript directly and answer the pending message by hand; do not repeat this recovery attempt automatically again for it.", + ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); + } catch { + orphanedReplyExhaustedNotified = false; + } + return; + } - guardFollowupActive = true; + orphanedReplyAttempts += 1; + orphanedReplyFollowupActive = true; try { const content = encodeFirstmateOperationalInput( "turn-end-guard", - "TURN WOULD END BLIND - supervision is off. " + - "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + - result.stderr, + "TURN ENDED WITHOUT A REPLY - the last message in this conversation (a captain message or a delivered watcher wake) has no visible " + + "assistant answer, and the turn already settled with nothing queued to continue it. This can happen when a watcher wake landed while " + + "another prompt was starting, racing Pi's own turn-start handling. Check the conversation history now: if a tool call is unresolved, " + + "finish it, then answer the pending message directly. Do not repeat any answer you already gave earlier in this conversation.", ); await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { - guardFollowupActive = false; + orphanedReplyFollowupActive = false; } }); diff --git a/docs/verification/pi-agent-session-toctou-repro.mjs b/docs/verification/pi-agent-session-toctou-repro.mjs new file mode 100644 index 00000000000..41fa4af0bee --- /dev/null +++ b/docs/verification/pi-agent-session-toctou-repro.mjs @@ -0,0 +1,107 @@ +// Reproduction: AgentSession.prototype.prompt() has no atomic check-and-set +// between reading `isStreaming` and committing to a new agent run inside +// _runAgentPrompt(). Two concurrent prompt() calls that are both idle at the +// moment they check isStreaming can both fall through to _runAgentPrompt(), +// which unconditionally sets _isAgentRunActive = true and invokes +// this.agent.prompt(messages) again - a genuine concurrent double-invocation. +// +// This drives the REAL, unmodified AgentSession.prototype.prompt from the +// installed @earendil-works/pi-coding-agent package against a minimal stub +// `this`, so the method under test is production code, not a reimplementation. +import { pathToFileURL } from "node:url"; + +const SDK_PATH = process.env.SDK_PATH; +const { AgentSession } = await import(pathToFileURL(SDK_PATH).href); +const promptFn = AgentSession.prototype.prompt; + +let concurrentRunAgentPromptCalls = 0; +let maxConcurrentRunAgentPromptCalls = 0; +const events = []; + +function log(label) { + events.push(`${(performance.now()).toFixed(2)}ms ${label}`); +} + +// Faithful to agent-session.js lines 747-760 (_runAgentPrompt): sets +// _isAgentRunActive = true as its very first (synchronous) statement, then +// awaits the underlying agent run. +async function fakeRunAgentPrompt(messages) { + this._isAgentRunActive = true; + concurrentRunAgentPromptCalls++; + maxConcurrentRunAgentPromptCalls = Math.max(maxConcurrentRunAgentPromptCalls, concurrentRunAgentPromptCalls); + log(`_runAgentPrompt ENTER (concurrent=${concurrentRunAgentPromptCalls}) messages=${JSON.stringify(messages).slice(0, 60)}`); + try { + // Simulate real inference/tool-call latency. + await new Promise((r) => setTimeout(r, 40)); + } finally { + concurrentRunAgentPromptCalls--; + this._isAgentRunActive = false; + log(`_runAgentPrompt EXIT`); + } +} + +function makeStubSession() { + return { + _isAgentRunActive: false, + get isStreaming() { + return this._isAgentRunActive; + }, + _compactionAbortController: undefined, + _pendingNextTurnMessages: [], + _systemPromptOverride: undefined, + _baseSystemPrompt: "base", + promptTemplates: [], + model: { provider: "test" }, + _modelRuntime: { + hasConfiguredAuth: () => true, + checkAuth: async () => "ok", + isUsingOAuth: () => false, + }, + _extensionRunner: { + hasHandlers: () => false, + emitInput: async () => ({ action: "pass" }), + // A REAL before_agent_start handler in this repo + // (.pi/extensions/fm-primary-turnend-guard.ts) awaits a spawned child + // process here. This delay stands in for that genuine async gap - it is + // not a contrived one - and is exactly the window a concurrently-fired + // watcher wake (fm-primary-pi-watch.ts sendWake) races against. + emitBeforeAgentStart: async () => { + log("emitBeforeAgentStart (simulating a real extension awaiting a child process)"); + await new Promise((r) => setTimeout(r, 20)); + return undefined; + }, + }, + _findLastAssistantMessage: () => undefined, + _checkCompaction: async () => false, + _flushPendingBashMessages: () => {}, + _expandSkillCommand: (t) => t, + _throwIfExtensionCommand: () => {}, + _runAgentPrompt: fakeRunAgentPrompt, + agent: { state: { systemPrompt: "base" } }, + }; +} + +const session = makeStubSession(); + +log("captain call: prompt('real captain message') START"); +const captainCall = promptFn.call(session, "real captain message", { source: "interactive" }); + +// Simulate fm-primary-pi-watch.ts's sendWake(): an unrelated background +// watcher-close callback calls pi.sendUserMessage(..., {deliverAs:"followUp"}) +// with zero coordination with the interactive call above. sendUserMessage +// forwards to prompt() with streamingBehavior: "followUp" (agent-session.js +// sendUserMessage(), lines 1110-1138). +log("watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START"); +const wakeCall = promptFn.call(session, "FIRSTMATE WATCHER WAKE: stale", { streamingBehavior: "followUp", source: "extension" }); + +await Promise.all([captainCall, wakeCall]); + +console.log(events.join("\n")); +console.log(`\nmaxConcurrentRunAgentPromptCalls = ${maxConcurrentRunAgentPromptCalls}`); +if (maxConcurrentRunAgentPromptCalls > 1) { + console.log("REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently."); + process.exit(1); +} else { + console.log("NOT REPRODUCED this run (race is timing-dependent)."); + process.exit(0); +} diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md new file mode 100644 index 00000000000..6677aab8769 --- /dev/null +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -0,0 +1,53 @@ +# Pi turn-settle reply recovery verification + +Audience: maintainer verification. + +This record supports the turn-settle reply recovery guarantee in [`watcher-continuity.md`](../watcher-continuity.md#turn-settle-reply-recovery). +Mechanism, contract, and active limits remain in that linked guide. +Task-specific chronology and the captain decision that chose detection-and-recovery over a submission-serializing mutex remain in private task evidence. + +## Root-cause reproduction + +Reproduced 2026-09-01 against the installed `@earendil-works/pi-coding-agent` v0.84.3 (Node v22.23.2) with `docs/verification/pi-agent-session-toctou-repro.mjs`, tracked alongside this record. +The script drives the real, unmodified `AgentSession.prototype.prompt` from the installed package against a minimal stub `this` exposing only the fields and methods that method touches, so the method under test is production code, not a reimplementation. + +Command: + +```sh +SDK_PATH=/home/vsole/.local/lib/node_modules/@earendil-works/pi-coding-agent/dist/core/agent-session.js \ + node docs/verification/pi-agent-session-toctou-repro.mjs +``` + +Observed output: + +``` +1274.78ms captain call: prompt('real captain message') START +1275.18ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +1275.38ms watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START +1275.41ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +1295.77ms _runAgentPrompt ENTER (concurrent=1) messages=[{"role":"user","content":[{"type":"text","text":"real capta +1295.89ms _runAgentPrompt ENTER (concurrent=2) messages=[{"role":"user","content":[{"type":"text","text":"FIRSTMATE +1336.12ms _runAgentPrompt EXIT +1336.16ms _runAgentPrompt EXIT + +maxConcurrentRunAgentPromptCalls = 2 +REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently. +exit=1 +``` + +`exit=1` is the script's own "reproduced" signal (documented at the top of the script), not a test failure. +Two `prompt()` calls - one modeling a captain's interactive message, one modeling `fm-primary-pi-watch.ts`'s `sendWake` delivering a watcher wake via `pi.sendUserMessage(..., {deliverAs: "followUp"})` - both observed `isStreaming` as false and both reached `_runAgentPrompt` concurrently, each setting `_isAgentRunActive = true` and invoking the underlying agent run against the same session state. +The `emitBeforeAgentStart` delay in the stub (20ms) models a real, not contrived, async gap: `.pi/extensions/fm-primary-turnend-guard.ts`'s own `before_agent_start` handler spawns and awaits `bin/fm-sessionstart-run.sh` on session-start-classified generations, and any `before_agent_start` extension handler doing real async work opens the same window. + +## Considered and rejected: prevention at the extension layer + +An earlier iteration of this fix (`fm/firstmate-captain-input-haenger`, since reverted) gated `fm-primary-pi-watch.ts`'s `sendWake` on a `before_agent_start`/`agent_settled`-tracked busy flag, deferring delivery while a turn was known to be in flight. +An automated review caught that this does not close the reproduced race: `before_agent_start` fires only after `prompt()` has already passed its `isStreaming` check (agent-session.js `prompt()`, the branch at `if (this.isStreaming) { ... return; }` runs before any extension hook in that call), so a call already past that check sees the SAME state the busy-tracking gate would only start protecting later. +The two calls in the reproduction above never see `owner.busy` as true until after both have already committed to `_runAgentPrompt` - the gate protects an already-safe mid-turn window (where Pi's own native `followUp`/`steer` queueing already works correctly) while leaving the actual idle-vs-idle race it was built for fully open. +Closing the race with certainty would require serializing every `prompt()`-triggering call - interactive and extension-sourced alike - through Pi's earliest hook (`input`, which fires before the `isStreaming` check itself) with a real mutex held until each call's outcome commits. +That was rejected as disproportionate new risk (a novel synchronization primitive with its own deadlock/regression surface) for a rare race, in favor of the detection-and-recovery mechanism this record supports. + +## Regression coverage + +`tests/fm-turnend-guard.test.sh`'s reply-recovery tests (`test_pi_reply_recovery_*`) exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler directly against a mocked `pi`/`ctx`, covering the dangling-tool-call and fully-unanswered detection cases, the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, and the session-start digest exclusion. +Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index be43542f2ab..98edab5cb74 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -45,6 +45,23 @@ No adapter starts a replacement with shell `&`. The turn-end guard remains the final backstop rather than the normal continuity mechanism and cooperates with the auto-arm in its `--claude` mode. +## Turn-settle reply recovery + +Pi's own `AgentSession.prototype.prompt()` (`agent-session.js`) has no atomic check-and-set between reading its `isStreaming` flag and committing to a new run: it awaits several extension hooks (`input`, compaction and auth checks, `before_agent_start`) before setting `_isAgentRunActive = true` inside `_runAgentPrompt`. +Two `prompt()` calls that both observe "idle" - a captain message submitted through the interactive loop and a watcher wake delivered through `fm-primary-pi-watch.ts`'s `sendWake` via `pi.sendUserMessage(..., {deliverAs: "followUp"})` - can both fall through to a concurrent run against the same session state. +That is a genuine Pi SDK gap: no extension hook fires early enough, or atomically enough with the internal check, to reliably prevent it from firstmate's own tracked code without a full submission-serializing mutex, which was rejected as disproportionate new risk for a rare race (`docs/verification/pi-watch-extension-reply-recovery.md` records the reproduction and that decision). +The chosen mitigation detects the race's visible symptom instead of trying to prevent it: a turn that settles without ever producing a synthesized reply to its last conversational message, typically a dangling tool call (the `bin/fm-wake-drain.sh` run a wake instructs) or a message with no assistant response at all. + +`.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler owns this contract, alongside its existing supervision-guard `followUp`. +On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last `type: "message"` entry (skipping non-message entries such as the session-start digest, which carries no reply expectation of its own). +The turn is healthy only when that entry is an assistant message whose content includes genuine text; anything else - a dangling tool call, a bare user or watcher-wake message with no reply, an error or aborted assistant message with no visible text - triggers exactly one recovery `followUp` instructing the model to check the transcript, finish any unresolved tool call, and answer the pending message without repeating an answer already given. +A `orphanedReplyFollowupActive` latch, mirroring the existing `guardFollowupActive` pattern, absorbs the settle produced by that same recovery turn so repetition of an unchanged stuck state never creates a second turn. +A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, currently 3) stops the retry loop after repeated distinct failures and commits one loud, once-only "recovery gave up" notice instead of retrying forever; a later healthy settle resets both the counter and the notice so a fresh, unrelated unanswered episode still gets its own attempts. +Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. + +This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. +`tests/fm-turnend-guard.test.sh`'s reply-recovery tests cover the dangling-tool-call and fully-unanswered detection cases, the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, and the session-start digest exclusion. + ## Recovery episode acknowledgement A recovery episode is one generation of `state/.watcher-down`, and it is retired only by the generation-bound acknowledgement the drain prints as `WAKE_ACK_REQUIRED`. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 54cfcdae861..5a7196f86f3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1084,6 +1084,310 @@ EOF pass ".pi primary extension: delivery failure resets the logical-run latch" } +# --- reply recovery: detect + recover from a turn that settled without ever +# producing a synthesized reply (Pi's own prompt() has no atomic check-and-set +# between reading isStreaming and committing to a new run, so a watcher wake +# delivered through pi.sendUserMessage can race a concurrently-submitted +# captain message; docs/watcher-continuity.md "Turn-settle reply recovery"). +# Fixtures give bin/fm-turnend-guard.sh a healthy (exit 0) verdict so every +# case here exercises the reply-recovery branch, never the supervision one. + +install_pi_reply_recovery_fixture() { # + local repo=$1 + mkdir -p "$repo/.pi/extensions/lib" "$repo/bin" + cp "$ROOT/.pi/extensions/fm-primary-turnend-guard.ts" "$repo/.pi/extensions/fm-primary-turnend-guard.ts" + cp "$ROOT/.pi/extensions/lib/fm-operational-input.ts" "$repo/.pi/extensions/lib/fm-operational-input.ts" + cp "$ROOT/bin/fm-operational-input.sh" "$repo/bin/fm-operational-input.sh" + cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' +#!/usr/bin/env bash +cat >/dev/null +exit 0 +SH + cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + chmod +x "$repo/bin/fm-turnend-guard.sh" "$repo/bin/fm-arm-pretool-check.sh" +} + +test_pi_reply_recovery_nudges_once_for_a_dangling_tool_call() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-dangling-root" + home="$TMP_ROOT/pi-reply-dangling-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message, options) { + prompts.push({ message, options }); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); +if (!settled) throw new Error("agent_settled handler was not registered"); + +// Last conversational entry is an assistant message with a tool call and no +// text: the reproduced race's visible signature (a dangling tool call, e.g. +// bin/fm-wake-drain.sh, left as the final transcript entry). +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`expected exactly one recovery follow-up, got ${prompts.length}`); +const [{ message, options }] = prompts; +if (!message.startsWith("⁣FIRSTMATE_OP: v1 turn-end-guard: ")) throw new Error(`untyped operational prompt: ${message}`); +if (!message.includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${message}`); +if (options?.deliverAs !== "followUp") throw new Error("recovery prompt was not a follow-up"); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must nudge once for a dangling tool call with no reply" + [ -z "$out" ] || fail "Pi reply-recovery dangling-tool-call test printed output: $out" + pass ".pi primary extension: reply recovery nudges once for a dangling tool call" +} + +test_pi_reply_recovery_stays_silent_for_a_healthy_reply() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-healthy-root" + home="$TMP_ROOT/pi-reply-healthy-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "here is the answer" }], timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 0) throw new Error(`expected no recovery follow-up for a healthy reply, got ${prompts}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must stay silent when the last turn produced a genuine reply" + [ -z "$out" ] || fail "Pi reply-recovery healthy-reply test printed output: $out" + pass ".pi primary extension: reply recovery stays silent for a healthy reply" +} + +test_pi_reply_recovery_is_idempotent_and_bounded() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-idempotent-root" + home="$TMP_ROOT/pi-reply-idempotent-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +const stuckCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], timestamp: 0 } }, + ], + }, +}; + +// Settle 1: unanswered -> one recovery follow-up (attempt 1/3). +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 1) throw new Error(`settle 1: expected 1 prompt, got ${prompts.length}`); + +// Settle 2: the latch absorbs the settle produced by that same follow-up's +// own turn - repetition of the identical stuck state must not create a +// second turn here, even though the trailing entries are unchanged. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 1) throw new Error(`settle 2 (latch-absorbed): expected still 1 prompt, got ${prompts.length}`); + +// Settle 3: still stuck, latch already consumed -> attempt 2/3. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 2) throw new Error(`settle 3: expected 2 prompts, got ${prompts.length}`); + +// Settle 4: latch-absorbed again. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 2) throw new Error(`settle 4 (latch-absorbed): expected still 2 prompts, got ${prompts.length}`); + +// Settle 5: still stuck -> attempt 3/3 (the configured limit). +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 3) throw new Error(`settle 5: expected 3 prompts, got ${prompts.length}`); + +// Settle 6: latch-absorbed. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 3) throw new Error(`settle 6 (latch-absorbed): expected still 3 prompts, got ${prompts.length}`); + +// Settle 7: attempts exhausted -> exactly one loud "gave up" notice, never a +// silent retry loop. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 4) throw new Error(`settle 7: expected the exhaustion notice (4 total), got ${prompts.length}`); +if (!prompts[3].includes("automatic recovery gave up after 3 attempts")) { + throw new Error(`settle 7: missing exhaustion wording: ${prompts[3]}`); +} + +// Settle 8: latch-absorbed (the notice was itself a follow-up turn). +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 4) throw new Error(`settle 8 (latch-absorbed): expected still 4 prompts, got ${prompts.length}`); + +// Settle 9: still exhausted and still stuck -> the notice must not repeat. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 4) throw new Error(`settle 9: exhaustion notice repeated, got ${prompts.length} total`); + +// A healthy settle clears both the attempt budget and the notice dedup, so a +// later, independent unanswered episode gets a fresh recovery attempt. +const healthyCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u2", parentId: null, timestamp: "t", message: { role: "user", content: "next captain input", timestamp: 0 } }, + { type: "message", id: "a2", parentId: "u2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "answered" }], timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, healthyCtx); +if (prompts.length !== 4) throw new Error(`healthy settle unexpectedly prompted, total ${prompts.length}`); + +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 5) throw new Error(`fresh episode after a healthy settle did not get a new attempt, total ${prompts.length}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard reply recovery must be idempotent and bounded, resetting after a healthy settle" + [ -z "$out" ] || fail "Pi reply-recovery idempotency test printed output: $out" + pass ".pi primary extension: reply recovery is idempotent, bounded, and resets after a healthy settle" +} + +test_pi_reply_recovery_flags_an_unanswered_user_message() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-unanswered-user-root" + home="$TMP_ROOT/pi-reply-unanswered-user-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +// The transcript's last conversational entry is a user message (a delivered +// watcher wake or a captain message) with no assistant reply at all - not +// even a dangling tool call. This is the fully-dropped case. +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "⁣FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 1) throw new Error(`expected one recovery follow-up for a fully unanswered message, got ${prompts}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must nudge once when the last message got no reply at all" + [ -z "$out" ] || fail "Pi reply-recovery unanswered-user test printed output: $out" + pass ".pi primary extension: reply recovery flags a fully unanswered message" +} + +test_pi_reply_recovery_ignores_the_session_start_digest() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-sessionstart-root" + home="$TMP_ROOT/pi-reply-sessionstart-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +// The session-start digest arrives as a custom_message entry (pi.sendMessage +// with a customType), not a "message" entry, and carries no reply +// expectation of its own. Nothing conversational has happened yet. +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "custom_message", id: "c1", parentId: null, timestamp: "t", customType: "firstmate-sessionstart-nudge", content: "digest", details: {}, display: false }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 0) throw new Error(`expected no recovery follow-up before any conversational message, got ${prompts}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not treat the session-start digest as an unanswered message" + [ -z "$out" ] || fail "Pi reply-recovery session-start test printed output: $out" + pass ".pi primary extension: reply recovery ignores the session-start digest with no conversational history" +} + # --- --claude cooperative mode ----------------------------------------------- # In --claude mode the guard ignores stop_hook_active (Claude marks every stop # after ANY stop-hook continuation true, including asyncRewake rewake turns) and @@ -1796,6 +2100,11 @@ test_codex_hook_ignores_nested_git_root_guard test_opencode_plugin_anchors_guard_to_worktree test_pi_extension_injects_once_per_logical_agent_run test_pi_extension_retries_after_followup_delivery_failure +test_pi_reply_recovery_nudges_once_for_a_dangling_tool_call +test_pi_reply_recovery_stays_silent_for_a_healthy_reply +test_pi_reply_recovery_is_idempotent_and_bounded +test_pi_reply_recovery_flags_an_unanswered_user_message +test_pi_reply_recovery_ignores_the_session_start_digest test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive From b98fe1143b9e47e2c2cfa417ae3cd11a89fd23bf Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:38:52 +0200 Subject: [PATCH 04/15] no-mistakes(review): fix(pi): correct orphaned-reply detection and latch lifetime --- .pi/extensions/fm-primary-turnend-guard.ts | 56 ++-- .squish/squish.db | Bin 0 -> 724992 bytes .../pi-watch-extension-reply-recovery.md | 2 +- docs/watcher-continuity.md | 9 +- tests/fm-turnend-guard.test.sh | 242 +++++++++++++++++- 5 files changed, 288 insertions(+), 21 deletions(-) create mode 100644 .squish/squish.db diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 01cd1b05f8a..d2a9e21c843 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -29,6 +29,12 @@ let orphanedReplyAttempts = 0; const ORPHANED_REPLY_ATTEMPT_LIMIT = 3; let orphanedReplyExhaustedNotified = false; +// Roles that actually carry a reply expectation. Every other message role Pi +// can append (bashExecution for an inline `!cmd`, extension-injected custom +// messages, branch and compaction summaries) is bookkeeping and must not be +// mistaken for an unanswered turn. +const CONVERSATIONAL_MESSAGE_ROLES = new Set(["user", "assistant", "toolResult"]); + type LockOwnership = "owned" | "missing" | "other"; const extensionFile = fileURLToPath(import.meta.url); @@ -469,15 +475,28 @@ function messageHasVisibleText(message: unknown): boolean { }); } +function messageHasUnresolvedToolCall(message: unknown): boolean { + const content = (message as { content?: unknown } | undefined)?.content; + if (!Array.isArray(content)) return false; + return content.some((part) => (part as { type?: unknown } | undefined)?.type === "toolCall"); +} + // Detects the reproduced race's visible signature: the settled turn's last -// conversational message-type entry (skipping non-message entries such as -// the session-start digest, which carries no reply expectation) is not an -// assistant message with genuine text content. That covers a dangling tool -// call with no follow-up reply, and a user or watcher-wake message that -// never received any answer at all. A healthy run only ever settles on a -// final assistant text reply (or an error/aborted assistant message, which -// this also flags - visible to the model, but still worth one recovery -// nudge since nothing confirms the captain ever saw it either). +// conversational message-type entry is not an assistant reply that both +// carries genuine text and leaves no tool call unresolved. That covers a +// dangling tool call with no follow-up reply - including the common shape +// where the same assistant message holds a short text preamble plus the +// unresolved call - and a user or watcher-wake message that never received +// any answer at all. +// Entries that carry no reply expectation of their own are skipped rather +// than counted as unanswered: non-message entries such as the session-start +// digest, and message entries whose role is not conversational (Pi flushes +// its inline `!cmd` bashExecution messages into the session right before +// agent_settled, and extensions can inject custom messages the same way). +// An assistant message with stopReason "aborted" is a deliberate captain +// stop and counts as healthy, so a turn the captain cancelled is never +// auto-restarted; "error" still earns its one recovery nudge, because +// nothing confirms the captain saw it and no human decided to stop there. function lastConversationalTurnUnanswered(ctx: ExtensionContext): boolean { let entries: SessionEntry[]; try { @@ -488,9 +507,12 @@ function lastConversationalTurnUnanswered(ctx: ExtensionContext): boolean { for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; if (!isSessionMessageEntry(entry)) continue; - const role = (entry.message as { role?: unknown } | undefined)?.role; - if (role === "assistant") return !messageHasVisibleText(entry.message); - return true; + const message = entry.message as { role?: unknown; stopReason?: unknown } | undefined; + const role = message?.role; + if (typeof role !== "string" || !CONVERSATIONAL_MESSAGE_ROLES.has(role)) continue; + if (role !== "assistant") return true; + if (message?.stopReason === "aborted") return false; + return !messageHasVisibleText(entry.message) || messageHasUnresolvedToolCall(entry.message); } return false; } @@ -637,6 +659,13 @@ export default function (pi: ExtensionAPI) { return; } + // Consumed on every settle before any branching, so the reply latch can + // never outlive the settle it was meant to absorb - not even when that + // settle is instead claimed by the supervision guard below and a later, + // genuinely unanswered episode would otherwise be swallowed by it. + const replyFollowupSettle = orphanedReplyFollowupActive; + orphanedReplyFollowupActive = false; + const result = await runGuard(); if (result.code === 2) { guardFollowupActive = true; @@ -656,10 +685,7 @@ export default function (pi: ExtensionAPI) { // Only one follow-up ever fires per settle: the supervision guard above // takes priority, and reply recovery below runs only once it is clean. - if (orphanedReplyFollowupActive) { - orphanedReplyFollowupActive = false; - return; - } + if (replyFollowupSettle) return; if (!lastConversationalTurnUnanswered(ctx)) { orphanedReplyAttempts = 0; diff --git a/.squish/squish.db b/.squish/squish.db new file mode 100644 index 0000000000000000000000000000000000000000..a9335f58ca7a05c8863e431f0e0b1c3fd010e3c5 GIT binary patch literal 724992 zcmeI*e{dYxVc7W@0Gz=OlC!(gYDCfWHkYO`BLNG5AoxS8H9Z8H6tp0?3k)REveaqJ zJb+EkOb@$zNJ6r*n&qx6D~eBN$FlEYUF0~)<+trPcP^LXD(9~JhkfNsoJw+cxyxNH zxhvb3IKJdkPO6+r?!E4wo}M2Va4BuH0KRJ*#Ju0{^M3SuGXT8)`ikWVZOeA5rl-v( zo=GH=iSO66L?ZFa^8bE8{$K0AugY&mS})|k$=LV1ef!Iamww_mkI2}u%x|kS|5fIX zGJlx)?aaT*{EN)*Wd3R9f6M&C@n=TAF{bNf2q1s}0tg_mp9N+zsqEsZWWuVI z#UuBAMIJm1v*FqPYojWvwqps`m}`Ca%*L@)cIH&F>L;i>_FYl(T%&G!cMPlis1xgG=RzxdD>))ecdBwuj6+OzT z<;FG5Dr-gkR#AIxZTUuF?Y8!+etSv=*UYL2B35q}wbhN46&0%L>J2Rmx8zuL&$4TN zq)J&8o>?|MGYTj`3s@&uGnWKZos6}MVwZ1#{=GbUDo6RO4-}f;au5euc%UB1aA2i5NJo1E7 zGb?gr1GJ6R<<~d#4qo*_R86ZALDe0xB^*&J2{#J+&f(?9Q`uLtyG?MPz&PjsGm#(L zJL#OCbo^mDoxL)XY##HMfw?Veo)PP}aZl{1<aV=v#YwkJ@8-`?sK-ApiL4a5_6Pll;=WKgW7n+Y>O-G530+2hGEFwZSx8 z)351k`s$*-9&H6VtDIMDUeZ@|x$i6%))xy){!Eo>L9Y06d|*}@eYOYJZa5_oYFMN~FZ)x;a7%aOj$%BJ zyO!LM-R{>z9TTZL)}KdV)?nPb9t_=^eaAL!*6S7PDYxk>L#gcgbU#daKsfcH{PM%i z=jGR0Q{B=(wJpC~UzcAlT$Nw`+Kl}2-23I1XOA6|Uq_DrZX)xyGGEA?li%`FtuV|(}OCvPtgk9_2QSdx6Fdb(aQOG1_7e`C0|;~BP77LMlG@}1q_O>NDrm4g>M zhM&$=MZGdKGX1HrC_i*s&$Ot{_#E@Vw#Qs0tg_000IagfB*srAb`L@ z5_o=iCfV~w#>o4IKN5yUZ%Pcme|S0!YrnfNbZq$aSdd-4XP`d+mp}B+e+VFe00Iag zfB*srAbWUs^ z)pFySW|g&~eygaxwzhnuuy$K}RlhwYgKK701QDw@i`weO%8CkAb@hgpgeha_ET|?N}E*(XD)Hee^`;WTE+HDwWP& zmo=Z+rRH!F^sPO;NA0n^{aesZkbiu2IGvrDNq%YGpJP3(?Fks^n0r0ZgXUqo+F%;4 z>DTl%eRWY^kG6uGRnDt6FX=0~+;x+dYf2K;cAXj`jJ}@hdKHGz9H=L3Pwmmg~ z?fGlhwyd(6)0*7aR-%8qXJwq_P_`yDg5MF*45e1f>ci@2rTqo+6Gkmy+r1^mOvgm;I?^xTQOCM=>7A zT}$rBZuje(8SwYcTFz4~Fi|zGIs<>-CEDl-u-`p;UH#dN<7Ob{cctA;agWH{!;ccfk1~HJQ^`Dk{BIop^wGbmV)zdM1l}!yAG>_qKZE``-QR~=N8Z-I znT~y@75c6{x70zS^$3y=o#=_@A3(Yuck6oli0kdt`#jl7cFmKAl-?(y+o)DeCwN2# zXSq#LE?c#2CbL*xwZrvYZ(>RKB2h8Otk$-cH=9(Ja%B*2-%6pd_pPt&IDNva+Y$cIJN-~Hb}P+ba6%dwkC5a8HyUADUYz;dlkOTKlpqgv3~{i8t%H%h-ha$U2>+h;zLsT#1TPNjM+fy_AssA>)ZT)ER2ss&D~RZ;c9LjF#@`!pr5*m8q$b_~Cv z!K9PV8~MiIjuD+T@{hmg{psxdeDZC(eRkS0OxLwbmcKaMFiSir+k2ww%roe4rAD>u zrrvRtlMl$S%GT2-{sus-8SP$lAPsjwL_f&fjxr@uzW=yJN1YiSTW? z<9Dh|X>kR4;oo>)?A_be5!O&=E%}Uo)C%}OI4;4vx2@19nR=h46_`_b^X7A@?82$! z8~%Gzttg|_p;*b!_b)k?vZL@;xas9Lp6%OmdDxA&{QdpQSMyXQ>nL0W=FMmNwY>dK zNo4ZA{w2p!b`;(Y&%fih*!lVYck6~nxd|GVo;=?EZz00IagfB*srAb3t$J76PdSvA9XMR0%owQ6PYNYp&bvxIBZ%&IV~auhJTxTY71y0$%+%FazE-|$mAqGEcMU2_d@r`}G! zV?+@PVYcPfCHv0NO?K3*Se_p~|6)4(!VAeC zz37>n6;TnUQ?qK@ZuHIfh7BneuCC}>6s+aOHO(q(Mg3M$du?s`Mq%x?_Nsn+N(R>* z`>rT?vbrF8O~0nE>8p$SdMnD!S>?Ry!jitCOPs~R`eI>8SD8z8?SXJypQm35@%Zjy z*7c&cy0NmN%25_U^{Y4Ap=L?3_lA3>vn@P7TxE6})vD?2^rkBd&$KFTlu%SRMY$}& zjJs}FuF4+;YgbopUR5DgQMH|&ejV~-s zJ)teH7WM1;niRfP*jOoQv#J$$OxJKl$q}C0KY^TR;(=K!3BxVfPH!m-elaCk!M5y9 z)EKK;w;fOA>nS$OVb`{-ve-RARl=4{&ukaz8bPU|+*79Ci0Nr)rPHh*6fFq`MlPeV&hY4{(7oGYGF)iuHA4XO7KEPg$RaJ78O4@8lXwHE=moxFFX*F zQ<`UPhto`J)TK;Ed-88he=wb$oJ@ZCguiS%7DdO4lO4;qBkEmS!VOnW);^10g%6tM zT|*J6`U7WqmC#WRV|;xEtIKueSQ{m~A?f>0eb2rpYJ(EEau4WKj)rEBQCBSx@*xnP zF+r=k=JeA~ELB|ZGn%ciq8=96z8@t+E&>)ItNd<4m5Q@>+)j_L2a6*+Q` z!rdyK>_Z(7qJf&_aAt9{R64&?fGQF zZ@hcIVtK+a8=mdIZj}(r<*v~x(cg}Ou(KU|P`{MHz7wR*wMsC({Du}gt4A3fyW$_d zTLItGzx-CRj>1)7{{9Q;?3Gi==KK7Os8x)7%5qH@Ea7TpJF%3GR;(63G=!le!X&q7W4^9rKH#*XG*89SsdfR#W z)Eh^2Wvd(4FU)tfB*srAbz^+5I_I{1Q0*~0R#|0pq~JL|F544VF)0A00IagfB*sr zAbOZ;wP?8{>>j5bCS=?AGlO1*XD$49<0^sk0C zlfR!VC4M*aBgg;I#KhPi9{rz=e)H&T;?1$jSfyWCPZ*ZEp7K-PyOz$rd?s1eQ`vwEZbAmuj^~tYir9l z3TwBuSM}Rdn)^BJ>dMWl`DXfK>Fo4$vRUY@TXwIbQ2F{}1L~B~lQ~yxTjeRu6HawX zt8drrsd&Bd=zL!L)N=9TH#dsf+RaZbFS#$LvokaO5lqkQXh~THygZ;ODt`CAZ&vKm zy|DX7Kbp>-Jv-X`Oi%aY6^2!Pa(S(|QCQIe@puAlV|{t`x+X_#AvdncL9ThC=1sM~ zeI**7$yli2*blApE0g{|ljHfvAGn-8dFIsU+Ydd*+TNCkYPMN&E|`thx2XNlimJ*IIGfRsH(%s%*^i>bkyGR5Rjc6se|Ju9Z@~n9}^_OhvuW zJab!yx{YepbawKxoKF^3HuQBZH(3@H;fcv9&90PR3bUziTL9YP&BBVlzNqI^Xi(1N zWPYkMJS=%ilTrDX2etEwTUHYfTkTQb_O?fb_G^y{?`n^}y7Xr1l0PtyKh-lZ<(A#9 z15*wMW=UVs_w~R?h-U3#05RDEVeJWFg%II1d`C0%J2mo+3+e3qe6sm$xIu*LQSHrg zQf*n=tzP@vbrAH?)>LTi%srVVbNAx=;*|EFYd73k=zJu^N{Ai_yR#X35gjzPt4OKFXWxJt*=46coQJO1}qCkmWxqitI)rkv#Iwrfhf+ZyxD6 zg1e%mCVcD^UR&}H?ag4G#!ujN$G$6*$I{AWwo~Yssk@}_KD4)s$}cRYvdd?Z3BPIX z{fgxYc?|Px|FwKPw66pNg zZxuV9Au_n#wjg>6V{^w7Zgfv+C79LXg?aWpAvZVS zdREo+#2%?1$R^9V(UWSfqs)>cWKm_q^m-EJT+i8(Rf*i>S$A@3vSvS&XVZ?{jkIlPCTtP$AZ4EV**FlpIC}L z4?*hh{|{yUAd&ea`Gx-wKmY**5I_I{1Q0*~0R#|0;2;Q$j}Iq46n11dIh;7k&;K8U zVWkfUAbfxvJ2q1s}0tg_000IagfB*uAM1cGMAsJ}8i2wo!AbVBqMCLa#w~zmS$G>#+#fkrO z;+qpE$G<-QYbBmRFbbTUu{xIzPhNd2dOOK=B#pFb#+N!(WT8|VSTZ%q)*9m+FcC_TD@7+RyS5w;-!RX29y#G zv0sELwQAMWkVmgecFhwt&z}{Y^{CVp)Afu-y=;1-EXS>=U)R@UQ?C^^R*G8A^_(rw zs*2p?S$A@3vSvS=%;&XMJIC|a*HYO}FYGZwRpD$4QWTcMsdT9*l5Ud);=;O251v zeU($dPdKPB*KRl^(OMH$+3g+al3BBBR!LSEmfmV}YdGW6&QCIM^2TeF2@SVq*4;a{ z*IRKg9&#$lIkvsi8}HfoL`@c8K4?!t1!TE~BOXY^c5pN^XH|k$tYLbssoN2}8C0gi z{5F(h38Rwsoa0UHwRHAVmy^v`;+ta7sc2}7k|Wd-k~PG3#;!!G{kF%hG+hP7_QDb?b^dwW-x zyY`L6HUrv5yrg!&mkhXc#5$sNYKV~1N}YkN^CtGibx|wJRFly&O|??)E0%cZFXpa&MFtx++Y@b#_9|>8ebSvp)g&I(Ek|yi?G0=^ z-yB*=XZ7i1^96q~`@{CgGhE@ilGcqj2zdbOj{8K%%I%Jg=TwWeJHBH_8G3bKBjh9q z4}v|7SRUMnSkAtUXva&5PYi5CNTPPK?!J6{a3f+l`!=E-pTDx4%C1lEwo!H0Yn(|H6CKmY**5I_I{1Q0*~fkPs|=l_Rfpy?(82q1s}0tg_000IagfWV;^ z;QoK8ho8LCE*%%(-AeVQb}hoo=7&Ik4L$-;~BP77LH++ z!}RwS*YrYB*Oph8^jlh3#`@h<_9G{f_v4wpow{gev-)LgCDCrKh9%|v0u2?Bzip+m zb0?AyrT??Q-w*D>s%ZUUZZ<|B*YX?3EMAgkQ9KzhZg9FdLrjzc#{7%?1B% zzjbmuojq|P`Q>LlbF(6X(BSJbwv-mpykFj%_?g}{Z>(XZEg8RVePi|s(yP) z2Dcj)M6c=B^fi5TQD1LGxjCzxSB+lMS9Hm|SXf^yEa}?n&7!utv9h8{shL#~w3Gg_8gEyUevGaYqDwA3L7g$ZPxFS?OL8?*NjJd#P9Tv zFAKNiSanr|-#EX_hEuoQppWWxRa9+f$0*s2n%BRSk|Rt{lnt{tKIeMQmSwURLI+MC85%k@;-UC;Cy zu9~;Iq!1kwd6z`7V$>YjC6(;4sx069ZjsKe%ci}&SJND2w|m>lPujLtIeRqDPiA`g z=5ytA_Ep)yj|^;}a@@Ufr6+1!SjM1+1xfNZN~x^*@@{*>F674CE)koLM>f;hmtRi4 z{er)H?2>GkV6$U?*(K4S4JYVR=Z2ugcAc&wciDZ^8kBP?9JXi$#&@5RU9DF{=Mq%e z$_>Z#*LJvhghk2e+104lrZVX^O7gKJSE|e{9LIK|ZNU*G`7n}CEXP(UL^*0nE3>~N zIl^sJJi~2NtERK#=N`|$VWzYCbh3G(-%N82xnkwk-fs=Ke{9hF>d4t=a=E#%ozXp7E za0B95`}RLdAlLinK0DArJ=gj*;WK+QA&0qOUD!V@Ygv;7B&UI`4;FTYI zM+J0m2=1rfTKRN3J3XEJ@~i!J0=K)u9|(ILoGrRz_geJ5X9R6yb@}xTT`jBD#OPdt zDyHPfC%HTt$~EN1pUB}X?pbhi9naB<=?3$t{TNn-M#23ygD5h1Xhn-X3h&$lJliuX zYOcCHZSfa>xO)sbD{ProrQwJ{Nfc1Yu5DS{QF~(ZxK(oi_MJL-Z2y2fv7b&hhy69J z#@nsO4)kgvwF7T=ETMLIc=}g~)$(aa0IXQ#*XnGJ5L6x|$cN zir(c_H@!R2jOu&x-Swc}Zu5Y{`giacJp4)dh?z+?PX&*d;KY8%bnh4?vvfxou2uD4 zse@d1WU*t;^hC$=`kQ=r{#;mizIk*boxLGjk>8~i?J@NqG~ETQ@6nLX?ETx)PLnSb zQ`wD~-DY!dKj(Ua*7*Ft|2r9kB7gt_2q1s}0tg_000IagaF7JJ{~x5$q%Q~{fB*sr zAbes97a|JCv2*uDO_ z2t@z^1Q0*~fp5D&^8-ho-N>IfJvNd&nHU=zy_XmnximXBTPj`LoGzE=&FS;ATl3TA zmYAL1+FB^jn)Bx_Ts&{OqOxVk-!%(Vten3gS6gCbk_ z8QDTn+LCR&JU_ivzI18&{8o8$`qJ#|x#{_H<=I(@C(g}_vS?7PL9#F4=w{KySXUtAddyZQ2u7;C}wa@aO1TNM&bdtx2LN;d1lTzNrkUA{a$H#=XRKEF`jn!a@TqA6dVyL@3z znDfFc1^rY%#_rk`Oa6kJ{CWOX-_QFqZdUbuD4KDD+va+rE(iD1FSZL5FHXH^)i&)% zFMcL$`vuwdpZ*OkYWvLia~snqqv_bp$hMcv^Oxq%m$s%$7q>1>pFclap57D}&rgd> z3tOgGxV$MYnCf>i<6~B{w(odq(B@l9<>U{f_G)-AHKTTyMY&OzEtbD$P!Um!=au%E zueDnIXzbaIv)MCaBV+y?o0jdJE0?w|pFdZgo?W;gBFs{8W!*-K(`b9&*D zT(;*+=g&>c@hwl6H!qr7=gyrI=4JUiQLzCw+=>wLw;BB=oNG6ry?DDu*-yVLe`V2m zU{*dZcO*H+=g!Nm#;Pq=8WPW0w>~Fl<-EW9bOfE7m6%r5s+f-D?G*3G-x;}Ms|7#% z(j}Q%IBM&Xze7$_HkY|Udn~7 zTM-XLMNMwI;rYJ`s^&>ulo}PeY##WtMS?6>>+%<()vtEG_|fw+e{1!siPkZTnj zRkeF|MRx4c#qvdQ?%d_+b6X4Nr_axwD@|{j^OvR<%3{I1U|uY3UD)*G1{$CCJ>z&g z9nB>9%W!7JZ=i_nyPd!v9(itKuD#bbPs(;&J~w+|VPWh1bm{!13vxP_FHT>+bZLIN zw0VAR!Q8sAH7~Zptt0k4Xl)#U^z7$fbi}r(sRx-@_kYjM64B0YI;A@jZQWmI;RC`S ztw#N(QC)`9d2vfV7|t!2)0+#k z7pE_Y`Eq&d@?1$8gj++LE!r)@1t1@$YBDZ0>J_UbTO74?)3#l&;QzW|yG`*Knix{s zL9`~-IGPvFZC#j?Yw)scot&Vub(ht%Ykuy+=4DZyy)buvc2iU=v1RyA)o=`h9alYV zZjVJ{=s!rW`iWzYQ#qC9zS&|?pZ^~{{(R#2FCYKf@$WnSPc!E;6PeFszLfd7)1xZkle||rp%OmH8*}3-;gh^0Hs&XM zg14_m{Wd2z`Hy~Zcp=&E$DAfq>h7N*8h>W^Y%>0XI%C=4`DCA;o*6wpd^*VO z|I|WS71sTe8L8)n^U2O1=R5L&;kj08Vm~=CqH5{$Lkh#HIq@G97<&Kk%$WZp1Mxm` z|38egojxOg00IagfB*srAbNG|`@z-W+OewR8jGMq>r@e|9J!$ZTviLv1G|1Tyo z|045ing2HPS2KS$^M{$=%>0wg*E9bh^CvQ2eD`}w4G18B00IagfB*srAb*WTe6sa3{o`Z9*`)tR{>Gy3Mx*c2(RV48J@~V_N7^Ng#7Y{D(hiLc zpBM|$2KWD8Psq>z|E>Jg|MxS$D?j=F-{oije_#GAz;9-L{os!PJwyNj1Q0*~0R#|0 z009ILKwzH;j0}$@kE$;d>dUzLGN!(asxN8vC8fT|&;Q4gBkIer`XWF79~(QSy7cjqM-;NfB*srAb)O2J-009ILKmY**5I_I{1Q0-A-w1I3-#0@= zLlHm#0R#|0009ILKmY**5O`_<{{H_{(~%JY1Q0*~0R#|0009ILKmdV#Bf#hX`(~(U zC;|u|fB*srAb0e7P zq<%K_p^?9N>$C+u1Rq(u2-e zwqtoa(!8i&*Vkl|uN5{{irTD-6*Xm7?undJktIi%o+ulp*At&}J!i|asv~LdN359BPbXp}FvTt30Ji{`{jy>1;t(c%r95 z8EZs|AH|Az)UgC&5k_a0?wV!nRI4d+^4)uw&R&)^r+aGdTkFGJYi%d*t}%+qfA&Et zJAWqG=&4IJSuXTgFCOe#QJA*778#s>bIS9V@>?(XOF33t>{~A^IatcEh(Sv^Ox9ZH zv6bw{$PMcWRxq;^XMApRNTGgt;Mjza`RkG-czl`oA^w&$r3GgB zX2wZpZ^(vR?9-62#dXJ$50stQ>ueGAI@nR3ZbS0h z-G0n>Ja&U=FU zerdvA5Iw!`3Hak33!^8xV{vrN<&s@{ARO29)D57|BQ_rYB+uElM9<;~qjo7pJ!69# zNbK+%&HnHS?1`G!`xr0T)w&}uG4hzZ<=EBMj8l0$`<|$|hHE}(ACOhJsBVgK*{W@8 zS66Oc^-F*1gYtO(&KKlESsvt$v}aCmi|L$`LD(01Cs0@7XbJ`4@hMvSp>+0TS<^&M zO>OvKq6T??XxGYO>3iyH&)Z5RoxOM>*?hiTpVW*NcYC?r>#e9eVN{J9y8@~MMQf|b zfAU@`J98rG#w)9vjvVxMMR)sE)J_zyq?I!-uf!kd-7T6QXeQFx*X2DpIK%X;8zVmX zRSSAjB)-_unx6Qc5>)egQsuhp$@BUDo^N81009ILKmY**5I_I{1Q0*~fqgH){eRyN zAss*f0R#|0009ILKmY**5I|s00q*~M5|ID_1Q0*~0R#|0009ILKmdV#FTnkO-wz=j zKmY**5I_I{1Q0*~0R#|0U{3+=|9cXV009ILKmY**5I_I{1Q0*~fqgG`G_Abl zdE~c}KQ!?VC+zV*N?ZwxG@B<=+4<8$pSNmd@yPWY(-Yg4aE*p59K$Ndf)YQyxTY71 zy0*N!q~Fr|q}6V&#)5NU5&84Y52Ui!PY)%6I__62PZ(yyv;Egbtf0B}_lcjp*L;6E zJAZblIp(L^6ctNs8KS%`Tj$xTZSkPQPjs{{9u=lmt&0ceWFg^etDL{zd|x_yQP%cs zZ*5zSUG1swPc5!}=|=PUboTV=p|6%abF(7at#5yq_dkg~&+;mLg3H1!Iab}X z>{@T0l3nw}BhQZrst|Rz{i;za3D-4beor`-8AWWFR;A$xLk^8qGCkXA=aD6DS!Gcx z2~Ez<73p!Uu(49qW?xzeFl4uklHI6za^4p8>-t(diQj@&ox3M#)jpt5JNgyfmqZI-I=%(CgV z2_IOlwQ0$vw_~^^yEUFIt;xD$J&;S^FTUgm`E9%_Q7dwCPHcHrRpchmx|36rHT&UY zKCi{6MWZh3eX88!`S<>kk8ObQy~Jp35j~rXAJN`sE;XM^WnVqJ+g{jF=DGN*#E)NU zKKsO3=Q^_f*qdJSnRNE-*`c@d{zes#jeno`&pS4&c+{ZnsRAQf+SJ}yU4DH-&vkZpN{cstN{gD4AI~>a%}hFb<>FBDbo{B} z+6_l;bVgNFZD%L;WC;Vp_{7)xJ<@tJ$L3sT0lBCG)so`_S`v`%VA+?t3_FFfdX$CEzNavl;{;-lhCj~!79_4mFDSTU2L)7*uEozIL`l30W&R#w_ z)J(=^zI?PtkL&idcN2f2&-Cg}+n!r)u3bw0R&y+sojW;nzoVMq2^z2BD}AeIr;8UK zgywHIM^oA9lS2r?x8PQ)c(I|BipqbsnU?47li|5Lh;fbfxi381x`{9M zEk2eoUPC)L|57uR$`(!zUS@79#kjW;<20Vtq~?Sni47EY)$E zY0tF2$IGzVvM8B5hPpfT95Ux;qjR8NmQ(FG8~MjM|8cO(A$87AvB!Z>9@yOQxUG`Q ziaJi&;z4jiHrz(FYC7#B_D{wtW2346F!~##UrBv2H9Gu%9r=|bwc%?+e?9T@ zL+;R}#78qfp84?cUpju{*zdT_d^-Ee*`a2}s+GkfPncDsDyo~pagDlT-xVd#u*x0b ziNCVArWcC3w!FHe-_mx=qTO8Wh|9H$%Re4zes4N^_UzEx%bvMe5%DtO-zWZJSXHrb zbw%$ePRosJnpM_{`mLh&+S>As!rE=^RsHsq3~tvHM6c=B^fi5TQD1LGxjCzxS1nx9 zS9IBx#lrexVM$k+8?JCD{QP3 zwaGwsQpMi2t(qtsrY9MT`gMIxHYG@vb3JFvv#KIDdDfkrnylFmC-Zr2^=7-Sis^cW zS@Nt0B36PbwkkZcYAAI+I#qwl<4NtaB zP6i`B6vnyutHfWv)SP_ctaBY%|5Ee)&9mw3jf+FgnV??JF+H)pV{D3wCAJJv-WG1_ zg`9lSqc=YBUw6#HzG-4*sIl&wCKpyJ=R@;D&F@KPUzhE=IG|loDZ94pFFZlZ;-&1~ zwpgnC<5cr>I(zZr(A#-`%|vl=wZ4IgpY2#hebWqDPEm$Ft0IiEMEBB&t*A)2XH5k$ z1G1{AV0b(Aa3RIlK+jAsIYLg8r(IIvI*IjlN{bpkr9};qtL78UGpX!nF7CE!q9Wo0 zW1Q<7ocQyfXukN)3!Cd-*q>`OH91A{L(S(qCvdQ$JX>w$QC2@N@n`!_)_9JN`RPaG zWKmH$a^-x!`9eDTvaI)1zk0VEyV|GbpBYqhD^H)g{it~Dk4Kx|mCok#t*1#8=eM=< zRpJ}{*MgsP&?4xrwP!Ae^$c7Ioor84glg!2(iPF&@hPpd7oBV4W6keQWpC#9TpPZ3 zY*F~H6W{z;^VB=Y-d+Yl_CI4bPo}c-r-wctpN4WojPRk}n*1^-@zedLUp#GW>Zw5u zi^$(^ekh&2a(bwFthqxDm})fP+MRbe|Of4=#FRQCGmJ!YjE{MM9G!}gPj#L(B$+0&;YlafM?>j?SLBXjQt<5p5?U;u<_?^S-2&~s(Y4Q3mPAf zlylaTw+KH%9YOH_A1citz#uR zCC|RJpeks%^4x8d>_)BkAU*51pjGGYNm{iJD8)Z;OZfQdLY3}^yI~dWvwRfQD!MZ& zSZ;w{>(oCP9OwPJf!nCdyM`#s>Na;GJcUKo1AU!E0VIyI}d?Wzl#?d+)a z8HFW&p<}^B;jy%8xkTZ)sEqt}b6OrEUKnZ)cU4n&tOxRh)mhKa?@~`Jac4!Ax>))J z|gw1 zhJSsR`r2tbD+|NxrgtZQxA|eIC@IF zfB*srAb5%Y^{dZDOm%d1QJEiDR;(rY(YweCDQzpyaWD(A1fl*+D8 zClh{^?){483Bzo7w*T7b&TY(fhn#zJXF8poo=(1f)-yLNqC39(^<3EIV&UqF-qSiQ zH?C<`Su5(dirQ;y%Qp&Zx3yRG+fy>Qv*AIK)tg0abz@~kThp)UYx?S&!uj^~F`fG)am7+GM z;_8lF7mg=e=jT&FB}bT^C>v&PmYnN3Tb@-FxyiHc}sMYBYisl54(kMt|nlf8`>JJY||RkTanx8zuLwL?U6NmMsQxop+8jk|6v zSXSIU>#C}UcFic+jau)-n^hCDV!ECYrt6rWD$=Z2ru%J~#j3PMz5LW`U~4j+z4$`1 zIqXlXiZGp;1aOVkx{~{56!?*ji4_IM)2f*jMdVtAm|ouaUO9VTNbYo1W0ri!cr72^ zrIvQm&Psyt{Oz-;?A!~<`&|{Z)>G7|{4T|}5_T5uue`1A>2K7YtZk#*F2%>vcGl32 z$j_ZgW%U cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash cat >/dev/null -exit 0 +# Healthy by default; a test that needs the supervision branch writes the +# desired exit code into state/.test-guard-verdict beforehand. +verdict="${FM_HOME:-}/state/.test-guard-verdict" +[ -f "$verdict" ] || exit 0 +printf 'supervision is unhealthy\n' >&2 +exit "$(cat "$verdict")" SH cat > "$repo/bin/fm-arm-pretool-check.sh" <<'SH' #!/usr/bin/env bash @@ -1388,6 +1393,237 @@ EOF pass ".pi primary extension: reply recovery ignores the session-start digest with no conversational history" } +test_pi_reply_recovery_ignores_a_flushed_inline_bash_message() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-bash-root" + home="$TMP_ROOT/pi-reply-bash-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +// Pi flushes an inline `!cmd` bashExecution message into the session right +// before it emits agent_settled, so it lands after an otherwise healthy +// assistant reply. It carries no reply expectation of its own. +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "here is the answer" }], stopReason: "stop", timestamp: 0 } }, + { type: "message", id: "b1", parentId: "a1", timestamp: "t", message: { role: "bashExecution", command: "ls", output: "x", exitCode: 0, cancelled: false, truncated: false, timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 0) throw new Error(`a flushed inline bash message must not look unanswered, got ${prompts} prompts`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not treat a flushed inline bash message as an unanswered turn" + [ -z "$out" ] || fail "Pi reply-recovery bash-flush test printed output: $out" + pass ".pi primary extension: reply recovery ignores a flushed inline bash message after a healthy reply" +} + +test_pi_reply_recovery_flags_a_tool_call_beside_a_text_preamble() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-preamble-root" + home="$TMP_ROOT/pi-reply-preamble-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +// The reproduced race's real shape: one assistant message holding a short +// text preamble AND the tool call that never resolved. The preamble must not +// pass as a finished reply. +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { + type: "message", + id: "a1", + parentId: "u1", + timestamp: "t", + message: { + role: "assistant", + content: [ + { type: "text", text: "Ich starte fm-wake-drain.sh." }, + { type: "toolCall", id: "tc1", name: "bash" }, + ], + stopReason: "toolUse", + timestamp: 0, + }, + }, + ], + }, +}; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`expected one recovery follow-up for a preamble plus dangling tool call, got ${prompts.length}`); +if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must flag a dangling tool call even when the same message carries a text preamble" + [ -z "$out" ] || fail "Pi reply-recovery preamble test printed output: $out" + pass ".pi primary extension: reply recovery flags a dangling tool call beside a text preamble" +} + +test_pi_reply_recovery_never_restarts_an_aborted_turn() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-aborted-root" + home="$TMP_ROOT/pi-reply-aborted-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +// The captain pressed Esc mid tool call: Pi terminates the run with an empty +// assistant message carrying stopReason "aborted". That is a deliberate human +// stop and must never be auto-restarted. +const abortedCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, + { type: "message", id: "r1", parentId: "a1", timestamp: "t", message: { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: "Operation aborted", isError: true, timestamp: 0 } }, + { type: "message", id: "a2", parentId: "r1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "" }], stopReason: "aborted", errorMessage: "Request aborted by user", timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, abortedCtx); +await settled({ type: "agent_settled" }, abortedCtx); +if (prompts !== 0) throw new Error(`an aborted turn must never be auto-restarted, got ${prompts} prompts`); + +// An error stop is not a human decision and still earns its one nudge. +const erroredCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u2", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a3", parentId: "u2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "" }], stopReason: "error", errorMessage: "boom", timestamp: 0 } }, + ], + }, +}; +await settled({ type: "agent_settled" }, erroredCtx); +if (prompts !== 1) throw new Error(`an errored turn must still get one recovery nudge, got ${prompts} prompts`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must leave a captain-aborted turn alone while still nudging an errored one" + [ -z "$out" ] || fail "Pi reply-recovery aborted-turn test printed output: $out" + pass ".pi primary extension: reply recovery never restarts a captain-aborted turn" +} + +test_pi_reply_recovery_latch_never_swallows_a_later_episode() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-latch-root" + home="$TMP_ROOT/pi-reply-latch-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { rmSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const verdict = `${process.env.FM_HOME}/state/.test-guard-verdict`; +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const settled = handlers.get("agent_settled"); + +const stuckCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, + ], + }, +}; + +// Settle A: supervision clean, turn unanswered -> recovery fires, reply latch set. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 1) throw new Error(`settle A: expected the recovery follow-up, got ${prompts.length}`); +if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`settle A: wrong prompt: ${prompts[0]}`); + +// Settle B: the recovery turn's own settle, but supervision went unhealthy in +// the meantime, so the supervision guard claims this settle instead. +writeFileSync(verdict, "2\n"); +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 2) throw new Error(`settle B: expected the supervision follow-up, got ${prompts.length}`); +if (!prompts[1].includes("TURN WOULD END BLIND")) throw new Error(`settle B: wrong prompt: ${prompts[1]}`); + +// Settle C: absorbed by the supervision guard's own latch. +rmSync(verdict); +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 2) throw new Error(`settle C (guard-latch-absorbed): expected still 2, got ${prompts.length}`); + +// Settle D: a genuinely unanswered turn again. A reply latch left over from +// settle A must not silently swallow it. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 3) throw new Error(`settle D: a stale reply latch swallowed a real episode, got ${prompts.length}`); +if (!prompts[2].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`settle D: wrong prompt: ${prompts[2]}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard reply latch must not survive a settle claimed by the supervision guard" + [ -z "$out" ] || fail "Pi reply-recovery latch-interleaving test printed output: $out" + pass ".pi primary extension: reply latch never swallows a later unanswered episode" +} + # --- --claude cooperative mode ----------------------------------------------- # In --claude mode the guard ignores stop_hook_active (Claude marks every stop # after ANY stop-hook continuation true, including asyncRewake rewake turns) and @@ -2105,6 +2341,10 @@ test_pi_reply_recovery_stays_silent_for_a_healthy_reply test_pi_reply_recovery_is_idempotent_and_bounded test_pi_reply_recovery_flags_an_unanswered_user_message test_pi_reply_recovery_ignores_the_session_start_digest +test_pi_reply_recovery_ignores_a_flushed_inline_bash_message +test_pi_reply_recovery_flags_a_tool_call_beside_a_text_preamble +test_pi_reply_recovery_never_restarts_an_aborted_turn +test_pi_reply_recovery_latch_never_swallows_a_later_episode test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive From efe556e1b63eb0e704252db4344fc1f4feb7bf69 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:56:13 +0200 Subject: [PATCH 05/15] no-mistakes(review): fix(pi): skip settles emitted while a run is in flight --- .gitignore | 1 + .pi/extensions/fm-primary-turnend-guard.ts | 18 +++ .squish/squish.db | Bin 724992 -> 0 bytes .../pi-agent-session-toctou-repro.mjs | 35 ++++- .../pi-watch-extension-reply-recovery.md | 24 ++-- docs/watcher-continuity.md | 3 + tests/fm-turnend-guard.test.sh | 124 ++++++++++++++++++ 7 files changed, 194 insertions(+), 11 deletions(-) delete mode 100644 .squish/squish.db diff --git a/.gitignore b/.gitignore index dd0a8f1df19..d8c31963f92 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ data/ scratchpad* .no-mistakes/ .lavish/ +.squish/ .fm-secondmate-home .fm-secondmate-parent .DS_Store diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index d2a9e21c843..dace1c38154 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -35,6 +35,19 @@ let orphanedReplyExhaustedNotified = false; // mistaken for an unanswered turn. const CONVERSATIONAL_MESSAGE_ROLES = new Set(["user", "assistant", "toolResult"]); +// Logical agent runs currently in flight. Pi emits `before_agent_start` only +// on prompt()'s not-streaming branch, never when a message is queued into an +// already-active run through steer or followUp, so this counts independent +// runs and not ordinary mid-turn continuations. It matters because the losing +// side of the reproduced race still reaches _runAgentPrompt, has its inner +// agent.prompt() reject at once with "Agent is already processing a prompt.", +// and its finally block emits `agent_settled` while the winning turn is still +// streaming. That settle is not terminal, and `_isAgentRunActive`/`isIdle()` +// cannot be used to recognise it because the race corrupts that same flag. +// A settle that still leaves a run in flight is therefore left unevaluated; +// the eventual settle that drains the counter is judged normally. +let inFlightAgentRuns = 0; + type LockOwnership = "owned" | "missing" | "other"; const extensionFile = fileURLToPath(import.meta.url); @@ -599,6 +612,7 @@ export default function (pi: ExtensionAPI) { ? startupRebuildSource(ctx) ?? "startup" : { new: "clear", resume: "resume", fork: "fork" }[reason]; markLoaded(); + inFlightAgentRuns = 0; if (!source) return; registerSessionstartExitListener(); sessionstartGeneration = createSessionstartGeneration( @@ -608,6 +622,7 @@ export default function (pi: ExtensionAPI) { }); pi.on?.("before_agent_start", async (_event, ctx) => { + inFlightAgentRuns += 1; const generation = sessionstartGeneration; if (!generation) return; const message = await claimSessionstartMessage(generation, ctx); @@ -654,6 +669,9 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_settled", async (_event, ctx) => { + inFlightAgentRuns = inFlightAgentRuns > 0 ? inFlightAgentRuns - 1 : 0; + if (inFlightAgentRuns > 0) return; + if (guardFollowupActive) { guardFollowupActive = false; return; diff --git a/.squish/squish.db b/.squish/squish.db deleted file mode 100644 index a9335f58ca7a05c8863e431f0e0b1c3fd010e3c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 724992 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 1; log(`_runAgentPrompt ENTER (concurrent=${concurrentRunAgentPromptCalls}) messages=${JSON.stringify(messages).slice(0, 60)}`); try { + if (isLoser) { + // pi-agent-core Agent.prototype.prompt (dist/agent.js) rejects at once + // when activeRun is set: "Agent is already processing a prompt." + throw new Error("Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion."); + } // Simulate real inference/tool-call latency. await new Promise((r) => setTimeout(r, 40)); } finally { concurrentRunAgentPromptCalls--; + // agent-session.js _runAgentPrompt's finally block runs _emitAgentSettled() + // unconditionally, which clears _isAgentRunActive and emits agent_settled + // to every extension - even for a run that never produced anything. this._isAgentRunActive = false; - log(`_runAgentPrompt EXIT`); + log(`_runAgentPrompt EXIT -> _emitAgentSettled()`); + if (concurrentRunAgentPromptCalls > 0) settlesWhileAnotherRunWasLive++; + extensionEvents.push(`agent_settled(runsStillLive=${concurrentRunAgentPromptCalls})`); } } @@ -67,6 +83,7 @@ function makeStubSession() { // watcher wake (fm-primary-pi-watch.ts sendWake) races against. emitBeforeAgentStart: async () => { log("emitBeforeAgentStart (simulating a real extension awaiting a child process)"); + extensionEvents.push("before_agent_start"); await new Promise((r) => setTimeout(r, 20)); return undefined; }, @@ -94,12 +111,26 @@ const captainCall = promptFn.call(session, "real captain message", { source: "in log("watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START"); const wakeCall = promptFn.call(session, "FIRSTMATE WATCHER WAKE: stale", { streamingBehavior: "followUp", source: "extension" }); -await Promise.all([captainCall, wakeCall]); +// allSettled, not all: the losing call rejects by design, exactly as the real +// nested agent.prompt() does when it finds an active run. +await Promise.allSettled([captainCall, wakeCall]); console.log(events.join("\n")); console.log(`\nmaxConcurrentRunAgentPromptCalls = ${maxConcurrentRunAgentPromptCalls}`); +console.log(`extension event order: ${extensionEvents.join(" -> ")}`); +console.log(`settlesWhileAnotherRunWasLive = ${settlesWhileAnotherRunWasLive}`); if (maxConcurrentRunAgentPromptCalls > 1) { console.log("REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently."); + if (settlesWhileAnotherRunWasLive > 0) { + // This is what the extension has to survive: it sees two + // before_agent_start events and then a settle that is NOT terminal. It + // counts logical runs in flight and leaves such a settle unevaluated, so + // only the trailing settle - the one that drains the counter - is judged. + // tests/fm-turnend-guard.test.sh's test_pi_reply_recovery_skips_a_spurious_mid_turn_settle + // and test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer + // replay exactly this event order against the real handler. + console.log("REPRODUCED: a spurious agent_settled fired while another logical run was still live."); + } process.exit(1); } else { console.log("NOT REPRODUCED this run (race is timing-dependent)."); diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index c59fc5a4c86..fae44f86cda 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -21,22 +21,28 @@ SDK_PATH=/home/vsole/.local/lib/node_modules/@earendil-works/pi-coding-agent/dis Observed output: ``` -1274.78ms captain call: prompt('real captain message') START -1275.18ms emitBeforeAgentStart (simulating a real extension awaiting a child process) -1275.38ms watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START -1275.41ms emitBeforeAgentStart (simulating a real extension awaiting a child process) -1295.77ms _runAgentPrompt ENTER (concurrent=1) messages=[{"role":"user","content":[{"type":"text","text":"real capta -1295.89ms _runAgentPrompt ENTER (concurrent=2) messages=[{"role":"user","content":[{"type":"text","text":"FIRSTMATE -1336.12ms _runAgentPrompt EXIT -1336.16ms _runAgentPrompt EXIT +1269.06ms captain call: prompt('real captain message') START +1269.72ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +1269.99ms watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START +1270.02ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +1290.46ms _runAgentPrompt ENTER (concurrent=1) messages=[{"role":"user","content":[{"type":"text","text":"real capta +1290.55ms _runAgentPrompt ENTER (concurrent=2) messages=[{"role":"user","content":[{"type":"text","text":"FIRSTMATE +1290.59ms _runAgentPrompt EXIT -> _emitAgentSettled() +1330.99ms _runAgentPrompt EXIT -> _emitAgentSettled() maxConcurrentRunAgentPromptCalls = 2 +extension event order: before_agent_start -> before_agent_start -> agent_settled(runsStillLive=1) -> agent_settled(runsStillLive=0) +settlesWhileAnotherRunWasLive = 1 REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently. +REPRODUCED: a spurious agent_settled fired while another logical run was still live. exit=1 ``` `exit=1` is the script's own "reproduced" signal (documented at the top of the script), not a test failure. Two `prompt()` calls - one modeling a captain's interactive message, one modeling `fm-primary-pi-watch.ts`'s `sendWake` delivering a watcher wake via `pi.sendUserMessage(..., {deliverAs: "followUp"})` - both observed `isStreaming` as false and both reached `_runAgentPrompt` concurrently, each setting `_isAgentRunActive = true` and invoking the underlying agent run against the same session state. +The losing call does not merely run concurrently: its inner `agent.prompt()` rejects at once with pi-agent-core's "Agent is already processing a prompt.", yet `_runAgentPrompt`'s `finally` block still clears `_isAgentRunActive` and emits `agent_settled` while the winning turn is mid-flight. +The recorded extension event order is therefore `before_agent_start -> before_agent_start -> agent_settled(runsStillLive=1) -> agent_settled(runsStillLive=0)`: a settle is not proof the session is idle, and `_isAgentRunActive`/`isIdle()` cannot disambiguate because the race corrupts that same flag. +`.pi/extensions/fm-primary-turnend-guard.ts` therefore counts logical runs in flight from `before_agent_start` and evaluates only the settle that drains that count. The `emitBeforeAgentStart` delay in the stub (20ms) models a real, not contrived, async gap: `.pi/extensions/fm-primary-turnend-guard.ts`'s own `before_agent_start` handler spawns and awaits `bin/fm-sessionstart-run.sh` on session-start-classified generations, and any `before_agent_start` extension handler doing real async work opens the same window. ## Considered and rejected: prevention at the extension layer @@ -49,5 +55,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s reply-recovery tests (`test_pi_reply_recovery_*`) exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler directly against a mocked `pi`/`ctx`, covering the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh`'s reply-recovery tests (`test_pi_reply_recovery_*`) exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler directly against a mocked `pi`/`ctx`, covering the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 7f31e0f24af..ab8ed1bcbdc 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -53,6 +53,9 @@ That is a genuine Pi SDK gap: no extension hook fires early enough, or atomicall The chosen mitigation detects the race's visible symptom instead of trying to prevent it: a turn that settles without ever producing a synthesized reply to its last conversational message, typically a dangling tool call (the `bin/fm-wake-drain.sh` run a wake instructs) or a message with no assistant response at all. `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler owns this contract, alongside its existing supervision-guard `followUp`. +Not every `agent_settled` is terminal, though: the losing side of that same race still reaches `_runAgentPrompt`, has its inner `agent.prompt()` reject at once with "Agent is already processing a prompt.", and its `finally` block emits a settle while the winning turn is still streaming. +`_isAgentRunActive` and `isIdle()` cannot recognise that settle, because the race corrupts exactly that flag. +The handler therefore counts logical runs in flight - incremented on `before_agent_start`, which Pi emits only for a genuinely new run and never for a message queued into an active one - and returns without evaluating anything while that count is still above zero, so both the supervision check and reply recovery are judged only on the settle that drains it. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). The turn is healthy only when that entry is an assistant message that both carries genuine text and leaves no tool call unresolved, so a short preamble alongside the tool call that never came back still counts as unanswered; anything else - a dangling tool call, a bare user or watcher-wake message with no reply, an errored assistant message with no visible text - triggers exactly one recovery `followUp` instructing the model to check the transcript, finish any unresolved tool call, and answer the pending message without repeating an answer already given. A turn the captain stopped by hand is the one exception: an assistant message with stopReason `aborted` is a deliberate human decision, counts as healthy, and is never auto-restarted. An `error` stop still earns its nudge, because nothing confirms the captain saw it and no human chose to stop there. diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index d3f8e603475..b2c3d1855df 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1101,6 +1101,7 @@ install_pi_reply_recovery_fixture() { # cat > "$repo/bin/fm-turnend-guard.sh" <<'SH' #!/usr/bin/env bash cat >/dev/null +[ -z "${FM_GUARD_LOG:-}" ] || printf 'run\n' >> "$FM_GUARD_LOG" # Healthy by default; a test that needs the supervision branch writes the # desired exit code into state/.test-guard-verdict beforehand. verdict="${FM_HOME:-}/state/.test-guard-verdict" @@ -1624,6 +1625,127 @@ EOF pass ".pi primary extension: reply latch never swallows a later unanswered episode" } +test_pi_reply_recovery_skips_a_spurious_mid_turn_settle() { + local repo home log ext out status + repo="$TMP_ROOT/pi-reply-spurious-root" + home="$TMP_ROOT/pi-reply-spurious-home" + log="$TMP_ROOT/pi-reply-spurious-guard.log" + mkdir -p "$home/state" + : > "$log" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" FM_GUARD_LOG="$log" node --input-type=module 2>&1 <<'EOF' +import { readFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); +if (!started) throw new Error("before_agent_start handler was not registered"); + +// The reproduced race: the captain prompt and the watcher wake both observe an +// idle session and both open a logical run. The loser's inner agent.prompt() +// rejects at once, but its finally block still emits agent_settled while the +// winner is mid-turn - so the transcript tail is a not-yet-resolved tool call +// that is going to be answered by the still-running winner. +const midFlightCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, + ], + }, +}; + +await started({ type: "before_agent_start" }, {}); +await started({ type: "before_agent_start" }, {}); + +// The loser's spurious settle must be left entirely unevaluated: no recovery +// follow-up, and not even a supervision-guard run. +await settled({ type: "agent_settled" }, midFlightCtx); +if (prompts.length !== 0) throw new Error(`spurious mid-turn settle produced ${prompts.length} follow-ups`); +if (readFileSync(process.env.FM_GUARD_LOG, "utf8").trim() !== "") { + throw new Error("spurious mid-turn settle still ran the supervision guard"); +} + +// The winner's own settle is terminal and is judged normally. +await settled({ type: "agent_settled" }, midFlightCtx); +if (prompts.length !== 1) throw new Error(`genuine settle produced ${prompts.length} follow-ups, expected exactly 1`); +if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must ignore a settle emitted while another logical run is still in flight" + [ -z "$out" ] || fail "Pi reply-recovery spurious-settle test printed output: $out" + pass ".pi primary extension: a spurious mid-turn settle is skipped and only the terminal settle is judged" +} + +test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-spurious-healthy-root" + home="$TMP_ROOT/pi-reply-spurious-healthy-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +let prompts = 0; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage() { + prompts += 1; + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// Same interleaving, but the winner goes on to answer properly. Nudging on the +// loser's spurious settle would start an extra turn on top of an answer that +// was already on its way - the duplicate answer the contract forbids. +const midFlight = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, +]; +let entries = midFlight; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await started({ type: "before_agent_start" }, {}); +await started({ type: "before_agent_start" }, {}); +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 0) throw new Error(`spurious mid-turn settle produced ${prompts} follow-ups`); + +entries = [ + ...midFlight, + { type: "message", id: "r1", parentId: "a1", timestamp: "t", message: { role: "toolResult", toolCallId: "tc1", toolName: "bash", content: "drained", isError: false, timestamp: 0 } }, + { type: "message", id: "a2", parentId: "r1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts !== 0) throw new Error(`the winner answered, yet ${prompts} follow-ups were sent`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not nudge when the still-running winner goes on to answer" + [ -z "$out" ] || fail "Pi reply-recovery spurious-healthy test printed output: $out" + pass ".pi primary extension: a spurious mid-turn settle never doubles an answer the winner still delivers" +} + # --- --claude cooperative mode ----------------------------------------------- # In --claude mode the guard ignores stop_hook_active (Claude marks every stop # after ANY stop-hook continuation true, including asyncRewake rewake turns) and @@ -2345,6 +2467,8 @@ test_pi_reply_recovery_ignores_a_flushed_inline_bash_message test_pi_reply_recovery_flags_a_tool_call_beside_a_text_preamble test_pi_reply_recovery_never_restarts_an_aborted_turn test_pi_reply_recovery_latch_never_swallows_a_later_episode +test_pi_reply_recovery_skips_a_spurious_mid_turn_settle +test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive From 9912babf60c1b2a9f9ca94b4533163fdb0266b86 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:48:51 +0200 Subject: [PATCH 06/15] no-mistakes(review): feat(pi): recover captain input lost to the turn-start race --- .pi/extensions/fm-primary-turnend-guard.ts | 155 +++++++++++- .../pi-agent-session-toctou-repro.mjs | 65 ++++- .../pi-watch-extension-reply-recovery.md | 28 +- docs/watcher-continuity.md | 18 +- tests/fm-turnend-guard.test.sh | 239 ++++++++++++++++++ 5 files changed, 474 insertions(+), 31 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index dace1c38154..64c6c604b12 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -48,6 +48,35 @@ const CONVERSATIONAL_MESSAGE_ROLES = new Set(["user", "assistant", "toolResult"] // the eventual settle that drains the counter is judged normally. let inFlightAgentRuns = 0; +// Monotonic count of logical runs ever started in this generation. A pending +// captain input can only be judged once a run has started after it was +// recorded, which keeps the window between prompt()'s `input` event and its +// own `before_agent_start` from looking like a loss. +let agentRunStarts = 0; + +// Captain-owned input recorded from prompt()'s `input` event, which fires +// before the isStreaming check and therefore also for the prompt() call that +// goes on to lose the race. That loser never appends anything: pi-agent-core's +// Agent.prototype.prompt throws "Agent is already processing a prompt." ahead +// of normalizePromptInput, so the captain's message is gone from the +// transcript entirely and no tail inspection can see it. +// Tracking is deliberately narrow. Only genuine captain sources are recorded +// (never "extension", which is how watcher wakes and this file's own +// follow-ups submit), and text that Pi expands after the event - a leading "/" +// for a skill command, prompt template or extension command - is skipped, +// because the appended message would then no longer contain the recorded text +// and an ordinary command would look lost. Inline "!" bash is skipped for the +// same reason. Plain captain prose, which is what the reported episodes lost, +// is appended verbatim and matches exactly. +type PendingCaptainInput = { + text: string; + afterEntryId: string | null; + startsAtRecord: number; +}; +const CAPTAIN_INPUT_SOURCES = new Set(["interactive", "rpc"]); +const PENDING_CAPTAIN_INPUT_LIMIT = 20; +let pendingCaptainInputs: PendingCaptainInput[] = []; + type LockOwnership = "owned" | "missing" | "other"; const extensionFile = fileURLToPath(import.meta.url); @@ -479,13 +508,20 @@ function isSessionMessageEntry(entry: SessionEntry): entry is SessionMessageEntr return entry.type === "message"; } -function messageHasVisibleText(message: unknown): boolean { +function messagePlainText(message: unknown): string { const content = (message as { content?: unknown } | undefined)?.content; - if (!Array.isArray(content)) return false; - return content.some((part) => { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + const parts: string[] = []; + for (const part of content) { const candidate = part as { type?: unknown; text?: unknown }; - return candidate?.type === "text" && typeof candidate.text === "string" && candidate.text.trim().length > 0; - }); + if (candidate?.type === "text" && typeof candidate.text === "string") parts.push(candidate.text); + } + return parts.join("\n"); +} + +function messageHasVisibleText(message: unknown): boolean { + return messagePlainText(message).trim().length > 0; } function messageHasUnresolvedToolCall(message: unknown): boolean { @@ -494,6 +530,74 @@ function messageHasUnresolvedToolCall(message: unknown): boolean { return content.some((part) => (part as { type?: unknown } | undefined)?.type === "toolCall"); } +function captainInputWorthTracking(source: string, text: string): boolean { + if (!CAPTAIN_INPUT_SOURCES.has(source)) return false; + const trimmed = text.trim(); + if (!trimmed) return false; + return !trimmed.startsWith("/") && !trimmed.startsWith("!"); +} + +function sessionEntries(ctx: ExtensionContext): SessionEntry[] | undefined { + try { + return ctx.sessionManager.getEntries(); + } catch { + return undefined; + } +} + +function lastEntryId(ctx: ExtensionContext): string | null { + const entries = sessionEntries(ctx); + if (!entries || entries.length === 0) return null; + return entries[entries.length - 1]?.id ?? null; +} + +// An unknown anchor (a compaction rewrote it away) scans from the start, so a +// message that is present is still found and never reported lost. +function captainInputReachedTranscript( + entries: SessionEntry[], + pending: PendingCaptainInput, + claimed: Set, +): string | undefined { + const anchorIndex = pending.afterEntryId === null + ? -1 + : entries.findIndex((entry) => entry.id === pending.afterEntryId); + for (let i = anchorIndex + 1; i < entries.length; i += 1) { + const entry = entries[i]; + if (!isSessionMessageEntry(entry) || claimed.has(entry.id)) continue; + const role = (entry.message as { role?: unknown } | undefined)?.role; + if (role !== "user") continue; + if (!messagePlainText(entry.message).includes(pending.text)) continue; + return entry.id; + } + return undefined; +} + +// Resolves every pending captain input that did reach the transcript and +// returns the first one that did not, already removed from the pending list so +// a later settle can never resubmit it twice. +function takeLostCaptainInput(ctx: ExtensionContext): PendingCaptainInput | undefined { + if (pendingCaptainInputs.length === 0) return undefined; + const entries = sessionEntries(ctx); + if (!entries) return undefined; + const claimed = new Set(); + const stillPending: PendingCaptainInput[] = []; + let lost: PendingCaptainInput | undefined; + for (const pending of pendingCaptainInputs) { + if (lost || agentRunStarts <= pending.startsAtRecord) { + stillPending.push(pending); + continue; + } + const entryId = captainInputReachedTranscript(entries, pending, claimed); + if (entryId) { + claimed.add(entryId); + continue; + } + lost = pending; + } + pendingCaptainInputs = stillPending; + return lost; +} + // Detects the reproduced race's visible signature: the settled turn's last // conversational message-type entry is not an assistant reply that both // carries genuine text and leaves no tool call unresolved. That covers a @@ -613,6 +717,10 @@ export default function (pi: ExtensionAPI) { : { new: "clear", resume: "resume", fork: "fork" }[reason]; markLoaded(); inFlightAgentRuns = 0; + agentRunStarts = 0; + pendingCaptainInputs = []; + orphanedReplyAttempts = 0; + orphanedReplyExhaustedNotified = false; if (!source) return; registerSessionstartExitListener(); sessionstartGeneration = createSessionstartGeneration( @@ -623,6 +731,7 @@ export default function (pi: ExtensionAPI) { pi.on?.("before_agent_start", async (_event, ctx) => { inFlightAgentRuns += 1; + agentRunStarts += 1; const generation = sessionstartGeneration; if (!generation) return; const message = await claimSessionstartMessage(generation, ctx); @@ -668,6 +777,20 @@ export default function (pi: ExtensionAPI) { return { block: true, reason: result.stderr.trim() || "denied by the watcher-arm PreToolUse seatbelt" }; }); + pi.on?.("input", (event, ctx) => { + const source = String((event as { source?: unknown }).source ?? ""); + const text = String((event as { text?: unknown }).text ?? ""); + if (!captainInputWorthTracking(source, text)) return; + pendingCaptainInputs.push({ + text, + afterEntryId: lastEntryId(ctx), + startsAtRecord: agentRunStarts, + }); + if (pendingCaptainInputs.length > PENDING_CAPTAIN_INPUT_LIMIT) { + pendingCaptainInputs = pendingCaptainInputs.slice(-PENDING_CAPTAIN_INPUT_LIMIT); + } + }); + pi.on("agent_settled", async (_event, ctx) => { inFlightAgentRuns = inFlightAgentRuns > 0 ? inFlightAgentRuns - 1 : 0; if (inFlightAgentRuns > 0) return; @@ -701,10 +824,30 @@ export default function (pi: ExtensionAPI) { return; } + // runGuard() spawns and awaits a child process, and Pi clears its own + // run flag before emitting this settle, so a fresh prompt can open a new + // logical run during that await. Judging the transcript afterwards would + // read that new run's mid-flight tail. + if (inFlightAgentRuns > 0) return; + // Only one follow-up ever fires per settle: the supervision guard above - // takes priority, and reply recovery below runs only once it is clean. + // takes priority, and the checks below run only once it is clean. if (replyFollowupSettle) return; + const lostCaptainInput = takeLostCaptainInput(ctx); + if (lostCaptainInput) { + const content = encodeFirstmateOperationalInput( + "turn-end-guard", + "CAPTAIN INPUT WAS LOST - the message quoted below was submitted by the captain and acknowledged in the interface, but it never " + + "reached the conversation at all, so no answer to it exists yet. This happens when a watcher wake and a captain message start a " + + "turn at the same moment and Pi drops one of them before it is recorded. Treat the quoted text as the captain's own message, " + + "arriving now, and answer it directly. Do not repeat any answer you already gave earlier in this conversation.\n\n" + + lostCaptainInput.text, + ); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); + return; + } + if (!lastConversationalTurnUnanswered(ctx)) { orphanedReplyAttempts = 0; orphanedReplyExhaustedNotified = false; diff --git a/docs/verification/pi-agent-session-toctou-repro.mjs b/docs/verification/pi-agent-session-toctou-repro.mjs index a37b1c25ce4..80e16c72166 100644 --- a/docs/verification/pi-agent-session-toctou-repro.mjs +++ b/docs/verification/pi-agent-session-toctou-repro.mjs @@ -22,6 +22,11 @@ const events = []; // winner is still running, so a settle is NOT proof that the session is idle. const extensionEvents = []; let settlesWhileAnotherRunWasLive = 0; +// Everything the losing prompt() call manages to append. pi-agent-core's +// Agent.prototype.prompt throws before normalizePromptInput/runPromptMessages, +// so a losing captain message never becomes a transcript entry at all. +const transcript = []; +const capturedInputs = []; function log(label) { events.push(`${(performance.now()).toFixed(2)}ms ${label}`); @@ -39,11 +44,15 @@ async function fakeRunAgentPrompt(messages) { try { if (isLoser) { // pi-agent-core Agent.prototype.prompt (dist/agent.js) rejects at once - // when activeRun is set: "Agent is already processing a prompt." + // when activeRun is set: "Agent is already processing a prompt." Nothing + // is appended, which is why the caller's message simply disappears. throw new Error("Agent is already processing a prompt. Use steer() or followUp() to queue messages, or wait for completion."); } + // Only the winner ever reaches the append path (message_end persistence). + for (const message of messages) transcript.push(message); // Simulate real inference/tool-call latency. await new Promise((r) => setTimeout(r, 40)); + transcript.push({ role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop" }); } finally { concurrentRunAgentPromptCalls--; // agent-session.js _runAgentPrompt's finally block runs _emitAgentSettled() @@ -74,8 +83,14 @@ function makeStubSession() { isUsingOAuth: () => false, }, _extensionRunner: { - hasHandlers: () => false, - emitInput: async () => ({ action: "pass" }), + // The turn-end guard registers an `input` handler, so this reports true + // and prompt() really does emit the event - before its isStreaming + // check, and therefore for the losing call too. + hasHandlers: (event) => event === "input", + emitInput: async (text, images, source) => { + capturedInputs.push({ text, source }); + return { action: "pass" }; + }, // A REAL before_agent_start handler in this repo // (.pi/extensions/fm-primary-turnend-guard.ts) awaits a spawned child // process here. This delay stands in for that genuine async gap - it is @@ -100,17 +115,21 @@ function makeStubSession() { const session = makeStubSession(); -log("captain call: prompt('real captain message') START"); -const captainCall = promptFn.call(session, "real captain message", { source: "interactive" }); - -// Simulate fm-primary-pi-watch.ts's sendWake(): an unrelated background -// watcher-close callback calls pi.sendUserMessage(..., {deliverAs:"followUp"}) -// with zero coordination with the interactive call above. sendUserMessage -// forwards to prompt() with streamingBehavior: "followUp" (agent-session.js -// sendUserMessage(), lines 1110-1138). +// The watcher wake is started first here so the CAPTAIN's call is the one that +// loses - the sub-case no tail inspection can detect, because the wake turn +// answers normally and leaves a perfectly healthy transcript behind. log("watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START"); const wakeCall = promptFn.call(session, "FIRSTMATE WATCHER WAKE: stale", { streamingBehavior: "followUp", source: "extension" }); +// The wake above models fm-primary-pi-watch.ts's sendWake(): an unrelated +// background watcher-close callback calling pi.sendUserMessage(..., +// {deliverAs:"followUp"}) with zero coordination with the interactive call +// below. sendUserMessage forwards to prompt() with streamingBehavior: +// "followUp" and source: "extension" (agent-session.js sendUserMessage()). +const CAPTAIN_TEXT = "bitte den Stand zusammenfassen"; +log(`captain call: prompt('${CAPTAIN_TEXT}') START`); +const captainCall = promptFn.call(session, CAPTAIN_TEXT, { source: "interactive" }); + // allSettled, not all: the losing call rejects by design, exactly as the real // nested agent.prompt() does when it finds an active run. await Promise.allSettled([captainCall, wakeCall]); @@ -119,6 +138,16 @@ console.log(events.join("\n")); console.log(`\nmaxConcurrentRunAgentPromptCalls = ${maxConcurrentRunAgentPromptCalls}`); console.log(`extension event order: ${extensionEvents.join(" -> ")}`); console.log(`settlesWhileAnotherRunWasLive = ${settlesWhileAnotherRunWasLive}`); + +const capturedCaptainInput = capturedInputs.some((i) => i.source === "interactive" && i.text === CAPTAIN_TEXT); +const captainTextInTranscript = transcript.some( + (m) => m.role === "user" && JSON.stringify(m.content ?? "").includes(CAPTAIN_TEXT), +); +const transcriptTail = transcript[transcript.length - 1]; +const tailLooksHealthy = transcriptTail?.role === "assistant" && transcriptTail?.stopReason === "stop"; +console.log(`\ncaptain input seen by the "input" event: ${capturedCaptainInput}`); +console.log(`captain text present in the transcript: ${captainTextInTranscript}`); +console.log(`transcript tail looks healthy: ${tailLooksHealthy}`); if (maxConcurrentRunAgentPromptCalls > 1) { console.log("REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently."); if (settlesWhileAnotherRunWasLive > 0) { @@ -131,6 +160,20 @@ if (maxConcurrentRunAgentPromptCalls > 1) { // replay exactly this event order against the real handler. console.log("REPRODUCED: a spurious agent_settled fired while another logical run was still live."); } + if (capturedCaptainInput && !captainTextInTranscript && tailLooksHealthy) { + // The BEFORE state for captain-input-loss recovery: the captain's message + // is gone from the transcript while the tail is a healthy assistant reply, + // so no tail inspection can detect it - but prompt()'s `input` event did + // see the message before the isStreaming check, which is what + // .pi/extensions/fm-primary-turnend-guard.ts records and resubmits. + // tests/fm-turnend-guard.test.sh's + // test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race + // drives the real handler through exactly this state (AFTER: exactly one + // resubmission carrying the lost text), and + // test_pi_input_recovery_stays_silent_for_a_delivered_captain_message is + // the negative control. + console.log("REPRODUCED: the captain's message was lost entirely while the transcript tail stayed healthy."); + } process.exit(1); } else { console.log("NOT REPRODUCED this run (race is timing-dependent)."); diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index fae44f86cda..02799d9c1c2 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -2,7 +2,7 @@ Audience: maintainer verification. -This record supports the turn-settle reply recovery guarantee in [`watcher-continuity.md`](../watcher-continuity.md#turn-settle-reply-recovery). +This record supports the turn-settle input and reply recovery guarantee in [`watcher-continuity.md`](../watcher-continuity.md#turn-settle-input-and-reply-recovery). Mechanism, contract, and active limits remain in that linked guide. Task-specific chronology and the captain decision that chose detection-and-recovery over a submission-serializing mutex remain in private task evidence. @@ -21,20 +21,25 @@ SDK_PATH=/home/vsole/.local/lib/node_modules/@earendil-works/pi-coding-agent/dis Observed output: ``` -1269.06ms captain call: prompt('real captain message') START -1269.72ms emitBeforeAgentStart (simulating a real extension awaiting a child process) -1269.99ms watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START -1270.02ms emitBeforeAgentStart (simulating a real extension awaiting a child process) -1290.46ms _runAgentPrompt ENTER (concurrent=1) messages=[{"role":"user","content":[{"type":"text","text":"real capta -1290.55ms _runAgentPrompt ENTER (concurrent=2) messages=[{"role":"user","content":[{"type":"text","text":"FIRSTMATE -1290.59ms _runAgentPrompt EXIT -> _emitAgentSettled() -1330.99ms _runAgentPrompt EXIT -> _emitAgentSettled() +2134.05ms watcher wake: prompt('FIRSTMATE WATCHER WAKE...') START +2134.44ms captain call: prompt('bitte den Stand zusammenfassen') START +2134.71ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +2134.90ms emitBeforeAgentStart (simulating a real extension awaiting a child process) +2155.86ms _runAgentPrompt ENTER (concurrent=1) messages=[{"role":"user","content":[{"type":"text","text":"FIRSTMATE +2156.02ms _runAgentPrompt ENTER (concurrent=2) messages=[{"role":"user","content":[{"type":"text","text":"bitte den +2156.07ms _runAgentPrompt EXIT -> _emitAgentSettled() +2195.55ms _runAgentPrompt EXIT -> _emitAgentSettled() maxConcurrentRunAgentPromptCalls = 2 extension event order: before_agent_start -> before_agent_start -> agent_settled(runsStillLive=1) -> agent_settled(runsStillLive=0) settlesWhileAnotherRunWasLive = 1 + +captain input seen by the "input" event: true +captain text present in the transcript: false +transcript tail looks healthy: true REPRODUCED: two concurrent prompt() calls both reached _runAgentPrompt() concurrently. REPRODUCED: a spurious agent_settled fired while another logical run was still live. +REPRODUCED: the captain's message was lost entirely while the transcript tail stayed healthy. exit=1 ``` @@ -43,6 +48,9 @@ Two `prompt()` calls - one modeling a captain's interactive message, one modelin The losing call does not merely run concurrently: its inner `agent.prompt()` rejects at once with pi-agent-core's "Agent is already processing a prompt.", yet `_runAgentPrompt`'s `finally` block still clears `_isAgentRunActive` and emits `agent_settled` while the winning turn is mid-flight. The recorded extension event order is therefore `before_agent_start -> before_agent_start -> agent_settled(runsStillLive=1) -> agent_settled(runsStillLive=0)`: a settle is not proof the session is idle, and `_isAgentRunActive`/`isIdle()` cannot disambiguate because the race corrupts that same flag. `.pi/extensions/fm-primary-turnend-guard.ts` therefore counts logical runs in flight from `before_agent_start` and evaluates only the settle that drains that count. +The same run drives the captain's own call into the losing position, which is the second, invisible outcome: `capturedCaptainInput` is true because `prompt()` emits its `input` event before the `isStreaming` check, yet `captainTextInTranscript` is false and the transcript tail is a healthy `stop` assistant reply. +That is the documented BEFORE state for captain-input-loss recovery - the captain's message is simply gone, and no tail inspection can detect it. +The AFTER state is asserted in `tests/fm-turnend-guard.test.sh`, which drives the real `agent_settled` handler through exactly this event order and transcript: exactly one resubmission fires, it carries the lost text, it is never repeated on later settles, and an ordinary delivered captain message produces none. The `emitBeforeAgentStart` delay in the stub (20ms) models a real, not contrived, async gap: `.pi/extensions/fm-primary-turnend-guard.ts`'s own `before_agent_start` handler spawns and awaits `bin/fm-sessionstart-run.sh` on session-start-classified generations, and any `before_agent_start` extension handler doing real async work opens the same window. ## Considered and rejected: prevention at the extension layer @@ -55,5 +63,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s reply-recovery tests (`test_pi_reply_recovery_*`) exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler directly against a mocked `pi`/`ctx`, covering the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. +`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case with its delivered-message and extension-wake negative controls, a run that opens while the supervision guard's child process is still running, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index ab8ed1bcbdc..84a4e5fe166 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -45,26 +45,36 @@ No adapter starts a replacement with shell `&`. The turn-end guard remains the final backstop rather than the normal continuity mechanism and cooperates with the auto-arm in its `--claude` mode. -## Turn-settle reply recovery +## Turn-settle input and reply recovery Pi's own `AgentSession.prototype.prompt()` (`agent-session.js`) has no atomic check-and-set between reading its `isStreaming` flag and committing to a new run: it awaits several extension hooks (`input`, compaction and auth checks, `before_agent_start`) before setting `_isAgentRunActive = true` inside `_runAgentPrompt`. Two `prompt()` calls that both observe "idle" - a captain message submitted through the interactive loop and a watcher wake delivered through `fm-primary-pi-watch.ts`'s `sendWake` via `pi.sendUserMessage(..., {deliverAs: "followUp"})` - can both fall through to a concurrent run against the same session state. That is a genuine Pi SDK gap: no extension hook fires early enough, or atomically enough with the internal check, to reliably prevent it from firstmate's own tracked code without a full submission-serializing mutex, which was rejected as disproportionate new risk for a rare race (`docs/verification/pi-watch-extension-reply-recovery.md` records the reproduction and that decision). The chosen mitigation detects the race's visible symptom instead of trying to prevent it: a turn that settles without ever producing a synthesized reply to its last conversational message, typically a dangling tool call (the `bin/fm-wake-drain.sh` run a wake instructs) or a message with no assistant response at all. +The race has a second, invisible outcome as well: the losing `prompt()` call never appends anything, because pi-agent-core's `Agent.prototype.prompt` throws "Agent is already processing a prompt." ahead of `normalizePromptInput`. +When the loser is the captain's own message, the winning wake turn answers normally and leaves a perfectly healthy transcript behind, so no inspection of the transcript can see that the captain was dropped. +Both outcomes are therefore covered, and captain-input loss is checked first because it is the one the transcript cannot reveal. `.pi/extensions/fm-primary-turnend-guard.ts`'s `agent_settled` handler owns this contract, alongside its existing supervision-guard `followUp`. Not every `agent_settled` is terminal, though: the losing side of that same race still reaches `_runAgentPrompt`, has its inner `agent.prompt()` reject at once with "Agent is already processing a prompt.", and its `finally` block emits a settle while the winning turn is still streaming. `_isAgentRunActive` and `isIdle()` cannot recognise that settle, because the race corrupts exactly that flag. -The handler therefore counts logical runs in flight - incremented on `before_agent_start`, which Pi emits only for a genuinely new run and never for a message queued into an active one - and returns without evaluating anything while that count is still above zero, so both the supervision check and reply recovery are judged only on the settle that drains it. +The handler therefore counts logical runs in flight - incremented on `before_agent_start`, which Pi emits only for a genuinely new run and never for a message queued into an active one - and returns without evaluating anything while that count is still above zero, so the checks below are judged only on the settle that drains it. +The same count is re-checked after the supervision guard's child process returns, because Pi clears its run flag before emitting the settle and a fresh prompt can open a new run during that await; a run that appeared in the meantime suppresses this settle's judgement entirely rather than letting it read the new turn's mid-flight tail. +Captain-owned input is recorded from Pi's `input` event, which fires at the very start of `prompt()` - before the `isStreaming` check - and therefore also for the call that goes on to lose. +On a genuine settle the handler first resolves every recorded input that did reach the transcript; the first one that did not is resubmitted exactly once through a follow-up that quotes it and identifies it as a captain message that never arrived, and it is dropped from the pending list at that moment, so no repetition can produce a second copy. +Only one follow-up fires per settle, so that resubmission takes priority over reply recovery, whose own turn settles and is judged normally afterwards. +Tracking is deliberately narrow: only genuine captain sources are recorded (never `extension`, which is how watcher wakes and this guard's own follow-ups submit), and text starting with `/` or `!` is skipped because Pi expands it after the event, which would make the appended message no longer contain the recorded text and an ordinary command look lost. +A recorded input is judged only once a run has started after it was recorded, which keeps the window between the `input` event and that call's own `before_agent_start` from being mistaken for a loss. + On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). The turn is healthy only when that entry is an assistant message that both carries genuine text and leaves no tool call unresolved, so a short preamble alongside the tool call that never came back still counts as unanswered; anything else - a dangling tool call, a bare user or watcher-wake message with no reply, an errored assistant message with no visible text - triggers exactly one recovery `followUp` instructing the model to check the transcript, finish any unresolved tool call, and answer the pending message without repeating an answer already given. A turn the captain stopped by hand is the one exception: an assistant message with stopReason `aborted` is a deliberate human decision, counts as healthy, and is never auto-restarted. An `error` stop still earns its nudge, because nothing confirms the captain saw it and no human chose to stop there. A `orphanedReplyFollowupActive` latch, mirroring the existing `guardFollowupActive` pattern, absorbs the settle produced by that same recovery turn so repetition of an unchanged stuck state never creates a second turn; it is consumed on the very next settle whichever branch handles it, so a settle claimed by the supervision guard can never leave a stale latch behind to swallow a later, genuinely unanswered episode. -A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, currently 3) stops the retry loop after repeated distinct failures and commits one loud, once-only "recovery gave up" notice instead of retrying forever; a later healthy settle resets both the counter and the notice so a fresh, unrelated unanswered episode still gets its own attempts. +A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, currently 3) stops the retry loop after repeated distinct failures and commits one loud, once-only "recovery gave up" notice instead of retrying forever; a later healthy settle resets both the counter and the notice, and `session_start` resets them along with the in-flight count and the pending captain-input list, so neither a fresh, unrelated unanswered episode nor a new generation ever inherits an exhausted budget. Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh`'s reply-recovery tests cover the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case and its delivered-message and extension-wake negative controls, the run that opens during the supervision check, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index b2c3d1855df..033e8bfd8d4 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1102,6 +1102,8 @@ install_pi_reply_recovery_fixture() { # #!/usr/bin/env bash cat >/dev/null [ -z "${FM_GUARD_LOG:-}" ] || printf 'run\n' >> "$FM_GUARD_LOG" +delay="${FM_HOME:-}/state/.test-guard-delay" +[ -f "$delay" ] && sleep "$(cat "$delay")" # Healthy by default; a test that needs the supervision branch writes the # desired exit code into state/.test-guard-verdict beforehand. verdict="${FM_HOME:-}/state/.test-guard-verdict" @@ -1746,6 +1748,239 @@ EOF pass ".pi primary extension: a spurious mid-turn settle never doubles an answer the winner still delivers" } +test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-lost-root" + home="$TMP_ROOT/pi-input-lost-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message, options) { + prompts.push({ message, options }); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); +if (!input) throw new Error("input handler was not registered"); + +// The watcher wake wins the race and answers normally. The captain's own +// prompt() call loses: pi-agent-core rejects it before normalizePromptInput, +// so its message never becomes a transcript entry at all - the tail is +// perfectly healthy and no tail inspection could ever see the loss. +const entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "bitte den Stand zusammenfassen", source: "interactive" }, ctx); +await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) throw new Error(`spurious settle acted: ${prompts.length} prompts`); + +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`expected exactly one recovery resubmission, got ${prompts.length}`); +const [{ message, options }] = prompts; +if (!message.startsWith("\u2063FIRSTMATE_OP: v1 turn-end-guard: ")) throw new Error(`untyped operational prompt: ${message}`); +if (!message.includes("CAPTAIN INPUT WAS LOST")) throw new Error(`not an input-recovery prompt: ${message}`); +if (message.includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`input recovery and reply recovery both fired: ${message}`); +if (!message.includes("bitte den Stand zusammenfassen")) throw new Error(`the lost captain text was not carried: ${message}`); +if (options?.deliverAs !== "followUp") throw new Error("recovery prompt was not a follow-up"); + +// Idempotent: the same unchanged state must never resubmit it a second time. +await settled({ type: "agent_settled" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`lost input was resubmitted again, total ${prompts.length}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must resubmit a captain message the race dropped before it reached the transcript" + [ -z "$out" ] || fail "Pi input-recovery loss test printed output: $out" + pass ".pi primary extension: a captain message lost to the race is resubmitted exactly once" +} + +test_pi_input_recovery_stays_silent_for_a_delivered_captain_message() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-delivered-root" + home="$TMP_ROOT/pi-input-delivered-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// Negative control: the ordinary, unraced case. The captain's message reaches +// the transcript and is answered, so nothing may be resubmitted. +let entries = []; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "bitte den Stand zusammenfassen", source: "interactive" }, ctx); +await started({ type: "before_agent_start" }, ctx); +entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: [{ type: "text", text: "bitte den Stand zusammenfassen" }], timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Hier der Stand." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) throw new Error(`a delivered captain message triggered ${prompts.length} prompts`); + +// A watcher wake is extension-sourced and is never tracked as captain input, +// so it can never be resubmitted even when it is missing from the transcript. +await input({ type: "input", text: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", source: "extension" }, ctx); +await started({ type: "before_agent_start" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) throw new Error(`an extension-sourced input was treated as captain input, ${prompts.length} prompts`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not resubmit a captain message that reached the transcript" + [ -z "$out" ] || fail "Pi input-recovery negative-control test printed output: $out" + pass ".pi primary extension: a delivered captain message and an extension wake are never resubmitted" +} + +test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-late-run-root" + home="$TMP_ROOT/pi-reply-late-run-home" + mkdir -p "$home/state" + printf '0.5\n' > "$home/state/.test-guard-delay" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// Pi clears its own run flag before emitting the settle, so a fresh prompt can +// open a new logical run while the handler is still awaiting the supervision +// guard's child process - and the transcript is only read after that await. +// The timer below lands inside exactly that window. +const ctx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "weiter", timestamp: 0 } }, + ], + }, +}; + +await started({ type: "before_agent_start" }, ctx); +const settlePromise = settled({ type: "agent_settled" }, ctx); +// The fixture guard sleeps, so this timer lands while the handler is still +// awaiting that child process - the window the re-check has to cover. +setTimeout(() => void started({ type: "before_agent_start" }, ctx), 0); +await settlePromise; +if (prompts.length !== 0) throw new Error(`nudged a healthy in-flight turn, got ${prompts.length} prompts`); + +// The new run's own settle is terminal and is judged normally. +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`the new run's genuine settle produced ${prompts.length} prompts, expected 1`); +if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not judge a transcript a new run started appending to during the guard check" + [ -z "$out" ] || fail "Pi reply-recovery late-run test printed output: $out" + pass ".pi primary extension: a run opened during the supervision check suppresses that settle's judgement" +} + +test_pi_reply_recovery_budget_resets_for_a_new_session_generation() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-generation-root" + home="$TMP_ROOT/pi-reply-generation-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const sessionStart = handlers.get("session_start"); +const settled = handlers.get("agent_settled"); +if (!sessionStart) throw new Error("session_start handler was not registered"); + +const stuckCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, + ], + }, +}; + +// Burn the whole budget in generation A: three attempts, then the one loud +// "gave up" notice, then silence. +for (let i = 0; i < 9; i += 1) await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 4) throw new Error(`generation A produced ${prompts.length} prompts, expected 3 attempts + 1 notice`); +if (!prompts[3].includes("automatic recovery gave up")) throw new Error(`missing exhaustion notice: ${prompts[3]}`); +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 4) throw new Error(`generation A kept prompting, total ${prompts.length}`); + +// /new starts a fresh generation, which must not inherit the exhausted budget. +await sessionStart({ type: "session_start", reason: "new" }, stuckCtx); +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 5) throw new Error(`a new generation did not get a fresh attempt, total ${prompts.length}`); +if (!prompts[4].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[4]}`); +if (prompts[4].includes("automatic recovery gave up")) throw new Error("a new generation reused the exhaustion notice"); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must give each session generation its own recovery attempt budget" + [ -z "$out" ] || fail "Pi reply-recovery generation-reset test printed output: $out" + pass ".pi primary extension: a new session generation gets a fresh recovery attempt budget" +} + # --- --claude cooperative mode ----------------------------------------------- # In --claude mode the guard ignores stop_hook_active (Claude marks every stop # after ANY stop-hook continuation true, including asyncRewake rewake turns) and @@ -2469,6 +2704,10 @@ test_pi_reply_recovery_never_restarts_an_aborted_turn test_pi_reply_recovery_latch_never_swallows_a_later_episode test_pi_reply_recovery_skips_a_spurious_mid_turn_settle test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer +test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race +test_pi_input_recovery_stays_silent_for_a_delivered_captain_message +test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check +test_pi_reply_recovery_budget_resets_for_a_new_session_generation test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy test_hook_claude_mode_reblocks_x_mode_without_tasks test_hook_claude_mode_allows_when_autoarm_owner_alive From f88cb6f7beca7cac607e1a0d796e52540d354cdd Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:30:03 +0200 Subject: [PATCH 07/15] no-mistakes(review): fix(pi): never replay withdrawn or double-judged captain input --- .pi/extensions/fm-primary-turnend-guard.ts | 53 +++++-- .../pi-watch-extension-reply-recovery.md | 2 +- docs/watcher-continuity.md | 4 +- tests/fm-turnend-guard.test.sh | 134 +++++++++++++++++- 4 files changed, 181 insertions(+), 12 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 64c6c604b12..4f5efe73039 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -11,8 +11,8 @@ import { let guardFollowupActive = false; -// Turn-settle reply recovery (docs/watcher-continuity.md "Turn-settle reply -// recovery" is the authoritative contract). Pi's own AgentSession.prompt() +// Turn-settle input and reply recovery (docs/watcher-continuity.md +// "Turn-settle input and reply recovery" is the authoritative contract). Pi's own AgentSession.prompt() // has no atomic check-and-set between reading isStreaming and committing to // a new run (agent-session.js), so two prompt() calls that both observe // "idle" - a captain message and a watcher wake delivered through @@ -48,6 +48,15 @@ const CONVERSATIONAL_MESSAGE_ROLES = new Set(["user", "assistant", "toolResult"] // the eventual settle that drains the counter is judged normally. let inFlightAgentRuns = 0; +// Pi's extension runner awaits handlers only within a single emit, so two +// settles can be inside this handler at once across its awaited guard child +// process. One judgement runs at a time, which keeps the latch snapshot and +// the attempt budget from being read and written by two overlapping +// judgements of the same state. The claim is released the moment a follow-up +// is actually delivered, so the settle that follow-up produces is judged +// normally and absorbed by the latch it just set. +let settleEvaluationActive = false; + // Monotonic count of logical runs ever started in this generation. A pending // captain input can only be judged once a run has started after it was // recorded, which keeps the window between prompt()'s `input` event and its @@ -68,6 +77,12 @@ let agentRunStarts = 0; // and an ordinary command would look lost. Inline "!" bash is skipped for the // same reason. Plain captain prose, which is what the reported episodes lost, // is appended verbatim and matches exactly. +// A defined streamingBehavior is skipped too. Pi sets it only when the message +// is queued into an already-active run, which is its own correct path and not +// what the idle-vs-idle race loses - and a queued message is appended only +// when the run consumes it, so Escape (which clears the queue back into the +// editor) legitimately leaves it absent. Resubmitting that would replay an +// instruction the captain withdrew. type PendingCaptainInput = { text: string; afterEntryId: string | null; @@ -530,8 +545,13 @@ function messageHasUnresolvedToolCall(message: unknown): boolean { return content.some((part) => (part as { type?: unknown } | undefined)?.type === "toolCall"); } -function captainInputWorthTracking(source: string, text: string): boolean { +function captainInputWorthTracking( + source: string, + text: string, + streamingBehavior: unknown, +): boolean { if (!CAPTAIN_INPUT_SOURCES.has(source)) return false; + if (streamingBehavior !== undefined) return false; const trimmed = text.trim(); if (!trimmed) return false; return !trimmed.startsWith("/") && !trimmed.startsWith("!"); @@ -780,7 +800,8 @@ export default function (pi: ExtensionAPI) { pi.on?.("input", (event, ctx) => { const source = String((event as { source?: unknown }).source ?? ""); const text = String((event as { text?: unknown }).text ?? ""); - if (!captainInputWorthTracking(source, text)) return; + const streamingBehavior = (event as { streamingBehavior?: unknown }).streamingBehavior; + if (!captainInputWorthTracking(source, text, streamingBehavior)) return; pendingCaptainInputs.push({ text, afterEntryId: lastEntryId(ctx), @@ -791,10 +812,24 @@ export default function (pi: ExtensionAPI) { } }); + const deliverFollowup = async (content: string): Promise => { + settleEvaluationActive = false; + await pi.sendUserMessage(content, { deliverAs: "followUp" }); + }; + pi.on("agent_settled", async (_event, ctx) => { inFlightAgentRuns = inFlightAgentRuns > 0 ? inFlightAgentRuns - 1 : 0; if (inFlightAgentRuns > 0) return; + if (settleEvaluationActive) return; + settleEvaluationActive = true; + try { + await evaluateSettle(ctx); + } finally { + settleEvaluationActive = false; + } + }); + async function evaluateSettle(ctx: ExtensionContext): Promise { if (guardFollowupActive) { guardFollowupActive = false; return; @@ -817,7 +852,7 @@ export default function (pi: ExtensionAPI) { "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + result.stderr, ); - await pi.sendUserMessage(content, { deliverAs: "followUp" }); + await deliverFollowup(content); } catch { guardFollowupActive = false; } @@ -844,7 +879,7 @@ export default function (pi: ExtensionAPI) { "arriving now, and answer it directly. Do not repeat any answer you already gave earlier in this conversation.\n\n" + lostCaptainInput.text, ); - await pi.sendUserMessage(content, { deliverAs: "followUp" }); + await deliverFollowup(content); return; } @@ -864,7 +899,7 @@ export default function (pi: ExtensionAPI) { "The last message in this conversation still has no visible assistant answer. " + "Check the transcript directly and answer the pending message by hand; do not repeat this recovery attempt automatically again for it.", ); - await pi.sendUserMessage(content, { deliverAs: "followUp" }); + await deliverFollowup(content); } catch { orphanedReplyExhaustedNotified = false; } @@ -881,11 +916,11 @@ export default function (pi: ExtensionAPI) { "another prompt was starting, racing Pi's own turn-start handling. Check the conversation history now: if a tool call is unresolved, " + "finish it, then answer the pending message directly. Do not repeat any answer you already gave earlier in this conversation.", ); - await pi.sendUserMessage(content, { deliverAs: "followUp" }); + await deliverFollowup(content); } catch { orphanedReplyFollowupActive = false; } - }); + } markLoaded(); } diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index 02799d9c1c2..69c2a7cbedc 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -63,5 +63,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case with its delivered-message and extension-wake negative controls, a run that opens while the supervision guard's child process is still running, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. +`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case with its delivered-message, extension-wake and withdrawn-queued-message negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 84a4e5fe166..07ad6d9e359 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -60,10 +60,12 @@ Not every `agent_settled` is terminal, though: the losing side of that same race `_isAgentRunActive` and `isIdle()` cannot recognise that settle, because the race corrupts exactly that flag. The handler therefore counts logical runs in flight - incremented on `before_agent_start`, which Pi emits only for a genuinely new run and never for a message queued into an active one - and returns without evaluating anything while that count is still above zero, so the checks below are judged only on the settle that drains it. The same count is re-checked after the supervision guard's child process returns, because Pi clears its run flag before emitting the settle and a fresh prompt can open a new run during that await; a run that appeared in the meantime suppresses this settle's judgement entirely rather than letting it read the new turn's mid-flight tail. +Pi's extension runner awaits handlers only within one emit, so two settles can reach this handler at once across that same await; one evaluation runs at a time, and a settle arriving mid-evaluation is dropped rather than judging the same state a second time with its own stale latch snapshot. Captain-owned input is recorded from Pi's `input` event, which fires at the very start of `prompt()` - before the `isStreaming` check - and therefore also for the call that goes on to lose. On a genuine settle the handler first resolves every recorded input that did reach the transcript; the first one that did not is resubmitted exactly once through a follow-up that quotes it and identifies it as a captain message that never arrived, and it is dropped from the pending list at that moment, so no repetition can produce a second copy. Only one follow-up fires per settle, so that resubmission takes priority over reply recovery, whose own turn settles and is judged normally afterwards. Tracking is deliberately narrow: only genuine captain sources are recorded (never `extension`, which is how watcher wakes and this guard's own follow-ups submit), and text starting with `/` or `!` is skipped because Pi expands it after the event, which would make the appended message no longer contain the recorded text and an ordinary command look lost. +A message Pi is queueing into an already-active run is skipped too - it reports that by setting `streamingBehavior` on the event - because that path is Pi's own correct handling, not what the idle-vs-idle race loses, and because a queued message is appended only when the run consumes it: pressing Escape clears the queue back into the editor, so its absence from the transcript is a withdrawal rather than a loss and must never be replayed. A recorded input is judged only once a run has started after it was recorded, which keeps the window between the `input` event and that call's own `before_agent_start` from being mistaken for a loss. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). @@ -74,7 +76,7 @@ A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, curren Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case and its delivered-message and extension-wake negative controls, the run that opens during the supervision check, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case and its delivered-message, extension-wake and withdrawn-queued-message negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 033e8bfd8d4..af6d205d561 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1088,7 +1088,8 @@ EOF # producing a synthesized reply (Pi's own prompt() has no atomic check-and-set # between reading isStreaming and committing to a new run, so a watcher wake # delivered through pi.sendUserMessage can race a concurrently-submitted -# captain message; docs/watcher-continuity.md "Turn-settle reply recovery"). +# captain message; docs/watcher-continuity.md "Turn-settle input and reply +# recovery"). # Fixtures give bin/fm-turnend-guard.sh a healthy (exit 0) verdict so every # case here exercises the reply-recovery branch, never the supervision one. @@ -1866,6 +1867,135 @@ EOF pass ".pi primary extension: a delivered captain message and an extension wake are never resubmitted" } +test_pi_input_recovery_never_replays_a_withdrawn_queued_message() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-withdrawn-root" + home="$TMP_ROOT/pi-input-withdrawn-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// A turn is already streaming, so the captain's message is queued rather than +// starting a run: Pi reports that by setting streamingBehavior on the input +// event. A queued message is only appended when the run consumes it, and +// Escape clears the queue back into the editor instead - so its absence from +// the transcript is a withdrawal, never a loss. +let entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "erster auftrag", timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await started({ type: "before_agent_start" }, ctx); +await input({ type: "input", text: "loesch den branch", source: "interactive", streamingBehavior: "steer" }, ctx); + +// Escape: the queue is cleared, the run aborts, and the queued text is back in +// the editor without ever reaching the transcript. +entries = [ + ...entries, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "" }], stopReason: "aborted", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); + +// A later, unrelated turn must not replay the withdrawn instruction. +await started({ type: "before_agent_start" }, ctx); +entries = [ + ...entries, + { type: "message", id: "u2", parentId: "a1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "was steht an?" }], timestamp: 0 } }, + { type: "message", id: "a2", parentId: "u2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Nichts offen." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`a withdrawn queued message was replayed: ${JSON.stringify(prompts)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must never resubmit a queued captain message the captain withdrew" + [ -z "$out" ] || fail "Pi input-recovery withdrawal test printed output: $out" + pass ".pi primary extension: a queued captain message withdrawn with Escape is never replayed" +} + +test_pi_reply_recovery_never_doubles_on_overlapping_settles() { + local repo home ext out status + repo="$TMP_ROOT/pi-reply-overlap-root" + home="$TMP_ROOT/pi-reply-overlap-home" + mkdir -p "$home/state" + printf '0.5\n' > "$home/state/.test-guard-delay" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +const stuckCtx = { + sessionManager: { + getEntries: () => [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "bash" }], stopReason: "toolUse", timestamp: 0 } }, + ], + }, +}; + +// Pi's extension runner does not serialize separate settle emissions, so a +// second run can start and settle while the first settle is still awaiting the +// supervision guard's child process. Both would otherwise judge the same stuck +// state with their own stale latch snapshot and send two recovery turns. +await started({ type: "before_agent_start" }, stuckCtx); +const first = settled({ type: "agent_settled" }, stuckCtx); +setTimeout(async () => { + await started({ type: "before_agent_start" }, stuckCtx); + await settled({ type: "agent_settled" }, stuckCtx); +}, 0); +await first; +await new Promise((r) => setTimeout(r, 900)); +if (prompts.length !== 1) { + throw new Error(`overlapping settles produced ${prompts.length} recovery turns, expected exactly 1`); +} +if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); + +// The latch must still absorb the recovery turn's own settle afterwards. +await settled({ type: "agent_settled" }, stuckCtx); +if (prompts.length !== 1) throw new Error(`the recovery turn's settle was not absorbed, total ${prompts.length}`); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must judge one settle at a time so overlapping settles cannot double a recovery turn" + [ -z "$out" ] || fail "Pi reply-recovery overlap test printed output: $out" + pass ".pi primary extension: overlapping settles never produce two recovery turns for one episode" +} + test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check() { local repo home ext out status repo="$TMP_ROOT/pi-reply-late-run-root" @@ -2706,6 +2836,8 @@ test_pi_reply_recovery_skips_a_spurious_mid_turn_settle test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race test_pi_input_recovery_stays_silent_for_a_delivered_captain_message +test_pi_input_recovery_never_replays_a_withdrawn_queued_message +test_pi_reply_recovery_never_doubles_on_overlapping_settles test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check test_pi_reply_recovery_budget_resets_for_a_new_session_generation test_hook_claude_mode_reblocks_stop_hook_active_when_unhealthy From 010006f19a8a501d23a499aa1dc2282db898e815 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:47:59 +0200 Subject: [PATCH 08/15] no-mistakes(review): fix(pi): commit captain input before judging it lost --- .pi/extensions/fm-primary-turnend-guard.ts | 76 +++++---- .../pi-watch-extension-reply-recovery.md | 2 +- docs/watcher-continuity.md | 8 +- tests/fm-turnend-guard.test.sh | 147 ++++++++++++++++++ 4 files changed, 197 insertions(+), 36 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 4f5efe73039..cf3f6b445ea 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -52,16 +52,11 @@ let inFlightAgentRuns = 0; // settles can be inside this handler at once across its awaited guard child // process. One judgement runs at a time, which keeps the latch snapshot and // the attempt budget from being read and written by two overlapping -// judgements of the same state. The claim is released the moment a follow-up -// is actually delivered, so the settle that follow-up produces is judged -// normally and absorbed by the latch it just set. +// judgements of the same state. Both latches are still consumed ahead of the +// claim, so a settle the claim drops can never leave one behind to swallow a +// later, genuinely unanswered episode. let settleEvaluationActive = false; -// Monotonic count of logical runs ever started in this generation. A pending -// captain input can only be judged once a run has started after it was -// recorded, which keeps the window between prompt()'s `input` event and its -// own `before_agent_start` from looking like a loss. -let agentRunStarts = 0; // Captain-owned input recorded from prompt()'s `input` event, which fires // before the isStreaming check and therefore also for the prompt() call that @@ -83,10 +78,20 @@ let agentRunStarts = 0; // when the run consumes it, so Escape (which clears the queue back into the // editor) legitimately leaves it absent. Resubmitting that would replay an // instruction the captain withdrew. +// A recording is only judged once its own prompt() call reached +// `before_agent_start`, which is the boundary that separates a call that +// committed to a run from one that died earlier: prompt() still throws for an +// unselected model or failed auth well before that hook, appending nothing +// while the captain sees an error and simply resends. Judging such a phantom +// would replay an instruction that already ran under the resend. Each +// `before_agent_start` commits at most the newest uncommitted recording, a +// new recording drops any uncommitted predecessor, and any recording still +// uncommitted when a settle arrives is dropped unjudged - by then its own +// call would long since have reached the hook. type PendingCaptainInput = { text: string; afterEntryId: string | null; - startsAtRecord: number; + committed: boolean; }; const CAPTAIN_INPUT_SOURCES = new Set(["interactive", "rpc"]); const PENDING_CAPTAIN_INPUT_LIMIT = 20; @@ -571,6 +576,11 @@ function lastEntryId(ctx: ExtensionContext): string | null { return entries[entries.length - 1]?.id ?? null; } +// Compared exactly, not by containment: a tracked recording never starts with +// "/", so Pi's skill-command and prompt-template expansion both return the +// text unchanged and the appended message carries it verbatim. Containment +// would let a longer later message ("weiter mit dem PR") silently absorb a +// genuinely lost short one ("weiter"). // An unknown anchor (a compaction rewrote it away) scans from the start, so a // message that is present is still found and never reported lost. function captainInputReachedTranscript( @@ -586,7 +596,7 @@ function captainInputReachedTranscript( if (!isSessionMessageEntry(entry) || claimed.has(entry.id)) continue; const role = (entry.message as { role?: unknown } | undefined)?.role; if (role !== "user") continue; - if (!messagePlainText(entry.message).includes(pending.text)) continue; + if (messagePlainText(entry.message).trim() !== pending.text.trim()) continue; return entry.id; } return undefined; @@ -603,7 +613,7 @@ function takeLostCaptainInput(ctx: ExtensionContext): PendingCaptainInput | unde const stillPending: PendingCaptainInput[] = []; let lost: PendingCaptainInput | undefined; for (const pending of pendingCaptainInputs) { - if (lost || agentRunStarts <= pending.startsAtRecord) { + if (lost || !pending.committed) { stillPending.push(pending); continue; } @@ -737,7 +747,6 @@ export default function (pi: ExtensionAPI) { : { new: "clear", resume: "resume", fork: "fork" }[reason]; markLoaded(); inFlightAgentRuns = 0; - agentRunStarts = 0; pendingCaptainInputs = []; orphanedReplyAttempts = 0; orphanedReplyExhaustedNotified = false; @@ -751,7 +760,12 @@ export default function (pi: ExtensionAPI) { pi.on?.("before_agent_start", async (_event, ctx) => { inFlightAgentRuns += 1; - agentRunStarts += 1; + for (let i = pendingCaptainInputs.length - 1; i >= 0; i -= 1) { + const pending = pendingCaptainInputs[i]; + if (pending.committed) continue; + pendingCaptainInputs[i] = { ...pending, committed: true }; + break; + } const generation = sessionstartGeneration; if (!generation) return; const message = await claimSessionstartMessage(generation, ctx); @@ -802,34 +816,22 @@ export default function (pi: ExtensionAPI) { const text = String((event as { text?: unknown }).text ?? ""); const streamingBehavior = (event as { streamingBehavior?: unknown }).streamingBehavior; if (!captainInputWorthTracking(source, text, streamingBehavior)) return; + pendingCaptainInputs = pendingCaptainInputs.filter((pending) => pending.committed); pendingCaptainInputs.push({ text, afterEntryId: lastEntryId(ctx), - startsAtRecord: agentRunStarts, + committed: false, }); if (pendingCaptainInputs.length > PENDING_CAPTAIN_INPUT_LIMIT) { pendingCaptainInputs = pendingCaptainInputs.slice(-PENDING_CAPTAIN_INPUT_LIMIT); } }); - const deliverFollowup = async (content: string): Promise => { - settleEvaluationActive = false; - await pi.sendUserMessage(content, { deliverAs: "followUp" }); - }; - pi.on("agent_settled", async (_event, ctx) => { inFlightAgentRuns = inFlightAgentRuns > 0 ? inFlightAgentRuns - 1 : 0; + pendingCaptainInputs = pendingCaptainInputs.filter((pending) => pending.committed); if (inFlightAgentRuns > 0) return; - if (settleEvaluationActive) return; - settleEvaluationActive = true; - try { - await evaluateSettle(ctx); - } finally { - settleEvaluationActive = false; - } - }); - async function evaluateSettle(ctx: ExtensionContext): Promise { if (guardFollowupActive) { guardFollowupActive = false; return; @@ -842,6 +844,16 @@ export default function (pi: ExtensionAPI) { const replyFollowupSettle = orphanedReplyFollowupActive; orphanedReplyFollowupActive = false; + if (settleEvaluationActive) return; + settleEvaluationActive = true; + try { + await evaluateSettle(ctx, replyFollowupSettle); + } finally { + settleEvaluationActive = false; + } + }); + + async function evaluateSettle(ctx: ExtensionContext, replyFollowupSettle: boolean): Promise { const result = await runGuard(); if (result.code === 2) { guardFollowupActive = true; @@ -852,7 +864,7 @@ export default function (pi: ExtensionAPI) { "The watcher cycle is missing, failed, or unhealthy. Follow the harness recovery instruction below before ending the turn.\n\n" + result.stderr, ); - await deliverFollowup(content); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { guardFollowupActive = false; } @@ -879,7 +891,7 @@ export default function (pi: ExtensionAPI) { "arriving now, and answer it directly. Do not repeat any answer you already gave earlier in this conversation.\n\n" + lostCaptainInput.text, ); - await deliverFollowup(content); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); return; } @@ -899,7 +911,7 @@ export default function (pi: ExtensionAPI) { "The last message in this conversation still has no visible assistant answer. " + "Check the transcript directly and answer the pending message by hand; do not repeat this recovery attempt automatically again for it.", ); - await deliverFollowup(content); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { orphanedReplyExhaustedNotified = false; } @@ -916,7 +928,7 @@ export default function (pi: ExtensionAPI) { "another prompt was starting, racing Pi's own turn-start handling. Check the conversation history now: if a tool call is unresolved, " + "finish it, then answer the pending message directly. Do not repeat any answer you already gave earlier in this conversation.", ); - await deliverFollowup(content); + await pi.sendUserMessage(content, { deliverAs: "followUp" }); } catch { orphanedReplyFollowupActive = false; } diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index 69c2a7cbedc..532905b00f2 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -63,5 +63,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case with its delivered-message, extension-wake and withdrawn-queued-message negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. +`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case (including a short message that a longer later one contains) with its delivered-message, extension-wake, withdrawn-queued-message and resent-after-submission-failure negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 07ad6d9e359..4fce0e7d2e5 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -60,13 +60,15 @@ Not every `agent_settled` is terminal, though: the losing side of that same race `_isAgentRunActive` and `isIdle()` cannot recognise that settle, because the race corrupts exactly that flag. The handler therefore counts logical runs in flight - incremented on `before_agent_start`, which Pi emits only for a genuinely new run and never for a message queued into an active one - and returns without evaluating anything while that count is still above zero, so the checks below are judged only on the settle that drains it. The same count is re-checked after the supervision guard's child process returns, because Pi clears its run flag before emitting the settle and a fresh prompt can open a new run during that await; a run that appeared in the meantime suppresses this settle's judgement entirely rather than letting it read the new turn's mid-flight tail. -Pi's extension runner awaits handlers only within one emit, so two settles can reach this handler at once across that same await; one evaluation runs at a time, and a settle arriving mid-evaluation is dropped rather than judging the same state a second time with its own stale latch snapshot. +Pi's extension runner awaits handlers only within one emit, so two settles can reach this handler at once across that same await; one evaluation runs at a time, and a settle arriving mid-evaluation is dropped rather than judging the same state a second time with its own stale latch snapshot - both latches are consumed before that claim, so a dropped settle still never leaves one behind. Captain-owned input is recorded from Pi's `input` event, which fires at the very start of `prompt()` - before the `isStreaming` check - and therefore also for the call that goes on to lose. On a genuine settle the handler first resolves every recorded input that did reach the transcript; the first one that did not is resubmitted exactly once through a follow-up that quotes it and identifies it as a captain message that never arrived, and it is dropped from the pending list at that moment, so no repetition can produce a second copy. Only one follow-up fires per settle, so that resubmission takes priority over reply recovery, whose own turn settles and is judged normally afterwards. Tracking is deliberately narrow: only genuine captain sources are recorded (never `extension`, which is how watcher wakes and this guard's own follow-ups submit), and text starting with `/` or `!` is skipped because Pi expands it after the event, which would make the appended message no longer contain the recorded text and an ordinary command look lost. A message Pi is queueing into an already-active run is skipped too - it reports that by setting `streamingBehavior` on the event - because that path is Pi's own correct handling, not what the idle-vs-idle race loses, and because a queued message is appended only when the run consumes it: pressing Escape clears the queue back into the editor, so its absence from the transcript is a withdrawal rather than a loss and must never be replayed. -A recorded input is judged only once a run has started after it was recorded, which keeps the window between the `input` event and that call's own `before_agent_start` from being mistaken for a loss. +A recorded input is judged only once its own `prompt()` call reached `before_agent_start`, the boundary that separates a call which committed to a run from one that died earlier - `prompt()` still throws for an unselected model or failed auth well before that hook, appending nothing while the captain sees the error and resends, and judging that phantom would replay an instruction the resend already carried out. +Each `before_agent_start` commits at most the newest uncommitted recording, a new recording drops any uncommitted predecessor, and a recording still uncommitted when a settle arrives is dropped unjudged. +The comparison against the transcript is exact rather than by containment: a tracked recording never starts with `/`, so Pi's skill-command and prompt-template expansion return it unchanged and the appended message carries it verbatim - containment would let a longer later message silently absorb a genuinely lost short one. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). The turn is healthy only when that entry is an assistant message that both carries genuine text and leaves no tool call unresolved, so a short preamble alongside the tool call that never came back still counts as unanswered; anything else - a dangling tool call, a bare user or watcher-wake message with no reply, an errored assistant message with no visible text - triggers exactly one recovery `followUp` instructing the model to check the transcript, finish any unresolved tool call, and answer the pending message without repeating an answer already given. @@ -76,7 +78,7 @@ A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, curren Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case and its delivered-message, extension-wake and withdrawn-queued-message negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) and its delivered-message, extension-wake, withdrawn-queued-message and resent-after-submission-failure negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index af6d205d561..3b5060a286d 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1933,6 +1933,151 @@ EOF pass ".pi primary extension: a queued captain message withdrawn with Escape is never replayed" } +test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-resend-root" + home="$TMP_ROOT/pi-input-resend-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// Pi's prompt() emits the input event and only then validates the model and +// auth, throwing before it ever reaches before_agent_start. Nothing is +// appended, the captain sees the error, and resends from history. +let entries = [ + { type: "message", id: "e5", parentId: null, timestamp: "t", message: { role: "user", content: [{ type: "text", text: "vorher" }], timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); +// prompt() throws here: no before_agent_start, no transcript entry. + +await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); +await started({ type: "before_agent_start" }, ctx); +entries = [ + ...entries, + { type: "message", id: "e6", parentId: "e5", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "loesch den branch" }], timestamp: 0 } }, + { type: "message", id: "e7", parentId: "e6", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Branch geloescht." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`the resent instruction was replayed: ${JSON.stringify(prompts)}`); +} + +// A later, unrelated run must not resurrect the failed submission either. +await started({ type: "before_agent_start" }, ctx); +entries = [ + ...entries, + { type: "message", id: "e8", parentId: "e7", timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "e9", parentId: "e8", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`a later run resurrected the failed submission: ${JSON.stringify(prompts)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must never replay a submission that failed before it started a run and was resent by hand" + [ -z "$out" ] || fail "Pi input-recovery resend test printed output: $out" + pass ".pi primary extension: a failed submission the captain resent is never replayed" +} + +test_pi_input_recovery_detects_a_short_message_a_longer_one_contains() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-short-root" + home="$TMP_ROOT/pi-input-short-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { rmSync, writeFileSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// "weiter" is lost to the race and never appended. Its judgement is deferred +// because the next settle is claimed by the supervision guard, and by the time +// it is judged the captain has sent "weiter mit dem PR", which was appended +// normally. A containment match would pair the lost short message with that +// longer entry and then declare the delivered longer message lost instead. +const verdict = `${process.env.FM_HOME}/state/.test-guard-verdict`; +let entries = []; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "weiter", source: "interactive" }, ctx); +await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start" }, ctx); +entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) throw new Error(`the loser's settle acted: ${prompts.length} prompts`); + +writeFileSync(verdict, "2"); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`expected the supervision follow-up, got ${prompts.length}`); +rmSync(verdict); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`guard-latch-absorbed settle acted, total ${prompts.length}`); + +await input({ type: "input", text: "weiter mit dem PR", source: "interactive" }, ctx); +await started({ type: "before_agent_start" }, ctx); +entries = [ + ...entries, + { type: "message", id: "u2", parentId: "a1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "weiter mit dem PR" }], timestamp: 0 } }, + { type: "message", id: "a2", parentId: "u2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "PR laeuft." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); + +if (prompts.length !== 2) { + throw new Error(`expected the lost short message to be recovered exactly once, got ${prompts.length - 1}`); +} +if (!prompts[1].includes("CAPTAIN INPUT WAS LOST")) throw new Error(`not an input-recovery prompt: ${prompts[1]}`); +if (prompts[1].includes("weiter mit dem PR")) { + throw new Error(`the delivered longer message was resubmitted instead of the lost short one: ${prompts[1]}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not let a longer later message absorb a genuinely lost short one" + [ -z "$out" ] || fail "Pi input-recovery short-message test printed output: $out" + pass ".pi primary extension: a lost short message is not absorbed by a longer later message" +} + test_pi_reply_recovery_never_doubles_on_overlapping_settles() { local repo home ext out status repo="$TMP_ROOT/pi-reply-overlap-root" @@ -2837,6 +2982,8 @@ test_pi_reply_recovery_spurious_settle_never_doubles_a_healthy_answer test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race test_pi_input_recovery_stays_silent_for_a_delivered_captain_message test_pi_input_recovery_never_replays_a_withdrawn_queued_message +test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent +test_pi_input_recovery_detects_a_short_message_a_longer_one_contains test_pi_reply_recovery_never_doubles_on_overlapping_settles test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check test_pi_reply_recovery_budget_resets_for_a_new_session_generation From 96c84442959cdc8270ae567172f4f156927a2568 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:01:45 +0200 Subject: [PATCH 09/15] no-mistakes(review): fix(pi): commit captain input only from its own turn start --- .pi/extensions/fm-primary-turnend-guard.ts | 28 ++-- .../pi-watch-extension-reply-recovery.md | 2 +- docs/watcher-continuity.md | 7 +- tests/fm-turnend-guard.test.sh | 145 ++++++++++++++++-- 4 files changed, 160 insertions(+), 22 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index cf3f6b445ea..f76d39fc744 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -57,7 +57,6 @@ let inFlightAgentRuns = 0; // later, genuinely unanswered episode. let settleEvaluationActive = false; - // Captain-owned input recorded from prompt()'s `input` event, which fires // before the isStreaming check and therefore also for the prompt() call that // goes on to lose the race. That loser never appends anything: pi-agent-core's @@ -83,13 +82,18 @@ let settleEvaluationActive = false; // committed to a run from one that died earlier: prompt() still throws for an // unselected model or failed auth well before that hook, appending nothing // while the captain sees an error and simply resends. Judging such a phantom -// would replay an instruction that already ran under the resend. Each -// `before_agent_start` commits at most the newest uncommitted recording, a -// new recording drops any uncommitted predecessor, and any recording still -// uncommitted when a settle arrives is dropped unjudged - by then its own -// call would long since have reached the hook. +// would replay an instruction that already ran under the resend. The hook's +// own event carries the prompt it is starting, and expansion is a no-op for a +// tracked recording, so a recording is committed only by a start that quotes +// it back verbatim - an unrelated run's start leaves it alone. A new +// recording drops any uncommitted predecessor, and a recording still +// uncommitted when a settle arrives is dropped unjudged, because a call that +// was going to commit reaches the hook well before any settle. +type UserMessageContent = Parameters[0]; +type UserMessagePart = Exclude[number]; type PendingCaptainInput = { text: string; + images: UserMessagePart[]; afterEntryId: string | null; committed: boolean; }; @@ -758,11 +762,12 @@ export default function (pi: ExtensionAPI) { ); }); - pi.on?.("before_agent_start", async (_event, ctx) => { + pi.on?.("before_agent_start", async (event, ctx) => { inFlightAgentRuns += 1; + const startedPrompt = String((event as { prompt?: unknown }).prompt ?? "").trim(); for (let i = pendingCaptainInputs.length - 1; i >= 0; i -= 1) { const pending = pendingCaptainInputs[i]; - if (pending.committed) continue; + if (pending.committed || pending.text.trim() !== startedPrompt) continue; pendingCaptainInputs[i] = { ...pending, committed: true }; break; } @@ -816,9 +821,11 @@ export default function (pi: ExtensionAPI) { const text = String((event as { text?: unknown }).text ?? ""); const streamingBehavior = (event as { streamingBehavior?: unknown }).streamingBehavior; if (!captainInputWorthTracking(source, text, streamingBehavior)) return; + const images = (event as { images?: unknown }).images; pendingCaptainInputs = pendingCaptainInputs.filter((pending) => pending.committed); pendingCaptainInputs.push({ text, + images: Array.isArray(images) ? (images as UserMessagePart[]) : [], afterEntryId: lastEntryId(ctx), committed: false, }); @@ -891,7 +898,10 @@ export default function (pi: ExtensionAPI) { "arriving now, and answer it directly. Do not repeat any answer you already gave earlier in this conversation.\n\n" + lostCaptainInput.text, ); - await pi.sendUserMessage(content, { deliverAs: "followUp" }); + const payload: UserMessageContent = lostCaptainInput.images.length === 0 + ? content + : [{ type: "text", text: content }, ...lostCaptainInput.images]; + await pi.sendUserMessage(payload, { deliverAs: "followUp" }); return; } diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index 532905b00f2..91699e4dcb6 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -63,5 +63,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case (including a short message that a longer later one contains) with its delivered-message, extension-wake, withdrawn-queued-message and resent-after-submission-failure negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. +`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case (including a short message that a longer later one contains) and its attachments, with its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure and unrelated-turn-start negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 4fce0e7d2e5..7256561d95d 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -62,12 +62,13 @@ The handler therefore counts logical runs in flight - incremented on `before_age The same count is re-checked after the supervision guard's child process returns, because Pi clears its run flag before emitting the settle and a fresh prompt can open a new run during that await; a run that appeared in the meantime suppresses this settle's judgement entirely rather than letting it read the new turn's mid-flight tail. Pi's extension runner awaits handlers only within one emit, so two settles can reach this handler at once across that same await; one evaluation runs at a time, and a settle arriving mid-evaluation is dropped rather than judging the same state a second time with its own stale latch snapshot - both latches are consumed before that claim, so a dropped settle still never leaves one behind. Captain-owned input is recorded from Pi's `input` event, which fires at the very start of `prompt()` - before the `isStreaming` check - and therefore also for the call that goes on to lose. -On a genuine settle the handler first resolves every recorded input that did reach the transcript; the first one that did not is resubmitted exactly once through a follow-up that quotes it and identifies it as a captain message that never arrived, and it is dropped from the pending list at that moment, so no repetition can produce a second copy. +On a genuine settle the handler first resolves every recorded input that did reach the transcript; the first one that did not is resubmitted exactly once through a follow-up that quotes it - together with any images it carried, so an attachment the question depends on is not lost - and identifies it as a captain message that never arrived, and it is dropped from the pending list at that moment, so no repetition can produce a second copy. Only one follow-up fires per settle, so that resubmission takes priority over reply recovery, whose own turn settles and is judged normally afterwards. Tracking is deliberately narrow: only genuine captain sources are recorded (never `extension`, which is how watcher wakes and this guard's own follow-ups submit), and text starting with `/` or `!` is skipped because Pi expands it after the event, which would make the appended message no longer contain the recorded text and an ordinary command look lost. A message Pi is queueing into an already-active run is skipped too - it reports that by setting `streamingBehavior` on the event - because that path is Pi's own correct handling, not what the idle-vs-idle race loses, and because a queued message is appended only when the run consumes it: pressing Escape clears the queue back into the editor, so its absence from the transcript is a withdrawal rather than a loss and must never be replayed. A recorded input is judged only once its own `prompt()` call reached `before_agent_start`, the boundary that separates a call which committed to a run from one that died earlier - `prompt()` still throws for an unselected model or failed auth well before that hook, appending nothing while the captain sees the error and resends, and judging that phantom would replay an instruction the resend already carried out. -Each `before_agent_start` commits at most the newest uncommitted recording, a new recording drops any uncommitted predecessor, and a recording still uncommitted when a settle arrives is dropped unjudged. +That hook's own event carries the prompt it is starting and expansion is a no-op for a tracked recording, so a recording is committed only by a start that quotes it back verbatim - an unrelated run's start leaves it alone. +A new recording drops any uncommitted predecessor, and a recording still uncommitted when a settle arrives is dropped unjudged, because a call that was going to commit reaches the hook well before any settle. The comparison against the transcript is exact rather than by containment: a tracked recording never starts with `/`, so Pi's skill-command and prompt-template expansion return it unchanged and the appended message carries it verbatim - containment would let a longer later message silently absorb a genuinely lost short one. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). @@ -78,7 +79,7 @@ A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, curren Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) and its delivered-message, extension-wake, withdrawn-queued-message and resent-after-submission-failure negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure and unrelated-turn-start negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 3b5060a286d..64abbecc0e3 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1787,8 +1787,8 @@ const entries = [ const ctx = { sessionManager: { getEntries: () => entries } }; await input({ type: "input", text: "bitte den Stand zusammenfassen", source: "interactive" }, ctx); -await started({ type: "before_agent_start" }, ctx); -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +await started({ type: "before_agent_start", prompt: "bitte den Stand zusammenfassen" }, ctx); await settled({ type: "agent_settled" }, ctx); if (prompts.length !== 0) throw new Error(`spurious settle acted: ${prompts.length} prompts`); @@ -1845,7 +1845,7 @@ let entries = []; const ctx = { sessionManager: { getEntries: () => entries } }; await input({ type: "input", text: "bitte den Stand zusammenfassen", source: "interactive" }, ctx); -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "bitte den Stand zusammenfassen" }, ctx); entries = [ { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: [{ type: "text", text: "bitte den Stand zusammenfassen" }], timestamp: 0 } }, { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Hier der Stand." }], stopReason: "stop", timestamp: 0 } }, @@ -1903,7 +1903,7 @@ let entries = [ ]; const ctx = { sessionManager: { getEntries: () => entries } }; -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "erster auftrag" }, ctx); await input({ type: "input", text: "loesch den branch", source: "interactive", streamingBehavior: "steer" }, ctx); // Escape: the queue is cleared, the run aborts, and the queued text is back in @@ -1915,7 +1915,7 @@ entries = [ await settled({ type: "agent_settled" }, ctx); // A later, unrelated turn must not replay the withdrawn instruction. -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "was steht an?" }, ctx); entries = [ ...entries, { type: "message", id: "u2", parentId: "a1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "was steht an?" }], timestamp: 0 } }, @@ -1971,7 +1971,7 @@ await input({ type: "input", text: "loesch den branch", source: "interactive" }, // prompt() throws here: no before_agent_start, no transcript entry. await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "loesch den branch" }, ctx); entries = [ ...entries, { type: "message", id: "e6", parentId: "e5", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "loesch den branch" }], timestamp: 0 } }, @@ -2001,6 +2001,131 @@ EOF pass ".pi primary extension: a failed submission the captain resent is never replayed" } +test_pi_input_recovery_ignores_an_unrelated_runs_turn_start() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-unrelated-root" + home="$TMP_ROOT/pi-input-unrelated-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// The captain's prompt() emitted its input event and then threw before it +// could start a run, so nothing was appended. The next run is a watcher wake, +// entirely unrelated: its turn start quotes the wake text, not the captain's, +// and must not adopt the captain's phantom recording. +let entries = []; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); + +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`an unrelated run adopted the phantom recording: ${JSON.stringify(prompts)}`); +} + +// And it stays dropped: a later run must not resurrect it either. +await started({ type: "before_agent_start", prompt: "was steht an?" }, ctx); +entries = [ + ...entries, + { type: "message", id: "u2", parentId: "a1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "was steht an?" }], timestamp: 0 } }, + { type: "message", id: "a2", parentId: "u2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Nichts offen." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`a later run resurrected the phantom recording: ${JSON.stringify(prompts)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must not let an unrelated run's turn start adopt a captain recording" + [ -z "$out" ] || fail "Pi input-recovery unrelated-start test printed output: $out" + pass ".pi primary extension: an unrelated run's turn start never adopts a captain recording" +} + +test_pi_input_recovery_carries_attached_images() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-images-root" + home="$TMP_ROOT/pi-input-images-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(content, options) { + prompts.push({ content, options }); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// The captain pasted a screenshot with the question. Recovering the sentence +// without the attachment would make the model answer about something it +// cannot see. +const screenshot = { type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" }; +const entries = [ + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: "was ist das im Log?", source: "interactive", images: [screenshot] }, ctx); +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +await started({ type: "before_agent_start", prompt: "was ist das im Log?" }, ctx); +await settled({ type: "agent_settled" }, ctx); +await settled({ type: "agent_settled" }, ctx); + +if (prompts.length !== 1) throw new Error(`expected one recovery resubmission, got ${prompts.length}`); +const [{ content, options }] = prompts; +if (!Array.isArray(content)) throw new Error(`the attachment was dropped, content was a bare string: ${content}`); +const textParts = content.filter((part) => part.type === "text"); +const imageParts = content.filter((part) => part.type === "image"); +if (textParts.length !== 1) throw new Error(`expected exactly one text part, got ${textParts.length}`); +if (!textParts[0].text.includes("CAPTAIN INPUT WAS LOST")) throw new Error(`not an input-recovery prompt: ${textParts[0].text}`); +if (!textParts[0].text.includes("was ist das im Log?")) throw new Error(`the lost text was not carried: ${textParts[0].text}`); +if (imageParts.length !== 1) throw new Error(`expected the screenshot to be carried, got ${imageParts.length} image parts`); +if (imageParts[0].data !== screenshot.data) throw new Error("the carried image was not the captain's attachment"); +if (options?.deliverAs !== "followUp") throw new Error("recovery prompt was not a follow-up"); +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must resubmit a lost captain message together with its attachments" + [ -z "$out" ] || fail "Pi input-recovery image test printed output: $out" + pass ".pi primary extension: a lost captain message keeps its attached images" +} + test_pi_input_recovery_detects_a_short_message_a_longer_one_contains() { local repo home ext out status repo="$TMP_ROOT/pi-input-short-root" @@ -2038,8 +2163,8 @@ let entries = []; const ctx = { sessionManager: { getEntries: () => entries } }; await input({ type: "input", text: "weiter", source: "interactive" }, ctx); -await started({ type: "before_agent_start" }, ctx); -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +await started({ type: "before_agent_start", prompt: "weiter" }, ctx); entries = [ { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, @@ -2055,7 +2180,7 @@ await settled({ type: "agent_settled" }, ctx); if (prompts.length !== 1) throw new Error(`guard-latch-absorbed settle acted, total ${prompts.length}`); await input({ type: "input", text: "weiter mit dem PR", source: "interactive" }, ctx); -await started({ type: "before_agent_start" }, ctx); +await started({ type: "before_agent_start", prompt: "weiter mit dem PR" }, ctx); entries = [ ...entries, { type: "message", id: "u2", parentId: "a1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "weiter mit dem PR" }], timestamp: 0 } }, @@ -2983,6 +3108,8 @@ test_pi_input_recovery_resubmits_a_captain_message_lost_to_the_race test_pi_input_recovery_stays_silent_for_a_delivered_captain_message test_pi_input_recovery_never_replays_a_withdrawn_queued_message test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent +test_pi_input_recovery_ignores_an_unrelated_runs_turn_start +test_pi_input_recovery_carries_attached_images test_pi_input_recovery_detects_a_short_message_a_longer_one_contains test_pi_reply_recovery_never_doubles_on_overlapping_settles test_pi_reply_recovery_skips_a_run_that_started_during_the_guard_check From c3318848c0ef7ee2a1a42317934b5ee9af93c549 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 10:45:54 +0200 Subject: [PATCH 10/15] no-mistakes(review): fix(pi): drop recovery for a captain-resent instruction --- .pi/extensions/fm-primary-turnend-guard.ts | 12 ++- .../pi-watch-extension-reply-recovery.md | 2 +- docs/watcher-continuity.md | 4 +- tests/fm-turnend-guard.test.sh | 84 +++++++++++++++++++ 4 files changed, 99 insertions(+), 3 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index f76d39fc744..b096966d251 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -89,6 +89,13 @@ let settleEvaluationActive = false; // recording drops any uncommitted predecessor, and a recording still // uncommitted when a settle arrives is dropped unjudged, because a call that // was going to commit reaches the hook well before any settle. +// A recording is also superseded the moment the captain submits the same text +// again. Losing the race is not silent for the captain: the loser's rejection +// escapes prompt() into the interactive loop, which prints it as a chat error, +// so the captain may simply resend. Recovering the earlier recording as well +// would execute one instruction twice, and the resend carries it anyway. +// Equivalence here is exact text, the only signal the events carry - a +// reworded resend is not recognised as the same instruction. type UserMessageContent = Parameters[0]; type UserMessagePart = Exclude[number]; type PendingCaptainInput = { @@ -822,7 +829,10 @@ export default function (pi: ExtensionAPI) { const streamingBehavior = (event as { streamingBehavior?: unknown }).streamingBehavior; if (!captainInputWorthTracking(source, text, streamingBehavior)) return; const images = (event as { images?: unknown }).images; - pendingCaptainInputs = pendingCaptainInputs.filter((pending) => pending.committed); + const trimmed = text.trim(); + pendingCaptainInputs = pendingCaptainInputs.filter( + (pending) => pending.committed && pending.text.trim() !== trimmed, + ); pendingCaptainInputs.push({ text, images: Array.isArray(images) ? (images as UserMessagePart[]) : [], diff --git a/docs/verification/pi-watch-extension-reply-recovery.md b/docs/verification/pi-watch-extension-reply-recovery.md index 91699e4dcb6..c188a2f25a4 100644 --- a/docs/verification/pi-watch-extension-reply-recovery.md +++ b/docs/verification/pi-watch-extension-reply-recovery.md @@ -63,5 +63,5 @@ That was rejected as disproportionate new risk (a novel synchronization primitiv ## Regression coverage -`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case (including a short message that a longer later one contains) and its attachments, with its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure and unrelated-turn-start negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. +`tests/fm-turnend-guard.test.sh`'s `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` tests exercise `.pi/extensions/fm-primary-turnend-guard.ts`'s `input`, `before_agent_start`, `session_start` and `agent_settled` handlers directly against a mocked `pi`/`ctx`, covering the captain-input loss case (including a short message that a longer later one contains) and its attachments, with its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, a run that opens while the supervision guard's child process is still running, two settles overlapping across that same await, the per-generation attempt-budget reset, the dangling-tool-call and fully-unanswered detection cases (including the common shape where the same assistant message carries a text preamble beside the unresolved call), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, the latch interleaving where a settle claimed by the supervision guard must not swallow the next unanswered episode, and the spurious mid-turn settle above - replayed as the exact recorded event order - which must be left entirely unevaluated whether the still-running winner ends up hanging or answering. Run: `bash tests/fm-turnend-guard.test.sh` (or `no-mistakes`-invoked `bin/fm-test-run.sh tests/fm-turnend-guard.test.sh`). diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 7256561d95d..775e0a34817 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -69,6 +69,8 @@ A message Pi is queueing into an already-active run is skipped too - it reports A recorded input is judged only once its own `prompt()` call reached `before_agent_start`, the boundary that separates a call which committed to a run from one that died earlier - `prompt()` still throws for an unselected model or failed auth well before that hook, appending nothing while the captain sees the error and resends, and judging that phantom would replay an instruction the resend already carried out. That hook's own event carries the prompt it is starting and expansion is a no-op for a tracked recording, so a recording is committed only by a start that quotes it back verbatim - an unrelated run's start leaves it alone. A new recording drops any uncommitted predecessor, and a recording still uncommitted when a settle arrives is dropped unjudged, because a call that was going to commit reaches the hook well before any settle. +A recording is superseded outright when the captain submits the same text again: losing the race is not silent for them, because the loser's rejection escapes `prompt()` into the interactive loop and is printed as a chat error, so a resend is the natural reaction and recovering the earlier recording too would execute one instruction twice. +Equivalence is exact text, the only signal the events carry, so a reworded resend is not recognised as the same instruction. The comparison against the transcript is exact rather than by containment: a tracked recording never starts with `/`, so Pi's skill-command and prompt-template expansion return it unchanged and the appended message carries it verbatim - containment would let a longer later message silently absorb a genuinely lost short one. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). @@ -79,7 +81,7 @@ A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, curren Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure and unrelated-turn-start negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 64abbecc0e3..a7d798db908 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -2065,6 +2065,89 @@ EOF pass ".pi primary extension: an unrelated run's turn start never adopts a captain recording" } +test_pi_input_recovery_never_doubles_a_manual_resend_after_the_race() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-resend-race-root" + home="$TMP_ROOT/pi-input-resend-race-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message) { + prompts.push(message); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// Losing the race is not silent for the captain: the loser's rejection escapes +// prompt() into the interactive loop, which prints "Agent is already +// processing a prompt..." as a chat error. The captain reacts by resending the +// same instruction from history - so replaying the lost recording as well +// would delete the branch twice. +let entries = [ + { type: "message", id: "e1", parentId: null, timestamp: "t", message: { role: "user", content: "vorher", timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +// The captain's message and a watcher wake both start from idle. +await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +await started({ type: "before_agent_start", prompt: "loesch den branch" }, ctx); + +// The captain's call loses and appends nothing; its settle is the spurious one +// the wake is still running behind. +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) throw new Error(`the loser's settle acted: ${prompts.length} prompts`); + +// The captain sees the error and resends by hand. That resubmission runs and +// is answered normally. +await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); +await started({ type: "before_agent_start", prompt: "loesch den branch" }, ctx); +entries = [ + ...entries, + { type: "message", id: "e2", parentId: "e1", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "loesch den branch" }], timestamp: 0 } }, + { type: "message", id: "e3", parentId: "e2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Branch geloescht." }], stopReason: "stop", timestamp: 0 } }, +]; + +// The wake's own settle, then the resend's settle. Neither may replay the +// instruction the manual resend already carried out. +await settled({ type: "agent_settled" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`the manually resent instruction was replayed: ${JSON.stringify(prompts)}`); +} + +// And it stays dropped for good. +await started({ type: "before_agent_start", prompt: "was steht an?" }, ctx); +entries = [ + ...entries, + { type: "message", id: "e4", parentId: "e3", timestamp: "t", message: { role: "user", content: [{ type: "text", text: "was steht an?" }], timestamp: 0 } }, + { type: "message", id: "e5", parentId: "e4", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Nichts offen." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 0) { + throw new Error(`a later settle replayed the superseded recording: ${JSON.stringify(prompts)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must never replay a captain instruction the captain already resent by hand" + [ -z "$out" ] || fail "Pi input-recovery manual-resend test printed output: $out" + pass ".pi primary extension: a manual resend after the race never doubles the instruction" +} + test_pi_input_recovery_carries_attached_images() { local repo home ext out status repo="$TMP_ROOT/pi-input-images-root" @@ -3109,6 +3192,7 @@ test_pi_input_recovery_stays_silent_for_a_delivered_captain_message test_pi_input_recovery_never_replays_a_withdrawn_queued_message test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent test_pi_input_recovery_ignores_an_unrelated_runs_turn_start +test_pi_input_recovery_never_doubles_a_manual_resend_after_the_race test_pi_input_recovery_carries_attached_images test_pi_input_recovery_detects_a_short_message_a_longer_one_contains test_pi_reply_recovery_never_doubles_on_overlapping_settles From 2a795eb0d49af941842dc27ae3be9ddcab8fb2e5 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:04:38 +0200 Subject: [PATCH 11/15] no-mistakes(document): classify reply-recovery verification doc, point guard doc to owner --- docs/documentation-audiences.json | 4 ++++ docs/turnend-guard.md | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/documentation-audiences.json b/docs/documentation-audiences.json index 8bb68bd4ba2..ae8a746f4bb 100644 --- a/docs/documentation-audiences.json +++ b/docs/documentation-audiences.json @@ -420,6 +420,10 @@ "path": "docs/verification/muse.md", "audience": "maintainer-verification" }, + { + "path": "docs/verification/pi-watch-extension-reply-recovery.md", + "audience": "maintainer-verification" + }, { "path": "docs/verification/process-event-sources.md", "audience": "maintainer-verification" diff --git a/docs/turnend-guard.md b/docs/turnend-guard.md index 134c2f5dc41..a7f64c3c5fb 100644 --- a/docs/turnend-guard.md +++ b/docs/turnend-guard.md @@ -52,7 +52,8 @@ If `jq` is missing or hook stdin is empty, the guard exits 0 because it cannot s - Claude registers two `Stop` hooks in `.claude/settings.json`, both anchored through `CLAUDE_PROJECT_DIR`: `bin/fm-turnend-guard.sh --claude`, and `bin/fm-claude-stop-autoarm.sh` with `asyncRewake: true` and `timeout: 28800`. - Codex registers a `Stop` hook in `.codex/hooks.json`, anchors the executable to the hook process working directory, verifies a Firstmate-shaped hook-bearing root, and passes the original payload to the shared guard. - OpenCode listens for `session.idle` in `.opencode/plugins/fm-primary-turnend-guard.js`, lets the watcher coordinator act first, and calls `client.session.promptAsync` once when the guard returns 2. -- Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, runs once per logical agent run, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. +- Pi listens for `agent_settled` in `.pi/extensions/fm-primary-turnend-guard.ts`, evaluates only the settle that drains its own count of logical runs in flight, and calls `pi.sendUserMessage(..., { deliverAs: "followUp" })` once when the guard returns 2. + The same handler owns one further follow-up path, taken only on a clean guard verdict and never alongside the guard's own, which recovers a captain message or a reply lost to Pi's turn-start race; [`watcher-continuity.md`](watcher-continuity.md#turn-settle-input-and-reply-recovery) owns that contract. - Cursor registers a `stop` hook in `.cursor/hooks.json` and delegates the whole turn boundary to `bin/fm-turnend-guard-cursor.sh`, the park described below. Cursor also loads `/.claude/settings.json`, so every tracked Claude-shaped entrypoint whose event Cursor covers stands down on a Cursor-delivered payload through `bin/fm-hook-host-lib.sh`. That predicate reads the delivered payload's own `cursor_version`, never the environment: Cursor exports `CURSOR_INVOKED_AS`, `CURSOR_PROJECT_DIR`, and `CURSOR_VERSION` into every child process, so an environment guard would also disable the hooks of a Claude session started by hand from a Cursor pane, which is the hazard the `GROK_SESSION_ID` exclusion below records. @@ -160,6 +161,7 @@ That warning uses `bin/fm-supervision-instructions.sh --repair-line`, so it alwa ## Regression coverage `tests/fm-turnend-guard.test.sh` covers the predicate, main and secondmate primary scope, child-worktree exclusion, `FM_HOME` and `FM_STATE_OVERRIDE` precedence, the live-lock and fresh-beacon guard predicate, the cooperative `--claude` open-generation claim wait, monotonic failed-epoch progression, bounded attended fail-open, post-alarm continuation suppression, positive recovery reset, generation and legacy claim cases that must block or clear instead of allowing a blind stop, Pi logical-run latching, missing-`jq` behavior, all five primary registrations, Grok native and legacy selection, typed field precedence, malformed input, and exactly-one-path safety. +Its `test_pi_input_recovery_*` and `test_pi_reply_recovery_*` cases belong to the turn-settle recovery contract owned by [`watcher-continuity.md`](watcher-continuity.md#turn-settle-input-and-reply-recovery). `tests/fm-guard-stale-banner.test.sh` covers the pull-guard predicate, including the persistent-model fresh-leftover-beacon negative control, the auto-arm model's healthy fresh-beacon-without-a-watcher case and stale-beacon alarm, and the extension model's live-watcher path, ownership-qualified fresh hand-off, held-lock failures, independently broken ownership signals, stale-beacon alarm, queued-wake warning, and Pi and pi-signed harness routing. It also covers true-reason banner wording and reason-keyed episode dedup surviving a beacon mtime change. `tests/fm-cursor-primary.test.sh` covers the Cursor park end to end over real processes with no harness installed: each tracked Claude-shaped entrypoint standing down on a Cursor payload, both follow-up sources, the bounded repair nag and its reset, the nested loop bounds, supersession, away-mode and lock-ownership inertness, Pi-host stand-down without Cursor identity and continued parking when `PI_CODING_AGENT` leaks alongside `CURSOR_AGENT` or `CURSOR_INVOKED_AS`, child-worktree exclusion, and that the adapter never exits 2. From 980b203ee4bb909311b4b15e568c1c86b6fabf6e Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:06:29 +0200 Subject: [PATCH 12/15] no-mistakes(document): record /ahoy captain-boundary gap for recovered input --- docs/watcher-continuity.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 775e0a34817..c1727e7266f 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -80,6 +80,10 @@ A `orphanedReplyFollowupActive` latch, mirroring the existing `guardFollowupActi A per-generation bounded attempt counter (`ORPHANED_REPLY_ATTEMPT_LIMIT`, currently 3) stops the retry loop after repeated distinct failures and commits one loud, once-only "recovery gave up" notice instead of retrying forever; a later healthy settle resets both the counter and the notice, and `session_start` resets them along with the in-flight count and the pending captain-input list, so neither a fresh, unrelated unanswered episode nor a new generation ever inherits an exhausted budget. Only one `followUp` is ever sent per settle: the pre-existing supervision guard takes priority, and reply recovery runs only once that guard is clean, so the two mechanisms can never race each other's delivery. +One known gap is left open deliberately: a recovered captain message reaches the model inside a `turn-end-guard` operational envelope, so it begins with the U+2063 `FIRSTMATE_OP:` prefix that the [`ahoy`](../.agents/skills/ahoy/SKILL.md) skill excludes from captain-boundary detection, while the captain's original submission is by definition absent from the transcript. +A recovered instruction therefore establishes no captain boundary at all and `/ahoy` recaps from an older one. +Closing it would change either that skill's boundary rules or this envelope's kind, so it is recorded here rather than fixed alongside the recovery mechanism. + This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. `tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. From 1ad85ab62968333521a0705d1a6501526a3bf095 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:02:47 +0200 Subject: [PATCH 13/15] no-mistakes(ci): test(pi): keep new heredocs parseable by stock Bash 3.2 The reply-recovery tests embed their Node fixtures as here-documents nested inside a $(...) command substitution. Bash 3.2's command substitution scanner does not understand here-documents: it treats the body as ordinary shell text, so an apostrophe in prose (a possessive in a comment or an error string) opens a quote for the scanner and unbalances the rest of the file. Stock macOS Bash 3.2 therefore failed to parse tests/fm-turnend-guard.test.sh at all. Drop the apostrophes from those here-document bodies, matching the convention the pre-existing fixtures in this file already follow. Only comment and message wording changes; no assertion or behavior changes. --- tests/fm-turnend-guard.test.sh | 66 +++++++++++++++++----------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index a7d798db908..6db898d062e 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1145,7 +1145,7 @@ const settled = handlers.get("agent_settled"); if (!settled) throw new Error("agent_settled handler was not registered"); // Last conversational entry is an assistant message with a tool call and no -// text: the reproduced race's visible signature (a dangling tool call, e.g. +// text: the visible signature of the raced turn (a dangling tool call, e.g. // bin/fm-wake-drain.sh, left as the final transcript entry). const ctx = { sessionManager: { @@ -1248,8 +1248,8 @@ const stuckCtx = { await settled({ type: "agent_settled" }, stuckCtx); if (prompts.length !== 1) throw new Error(`settle 1: expected 1 prompt, got ${prompts.length}`); -// Settle 2: the latch absorbs the settle produced by that same follow-up's -// own turn - repetition of the identical stuck state must not create a +// Settle 2: the latch absorbs the settle produced by that same follow-up +// turn itself - repetition of the identical stuck state must not create a // second turn here, even though the trailing entries are unchanged. await settled({ type: "agent_settled" }, stuckCtx); if (prompts.length !== 1) throw new Error(`settle 2 (latch-absorbed): expected still 1 prompt, got ${prompts.length}`); @@ -1333,7 +1333,7 @@ const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); const settled = handlers.get("agent_settled"); -// The transcript's last conversational entry is a user message (a delivered +// The last conversational transcript entry is a user message (a delivered // watcher wake or a captain message) with no assistant reply at all - not // even a dangling tool call. This is the fully-dropped case. const ctx = { @@ -1467,7 +1467,7 @@ const mod = await import(pathToFileURL(process.env.PLUGIN).href); mod.default(pi); const settled = handlers.get("agent_settled"); -// The reproduced race's real shape: one assistant message holding a short +// The real shape of the reproduced race: one assistant message with a short // text preamble AND the tool call that never resolved. The preamble must not // pass as a finished reply. const ctx = { @@ -1603,14 +1603,14 @@ await settled({ type: "agent_settled" }, stuckCtx); if (prompts.length !== 1) throw new Error(`settle A: expected the recovery follow-up, got ${prompts.length}`); if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`settle A: wrong prompt: ${prompts[0]}`); -// Settle B: the recovery turn's own settle, but supervision went unhealthy in -// the meantime, so the supervision guard claims this settle instead. +// Settle B: the settle of the recovery turn itself, but supervision went +// unhealthy in the meantime, so the supervision guard claims this settle. writeFileSync(verdict, "2\n"); await settled({ type: "agent_settled" }, stuckCtx); if (prompts.length !== 2) throw new Error(`settle B: expected the supervision follow-up, got ${prompts.length}`); if (!prompts[1].includes("TURN WOULD END BLIND")) throw new Error(`settle B: wrong prompt: ${prompts[1]}`); -// Settle C: absorbed by the supervision guard's own latch. +// Settle C: absorbed by the latch of the supervision guard itself. rmSync(verdict); await settled({ type: "agent_settled" }, stuckCtx); if (prompts.length !== 2) throw new Error(`settle C (guard-latch-absorbed): expected still 2, got ${prompts.length}`); @@ -1658,7 +1658,7 @@ const settled = handlers.get("agent_settled"); if (!started) throw new Error("before_agent_start handler was not registered"); // The reproduced race: the captain prompt and the watcher wake both observe an -// idle session and both open a logical run. The loser's inner agent.prompt() +// idle session and both open a logical run. The losing inner agent.prompt() // rejects at once, but its finally block still emits agent_settled while the // winner is mid-turn - so the transcript tail is a not-yet-resolved tool call // that is going to be answered by the still-running winner. @@ -1674,7 +1674,7 @@ const midFlightCtx = { await started({ type: "before_agent_start" }, {}); await started({ type: "before_agent_start" }, {}); -// The loser's spurious settle must be left entirely unevaluated: no recovery +// The losing spurious settle must be left entirely unevaluated: no recovery // follow-up, and not even a supervision-guard run. await settled({ type: "agent_settled" }, midFlightCtx); if (prompts.length !== 0) throw new Error(`spurious mid-turn settle produced ${prompts.length} follow-ups`); @@ -1682,7 +1682,7 @@ if (readFileSync(process.env.FM_GUARD_LOG, "utf8").trim() !== "") { throw new Error("spurious mid-turn settle still ran the supervision guard"); } -// The winner's own settle is terminal and is judged normally. +// The settle of the winner itself is terminal and is judged normally. await settled({ type: "agent_settled" }, midFlightCtx); if (prompts.length !== 1) throw new Error(`genuine settle produced ${prompts.length} follow-ups, expected exactly 1`); if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); @@ -1720,7 +1720,7 @@ const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); // Same interleaving, but the winner goes on to answer properly. Nudging on the -// loser's spurious settle would start an extra turn on top of an answer that +// losing spurious settle would start an extra turn on top of an answer that // was already on its way - the duplicate answer the contract forbids. const midFlight = [ { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "captain input", timestamp: 0 } }, @@ -1776,7 +1776,7 @@ const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); if (!input) throw new Error("input handler was not registered"); -// The watcher wake wins the race and answers normally. The captain's own +// The watcher wake wins the race and answers normally. The captain side // prompt() call loses: pi-agent-core rejects it before normalizePromptInput, // so its message never becomes a transcript entry at all - the tail is // perfectly healthy and no tail inspection could ever see the loss. @@ -1839,7 +1839,7 @@ const input = handlers.get("input"); const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); -// Negative control: the ordinary, unraced case. The captain's message reaches +// Negative control: the ordinary, unraced case. The captain message reaches // the transcript and is answered, so nothing may be resubmitted. let entries = []; const ctx = { sessionManager: { getEntries: () => entries } }; @@ -1893,7 +1893,7 @@ const input = handlers.get("input"); const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); -// A turn is already streaming, so the captain's message is queued rather than +// A turn is already streaming, so the captain message is queued rather than // starting a run: Pi reports that by setting streamingBehavior on the input // event. A queued message is only appended when the run consumes it, and // Escape clears the queue back into the editor instead - so its absence from @@ -1959,7 +1959,7 @@ const input = handlers.get("input"); const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); -// Pi's prompt() emits the input event and only then validates the model and +// The Pi prompt() emits the input event and only then validates the model and // auth, throwing before it ever reaches before_agent_start. Nothing is // appended, the captain sees the error, and resends from history. let entries = [ @@ -2027,10 +2027,10 @@ const input = handlers.get("input"); const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); -// The captain's prompt() emitted its input event and then threw before it +// The captain prompt() emitted its input event and then threw before it // could start a run, so nothing was appended. The next run is a watcher wake, -// entirely unrelated: its turn start quotes the wake text, not the captain's, -// and must not adopt the captain's phantom recording. +// entirely unrelated: its turn start quotes the wake text, not the captain +// text, and must not adopt the phantom captain recording. let entries = []; const ctx = { sessionManager: { getEntries: () => entries } }; @@ -2091,7 +2091,7 @@ const input = handlers.get("input"); const started = handlers.get("before_agent_start"); const settled = handlers.get("agent_settled"); -// Losing the race is not silent for the captain: the loser's rejection escapes +// Losing the race is not silent for the captain: the losing rejection escapes // prompt() into the interactive loop, which prints "Agent is already // processing a prompt..." as a chat error. The captain reacts by resending the // same instruction from history - so replaying the lost recording as well @@ -2101,15 +2101,15 @@ let entries = [ ]; const ctx = { sessionManager: { getEntries: () => entries } }; -// The captain's message and a watcher wake both start from idle. +// The captain message and a watcher wake both start from idle. await input({ type: "input", text: "loesch den branch", source: "interactive" }, ctx); await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); await started({ type: "before_agent_start", prompt: "loesch den branch" }, ctx); -// The captain's call loses and appends nothing; its settle is the spurious one +// The captain call loses and appends nothing; its settle is the spurious one // the wake is still running behind. await settled({ type: "agent_settled" }, ctx); -if (prompts.length !== 0) throw new Error(`the loser's settle acted: ${prompts.length} prompts`); +if (prompts.length !== 0) throw new Error(`the losing settle acted: ${prompts.length} prompts`); // The captain sees the error and resends by hand. That resubmission runs and // is answered normally. @@ -2121,7 +2121,7 @@ entries = [ { type: "message", id: "e3", parentId: "e2", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Branch geloescht." }], stopReason: "stop", timestamp: 0 } }, ]; -// The wake's own settle, then the resend's settle. Neither may replay the +// The wake settle, then the resend settle. Neither may replay the // instruction the manual resend already carried out. await settled({ type: "agent_settled" }, ctx); await settled({ type: "agent_settled" }, ctx); @@ -2199,7 +2199,7 @@ if (textParts.length !== 1) throw new Error(`expected exactly one text part, got if (!textParts[0].text.includes("CAPTAIN INPUT WAS LOST")) throw new Error(`not an input-recovery prompt: ${textParts[0].text}`); if (!textParts[0].text.includes("was ist das im Log?")) throw new Error(`the lost text was not carried: ${textParts[0].text}`); if (imageParts.length !== 1) throw new Error(`expected the screenshot to be carried, got ${imageParts.length} image parts`); -if (imageParts[0].data !== screenshot.data) throw new Error("the carried image was not the captain's attachment"); +if (imageParts[0].data !== screenshot.data) throw new Error("the carried image was not the captain attachment"); if (options?.deliverAs !== "followUp") throw new Error("recovery prompt was not a follow-up"); EOF ) @@ -2253,7 +2253,7 @@ entries = [ { type: "message", id: "a1", parentId: "u1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, ]; await settled({ type: "agent_settled" }, ctx); -if (prompts.length !== 0) throw new Error(`the loser's settle acted: ${prompts.length} prompts`); +if (prompts.length !== 0) throw new Error(`the losing settle acted: ${prompts.length} prompts`); writeFileSync(verdict, "2"); await settled({ type: "agent_settled" }, ctx); @@ -2321,9 +2321,9 @@ const stuckCtx = { }, }; -// Pi's extension runner does not serialize separate settle emissions, so a +// The Pi extension runner does not serialize separate settle emissions, so a // second run can start and settle while the first settle is still awaiting the -// supervision guard's child process. Both would otherwise judge the same stuck +// supervision guard child process. Both would otherwise judge the same stuck // state with their own stale latch snapshot and send two recovery turns. await started({ type: "before_agent_start" }, stuckCtx); const first = settled({ type: "agent_settled" }, stuckCtx); @@ -2338,9 +2338,9 @@ if (prompts.length !== 1) { } if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); -// The latch must still absorb the recovery turn's own settle afterwards. +// The latch must still absorb the own settle of the recovery turn afterwards. await settled({ type: "agent_settled" }, stuckCtx); -if (prompts.length !== 1) throw new Error(`the recovery turn's settle was not absorbed, total ${prompts.length}`); +if (prompts.length !== 1) throw new Error(`the settle of the recovery turn was not absorbed, total ${prompts.length}`); EOF ) status=$? @@ -2377,7 +2377,7 @@ const settled = handlers.get("agent_settled"); // Pi clears its own run flag before emitting the settle, so a fresh prompt can // open a new logical run while the handler is still awaiting the supervision -// guard's child process - and the transcript is only read after that await. +// guard child process - and the transcript is only read after that await. // The timer below lands inside exactly that window. const ctx = { sessionManager: { @@ -2395,9 +2395,9 @@ setTimeout(() => void started({ type: "before_agent_start" }, ctx), 0); await settlePromise; if (prompts.length !== 0) throw new Error(`nudged a healthy in-flight turn, got ${prompts.length} prompts`); -// The new run's own settle is terminal and is judged normally. +// The own settle of the new run is terminal and is judged normally. await settled({ type: "agent_settled" }, ctx); -if (prompts.length !== 1) throw new Error(`the new run's genuine settle produced ${prompts.length} prompts, expected 1`); +if (prompts.length !== 1) throw new Error(`the genuine settle of the new run produced ${prompts.length} prompts, expected 1`); if (!prompts[0].includes("TURN ENDED WITHOUT A REPLY")) throw new Error(`unexpected prompt: ${prompts[0]}`); EOF ) From 120ab9013d51e12e1ae55754d7a1649b6e6530a2 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:20:22 +0200 Subject: [PATCH 14/15] no-mistakes: apply CI fixes --- .pi/extensions/fm-primary-turnend-guard.ts | 62 +++++++++++++- docs/watcher-continuity.md | 7 +- tests/fm-turnend-guard.test.sh | 98 +++++++++++++++++++++- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index b096966d251..57040d3cfcf 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -96,6 +96,30 @@ let settleEvaluationActive = false; // would execute one instruction twice, and the resend carries it anyway. // Equivalence here is exact text, the only signal the events carry - a // reworded resend is not recognised as the same instruction. +// That supersession only reaches a resend the captain submits before a settle +// picked the recording up. The chat error is theirs to react to at any moment, +// so the resend can just as well arrive after recovery already went out, while +// the recovered copy is still being carried out - and the recording it would +// have superseded is gone by then. Recovery therefore keeps the text it just +// resubmitted, and a captain submission of exactly that text is delivered with +// the duplication stated in front of it instead of arriving as a second, +// unrelated-looking order. The captain's own words are kept verbatim behind +// that notice, because dropping their submission would be the same lost input +// this whole mechanism exists to prevent. +// The window closes on the settle that ends the recovery turn: once an answer +// to the recovered instruction exists, an identical submission after it is a +// deliberate repeat and must reach the model untouched. +const DUPLICATE_RECOVERED_INPUT_NOTICE = + "FIRSTMATE HARNESS NOTE - the instruction below was already delivered to you moments ago by automatic recovery, because Pi lost " + + "this exact submission at turn start, and that recovery is still being carried out. This is the captain sending the same text " + + "again by hand after seeing the error, not an order to do the work twice. Carry it out exactly once: if you have already done " + + "it, say so instead of repeating it. The captain's message follows verbatim."; +let recoveredCaptainInputText: string | null = null; + +function markDuplicateOfRecoveredInput(text: string): string { + return `${DUPLICATE_RECOVERED_INPUT_NOTICE}\n\n${text}`; +} + type UserMessageContent = Parameters[0]; type UserMessagePart = Exclude[number]; type PendingCaptainInput = { @@ -759,6 +783,7 @@ export default function (pi: ExtensionAPI) { markLoaded(); inFlightAgentRuns = 0; pendingCaptainInputs = []; + recoveredCaptainInputText = null; orphanedReplyAttempts = 0; orphanedReplyExhaustedNotified = false; if (!source) return; @@ -827,14 +852,26 @@ export default function (pi: ExtensionAPI) { const source = String((event as { source?: unknown }).source ?? ""); const text = String((event as { text?: unknown }).text ?? ""); const streamingBehavior = (event as { streamingBehavior?: unknown }).streamingBehavior; - if (!captainInputWorthTracking(source, text, streamingBehavior)) return; - const images = (event as { images?: unknown }).images; const trimmed = text.trim(); + // Judged for every captain submission, not only a tracked one: the resend + // that races an in-flight recovery is typically queued into that very + // turn, which sets streamingBehavior and takes it out of tracking. + const duplicatesRecovery = CAPTAIN_INPUT_SOURCES.has(source) && + recoveredCaptainInputText !== null && + trimmed === recoveredCaptainInputText; + const delivered = duplicatesRecovery ? markDuplicateOfRecoveredInput(text) : text; + const result = duplicatesRecovery + ? ({ action: "transform", text: delivered } as const) + : undefined; + if (!captainInputWorthTracking(source, text, streamingBehavior)) return result; + const images = (event as { images?: unknown }).images; pendingCaptainInputs = pendingCaptainInputs.filter( (pending) => pending.committed && pending.text.trim() !== trimmed, ); + // Recorded as it will be appended, not as it was typed, so the transcript + // comparison and the before_agent_start commit still match exactly. pendingCaptainInputs.push({ - text, + text: delivered, images: Array.isArray(images) ? (images as UserMessagePart[]) : [], afterEntryId: lastEntryId(ctx), committed: false, @@ -842,6 +879,7 @@ export default function (pi: ExtensionAPI) { if (pendingCaptainInputs.length > PENDING_CAPTAIN_INPUT_LIMIT) { pendingCaptainInputs = pendingCaptainInputs.slice(-PENDING_CAPTAIN_INPUT_LIMIT); } + return result; }); pi.on("agent_settled", async (_event, ctx) => { @@ -863,6 +901,14 @@ export default function (pi: ExtensionAPI) { if (settleEvaluationActive) return; settleEvaluationActive = true; + // The duplicate-resend window belongs to the recovery turn, and this is + // the settle that ends it: from here on an identical captain submission + // is a deliberate repeat and is delivered untouched. Unlike the latches + // above it is closed after the claim rather than before, because it + // suppresses nothing - a settle dropped here is concurrent with the very + // evaluation that may still be opening the window, and closing it from + // there would reopen the double execution it exists to prevent. + recoveredCaptainInputText = null; try { await evaluateSettle(ctx, replyFollowupSettle); } finally { @@ -911,7 +957,15 @@ export default function (pi: ExtensionAPI) { const payload: UserMessageContent = lostCaptainInput.images.length === 0 ? content : [{ type: "text", text: content }, ...lostCaptainInput.images]; - await pi.sendUserMessage(payload, { deliverAs: "followUp" }); + // Opened before the delivery await, because the captain can resend + // during it, and closed again if the delivery never happened. + recoveredCaptainInputText = lostCaptainInput.text.trim(); + try { + await pi.sendUserMessage(payload, { deliverAs: "followUp" }); + } catch (error) { + recoveredCaptainInputText = null; + throw error; + } return; } diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index c1727e7266f..40a6167b59b 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -71,6 +71,11 @@ That hook's own event carries the prompt it is starting and expansion is a no-op A new recording drops any uncommitted predecessor, and a recording still uncommitted when a settle arrives is dropped unjudged, because a call that was going to commit reaches the hook well before any settle. A recording is superseded outright when the captain submits the same text again: losing the race is not silent for them, because the loser's rejection escapes `prompt()` into the interactive loop and is printed as a chat error, so a resend is the natural reaction and recovering the earlier recording too would execute one instruction twice. Equivalence is exact text, the only signal the events carry, so a reworded resend is not recognised as the same instruction. +That supersession only reaches a resend submitted before a settle picked the recording up, and the captain reaction is not bound to arrive that early: it can just as well land after recovery already went out, while the recovered copy is still being carried out, and by then the recording it would have superseded is gone. +Recovery therefore keeps the text it just resubmitted, and a captain submission of exactly that text is transformed through the `input` event into the same words behind a harness notice that states the duplication and asks for a single execution. +The captain's own words are kept verbatim behind that notice rather than dropped, because swallowing their submission would be the lost input this whole mechanism exists to prevent, and the notice is plain text rather than an operational envelope, so the marked resend still establishes an ordinary captain boundary. +It is also recorded for tracking as it will be appended, not as it was typed, so the transcript comparison and the `before_agent_start` commit still match it exactly and a marked resend is never judged lost itself. +The window closes on the settle that ends the recovery turn: once an answer to the recovered instruction exists, an identical submission after it is a deliberate repeat and reaches the model untouched. The comparison against the transcript is exact rather than by containment: a tracked recording never starts with `/`, so Pi's skill-command and prompt-template expansion return it unchanged and the appended message carries it verbatim - containment would let a longer later message silently absorb a genuinely lost short one. On every settle where the supervision guard itself found nothing to say, it reads `ctx.sessionManager.getEntries()` and inspects the last conversational message entry, skipping everything that carries no reply expectation of its own - non-message entries such as the session-start digest, and message entries whose role is bookkeeping rather than conversation (Pi flushes an inline `!cmd` bashExecution message into the session right before `agent_settled`). @@ -85,7 +90,7 @@ A recovered instruction therefore establishes no captain boundary at all and `/a Closing it would change either that skill's boundary rules or this envelope's kind, so it is recorded here rather than fixed alongside the recovery mechanism. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the resend that races the recovery it already sent (marked once, not doubled, and untouched again once the recovery was answered), the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 6db898d062e..6be34d3d4f1 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -1339,7 +1339,7 @@ const settled = handlers.get("agent_settled"); const ctx = { sessionManager: { getEntries: () => [ - { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "⁣FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "u1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, ], }, }; @@ -2148,6 +2148,101 @@ EOF pass ".pi primary extension: a manual resend after the race never doubles the instruction" } +test_pi_input_recovery_marks_a_resend_that_races_its_own_recovery() { + local repo home ext out status + repo="$TMP_ROOT/pi-input-resend-window-root" + home="$TMP_ROOT/pi-input-resend-window-home" + mkdir -p "$home/state" + install_pi_reply_recovery_fixture "$repo" + ext="$repo/.pi/extensions/fm-primary-turnend-guard.ts" + out=$(PLUGIN="$ext" FM_HOME="$home" node --input-type=module 2>&1 <<'EOF' +import { pathToFileURL } from "node:url"; + +const handlers = new Map(); +const prompts = []; +const pi = { + on(event, handler) { + handlers.set(event, handler); + }, + async sendUserMessage(message, options) { + prompts.push({ message, options }); + }, +}; +const mod = await import(pathToFileURL(process.env.PLUGIN).href); +mod.default(pi); +const input = handlers.get("input"); +const started = handlers.get("before_agent_start"); +const settled = handlers.get("agent_settled"); + +// The chat error the losing prompt() prints is the captain reason to resend by +// hand, and that reaction is not bound to arrive before the settle picks the +// lost recording up. Here recovery goes first and the resend lands while the +// recovered copy is still being carried out. +const captainText = "loesch den branch"; +let entries = [ + { type: "message", id: "e1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, + { type: "message", id: "e2", parentId: "e1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, +]; +const ctx = { sessionManager: { getEntries: () => entries } }; + +await input({ type: "input", text: captainText, source: "interactive" }, ctx); +await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); +await started({ type: "before_agent_start", prompt: captainText }, ctx); +await settled({ type: "agent_settled" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) throw new Error(`expected exactly one recovery resubmission, got ${prompts.length}`); +const recoveryEnvelope = prompts[0].message; + +// The captain retypes the same instruction before the recovery turn is done. +const resend = await input({ type: "input", text: captainText, source: "interactive" }, ctx); +if (resend?.action !== "transform") { + throw new Error(`the resend was delivered as an unrelated second order: ${JSON.stringify(resend)}`); +} +if (!resend.text.includes(captainText)) throw new Error(`the captain words were dropped: ${resend.text}`); +if (!resend.text.includes("FIRSTMATE HARNESS NOTE")) throw new Error(`the duplication was not stated: ${resend.text}`); +if (!resend.text.includes("exactly once")) throw new Error(`the resend did not state a single execution: ${resend.text}`); +if (resend.text === captainText) throw new Error("the resend was left unmarked"); + +// Both the recovery follow-up and that resend run and are answered. The +// resend reaches the transcript as it was delivered, so it must not be +// judged lost and recovered a second time. +await started({ type: "before_agent_start", prompt: recoveryEnvelope }, ctx); +await started({ type: "before_agent_start", prompt: resend.text }, ctx); +entries = [ + ...entries, + { type: "message", id: "e3", parentId: "e2", timestamp: "t", message: { role: "user", content: [{ type: "text", text: recoveryEnvelope }], timestamp: 0 } }, + { type: "message", id: "e4", parentId: "e3", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Branch geloescht." }], stopReason: "stop", timestamp: 0 } }, + { type: "message", id: "e5", parentId: "e4", timestamp: "t", message: { role: "user", content: [{ type: "text", text: resend.text }], timestamp: 0 } }, + { type: "message", id: "e6", parentId: "e5", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Der Branch ist schon geloescht." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) { + throw new Error(`the marked resend was recovered again: ${JSON.stringify(prompts)}`); +} + +// Once an answer to the recovered instruction exists, the same text is a +// deliberate repeat and must reach the model untouched. +const later = await input({ type: "input", text: captainText, source: "interactive" }, ctx); +if (later !== undefined) throw new Error(`a deliberate repeat was marked as a duplicate: ${JSON.stringify(later)}`); +await started({ type: "before_agent_start", prompt: captainText }, ctx); +entries = [ + ...entries, + { type: "message", id: "e7", parentId: "e6", timestamp: "t", message: { role: "user", content: [{ type: "text", text: captainText }], timestamp: 0 } }, + { type: "message", id: "e8", parentId: "e7", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Erneut geprueft." }], stopReason: "stop", timestamp: 0 } }, +]; +await settled({ type: "agent_settled" }, ctx); +if (prompts.length !== 1) { + throw new Error(`the deliberate repeat produced a recovery: ${JSON.stringify(prompts)}`); +} +EOF +) + status=$? + expect_code 0 "$status" "Pi guard must mark a captain resend that races the recovery it already sent" + [ -z "$out" ] || fail "Pi input-recovery resend-window test printed output: $out" + pass ".pi primary extension: a resend racing its own recovery is delivered marked, not doubled" +} + test_pi_input_recovery_carries_attached_images() { local repo home ext out status repo="$TMP_ROOT/pi-input-images-root" @@ -3193,6 +3288,7 @@ test_pi_input_recovery_never_replays_a_withdrawn_queued_message test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent test_pi_input_recovery_ignores_an_unrelated_runs_turn_start test_pi_input_recovery_never_doubles_a_manual_resend_after_the_race +test_pi_input_recovery_marks_a_resend_that_races_its_own_recovery test_pi_input_recovery_carries_attached_images test_pi_input_recovery_detects_a_short_message_a_longer_one_contains test_pi_reply_recovery_never_doubles_on_overlapping_settles From ff88b9b9bc9cfd7203465438b447e8a6cd6634e5 Mon Sep 17 00:00:00 2001 From: Valentino-Sole <171032438+Valentino-Sole@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:36:57 +0200 Subject: [PATCH 15/15] no-mistakes: apply CI fixes --- .pi/extensions/fm-primary-turnend-guard.ts | 56 ++++++++++++++-------- docs/watcher-continuity.md | 10 ++-- tests/fm-turnend-guard.test.sh | 53 ++++++++++++-------- 3 files changed, 74 insertions(+), 45 deletions(-) diff --git a/.pi/extensions/fm-primary-turnend-guard.ts b/.pi/extensions/fm-primary-turnend-guard.ts index 57040d3cfcf..8e1a670214e 100644 --- a/.pi/extensions/fm-primary-turnend-guard.ts +++ b/.pi/extensions/fm-primary-turnend-guard.ts @@ -101,23 +101,37 @@ let settleEvaluationActive = false; // so the resend can just as well arrive after recovery already went out, while // the recovered copy is still being carried out - and the recording it would // have superseded is gone by then. Recovery therefore keeps the text it just -// resubmitted, and a captain submission of exactly that text is delivered with -// the duplication stated in front of it instead of arriving as a second, -// unrelated-looking order. The captain's own words are kept verbatim behind -// that notice, because dropping their submission would be the same lost input -// this whole mechanism exists to prevent. +// resubmitted, and a captain submission of exactly that text is not delivered +// to the model a second time at all: it is answered to the captain directly +// instead. Delivering it - even behind a note asking for a single execution - +// would put a second executable copy of that instruction in front of the +// model, and prose cannot enforce idempotence, so a destructive instruction +// could run twice. The instruction is not lost by that: the identical text is +// already in the conversation verbatim and is being carried out, which is +// exactly what the captain resent it to make happen. +// Suppressing it silently would be the lost input this whole mechanism exists +// to prevent, so the captain is told in the chat why the resend was not +// needed, through the one channel the model never reads. // The window closes on the settle that ends the recovery turn: once an answer // to the recovered instruction exists, an identical submission after it is a // deliberate repeat and must reach the model untouched. const DUPLICATE_RECOVERED_INPUT_NOTICE = - "FIRSTMATE HARNESS NOTE - the instruction below was already delivered to you moments ago by automatic recovery, because Pi lost " + - "this exact submission at turn start, and that recovery is still being carried out. This is the captain sending the same text " + - "again by hand after seeing the error, not an order to do the work twice. Carry it out exactly once: if you have already done " + - "it, say so instead of repeating it. The captain's message follows verbatim."; + "Resend not sent again: this exact instruction was lost by Pi at turn start, automatic recovery already delivered it, and it is " + + "being carried out right now. Sending it a second time could execute it twice, so it was answered here instead. Wait for the " + + "running answer, or reword the instruction to submit it as a new one."; let recoveredCaptainInputText: string | null = null; -function markDuplicateOfRecoveredInput(text: string): string { - return `${DUPLICATE_RECOVERED_INPUT_NOTICE}\n\n${text}`; +// Captain-facing only: ctx.ui.notify appends a line to the chat scrollback and +// never enters the model's context. Best effort - a headless or RPC context +// may carry no UI at all, and the suppression is the safety property, not the +// notice. +function notifyDuplicateOfRecoveredInput(ctx: unknown): void { + const notify = (ctx as { ui?: { notify?: unknown } } | undefined)?.ui?.notify; + if (typeof notify !== "function") return; + try { + notify.call((ctx as { ui: unknown }).ui, DUPLICATE_RECOVERED_INPUT_NOTICE, "warning"); + } catch { + } } type UserMessageContent = Parameters[0]; @@ -859,19 +873,21 @@ export default function (pi: ExtensionAPI) { const duplicatesRecovery = CAPTAIN_INPUT_SOURCES.has(source) && recoveredCaptainInputText !== null && trimmed === recoveredCaptainInputText; - const delivered = duplicatesRecovery ? markDuplicateOfRecoveredInput(text) : text; - const result = duplicatesRecovery - ? ({ action: "transform", text: delivered } as const) - : undefined; - if (!captainInputWorthTracking(source, text, streamingBehavior)) return result; + if (duplicatesRecovery) { + // Never delivered, so never appended either: tracking it would let the + // settle judge it lost and resubmit the very copy just suppressed. + notifyDuplicateOfRecoveredInput(ctx); + return { action: "handled" } as const; + } + if (!captainInputWorthTracking(source, text, streamingBehavior)) return undefined; const images = (event as { images?: unknown }).images; pendingCaptainInputs = pendingCaptainInputs.filter( (pending) => pending.committed && pending.text.trim() !== trimmed, ); - // Recorded as it will be appended, not as it was typed, so the transcript - // comparison and the before_agent_start commit still match exactly. + // Recorded exactly as it will be appended, so the transcript comparison + // and the before_agent_start commit still match it. pendingCaptainInputs.push({ - text: delivered, + text, images: Array.isArray(images) ? (images as UserMessagePart[]) : [], afterEntryId: lastEntryId(ctx), committed: false, @@ -879,7 +895,7 @@ export default function (pi: ExtensionAPI) { if (pendingCaptainInputs.length > PENDING_CAPTAIN_INPUT_LIMIT) { pendingCaptainInputs = pendingCaptainInputs.slice(-PENDING_CAPTAIN_INPUT_LIMIT); } - return result; + return undefined; }); pi.on("agent_settled", async (_event, ctx) => { diff --git a/docs/watcher-continuity.md b/docs/watcher-continuity.md index 40a6167b59b..d706f9fde05 100644 --- a/docs/watcher-continuity.md +++ b/docs/watcher-continuity.md @@ -72,9 +72,11 @@ A new recording drops any uncommitted predecessor, and a recording still uncommi A recording is superseded outright when the captain submits the same text again: losing the race is not silent for them, because the loser's rejection escapes `prompt()` into the interactive loop and is printed as a chat error, so a resend is the natural reaction and recovering the earlier recording too would execute one instruction twice. Equivalence is exact text, the only signal the events carry, so a reworded resend is not recognised as the same instruction. That supersession only reaches a resend submitted before a settle picked the recording up, and the captain reaction is not bound to arrive that early: it can just as well land after recovery already went out, while the recovered copy is still being carried out, and by then the recording it would have superseded is gone. -Recovery therefore keeps the text it just resubmitted, and a captain submission of exactly that text is transformed through the `input` event into the same words behind a harness notice that states the duplication and asks for a single execution. -The captain's own words are kept verbatim behind that notice rather than dropped, because swallowing their submission would be the lost input this whole mechanism exists to prevent, and the notice is plain text rather than an operational envelope, so the marked resend still establishes an ordinary captain boundary. -It is also recorded for tracking as it will be appended, not as it was typed, so the transcript comparison and the `before_agent_start` commit still match it exactly and a marked resend is never judged lost itself. +Recovery therefore keeps the text it just resubmitted, and a captain submission of exactly that text is claimed by the `input` event and not delivered to the model at all (`action: "handled"`). +Delivering it - even behind a note that states the duplication and asks for a single execution - would put a second executable copy of that instruction in front of the model, and prose cannot enforce idempotence, so a destructive instruction could still run twice. +Nothing is lost by that: the identical text is already in the conversation verbatim and is being carried out, which is exactly what the resend was meant to achieve. +Suppressing it silently would be the lost input this whole mechanism exists to prevent, so the captain is told through `ctx.ui.notify` - a chat line the model never reads - that the instruction is already running and that a reworded submission is the way to send a genuinely new one; the notice is best effort, because a headless or RPC context may carry no UI, and the suppression rather than the notice is the safety property. +A suppressed resend is not tracked either, because it never reaches the transcript and tracking it would let the next settle judge it lost and resubmit the very copy that was just withheld. The window closes on the settle that ends the recovery turn: once an answer to the recovered instruction exists, an identical submission after it is a deliberate repeat and reaches the model untouched. The comparison against the transcript is exact rather than by containment: a tracked recording never starts with `/`, so Pi's skill-command and prompt-template expansion return it unchanged and the appended message carries it verbatim - containment would let a longer later message silently absorb a genuinely lost short one. @@ -90,7 +92,7 @@ A recovered instruction therefore establishes no captain boundary at all and `/a Closing it would change either that skill's boundary rules or this envelope's kind, so it is recorded here rather than fixed alongside the recovery mechanism. This remains a detection-and-recovery backstop, not a fix for the underlying SDK race, and is currently Pi-only because that is where the race was reproduced and where `agent_settled`/`sessionManager.getEntries()` are available to an extension; Claude, Codex, OpenCode, Grok, and Cursor are unaffected by this specific gap because none of them deliver an autonomous wake through the same in-process `sendUserMessage` primitive. -`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the resend that races the recovery it already sent (marked once, not doubled, and untouched again once the recovery was answered), the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. +`tests/fm-turnend-guard.test.sh` covers the captain-input loss case (including a short message a longer later one contains) with its attachments, and its delivered-message, extension-wake, withdrawn-queued-message, resent-after-submission-failure, unrelated-turn-start and manual-resend-after-the-race negative controls, the resend that races the recovery it already sent (suppressed rather than delivered a second time, reported to the captain exactly once, never resubmitted as a lost input of its own, and delivered untouched again once the recovery was answered), the run that opens during the supervision check, overlapping settles, the per-generation budget reset, and reply-recovery cases for the dangling-tool-call and fully-unanswered detection cases (including a tool call beside a text preamble), the healthy no-op case, the idempotent bounded-retry-then-notice sequence and its reset after a healthy settle, the session-start digest and flushed inline-bash exclusions, the captain-abort exclusion against a still-nudged error stop, and the latch interleaving where a supervision-claimed settle must not swallow the next unanswered episode. ## Recovery episode acknowledgement diff --git a/tests/fm-turnend-guard.test.sh b/tests/fm-turnend-guard.test.sh index 6be34d3d4f1..a954266208c 100755 --- a/tests/fm-turnend-guard.test.sh +++ b/tests/fm-turnend-guard.test.sh @@ -2148,7 +2148,7 @@ EOF pass ".pi primary extension: a manual resend after the race never doubles the instruction" } -test_pi_input_recovery_marks_a_resend_that_races_its_own_recovery() { +test_pi_input_recovery_suppresses_a_resend_that_races_its_own_recovery() { local repo home ext out status repo="$TMP_ROOT/pi-input-resend-window-root" home="$TMP_ROOT/pi-input-resend-window-home" @@ -2183,7 +2183,11 @@ let entries = [ { type: "message", id: "e1", parentId: null, timestamp: "t", message: { role: "user", content: "\u2063FIRSTMATE_OP: v1 watcher: stale: ...", timestamp: 0 } }, { type: "message", id: "e2", parentId: "e1", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Wake abgearbeitet." }], stopReason: "stop", timestamp: 0 } }, ]; -const ctx = { sessionManager: { getEntries: () => entries } }; +const notices = []; +const ctx = { + sessionManager: { getEntries: () => entries }, + ui: { notify: (message, type) => notices.push({ message, type }) }, +}; await input({ type: "input", text: captainText, source: "interactive" }, ctx); await started({ type: "before_agent_start", prompt: "\u2063FIRSTMATE_OP: v1 watcher: stale: ..." }, ctx); @@ -2194,42 +2198,49 @@ if (prompts.length !== 1) throw new Error(`expected exactly one recovery resubmi const recoveryEnvelope = prompts[0].message; // The captain retypes the same instruction before the recovery turn is done. +// A second executable copy of a destructive instruction must never reach the +// model, and no prose in front of it can enforce that, so the resend is not +// delivered at all. const resend = await input({ type: "input", text: captainText, source: "interactive" }, ctx); -if (resend?.action !== "transform") { - throw new Error(`the resend was delivered as an unrelated second order: ${JSON.stringify(resend)}`); +if (resend?.action !== "handled") { + throw new Error(`the resend was delivered as a second executable copy: ${JSON.stringify(resend)}`); +} +if (Object.prototype.hasOwnProperty.call(resend, "text")) { + throw new Error(`the resend still carried delivery text: ${JSON.stringify(resend)}`); +} + +// Swallowing it without a word would be the lost captain input this whole +// mechanism exists to prevent, so the captain is told in the chat. +if (notices.length !== 1) throw new Error(`expected exactly one captain notice, got ${JSON.stringify(notices)}`); +if (notices[0].type !== "warning") throw new Error(`the notice was not raised to the captain: ${JSON.stringify(notices[0])}`); +if (!/already delivered it/.test(notices[0].message)) { + throw new Error(`the notice did not explain the suppression: ${notices[0].message}`); } -if (!resend.text.includes(captainText)) throw new Error(`the captain words were dropped: ${resend.text}`); -if (!resend.text.includes("FIRSTMATE HARNESS NOTE")) throw new Error(`the duplication was not stated: ${resend.text}`); -if (!resend.text.includes("exactly once")) throw new Error(`the resend did not state a single execution: ${resend.text}`); -if (resend.text === captainText) throw new Error("the resend was left unmarked"); -// Both the recovery follow-up and that resend run and are answered. The -// resend reaches the transcript as it was delivered, so it must not be -// judged lost and recovered a second time. +// Only the recovery turn runs. The suppressed resend never reaches the +// transcript, and must not be judged lost and resubmitted because of that. await started({ type: "before_agent_start", prompt: recoveryEnvelope }, ctx); -await started({ type: "before_agent_start", prompt: resend.text }, ctx); entries = [ ...entries, { type: "message", id: "e3", parentId: "e2", timestamp: "t", message: { role: "user", content: [{ type: "text", text: recoveryEnvelope }], timestamp: 0 } }, { type: "message", id: "e4", parentId: "e3", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Branch geloescht." }], stopReason: "stop", timestamp: 0 } }, - { type: "message", id: "e5", parentId: "e4", timestamp: "t", message: { role: "user", content: [{ type: "text", text: resend.text }], timestamp: 0 } }, - { type: "message", id: "e6", parentId: "e5", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Der Branch ist schon geloescht." }], stopReason: "stop", timestamp: 0 } }, ]; await settled({ type: "agent_settled" }, ctx); await settled({ type: "agent_settled" }, ctx); if (prompts.length !== 1) { - throw new Error(`the marked resend was recovered again: ${JSON.stringify(prompts)}`); + throw new Error(`the suppressed resend was recovered anyway: ${JSON.stringify(prompts)}`); } // Once an answer to the recovered instruction exists, the same text is a // deliberate repeat and must reach the model untouched. const later = await input({ type: "input", text: captainText, source: "interactive" }, ctx); -if (later !== undefined) throw new Error(`a deliberate repeat was marked as a duplicate: ${JSON.stringify(later)}`); +if (later !== undefined) throw new Error(`a deliberate repeat was suppressed: ${JSON.stringify(later)}`); +if (notices.length !== 1) throw new Error(`a deliberate repeat produced a notice: ${JSON.stringify(notices)}`); await started({ type: "before_agent_start", prompt: captainText }, ctx); entries = [ ...entries, - { type: "message", id: "e7", parentId: "e6", timestamp: "t", message: { role: "user", content: [{ type: "text", text: captainText }], timestamp: 0 } }, - { type: "message", id: "e8", parentId: "e7", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Erneut geprueft." }], stopReason: "stop", timestamp: 0 } }, + { type: "message", id: "e5", parentId: "e4", timestamp: "t", message: { role: "user", content: [{ type: "text", text: captainText }], timestamp: 0 } }, + { type: "message", id: "e6", parentId: "e5", timestamp: "t", message: { role: "assistant", content: [{ type: "text", text: "Erneut geprueft." }], stopReason: "stop", timestamp: 0 } }, ]; await settled({ type: "agent_settled" }, ctx); if (prompts.length !== 1) { @@ -2238,9 +2249,9 @@ if (prompts.length !== 1) { EOF ) status=$? - expect_code 0 "$status" "Pi guard must mark a captain resend that races the recovery it already sent" + expect_code 0 "$status" "Pi guard must suppress a captain resend that races the recovery it already sent" [ -z "$out" ] || fail "Pi input-recovery resend-window test printed output: $out" - pass ".pi primary extension: a resend racing its own recovery is delivered marked, not doubled" + pass ".pi primary extension: a resend racing its own recovery is suppressed and reported, not doubled" } test_pi_input_recovery_carries_attached_images() { @@ -3288,7 +3299,7 @@ test_pi_input_recovery_never_replays_a_withdrawn_queued_message test_pi_input_recovery_never_replays_a_failed_submission_the_captain_resent test_pi_input_recovery_ignores_an_unrelated_runs_turn_start test_pi_input_recovery_never_doubles_a_manual_resend_after_the_race -test_pi_input_recovery_marks_a_resend_that_races_its_own_recovery +test_pi_input_recovery_suppresses_a_resend_that_races_its_own_recovery test_pi_input_recovery_carries_attached_images test_pi_input_recovery_detects_a_short_message_a_longer_one_contains test_pi_reply_recovery_never_doubles_on_overlapping_settles