Skip to content

Commit 58bab0c

Browse files
lanzhi-leesteipete
andauthored
fix(agents): dispatch subagent spawn in process (#90612)
* fix(agents): dispatch subagent spawn in process * docs: update subagent gateway dispatch note * fix(gateway): keep in-process dispatch timeout budget * test(gateway): avoid promise executor timer returns --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 48da8d8 commit 58bab0c

7 files changed

Lines changed: 307 additions & 19 deletions

File tree

docs/tools/subagents.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -621,10 +621,12 @@ tombstoned sessions.
621621
<Note>
622622
If a sub-agent spawn fails with Gateway `PAIRING_REQUIRED` /
623623
`scope-upgrade`, check the RPC caller before editing pairing state.
624-
Internal `sessions_spawn` coordination should connect as
625-
`client.id: "gateway-client"` with `client.mode: "backend"` over direct
626-
loopback shared-token/password auth; that path does not depend on the
627-
CLI's paired-device scope baseline. Remote callers, explicit
624+
Internal `sessions_spawn` coordination dispatches in process when the
625+
caller is already running inside the gateway request context, so it does
626+
not open a loopback WebSocket or depend on the CLI's paired-device scope
627+
baseline. Callers outside the gateway process still use the WebSocket
628+
fallback as `client.id: "gateway-client"` with `client.mode: "backend"`
629+
over direct loopback shared-token/password auth. Remote callers, explicit
628630
`deviceIdentity`, explicit device-token paths, and browser/node clients
629631
still need normal device approval for scope upgrades.
630632
</Note>

src/agents/subagent-spawn.runtime.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export {
1717
export { ensureContextEnginesInitialized } from "../context-engine/init.js";
1818
export { resolveContextEngine } from "../context-engine/registry.js";
1919
export { callGateway } from "../gateway/call.js";
20+
export {
21+
dispatchGatewayMethodInProcess,
22+
hasInProcessGatewayContext,
23+
} from "../gateway/server-plugins.js";
2024
export { ADMIN_SCOPE, isAdminOnlyMethod } from "../gateway/method-scopes.js";
2125
export { getSessionBindingService } from "../infra/outbound/session-binding-service.js";
2226
export {

src/agents/subagent-spawn.test-helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export function expectPersistedRuntimeModel(params: {
128128
/** Load subagent-spawn with runtime dependencies replaced by test doubles. */
129129
export async function loadSubagentSpawnModuleForTest(params: {
130130
callGatewayMock: MockFn;
131+
dispatchGatewayMethodInProcessMock?: MockFn;
132+
hasInProcessGatewayContextMock?: MockFn;
131133
getRuntimeConfig?: () => Record<string, unknown>;
132134
loadSessionStoreMock?: MockFn;
133135
ensureContextEnginesInitializedMock?: MockFn;
@@ -209,6 +211,9 @@ export async function loadSubagentSpawnModuleForTest(params: {
209211

210212
vi.doMock("./subagent-spawn.runtime.js", () => ({
211213
callGateway: (opts: unknown) => params.callGatewayMock(opts),
214+
dispatchGatewayMethodInProcess: (...args: unknown[]) =>
215+
params.dispatchGatewayMethodInProcessMock?.(...args),
216+
hasInProcessGatewayContext: () => Boolean(params.hasInProcessGatewayContextMock?.()),
212217
buildSubagentSystemPrompt: () => "system-prompt",
213218
forkSessionFromParent:
214219
params.forkSessionFromParentMock ??

src/agents/subagent-spawn.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ const hoisted = vi.hoisted(() => ({
1717
pruneLegacyStoreKeysMock: vi.fn(),
1818
registerSubagentRunMock: vi.fn(),
1919
emitSessionLifecycleEventMock: vi.fn(),
20+
dispatchGatewayMethodInProcessMock: vi.fn(),
21+
hasInProcessGatewayContextMock: vi.fn(),
2022
resolveAgentConfigMock: vi.fn(),
2123
configOverride: {} as Record<string, unknown>,
2224
}));
@@ -67,6 +69,8 @@ describe("spawnSubagentDirect seam flow", () => {
6769
beforeAll(async () => {
6870
({ resetSubagentRegistryForTests, spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
6971
callGatewayMock: hoisted.callGatewayMock,
72+
dispatchGatewayMethodInProcessMock: hoisted.dispatchGatewayMethodInProcessMock,
73+
hasInProcessGatewayContextMock: hoisted.hasInProcessGatewayContextMock,
7074
getRuntimeConfig: () => hoisted.configOverride,
7175
loadSessionStoreMock: hoisted.loadSessionStoreMock,
7276
updateSessionStoreMock: hoisted.updateSessionStoreMock,
@@ -88,6 +92,8 @@ describe("spawnSubagentDirect seam flow", () => {
8892
hoisted.pruneLegacyStoreKeysMock.mockReset();
8993
hoisted.registerSubagentRunMock.mockReset();
9094
hoisted.emitSessionLifecycleEventMock.mockReset();
95+
hoisted.dispatchGatewayMethodInProcessMock.mockReset();
96+
hoisted.hasInProcessGatewayContextMock.mockReset().mockReturnValue(false);
9197
hoisted.resolveAgentConfigMock.mockReset();
9298
hoisted.resolveAgentConfigMock.mockImplementation(
9399
(cfg: { agents?: { list?: Array<{ id?: string }> } }, agentId: string) =>
@@ -266,6 +272,76 @@ describe("spawnSubagentDirect seam flow", () => {
266272
expect(agentParams.cleanupBundleMcpOnRunEnd).toBe(true);
267273
});
268274

275+
it("dispatches spawned agent runs in process when a gateway context is available", async () => {
276+
hoisted.hasInProcessGatewayContextMock.mockReturnValue(true);
277+
hoisted.callGatewayMock.mockRejectedValue(new Error("unexpected websocket gateway call"));
278+
hoisted.dispatchGatewayMethodInProcessMock.mockImplementation(async (method: string) => {
279+
if (method === "agent") {
280+
return { runId: "run-in-process" };
281+
}
282+
return { ok: true };
283+
});
284+
285+
const result = await spawnSubagentDirect(
286+
{
287+
task: "spawn without websocket self-connection",
288+
},
289+
{
290+
agentSessionKey: "agent:main:main",
291+
},
292+
);
293+
294+
expect(result.status).toBe("accepted");
295+
expect(result.runId).toBe("run-in-process");
296+
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
297+
expect(hoisted.dispatchGatewayMethodInProcessMock).toHaveBeenCalledWith(
298+
"agent",
299+
expect.objectContaining({
300+
message: expect.stringContaining("spawn without websocket self-connection"),
301+
sessionKey: result.childSessionKey,
302+
}),
303+
expect.objectContaining({
304+
timeoutMs: expect.any(Number),
305+
}),
306+
);
307+
});
308+
309+
it("keeps admin-scoped cleanup on in-process spawn failure", async () => {
310+
hoisted.hasInProcessGatewayContextMock.mockReturnValue(true);
311+
hoisted.callGatewayMock.mockRejectedValue(new Error("unexpected websocket gateway call"));
312+
hoisted.dispatchGatewayMethodInProcessMock.mockImplementation(async (method: string) => {
313+
if (method === "agent") {
314+
throw new Error("spawn failed");
315+
}
316+
return { ok: true };
317+
});
318+
319+
const result = await spawnSubagentDirect(
320+
{
321+
task: "spawn failure cleanup",
322+
},
323+
{
324+
agentSessionKey: "agent:main:main",
325+
},
326+
);
327+
328+
expect(result.status).toBe("error");
329+
expect(result.error).toContain("spawn failed");
330+
expect(hoisted.callGatewayMock).not.toHaveBeenCalled();
331+
expect(hoisted.dispatchGatewayMethodInProcessMock).toHaveBeenCalledWith(
332+
"sessions.delete",
333+
expect.objectContaining({
334+
key: result.childSessionKey,
335+
deleteTranscript: true,
336+
}),
337+
expect.objectContaining({
338+
forceSyntheticClient: true,
339+
syntheticScopes: ["operator.admin"],
340+
timeoutMs: 60_000,
341+
}),
342+
);
343+
});
344+
269345
it("inherits requester thinking level when no spawn or subagent default is configured", async () => {
270346
let persistedStore: Record<string, Record<string, unknown>> | undefined;
271347
hoisted.loadSessionStoreMock.mockReturnValue({

src/agents/subagent-spawn.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,13 @@ import {
8787
DEFAULT_SUBAGENT_MAX_SPAWN_DEPTH,
8888
buildSubagentSystemPrompt,
8989
callGateway,
90+
dispatchGatewayMethodInProcess,
9091
emitSessionLifecycleEvent,
9192
forkSessionFromParent,
9293
getGlobalHookRunner,
9394
getSessionBindingService,
9495
getRuntimeConfig,
96+
hasInProcessGatewayContext,
9597
loadSessionStore,
9698
mergeSessionEntry,
9799
mergeDeliveryContext,
@@ -133,9 +135,11 @@ function resolveConfiguredAgentIds(cfg: OpenClawConfig): string[] {
133135

134136
type SubagentSpawnDeps = {
135137
callGateway: typeof callGateway;
138+
dispatchGatewayMethodInProcess: typeof dispatchGatewayMethodInProcess;
136139
forkSessionFromParent: typeof forkSessionFromParent;
137140
getGlobalHookRunner: () => SubagentLifecycleHookRunner | null;
138141
getRuntimeConfig: typeof getRuntimeConfig;
142+
hasInProcessGatewayContext: typeof hasInProcessGatewayContext;
139143
ensureContextEnginesInitialized: typeof ensureContextEnginesInitialized;
140144
resolveContextEngine: typeof resolveContextEngine;
141145
resolveParentForkDecision: typeof resolveParentForkDecision;
@@ -144,9 +148,11 @@ type SubagentSpawnDeps = {
144148

145149
const defaultSubagentSpawnDeps: SubagentSpawnDeps = {
146150
callGateway,
151+
dispatchGatewayMethodInProcess,
147152
forkSessionFromParent,
148153
getGlobalHookRunner,
149154
getRuntimeConfig,
155+
hasInProcessGatewayContext,
150156
ensureContextEnginesInitialized,
151157
resolveContextEngine,
152158
resolveParentForkDecision,
@@ -245,10 +251,30 @@ async function callSubagentGateway(
245251
// Only admin-only methods are pinned to ADMIN_SCOPE; other methods (e.g.
246252
// "agent" -> write) keep their least-privilege scope.
247253
const scopes = params.scopes ?? (isAdminOnlyMethod(params.method) ? [ADMIN_SCOPE] : undefined);
248-
return await subagentSpawnDeps.callGateway({
254+
const request = {
249255
...params,
250256
...(scopes != null ? { scopes } : {}),
251-
});
257+
};
258+
if (
259+
subagentSpawnDeps.hasInProcessGatewayContext() &&
260+
request.params != null &&
261+
typeof request.params === "object" &&
262+
!Array.isArray(request.params)
263+
) {
264+
// Spawn is already running in the gateway process for channel/tool calls.
265+
// Direct dispatch avoids self-connecting over WS while the same event loop is busy.
266+
return await subagentSpawnDeps.dispatchGatewayMethodInProcess(
267+
request.method,
268+
request.params as Record<string, unknown>,
269+
{
270+
expectFinal: request.expectFinal,
271+
...(scopes != null ? { forceSyntheticClient: true } : {}),
272+
timeoutMs: request.timeoutMs,
273+
...(scopes != null ? { syntheticScopes: scopes } : {}),
274+
},
275+
);
276+
}
277+
return await subagentSpawnDeps.callGateway(request);
252278
}
253279

254280
function readGatewayRunId(response: Awaited<ReturnType<typeof callGateway>>): string | undefined {

src/gateway/server-plugins.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -773,6 +773,97 @@ describe("loadGatewayPlugins", () => {
773773
});
774774
});
775775

776+
test("times out while waiting for the first in-process gateway response", async () => {
777+
serverPluginsModule.setFallbackGatewayContext(createTestContext("initial-response-timeout"));
778+
handleGatewayRequest.mockImplementationOnce(async () => {
779+
await new Promise(() => {});
780+
});
781+
782+
await expect(
783+
serverPluginsModule.dispatchGatewayMethodInProcess(
784+
"sessions.delete",
785+
{ key: "stuck-session" },
786+
{ timeoutMs: 5 },
787+
),
788+
).rejects.toThrow("gateway request timeout for sessions.delete");
789+
});
790+
791+
test("returns an accepted in-process response without waiting for handler completion", async () => {
792+
serverPluginsModule.setFallbackGatewayContext(createTestContext("accepted-before-complete"));
793+
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
794+
opts.respond(true, { status: "accepted", runId: "run-accepted" });
795+
await new Promise(() => {});
796+
});
797+
798+
await expect(
799+
serverPluginsModule.dispatchGatewayMethodInProcess(
800+
"agent",
801+
{ sessionKey: "s-accepted" },
802+
{ timeoutMs: 5 },
803+
),
804+
).resolves.toEqual({ status: "accepted", runId: "run-accepted" });
805+
});
806+
807+
test("uses one timeout budget across accepted and final in-process responses", async () => {
808+
vi.useFakeTimers();
809+
try {
810+
serverPluginsModule.setFallbackGatewayContext(createTestContext("single-final-deadline"));
811+
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
812+
setTimeout(() => {
813+
opts.respond(true, { status: "accepted", runId: "run-deadline" });
814+
}, 7);
815+
setTimeout(() => {
816+
opts.respond(true, { status: "ok", runId: "run-deadline" });
817+
}, 13);
818+
await new Promise((resolve) => {
819+
setTimeout(resolve, 13);
820+
});
821+
});
822+
823+
const result = expect(
824+
serverPluginsModule.dispatchGatewayMethodInProcess(
825+
"agent",
826+
{ sessionKey: "s-deadline" },
827+
{ expectFinal: true, timeoutMs: 10 },
828+
),
829+
).rejects.toThrow("gateway request timeout for agent");
830+
831+
await vi.advanceTimersByTimeAsync(10);
832+
await result;
833+
await vi.advanceTimersByTimeAsync(10);
834+
} finally {
835+
vi.useRealTimers();
836+
}
837+
});
838+
839+
test("clears final-response timeout when handler rejects after accepted response", async () => {
840+
vi.useFakeTimers();
841+
try {
842+
serverPluginsModule.setFallbackGatewayContext(createTestContext("accepted-then-error"));
843+
handleGatewayRequest.mockImplementationOnce(async (opts: HandleGatewayRequestOptions) => {
844+
opts.respond(true, { status: "accepted", runId: "run-error-after-accepted" });
845+
await new Promise((resolve) => {
846+
setTimeout(resolve, 5);
847+
});
848+
throw new Error("handler failed after accepted");
849+
});
850+
851+
const result = expect(
852+
serverPluginsModule.dispatchGatewayMethodInProcess(
853+
"agent",
854+
{ sessionKey: "s-error-after-accepted" },
855+
{ expectFinal: true, timeoutMs: 1_000 },
856+
),
857+
).rejects.toThrow("handler failed after accepted");
858+
859+
await vi.advanceTimersByTimeAsync(5);
860+
await result;
861+
expect(vi.getTimerCount()).toBe(0);
862+
} finally {
863+
vi.useRealTimers();
864+
}
865+
});
866+
776867
test("filters connected plugin nodes locally without sending unsupported node.list params", async () => {
777868
loadOpenClawPlugins.mockReturnValue(createRegistry([]));
778869
loadGatewayStartupPluginsForTest();
@@ -1388,11 +1479,14 @@ describe("loadGatewayPlugins", () => {
13881479
const runtime = await createSubagentRuntime(serverPlugins);
13891480
let currentContext = createTestContext("before-resolver-update");
13901481

1482+
expect(serverPlugins.hasInProcessGatewayContext()).toBe(false);
13911483
serverPlugins.setFallbackGatewayContextResolver(() => currentContext);
1484+
expect(serverPlugins.hasInProcessGatewayContext()).toBe(true);
13921485
await runtime.run({ sessionKey: "s-4", message: "before resolver update" });
13931486
expect(getLastDispatchedContext()).toBe(currentContext);
13941487

13951488
currentContext = createTestContext("after-resolver-update");
1489+
expect(serverPlugins.hasInProcessGatewayContext()).toBe(true);
13961490
await runtime.run({ sessionKey: "s-4", message: "after resolver update" });
13971491
expect(getLastDispatchedContext()).toBe(currentContext);
13981492
});
@@ -1434,6 +1528,7 @@ describe("loadGatewayPlugins", () => {
14341528

14351529
serverPlugins.clearFallbackGatewayContext();
14361530

1531+
expect(serverPlugins.hasInProcessGatewayContext()).toBe(false);
14371532
await expect(runtime.run({ sessionKey: "s-7", message: "after clear" })).rejects.toThrow(
14381533
"No scope set and no fallback context available",
14391534
);

0 commit comments

Comments
 (0)