From a2bd5538c768677ff877e2053af697afc659c172 Mon Sep 17 00:00:00 2001 From: L4XB Date: Fri, 11 Sep 2026 09:16:51 +0200 Subject: [PATCH] fix(agent-runtime): deliver delegate reports that settled before the parent idled resumeAfterDelegations kept the turn open and auto-fed reports only while delegates of the current turn were still running. A delegate that finished before the parent went idle, with no TaskWait in between, was settled but never delivered: runningDelegations() was empty, the resume loop returned, and the parent continued as if the delegate had produced nothing. The wait set is now "current turn, report not yet delivered": a new reportDelivered flag on each record, pendingCurrentTurnDelegations() as the loop/keep-open condition, and single-shot delivery. TaskWait marks the settled records it returned as delivered so the idle resume does not repeat them; still-running records keep their shot, so a timeout or an early mode "any" convergence does not consume a future auto-resume. Stopped and aborted runs are not auto-delivered, as before. Refs #226 --- packages/agent-runtime/src/runtime.test.ts | 94 ++++++++++++++++++++++ packages/agent-runtime/src/runtime.ts | 41 +++++++++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/packages/agent-runtime/src/runtime.test.ts b/packages/agent-runtime/src/runtime.test.ts index 801e567de..98fa6ef4d 100644 --- a/packages/agent-runtime/src/runtime.test.ts +++ b/packages/agent-runtime/src/runtime.test.ts @@ -5921,6 +5921,100 @@ describe("DesktopAgentRuntime subagents", () => { await runtime.dispose(); }); + it("feeds a report that settled before the parent idled (#226)", async () => { + const runtime = createRuntime({ subagents: [explorer] }); + subagentRuns.calls.length = 0; + subagentRuns.instances.length = 0; + subagentRuns.deferred = true; + subagentRuns.resolveRun = undefined; + const tool = taskTool(runtime); + const prompt = vi.fn(async () => undefined); + (runtime as any).agent.prompt = prompt; + (runtime as any).agent.waitForIdle = vi.fn(async () => undefined); + + const started = await tool.execute("task-1", { + agent: "explorer", + task: "Find it.", + }); + const delegationId = (started.details as any).delegationId as string; + + // The explorer finishes while the parent is still on its own line of work… + subagentRuns.resolveRun!({ + agentName: "explorer", + status: "completed", + report: "src/app.ts:12 misses the null check.", + turns: 1, + toolCalls: 1, + }); + await vi.waitFor(() => { + expect((runtime as any).delegations.get(delegationId).status).toBe( + "completed", + ); + }); + expect((runtime as any).keepTurnOpenForDelegates()).toBe(true); + + // …and the parent then idles without a TaskWait. The settled report is + // "done and unpublished", not "unfinished": it must still reach the parent. + await (runtime as any).resumeAfterDelegations(); + + expect(prompt).toHaveBeenCalledTimes(1); + const delivered = String( + (prompt.mock.calls as unknown as unknown[][])[0]?.[0] ?? "", + ); + expect(delivered).toContain("src/app.ts:12 misses the null check."); + expect((runtime as any).keepTurnOpenForDelegates()).toBe(false); + + // Single shot: a later idle does not replay it. + await (runtime as any).resumeAfterDelegations(); + expect(prompt).toHaveBeenCalledTimes(1); + + subagentRuns.deferred = false; + await runtime.dispose(); + }); + + it("does not replay a report that TaskWait already returned", async () => { + const runtime = createRuntime({ subagents: [explorer] }); + subagentRuns.calls.length = 0; + subagentRuns.instances.length = 0; + subagentRuns.deferred = true; + subagentRuns.resolveRun = undefined; + const tool = taskTool(runtime); + const wait = (runtime as any).agent.state.tools.find( + (entry: any) => entry.name === "TaskWait", + ); + const prompt = vi.fn(async () => undefined); + (runtime as any).agent.prompt = prompt; + (runtime as any).agent.waitForIdle = vi.fn(async () => undefined); + + const started = await tool.execute("task-1", { + agent: "explorer", + task: "Find it.", + }); + const delegationId = (started.details as any).delegationId as string; + subagentRuns.resolveRun!({ + agentName: "explorer", + status: "completed", + report: "src/app.ts:12 misses the null check.", + turns: 1, + toolCalls: 1, + }); + await vi.waitFor(() => { + expect((runtime as any).delegations.get(delegationId).status).toBe( + "completed", + ); + }); + + const result = await wait.execute("wait-1", { delegationIds: [delegationId] }); + expect(result.content[0].text).toContain("src/app.ts:12 misses the null check."); + expect((runtime as any).keepTurnOpenForDelegates()).toBe(false); + + await (runtime as any).resumeAfterDelegations(); + expect(prompt).not.toHaveBeenCalled(); + + subagentRuns.deferred = false; + await runtime.dispose(); + }); + it("lists a heartbeat for a running delegate", async () => { const runtime = createRuntime({ subagents: [explorer] }); subagentRuns.calls.length = 0; diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 4f2ad8cb0..955d3a63e 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -325,6 +325,10 @@ export type DelegationRecord = { /** `prompt()` / `executeApprovedPlan()` generation that started this run. * Resume-after-idle only waits for the current turn's delegates (D352). */ startedEpoch: number; + /** The settled report reached the parent's context once: through a + * `TaskWait` result or the resume-after-idle prompt. Auto-delivery is a + * single shot per record. */ + reportDelivered: boolean; }; function delegationSummary(record: DelegationRecord): Record { @@ -3422,6 +3426,7 @@ Delegation rules: lastActivityAt: startedAt, lastPhase: "waiting-model", startedEpoch: this.turnEpoch, + reportDelivered: false, }; this.delegations.set(delegationId, record); const scopedTools = this.scopeDelegateTools(tools, definition); @@ -3568,6 +3573,24 @@ Delegation rules: ); } + /** + * Current-turn delegates whose report the parent has not seen yet: still + * running, or settled before the parent idled and never read through + * `TaskWait`. A delegate that finished in a few hundred milliseconds is + * "done and unpublished", not "unfinished"; keying the idle resume on + * running delegates alone dropped such reports (#226). Stopped and + * aborted runs are not auto-delivered. + */ + private pendingCurrentTurnDelegations(): DelegationRecord[] { + return [...this.delegations.values()].filter( + (record) => + record.startedEpoch === this.turnEpoch && + !record.reportDelivered && + record.status !== "stopped" && + record.status !== "aborted", + ); + } + private abortDelegationsFromPreviousTurns(): void { for (const record of this.runningDelegations()) { if (record.startedEpoch !== this.turnEpoch) record.abort(); @@ -3588,7 +3611,8 @@ Delegation rules: /** D328 keeps the turn open on parent idle, not on a fatal parent error. */ private keepTurnOpenForDelegates(): boolean { return ( - this.runningDelegations().length > 0 && + (this.runningDelegations().length > 0 || + this.pendingCurrentTurnDelegations().length > 0) && !this.runCancelled && !this.turnHadError ); @@ -3696,9 +3720,9 @@ Delegation rules: !this.runCancelled && !this.turnHadError && epoch === this.turnEpoch && - this.currentTurnDelegations().length > 0 + this.pendingCurrentTurnDelegations().length > 0 ) { - const targets = this.currentTurnDelegations(); + const targets = this.pendingCurrentTurnDelegations(); this.beginDelegationWait(targets); await this.waitForDelegations(targets, targets.length, null); this.endDelegationWait(); @@ -3711,8 +3735,11 @@ Delegation rules: if (this.turnHadError) this.terminateParentTurn(); return; } - const settled = targets.filter((record) => record.status !== "running"); + const settled = targets.filter( + (record) => record.status !== "running" && !record.reportDelivered, + ); if (settled.length === 0) return; + for (const record of settled) record.reportDelivered = true; const results = settled.map((record) => ({ delegationId: record.delegationId, agent: record.agentName, @@ -3827,6 +3854,12 @@ Delegation rules: } finally { this.endDelegationWait(); } + // A settled report returned here reached the parent; the idle resume + // must not deliver it a second time. Running delegates keep their + // shot: a timeout or an early `any` convergence has not consumed it. + for (const record of targets) { + if (record.status !== "running") record.reportDelivered = true; + } const results = targets.map((record) => ({ delegationId: record.delegationId, agent: record.agentName,