Skip to content

Commit bf2da1c

Browse files
authored
fix(agents): parent resumes after yielded child handoff (#110922)
* fix(agents): resume handoff after requester yield * fix(agents): retain handoff through requester settle * fix(agents): avoid duplicate future-child wake * fix(agents): rearm in-flight handoff on yield
1 parent 2632969 commit bf2da1c

19 files changed

Lines changed: 936 additions & 32 deletions

src/agents/embedded-agent-runner/run/attempt-execution-settle.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ const mocks = vi.hoisted(() => ({
66
finalizeStream: vi.fn(),
77
logDebug: vi.fn(),
88
logError: vi.fn(),
9+
settleRequesterAfterSessionSpawns: vi.fn(),
910
runPrompt: vi.fn(),
1011
}));
1112

1213
vi.mock("../logger.js", () => ({
1314
log: { debug: mocks.logDebug, error: mocks.logError },
1415
}));
16+
vi.mock("../../subagent-registry.js", () => ({
17+
settleRequesterAfterSessionSpawns: mocks.settleRequesterAfterSessionSpawns,
18+
}));
1519
vi.mock("../runs.js", () => ({ clearActiveEmbeddedRun: mocks.clearActiveEmbeddedRun }));
1620
vi.mock("./attempt-prompt-phase.js", () => ({
1721
runEmbeddedAttemptPromptPhase: mocks.runPrompt,
@@ -328,4 +332,68 @@ describe("runEmbeddedAttemptSettledPhase", () => {
328332
expect.stringContaining("unsubscribe failed, possible resource leak"),
329333
);
330334
});
335+
336+
it("re-arms delivered children only after a yielded requester becomes idle", async () => {
337+
const fixture = createFixture();
338+
mocks.completeResult.mockImplementationOnce(() => {
339+
fixture.order.push("result");
340+
return {
341+
...fixture.result,
342+
yieldDetected: true,
343+
acceptedSessionSpawns: [
344+
{ runId: "child-run", childSessionKey: "agent:main:subagent:child" },
345+
],
346+
};
347+
});
348+
mocks.settleRequesterAfterSessionSpawns.mockImplementationOnce(() => {
349+
fixture.order.push("resume-requester");
350+
return true;
351+
});
352+
353+
await runEmbeddedAttemptSettledPhase(fixture.input);
354+
355+
expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({
356+
requesterSessionKey: "agent:main",
357+
requesterTurnRunId: "run-1",
358+
requesterYielded: true,
359+
acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }],
360+
});
361+
expect(fixture.order.indexOf("clear-active-run")).toBeLessThan(
362+
fixture.order.indexOf("resume-requester"),
363+
);
364+
});
365+
366+
it("releases requester-turn retention after a normal final answer", async () => {
367+
const fixture = createFixture();
368+
mocks.completeResult.mockReturnValueOnce({
369+
...fixture.result,
370+
yieldDetected: false,
371+
acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }],
372+
});
373+
374+
await runEmbeddedAttemptSettledPhase(fixture.input);
375+
376+
expect(mocks.settleRequesterAfterSessionSpawns).toHaveBeenCalledWith({
377+
requesterSessionKey: "agent:main",
378+
requesterTurnRunId: "run-1",
379+
requesterYielded: false,
380+
acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }],
381+
});
382+
});
383+
384+
it("surfaces durable re-arm failures after releasing the active requester", async () => {
385+
const fixture = createFixture();
386+
const failure = new Error("sqlite unavailable");
387+
mocks.completeResult.mockReturnValueOnce({
388+
...fixture.result,
389+
yieldDetected: true,
390+
acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:subagent:child" }],
391+
});
392+
mocks.settleRequesterAfterSessionSpawns.mockImplementationOnce(() => {
393+
throw failure;
394+
});
395+
396+
await expect(runEmbeddedAttemptSettledPhase(fixture.input)).rejects.toThrow(failure);
397+
expect(fixture.order).toContain("clear-active-run");
398+
});
331399
});

src/agents/embedded-agent-runner/run/attempt-execution-settle.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/** Runs prompt dispatch, stream settlement, cleanup, and result projection. */
22
import type { AssistantMessage } from "../../../llm/types.js";
33
import type { AgentMessage } from "../../runtime/index.js";
4+
import { settleRequesterAfterSessionSpawns } from "../../subagent-registry.js";
45
import type { NormalizedUsage } from "../../usage.js";
56
import { log } from "../logger.js";
67
import type { PromptCacheBreak, PromptCacheChange } from "../prompt-cache-observability.js";
@@ -410,5 +411,13 @@ export async function runEmbeddedAttemptSettledPhase(
410411
trajectoryRecorder,
411412
});
412413
state.trajectoryEndRecorded = true;
414+
if (attempt.sessionKey && result.acceptedSessionSpawns?.length) {
415+
settleRequesterAfterSessionSpawns({
416+
requesterSessionKey: attempt.sessionKey,
417+
requesterTurnRunId: attempt.runId,
418+
requesterYielded: result.yieldDetected === true,
419+
acceptedSessionSpawns: result.acceptedSessionSpawns,
420+
});
421+
}
413422
return result;
414423
}

