Skip to content

Commit cd3eb43

Browse files
openperfvincentkoc
andauthored
fix(agents): pause yielded subagent runs whose terminal also signals abort (#92631)
* fix(agents): pause yielded subagent runs whose terminal also signals abort (#92448) * fix(agents): clear yielded subagent grace timers --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 26281a8 commit cd3eb43

3 files changed

Lines changed: 203 additions & 13 deletions

File tree

src/agents/subagent-registry-run-manager.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export function createSubagentRunManager(params: {
180180
stopSweeper(): void;
181181
resumeSubagentRun(runId: string): void;
182182
clearPendingLifecycleError(runId: string): void;
183+
clearPendingLifecycleTimeout(runId: string): void;
183184
resolveSubagentWaitTimeoutMs(cfg: OpenClawConfig, runTimeoutSeconds?: number): number;
184185
scheduleOrphanRecovery(args?: { delayMs?: number; maxRetries?: number }): void;
185186
resolveSubagentSessionCompletion(args: {
@@ -264,6 +265,8 @@ export function createSubagentRunManager(params: {
264265
waitTerminalOutcome?.reason === "aborted" || waitTerminalOutcome?.reason === "cancelled";
265266
const waitStatus = waitTerminalOutcome?.status ?? wait.status;
266267
if (wait.yielded === true && waitStatus !== "timeout" && !waitBlocked) {
268+
params.clearPendingLifecycleError(runId);
269+
params.clearPendingLifecycleTimeout(runId);
267270
if (
268271
markSubagentRunPausedAfterYield({
269272
entry,

src/agents/subagent-registry.test.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,185 @@ describe("subagent registry seam flow", () => {
20002000
expect(replacement?.endedAt).toBeUndefined();
20012001
});
20022002

2003+
it("keeps yield terminals paused when the lifecycle event also signals abort (#92448)", async () => {
2004+
// sessions_yield ends the turn by aborting the run signal, so a depth-1
2005+
// subagent's yield terminal can arrive carrying yielded plus aborted (or
2006+
// stopReason="aborted"). The event handler must still pause the run, not
2007+
// settle it `cancelled` and deliver a false notice to the requester.
2008+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2009+
if (request.method === "agent.wait") {
2010+
return { status: "pending" };
2011+
}
2012+
return {};
2013+
});
2014+
2015+
const cases = [
2016+
{ runId: "run-yield-stopreason-aborted", extra: { stopReason: "aborted" } },
2017+
{ runId: "run-yield-aborted-flag", extra: { aborted: true } },
2018+
];
2019+
2020+
for (const testCase of cases) {
2021+
mod.registerSubagentRun({
2022+
runId: testCase.runId,
2023+
childSessionKey: `agent:main:subagent:${testCase.runId}`,
2024+
requesterSessionKey: "agent:main:main",
2025+
requesterDisplayKey: "main",
2026+
task: "wait for child continuation",
2027+
cleanup: "keep",
2028+
});
2029+
2030+
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
2031+
mocks.onAgentEvent.mock.calls.length - 1
2032+
] as unknown as
2033+
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
2034+
| undefined;
2035+
const lifecycleHandler = lastOnAgentEventCall?.[0];
2036+
expect(lifecycleHandler).toBeTypeOf("function");
2037+
2038+
lifecycleHandler?.({
2039+
runId: testCase.runId,
2040+
stream: "lifecycle",
2041+
data: {
2042+
phase: "end",
2043+
startedAt: 111,
2044+
endedAt: 222,
2045+
yielded: true,
2046+
...testCase.extra,
2047+
},
2048+
});
2049+
2050+
await waitForFast(() => {
2051+
const run = mod
2052+
.listSubagentRunsForRequester("agent:main:main")
2053+
.find((entry) => entry.runId === testCase.runId);
2054+
expect(run?.pauseReason).toBe("sessions_yield");
2055+
expect(run?.outcome?.status).not.toBe("error");
2056+
});
2057+
}
2058+
2059+
// Paused, never killed → no farewell/cancellation notice reaches the requester.
2060+
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
2061+
});
2062+
2063+
it("cancels a pending grace timer when a yield follows an intermediate aborted terminal (#92448)", async () => {
2064+
// An earlier aborted terminal schedules a deferred kill grace timer; a
2065+
// following yield must clear it, or it fires and settles the now-paused run.
2066+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2067+
if (request.method === "agent.wait") {
2068+
return { status: "pending" };
2069+
}
2070+
return {};
2071+
});
2072+
2073+
mod.registerSubagentRun({
2074+
runId: "run-yield-after-pending-timeout",
2075+
childSessionKey: "agent:main:subagent:pending-timeout",
2076+
requesterSessionKey: "agent:main:main",
2077+
requesterDisplayKey: "main",
2078+
task: "wait for child continuation",
2079+
cleanup: "keep",
2080+
});
2081+
2082+
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
2083+
mocks.onAgentEvent.mock.calls.length - 1
2084+
] as unknown as
2085+
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
2086+
| undefined;
2087+
const lifecycleHandler = lastOnAgentEventCall?.[0];
2088+
expect(lifecycleHandler).toBeTypeOf("function");
2089+
2090+
// Intermediate aborted terminal → schedules the deferred kill grace timer.
2091+
lifecycleHandler?.({
2092+
runId: "run-yield-after-pending-timeout",
2093+
stream: "lifecycle",
2094+
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
2095+
});
2096+
// Yield terminal → must pause and cancel the pending grace timer.
2097+
lifecycleHandler?.({
2098+
runId: "run-yield-after-pending-timeout",
2099+
stream: "lifecycle",
2100+
data: { phase: "end", startedAt: 111, endedAt: 333, yielded: true },
2101+
});
2102+
2103+
await waitForFast(() => {
2104+
const run = mod
2105+
.listSubagentRunsForRequester("agent:main:main")
2106+
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
2107+
expect(run?.pauseReason).toBe("sessions_yield");
2108+
});
2109+
2110+
// Advancing well past the 15s grace window must not undo the pause.
2111+
await vi.advanceTimersByTimeAsync(60_000);
2112+
const run = mod
2113+
.listSubagentRunsForRequester("agent:main:main")
2114+
.find((entry) => entry.runId === "run-yield-after-pending-timeout");
2115+
expect(run?.pauseReason).toBe("sessions_yield");
2116+
expect(run?.outcome?.status).not.toBe("error");
2117+
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
2118+
});
2119+
2120+
it("cancels a pending grace timer when agent.wait observes the yield after an aborted terminal (#92448)", async () => {
2121+
let resolveWait: (value: {
2122+
status: "ok";
2123+
startedAt: number;
2124+
endedAt: number;
2125+
yielded: true;
2126+
}) => void = () => {};
2127+
const waitResult = new Promise<{
2128+
status: "ok";
2129+
startedAt: number;
2130+
endedAt: number;
2131+
yielded: true;
2132+
}>((resolve) => {
2133+
resolveWait = resolve;
2134+
});
2135+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2136+
if (request.method === "agent.wait") {
2137+
return waitResult;
2138+
}
2139+
return {};
2140+
});
2141+
2142+
mod.registerSubagentRun({
2143+
runId: "run-wait-yield-after-pending-timeout",
2144+
childSessionKey: "agent:main:subagent:pending-wait-timeout",
2145+
requesterSessionKey: "agent:main:main",
2146+
requesterDisplayKey: "main",
2147+
task: "wait for child continuation through wait",
2148+
cleanup: "keep",
2149+
});
2150+
2151+
const lastOnAgentEventCall = mocks.onAgentEvent.mock.calls[
2152+
mocks.onAgentEvent.mock.calls.length - 1
2153+
] as unknown as
2154+
| [(evt: { runId: string; stream: string; data: Record<string, unknown> }) => void]
2155+
| undefined;
2156+
const lifecycleHandler = lastOnAgentEventCall?.[0];
2157+
expect(lifecycleHandler).toBeTypeOf("function");
2158+
2159+
lifecycleHandler?.({
2160+
runId: "run-wait-yield-after-pending-timeout",
2161+
stream: "lifecycle",
2162+
data: { phase: "end", startedAt: 111, endedAt: 222, aborted: true },
2163+
});
2164+
resolveWait({ status: "ok", startedAt: 111, endedAt: 333, yielded: true });
2165+
2166+
await waitForFast(() => {
2167+
const run = mod
2168+
.listSubagentRunsForRequester("agent:main:main")
2169+
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
2170+
expect(run?.pauseReason).toBe("sessions_yield");
2171+
});
2172+
2173+
await vi.advanceTimersByTimeAsync(60_000);
2174+
const run = mod
2175+
.listSubagentRunsForRequester("agent:main:main")
2176+
.find((entry) => entry.runId === "run-wait-yield-after-pending-timeout");
2177+
expect(run?.pauseReason).toBe("sessions_yield");
2178+
expect(run?.outcome?.status).not.toBe("timeout");
2179+
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
2180+
});
2181+
20032182
it("announces blocked agent.wait snapshots as errors instead of success", async () => {
20042183
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
20052184
if (request.method === "agent.wait") {

src/agents/subagent-registry.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ function schedulePendingLifecycleTimeout(params: {
480480
if (!entry) {
481481
return;
482482
}
483-
if (entry.outcome?.status === "ok") {
483+
if (entry.outcome?.status === "ok" || entry.pauseReason === "sessions_yield") {
484484
return;
485485
}
486486
const completionParams = {
@@ -1106,6 +1106,25 @@ function ensureListener() {
11061106
});
11071107
return;
11081108
}
1109+
// sessions_yield ends the turn by aborting the run signal, so a yielded
1110+
// terminal can also look aborted. An explicit yield is authoritative — pause,
1111+
// don't kill — else the tracking task settles `cancelled` with a false notice (#92448).
1112+
if (evt.data?.yielded === true) {
1113+
// Drop any grace timer from an earlier aborted/error terminal so it can't
1114+
// later fire and settle this now-paused run with a false notice.
1115+
clearPendingLifecycleError(evt.runId);
1116+
clearPendingLifecycleTimeout(evt.runId);
1117+
if (
1118+
markSubagentRunPausedAfterYield({
1119+
entry,
1120+
endedAt,
1121+
startedAt: startedAt ?? entry.startedAt,
1122+
})
1123+
) {
1124+
persistSubagentRuns();
1125+
}
1126+
return;
1127+
}
11091128
if (isAbortedAgentStopReason(stopReason)) {
11101129
clearPendingLifecycleError(evt.runId);
11111130
clearPendingLifecycleTimeout(evt.runId);
@@ -1154,18 +1173,6 @@ function ensureListener() {
11541173
});
11551174
return;
11561175
}
1157-
if (evt.data?.yielded === true) {
1158-
if (
1159-
markSubagentRunPausedAfterYield({
1160-
entry,
1161-
endedAt,
1162-
startedAt: startedAt ?? entry.startedAt,
1163-
})
1164-
) {
1165-
persistSubagentRuns();
1166-
}
1167-
return;
1168-
}
11691176
clearPendingLifecycleError(evt.runId);
11701177
clearPendingLifecycleTimeout(evt.runId);
11711178
const completionParams = {
@@ -1203,6 +1210,7 @@ const subagentRunManager = createSubagentRunManager({
12031210
stopSweeper,
12041211
resumeSubagentRun,
12051212
clearPendingLifecycleError,
1213+
clearPendingLifecycleTimeout,
12061214
resolveSubagentWaitTimeoutMs,
12071215
scheduleOrphanRecovery: (args) => scheduleSubagentOrphanRecovery(args),
12081216
resolveSubagentSessionCompletion,

0 commit comments

Comments
 (0)