From dff5aa4ef4517557a31eb421558bb65fb77bcc5a Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 00:58:43 +0800 Subject: [PATCH 1/2] fix(subagents): wake the parent for deferred results Results that settled while the parent was busy were deliberately flushed as nextTurn at agent_settled. Pi only consumes that queue on another user prompt, so fire-and-forget results could not fulfill their documented automatic re-invocation contract. Flush the pending batch as one follow-up at the authoritative parent boundary, while preserving wait consumption, exactly-once delivery, synchronous retry, and aborted-turn suppression. Closes #47 --- extensions/subagents/docs/design-plan.md | 16 ++- extensions/subagents/index.test.ts | 113 ++++++++++++---- extensions/subagents/index.ts | 74 ++++++----- extensions/subagents/result-delivery.test.ts | 129 +++++++++++++++++-- extensions/subagents/src/result-delivery.ts | 57 +++++++- 5 files changed, 311 insertions(+), 78 deletions(-) diff --git a/extensions/subagents/docs/design-plan.md b/extensions/subagents/docs/design-plan.md index 76556a50..03c03456 100644 --- a/extensions/subagents/docs/design-plan.md +++ b/extensions/subagents/docs/design-plan.md @@ -71,13 +71,17 @@ the parent conversation. ### 1.3 Result delivery back to the parent - When a child settles **unconsumed**, `onSettled` defers it into a tiny - `createDeferredResultDelivery` buffer (defer/consume/drain/clear keyed by id). + `createSubagentResultDelivery` buffer (defer/consume/clear keyed by id). - Flush happens when the parent goes idle: immediately if `sessionContext.isIdle()`, - otherwise on the parent's `agent_settled` event. A later `subagent_wait` can still - consume a deferred result before flush (that's why it is a buffer, not an immediate - send). -- Delivery = `pi.sendMessage({ customType: "subagent-result", content, display: true, - details: { id, title, status } }, { deliverAs: "followUp", triggerTurn: true })`. + otherwise on the parent's authoritative `agent_settled` event. Busy-period results + are batched into one automatic wake-up so none can be stranded until the user's next + prompt. A later `subagent_wait` can still consume a deferred result before flush. + If the parent run was aborted, delivery uses `nextTurn` rather than resurrecting + work the user explicitly stopped. +- Normal delivery = `pi.sendMessage({ customType: "subagent-result", content, + display: false, details: { id, title, status } }, + { deliverAs: "followUp", triggerTurn: true })`; a separate session entry renders the + report at its actual completion point. Content is built by `buildSubagentResultMessage` (`Subagent sa-N "title" finished/failed.` + optional `Error:` line + output truncated to 24KB/600 lines with a pointer to the child session file for the full transcript). diff --git a/extensions/subagents/index.test.ts b/extensions/subagents/index.test.ts index a52114ce..6df000db 100644 --- a/extensions/subagents/index.test.ts +++ b/extensions/subagents/index.test.ts @@ -9,9 +9,12 @@ import type { ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { PLAN_MODE_CHANNEL } from "../shared/plan-mode-state.ts"; -import subagents, { createSubagentResultDispatcher } from "./index.ts"; +import subagents, { + createSubagentResultDispatcher, + registerSubagentResultDeliveryLifecycle, +} from "./index.ts"; -test("deferred subagent results render before a hidden next-turn injection", () => { +test("subagent results render before the hidden wake-up message", () => { const events: unknown[] = []; const pi = { appendEntry(customType: string, data: unknown) { @@ -23,29 +26,26 @@ test("deferred subagent results render before a hidden next-turn injection", () } as unknown as ExtensionAPI; const dispatch = createSubagentResultDispatcher(pi, () => "report"); - dispatch( - [ - { - id: "sa-3", - origin: "model", - backend: "pi", - title: "investigate plan mode", - prompt: "inspect", - cwd: process.cwd(), - status: "done", - createdAt: 0, - settledAt: 1_000, - meta: { backend: "pi" }, - usage: {}, - transcript: [], - liveTools: [], - queued: [], - finalText: "report", - turns: 1, - }, - ], - false, - ); + dispatch([ + { + id: "sa-3", + origin: "model", + backend: "pi", + title: "investigate plan mode", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcript: [], + liveTools: [], + queued: [], + finalText: "report", + turns: 1, + }, + ]); assert.deepEqual(events, [ { @@ -74,9 +74,70 @@ test("deferred subagent results render before a hidden next-turn injection", () status: "done", }, }, - options: { deliverAs: "nextTurn" }, + options: { deliverAs: "followUp", triggerTurn: true }, }, ]); + + events.length = 0; + dispatch( + [ + { + id: "sa-4", + origin: "model", + backend: "pi", + title: "aborted parent", + prompt: "inspect", + cwd: process.cwd(), + status: "done", + createdAt: 0, + settledAt: 1_000, + meta: { backend: "pi" }, + usage: {}, + transcript: [], + liveTools: [], + queued: [], + finalText: "report", + turns: 1, + }, + ], + false, + ); + assert.deepEqual((events.at(-1) as { options: unknown }).options, { + deliverAs: "nextTurn", + }); +}); + +test("the parent lifecycle wakes deferred results unless the run was aborted", () => { + const handlers = new Map void>(); + const pi = { + on(event: string, handler: (event: never) => void) { + handlers.set(event, handler); + }, + } as unknown as ExtensionAPI; + const settled: Array = []; + registerSubagentResultDeliveryLifecycle(pi, { + parentSettled: (aborted) => settled.push(aborted), + }); + + handlers.get("agent_start")?.({ type: "agent_start" } as never); + handlers.get("agent_end")?.({ + type: "agent_end", + messages: [{ role: "assistant", stopReason: "stop" }], + } as never); + handlers.get("agent_settled")?.({ type: "agent_settled" } as never); + + handlers.get("agent_start")?.({ type: "agent_start" } as never); + handlers.get("agent_end")?.({ + type: "agent_end", + messages: [ + { role: "assistant", stopReason: "stop" }, + { role: "toolResult" }, + { role: "assistant", stopReason: "aborted" }, + ], + } as never); + handlers.get("agent_settled")?.({ type: "agent_settled" } as never); + + assert.deepEqual(settled, [false, true]); }); test("the visible subagent result entry renders the completed report", () => { diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 59d8fd3a..41c05ba2 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -95,8 +95,7 @@ import { SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS, SUBAGENT_WAIT_TOOL_DESCRIPTION, } from "./src/prompt.ts"; -import { createDeferredResultDelivery } from "./src/result-delivery.ts"; -import { resultDeliveryOptions } from "../background-terminals/src/result-delivery.ts"; +import { createSubagentResultDelivery } from "./src/result-delivery.ts"; import { effectiveChildToolAllowlist, resolveStandaloneChildProjectTrust, @@ -210,7 +209,7 @@ export function createSubagentResultDispatcher( pi: ExtensionAPI, outputFor: (snap: SubagentSnapshot) => string = truncatedOutput, ) { - return (snaps: readonly SubagentSnapshot[], wake: boolean) => { + return (snaps: readonly SubagentSnapshot[], wake = true) => { if (snaps.length === 0) return; const content = snaps .map((snap) => @@ -249,11 +248,37 @@ export function createSubagentResultDispatcher( display: false, details, }, - resultDeliveryOptions(wake), + wake + ? { deliverAs: "followUp", triggerTurn: true } + : { deliverAs: "nextTurn" }, ); }; } +export function registerSubagentResultDeliveryLifecycle( + pi: ExtensionAPI, + delivery: { parentSettled(aborted?: boolean): void }, +) { + let parentRunAborted = false; + + pi.on("agent_start", () => { + parentRunAborted = false; + }); + pi.on("agent_end", (event) => { + for (let index = event.messages.length - 1; index >= 0; index--) { + const message = event.messages[index]; + if (message?.role !== "assistant") continue; + parentRunAborted = message.stopReason === "aborted"; + break; + } + }); + pi.on("agent_settled", () => delivery.parentSettled(parentRunAborted)); + + return () => { + parentRunAborted = false; + }; +} + type SubagentResultTheme = Parameters[2]; function renderSubagentResult( @@ -322,8 +347,17 @@ export default function (pi: ExtensionAPI) { let requestWidgetRender: (() => void) | undefined; let navigationLayerRegistered = false; let dashboardOpen = false; - const resultDelivery = createDeferredResultDelivery(); const dispatchResults = createSubagentResultDispatcher(pi); + const resultDelivery = createSubagentResultDelivery({ + isIdle: () => sessionContext?.isIdle() === true, + // Every unconsumed fire-and-forget result must reach the parent. The + // delivery coordinator batches results that settled while it was busy. + deliver: dispatchResults, + }); + const resetResultDeliveryLifecycle = registerSubagentResultDeliveryLifecycle( + pi, + resultDelivery, + ); const hideLifecycleTools = () => patchOwnedTools(pi, "subagents", { disable: OPENPI_TOOL_SURFACE.subagents.deferred, @@ -436,25 +470,6 @@ export default function (pi: ExtensionAPI) { navigationLayerRegistered = true; }; - /** - * `wake` decides whether this costs the model a turn. A subagent that - * settled while the model sits idle is the result it is waiting on. A - * backlog that piled up while it worked is not: waking once per stale - * subagent forces a turn each, and the model can only answer "that one - * already finished". `nextTurn` still enters context with the user's next - * message, without demanding a reply. - */ - const deliverResults = ( - snaps: readonly SubagentSnapshot[], - wake: boolean, - ) => { - dispatchResults(snaps, wake); - }; - - const flushResults = (wake: boolean) => { - deliverResults(resultDelivery.drain(), wake); - }; - const deliverBtwResult = (snap: SubagentSnapshot) => { // appendEntry is a synchronous SessionManager operation and emits an // entry_appended event, so it is safe while the parent is streaming and @@ -500,10 +515,10 @@ export default function (pi: ExtensionAPI) { // subagent_wait can consume it before agent_settled flushes follow-ups. // Defer a copy: the live snapshot keeps mutating if the subagent is // restarted before the deferred result flushes. + // The delivery coordinator closes both sides of the wake-up race: it + // flushes now if the parent is already idle, otherwise the parent's next + // agent_settled edge rechecks this same pending Map. resultDelivery.defer({ ...snap, meta: { ...snap.meta } }); - // Settled while the model sits idle: it has nothing else in flight, so - // this is the result it is waiting on — wake it. - if (sessionContext?.isIdle()) flushResults(true); }; pi.on("session_start", (_event, ctx) => { @@ -530,10 +545,6 @@ export default function (pi: ExtensionAPI) { managerPromise?.then(updateStatus).catch(() => undefined); }); - // These settled while the model was working on something else, so they go - // into context without forcing a turn per stale subagent. - pi.on("agent_settled", () => flushResults(false)); - pi.on("session_shutdown", async () => { if (navigationLayerRegistered) { removeEditorLayer(pi, "subagents"); @@ -555,6 +566,7 @@ export default function (pi: ExtensionAPI) { requestWidgetRender = undefined; stripState.focused = false; dashboardOpen = false; + resetResultDeliveryLifecycle(); const closing = runtime; runtime = undefined; managerPromise = undefined; diff --git a/extensions/subagents/result-delivery.test.ts b/extensions/subagents/result-delivery.test.ts index 44aaab25..29f5edf9 100644 --- a/extensions/subagents/result-delivery.test.ts +++ b/extensions/subagents/result-delivery.test.ts @@ -1,27 +1,136 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { createDeferredResultDelivery } from "./src/result-delivery.ts"; +import { createSubagentResultDelivery } from "./src/result-delivery.ts"; -test("a result consumed by a later wait is not delivered", () => { - const delivery = createDeferredResultDelivery<{ +interface DeliveredBatch { + results: readonly { id: string; output?: string }[]; + wake: boolean; +} + +function harness(initialIdle: boolean) { + let idle = initialIdle; + const deliveries: DeliveredBatch[] = []; + const delivery = createSubagentResultDelivery<{ id: string; - output: string; - }>(); + output?: string; + }>({ + isIdle: () => idle, + deliver: (results, wake) => deliveries.push({ results, wake }), + }); + return { + delivery, + deliveries, + setIdle(value: boolean) { + idle = value; + }, + }; +} + +test("a result consumed by a later wait is not delivered", () => { + const { delivery, deliveries } = harness(false); delivery.defer({ id: "sa-1", output: "done" }); delivery.consume(["sa-1"]); + delivery.parentSettled(); + + assert.deepEqual(deliveries, []); +}); + +test("an idle child settlement wakes the parent immediately", () => { + const { delivery, deliveries } = harness(true); + + delivery.defer({ id: "sa-1" }); + + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); +}); + +test("a busy child settlement wakes when the parent settles", () => { + const { delivery, deliveries, setIdle } = harness(false); + + delivery.defer({ id: "sa-1" }); + assert.deepEqual(deliveries, []); + + setIdle(true); + delivery.parentSettled(); + + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); +}); + +test("the parent boundary still delivers if another extension started a run", () => { + const { delivery, deliveries } = harness(false); + + delivery.defer({ id: "sa-1" }); + // Pi has already emitted parent agent_settled, but an earlier extension + // handler started another run before this extension's handler executes. + delivery.parentSettled(); - assert.deepEqual(delivery.drain(), []); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); }); -test("unconsumed results are delivered once in settlement order", () => { - const delivery = createDeferredResultDelivery<{ id: string }>(); +test("busy results batch in settlement order into one parent wake-up", () => { + const { delivery, deliveries } = harness(false); const first = { id: "sa-1" }; const second = { id: "sa-2" }; delivery.defer(first); delivery.defer(second); + delivery.parentSettled(); + + assert.deepEqual(deliveries, [{ results: [first, second], wake: true }]); +}); + +test("the child-settled and parent-settled edges cannot double deliver", () => { + const { delivery, deliveries, setIdle } = harness(false); + + delivery.defer({ id: "sa-1" }); + setIdle(true); + delivery.parentSettled(); + delivery.parentSettled(); + + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); +}); + +test("an aborted parent queues the batch without resurrecting a turn", () => { + const { delivery, deliveries } = harness(false); + + delivery.defer({ id: "sa-1" }); + delivery.parentSettled(true); + + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: false }]); +}); + +test("a child settling during the wake turn waits for its boundary", () => { + const { delivery, deliveries, setIdle } = harness(true); + + delivery.defer({ id: "sa-1" }); + setIdle(false); + delivery.defer({ id: "sa-2" }); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); + + delivery.parentSettled(); + assert.deepEqual(deliveries, [ + { results: [{ id: "sa-1" }], wake: true }, + { results: [{ id: "sa-2" }], wake: true }, + ]); +}); + +test("a synchronous delivery failure restores the batch in order", () => { + let attempts = 0; + const delivered: string[][] = []; + const delivery = createSubagentResultDelivery<{ id: string }>({ + isIdle: () => false, + deliver(results) { + attempts++; + if (attempts === 1) throw new Error("session switching"); + delivered.push(results.map(({ id }) => id)); + }, + }); + + delivery.defer({ id: "sa-1" }); + delivery.defer({ id: "sa-2" }); + assert.throws(() => delivery.parentSettled(), /session switching/); + delivery.defer({ id: "sa-3" }); + delivery.parentSettled(); - assert.deepEqual(delivery.drain(), [first, second]); - assert.deepEqual(delivery.drain(), []); + assert.deepEqual(delivered, [["sa-1", "sa-2", "sa-3"]]); }); diff --git a/extensions/subagents/src/result-delivery.ts b/extensions/subagents/src/result-delivery.ts index 9c7bb6da..361bf54c 100644 --- a/extensions/subagents/src/result-delivery.ts +++ b/extensions/subagents/src/result-delivery.ts @@ -1,17 +1,64 @@ -export function createDeferredResultDelivery() { +export interface SubagentResultDeliveryOptions { + /** True only when the parent has no run or queued continuation in flight. */ + readonly isIdle: () => boolean; + /** Deliver one drained batch, waking the parent unless `wake` is false. */ + readonly deliver: (results: readonly T[], wake: boolean) => void; +} + +/** + * One-shot result delivery for fire-and-forget subagents. + * + * The tool contract promises that a settled child re-invokes the parent. A + * child that settles while the parent is busy therefore remains retractable + * until the parent's `agent_settled` event, but it must never be downgraded to + * a `nextTurn` message that needs another user prompt. There are two symmetric + * wake-up edges so no ordering can lose the notification: + * + * 1. child settles after the parent became idle -> `defer` flushes now; + * 2. parent settles after the child -> `parentSettled` flushes the batch. + * + * The parent boundary normally wakes even if an earlier extension handler has + * already started another turn: Pi queues the follow-up into that active run. + * An aborted boundary is the exception — it queues for the next explicit user + * turn rather than resurrecting work the user just stopped. + * + * The Map is the one-shot gate: `subagent_wait` may consume a result before it + * is delivered, and whichever path drains first prevents duplicate delivery. + */ +export function createSubagentResultDelivery( + options: SubagentResultDeliveryOptions, +) { const pending = new Map(); + const flush = (wake: boolean) => { + if (pending.size === 0) return; + const results = [...pending.values()]; + pending.clear(); + try { + options.deliver(results, wake); + } catch (error) { + // A synchronous session teardown may reject append/send. Preserve the + // original batch ahead of anything deferred re-entrantly while delivery + // ran, so a later boundary can retry without loss or reordering. + const current = [...pending.values()]; + pending.clear(); + for (const result of results) pending.set(result.id, result); + for (const result of current) pending.set(result.id, result); + throw error; + } + }; + return { defer(result: T) { pending.set(result.id, result); + if (options.isIdle()) flush(true); }, consume(ids: Iterable) { for (const id of ids) pending.delete(id); }, - drain() { - const results = [...pending.values()]; - pending.clear(); - return results; + /** Flush at the authoritative parent boundary; aborts must not self-wake. */ + parentSettled(aborted = false) { + flush(!aborted); }, clear() { pending.clear(); From de683c787f5ea2c8930342a511bb00940b56695f Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 01:54:06 +0800 Subject: [PATCH 2/2] fix(subagents): always wake for deferred results --- extensions/subagents/docs/design-plan.md | 2 - extensions/subagents/index.test.ts | 66 +------------------- extensions/subagents/index.ts | 36 +---------- extensions/subagents/result-delivery.test.ts | 28 +++------ extensions/subagents/src/result-delivery.ts | 22 +++---- 5 files changed, 23 insertions(+), 131 deletions(-) diff --git a/extensions/subagents/docs/design-plan.md b/extensions/subagents/docs/design-plan.md index 03c03456..007dbc0a 100644 --- a/extensions/subagents/docs/design-plan.md +++ b/extensions/subagents/docs/design-plan.md @@ -76,8 +76,6 @@ the parent conversation. otherwise on the parent's authoritative `agent_settled` event. Busy-period results are batched into one automatic wake-up so none can be stranded until the user's next prompt. A later `subagent_wait` can still consume a deferred result before flush. - If the parent run was aborted, delivery uses `nextTurn` rather than resurrecting - work the user explicitly stopped. - Normal delivery = `pi.sendMessage({ customType: "subagent-result", content, display: false, details: { id, title, status } }, { deliverAs: "followUp", triggerTurn: true })`; a separate session entry renders the diff --git a/extensions/subagents/index.test.ts b/extensions/subagents/index.test.ts index 6df000db..257dc896 100644 --- a/extensions/subagents/index.test.ts +++ b/extensions/subagents/index.test.ts @@ -9,10 +9,7 @@ import type { ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { PLAN_MODE_CHANNEL } from "../shared/plan-mode-state.ts"; -import subagents, { - createSubagentResultDispatcher, - registerSubagentResultDeliveryLifecycle, -} from "./index.ts"; +import subagents, { createSubagentResultDispatcher } from "./index.ts"; test("subagent results render before the hidden wake-up message", () => { const events: unknown[] = []; @@ -77,67 +74,6 @@ test("subagent results render before the hidden wake-up message", () => { options: { deliverAs: "followUp", triggerTurn: true }, }, ]); - - events.length = 0; - dispatch( - [ - { - id: "sa-4", - origin: "model", - backend: "pi", - title: "aborted parent", - prompt: "inspect", - cwd: process.cwd(), - status: "done", - createdAt: 0, - settledAt: 1_000, - meta: { backend: "pi" }, - usage: {}, - transcript: [], - liveTools: [], - queued: [], - finalText: "report", - turns: 1, - }, - ], - false, - ); - assert.deepEqual((events.at(-1) as { options: unknown }).options, { - deliverAs: "nextTurn", - }); -}); - -test("the parent lifecycle wakes deferred results unless the run was aborted", () => { - const handlers = new Map void>(); - const pi = { - on(event: string, handler: (event: never) => void) { - handlers.set(event, handler); - }, - } as unknown as ExtensionAPI; - const settled: Array = []; - registerSubagentResultDeliveryLifecycle(pi, { - parentSettled: (aborted) => settled.push(aborted), - }); - - handlers.get("agent_start")?.({ type: "agent_start" } as never); - handlers.get("agent_end")?.({ - type: "agent_end", - messages: [{ role: "assistant", stopReason: "stop" }], - } as never); - handlers.get("agent_settled")?.({ type: "agent_settled" } as never); - - handlers.get("agent_start")?.({ type: "agent_start" } as never); - handlers.get("agent_end")?.({ - type: "agent_end", - messages: [ - { role: "assistant", stopReason: "stop" }, - { role: "toolResult" }, - { role: "assistant", stopReason: "aborted" }, - ], - } as never); - handlers.get("agent_settled")?.({ type: "agent_settled" } as never); - - assert.deepEqual(settled, [false, true]); }); test("the visible subagent result entry renders the completed report", () => { diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index 41c05ba2..a5db7af8 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -209,7 +209,7 @@ export function createSubagentResultDispatcher( pi: ExtensionAPI, outputFor: (snap: SubagentSnapshot) => string = truncatedOutput, ) { - return (snaps: readonly SubagentSnapshot[], wake = true) => { + return (snaps: readonly SubagentSnapshot[]) => { if (snaps.length === 0) return; const content = snaps .map((snap) => @@ -248,37 +248,11 @@ export function createSubagentResultDispatcher( display: false, details, }, - wake - ? { deliverAs: "followUp", triggerTurn: true } - : { deliverAs: "nextTurn" }, + { deliverAs: "followUp", triggerTurn: true }, ); }; } -export function registerSubagentResultDeliveryLifecycle( - pi: ExtensionAPI, - delivery: { parentSettled(aborted?: boolean): void }, -) { - let parentRunAborted = false; - - pi.on("agent_start", () => { - parentRunAborted = false; - }); - pi.on("agent_end", (event) => { - for (let index = event.messages.length - 1; index >= 0; index--) { - const message = event.messages[index]; - if (message?.role !== "assistant") continue; - parentRunAborted = message.stopReason === "aborted"; - break; - } - }); - pi.on("agent_settled", () => delivery.parentSettled(parentRunAborted)); - - return () => { - parentRunAborted = false; - }; -} - type SubagentResultTheme = Parameters[2]; function renderSubagentResult( @@ -354,10 +328,7 @@ export default function (pi: ExtensionAPI) { // delivery coordinator batches results that settled while it was busy. deliver: dispatchResults, }); - const resetResultDeliveryLifecycle = registerSubagentResultDeliveryLifecycle( - pi, - resultDelivery, - ); + pi.on("agent_settled", () => resultDelivery.parentSettled()); const hideLifecycleTools = () => patchOwnedTools(pi, "subagents", { disable: OPENPI_TOOL_SURFACE.subagents.deferred, @@ -566,7 +537,6 @@ export default function (pi: ExtensionAPI) { requestWidgetRender = undefined; stripState.focused = false; dashboardOpen = false; - resetResultDeliveryLifecycle(); const closing = runtime; runtime = undefined; managerPromise = undefined; diff --git a/extensions/subagents/result-delivery.test.ts b/extensions/subagents/result-delivery.test.ts index 29f5edf9..38c910a4 100644 --- a/extensions/subagents/result-delivery.test.ts +++ b/extensions/subagents/result-delivery.test.ts @@ -4,7 +4,6 @@ import { createSubagentResultDelivery } from "./src/result-delivery.ts"; interface DeliveredBatch { results: readonly { id: string; output?: string }[]; - wake: boolean; } function harness(initialIdle: boolean) { @@ -15,7 +14,7 @@ function harness(initialIdle: boolean) { output?: string; }>({ isIdle: () => idle, - deliver: (results, wake) => deliveries.push({ results, wake }), + deliver: (results) => deliveries.push({ results }), }); return { delivery, @@ -41,7 +40,7 @@ test("an idle child settlement wakes the parent immediately", () => { delivery.defer({ id: "sa-1" }); - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }] }]); }); test("a busy child settlement wakes when the parent settles", () => { @@ -53,7 +52,7 @@ test("a busy child settlement wakes when the parent settles", () => { setIdle(true); delivery.parentSettled(); - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }] }]); }); test("the parent boundary still delivers if another extension started a run", () => { @@ -64,7 +63,7 @@ test("the parent boundary still delivers if another extension started a run", () // handler started another run before this extension's handler executes. delivery.parentSettled(); - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }] }]); }); test("busy results batch in settlement order into one parent wake-up", () => { @@ -76,7 +75,7 @@ test("busy results batch in settlement order into one parent wake-up", () => { delivery.defer(second); delivery.parentSettled(); - assert.deepEqual(deliveries, [{ results: [first, second], wake: true }]); + assert.deepEqual(deliveries, [{ results: [first, second] }]); }); test("the child-settled and parent-settled edges cannot double deliver", () => { @@ -87,16 +86,7 @@ test("the child-settled and parent-settled edges cannot double deliver", () => { delivery.parentSettled(); delivery.parentSettled(); - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); -}); - -test("an aborted parent queues the batch without resurrecting a turn", () => { - const { delivery, deliveries } = harness(false); - - delivery.defer({ id: "sa-1" }); - delivery.parentSettled(true); - - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: false }]); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }] }]); }); test("a child settling during the wake turn waits for its boundary", () => { @@ -105,12 +95,12 @@ test("a child settling during the wake turn waits for its boundary", () => { delivery.defer({ id: "sa-1" }); setIdle(false); delivery.defer({ id: "sa-2" }); - assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }], wake: true }]); + assert.deepEqual(deliveries, [{ results: [{ id: "sa-1" }] }]); delivery.parentSettled(); assert.deepEqual(deliveries, [ - { results: [{ id: "sa-1" }], wake: true }, - { results: [{ id: "sa-2" }], wake: true }, + { results: [{ id: "sa-1" }] }, + { results: [{ id: "sa-2" }] }, ]); }); diff --git a/extensions/subagents/src/result-delivery.ts b/extensions/subagents/src/result-delivery.ts index 361bf54c..52eb2739 100644 --- a/extensions/subagents/src/result-delivery.ts +++ b/extensions/subagents/src/result-delivery.ts @@ -1,8 +1,8 @@ export interface SubagentResultDeliveryOptions { /** True only when the parent has no run or queued continuation in flight. */ readonly isIdle: () => boolean; - /** Deliver one drained batch, waking the parent unless `wake` is false. */ - readonly deliver: (results: readonly T[], wake: boolean) => void; + /** Deliver one drained batch and wake the parent. */ + readonly deliver: (results: readonly T[]) => void; } /** @@ -17,10 +17,8 @@ export interface SubagentResultDeliveryOptions { * 1. child settles after the parent became idle -> `defer` flushes now; * 2. parent settles after the child -> `parentSettled` flushes the batch. * - * The parent boundary normally wakes even if an earlier extension handler has - * already started another turn: Pi queues the follow-up into that active run. - * An aborted boundary is the exception — it queues for the next explicit user - * turn rather than resurrecting work the user just stopped. + * The parent boundary wakes even if an earlier extension handler has already + * started another turn: Pi queues the follow-up into that active run. * * The Map is the one-shot gate: `subagent_wait` may consume a result before it * is delivered, and whichever path drains first prevents duplicate delivery. @@ -30,12 +28,12 @@ export function createSubagentResultDelivery( ) { const pending = new Map(); - const flush = (wake: boolean) => { + const flush = () => { if (pending.size === 0) return; const results = [...pending.values()]; pending.clear(); try { - options.deliver(results, wake); + options.deliver(results); } catch (error) { // A synchronous session teardown may reject append/send. Preserve the // original batch ahead of anything deferred re-entrantly while delivery @@ -51,14 +49,14 @@ export function createSubagentResultDelivery( return { defer(result: T) { pending.set(result.id, result); - if (options.isIdle()) flush(true); + if (options.isIdle()) flush(); }, consume(ids: Iterable) { for (const id of ids) pending.delete(id); }, - /** Flush at the authoritative parent boundary; aborts must not self-wake. */ - parentSettled(aborted = false) { - flush(!aborted); + /** Flush at the authoritative parent boundary. */ + parentSettled() { + flush(); }, clear() { pending.clear();