src/agents/openclaw-tools.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,8 @@ export function createOpenClawTools(
288288
const taskSuggestionSessionKey = normalizeOptionalString(
289289
options?.runSessionKey ?? options?.agentSessionKey,
290290
);
291+
const requesterSessionKey = options?.agentSessionKey;
292+
const requesterTurnRunId = options?.runId;
291293
const imageToolAgentDir = options?.agentDir;
292294
const imageTool = resolveImageToolFactoryAvailable({
293295
config: availabilityConfig ?? resolvedConfig,
@@ -647,6 +649,7 @@ export function createOpenClawTools(
647649
? [
648650
createSessionsSpawnTool({
649651
agentSessionKey: options?.agentSessionKey,
652+
requesterTurnRunId: options?.runId,
650653
completionOwnerKey: options?.runSessionKey,
651654
agentChannel: options?.agentChannel,
652655
agentAccountId: options?.agentAccountId,
@@ -671,6 +674,13 @@ export function createOpenClawTools(
671674
: []),
672675
createSessionsYieldTool({
673676
sessionId: options?.sessionId,
677+
onBeforeYield:
678+
requesterSessionKey && requesterTurnRunId
679+
? async () => {
680+
const { markRequesterTurnYielded } = await import("./subagent-registry.js");
681+
markRequesterTurnYielded({ requesterSessionKey, requesterTurnRunId });
682+
}
683+
: undefined,
674684
onYield: options?.onYield,
675685
}),
676686
createSubagentsTool({

src/agents/subagent-announce.requester-settle-wake.test.ts

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,18 @@ function transitionBatch(runIds: readonly string[], state: RequesterSettleWakeBa
105105
}
106106
}
107107

108-
function completeBatch(runIds: readonly string[]): void {
109-
completeBatchSpy(runIds);
108+
function completeBatch(runIds: readonly string[], rearmGeneration?: number): void {
109+
if (rearmGeneration === undefined) {
110+
completeBatchSpy(runIds);
111+
} else {
112+
completeBatchSpy(runIds, rearmGeneration);
113+
}
110114
const selected = new Set(runIds);
111115
for (const entry of listedRequesterRuns()) {
112-
if (selected.has(entry.runId)) {
116+
if (
117+
selected.has(entry.runId) &&
118+
entry.requesterSettleWake?.rearmGeneration === rearmGeneration
119+
) {
113120
entry.requesterSettleWake = undefined;
114121
}
115122
}
@@ -359,9 +366,18 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
359366
expect(deliverSpy).not.toHaveBeenCalled();
360367
});
361368

362-
it("does not add a wake turn after a single delivered completion", async () => {
369+
it("does not add a wake turn for an ordinary frozen single completion", async () => {
363370
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([
364-
makeSettledChild({ runId: "run-b", delivery: { status: "delivered" } }),
371+
makeSettledChild({
372+
runId: "run-b",
373+
delivery: { status: "delivered" },
374+
requesterSettleWake: {
375+
status: "dispatching",
376+
attemptCount: 1,
377+
batchRunIds: ["run-b"],
378+
requesterYieldBatch: true,
379+
},
380+
}),
365381
]);
366382

367383
const woke = await maybeWakeRequesterAfterAllChildrenSettled(wakeParams());
@@ -370,6 +386,33 @@ describe("maybeWakeRequesterAfterAllChildrenSettled", () => {
370386
expect(deliverSpy).not.toHaveBeenCalled();
371387
});
372388

389+
it("wakes after a requester yields with one already-delivered completion", async () => {
390+
const child = makeSettledChild({
391+
runId: "run-b",
392+
delivery: { status: "delivered" },
393+
requesterSettleWake: {
394+
status: "pending",
395+
attemptCount: 0,
396+
batchRunIds: ["run-b"],
397+
requesterYieldBatch: true,
398+
afterRequesterYield: true,
399+
rearmGeneration: 1,
400+
},
401+
});
402+
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([child]);
403+
404+
const woke = await maybeWakeRequesterAfterAllChildrenSettled(
405+
wakeParams({ settledEntry: child }),
406+
);
407+
408+
expect(woke).toBe(true);
409+
expect(deliverSpy).toHaveBeenCalledOnce();
410+
expect(deliveredCallArg().directIdempotencyKey).toBe(
411+
`announce:requester-settle:${REQUESTER}:run-b:yield-1`,
412+
);
413+
expect(completeBatchSpy).toHaveBeenCalledWith(["run-b"], 1);
414+
});
415+
373416
it("wakes for a single required completion whose announce never delivered", async () => {
374417
registryRuntimeMock.listSubagentRunsForRequester.mockReturnValue([
375418
makeSettledChild({

0 commit comments

Comments
 (0)