Skip to content

Commit a9a86f7

Browse files
authored
fix(agents): dedupe subagent browser session cleanup
Deduplicate the browser lifecycle cleanup wrapper for embedded subagent completions while preserving retire and announce finalization for duplicate callers.\n\nAdds regression coverage for parallel completion callers and the held-first-cleanup duplicate-tail path.\n\nFixes #68668.\n\nCo-authored-by: Feelw00 <[email protected]>
1 parent 371a8ab commit a9a86f7

4 files changed

Lines changed: 115 additions & 14 deletions

File tree

src/agents/subagent-registry-lifecycle.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,4 +1311,93 @@ describe("subagent registry lifecycle hardening", () => {
13111311
).not.toHaveBeenCalled();
13121312
expect(runSubagentAnnounceFlow).not.toHaveBeenCalled();
13131313
});
1314+
1315+
it("dedupes browser cleanup when two callers complete the same run in parallel", async () => {
1316+
// registerSubagentRun fires both an in-process listener (phase='end') and a
1317+
// gateway waitForSubagentCompletion RPC; in embedded mode both resolve to
1318+
// the same runId and call completeSubagentRun. Without a per-entry dispatch
1319+
// guard, cleanupBrowserSessionsForLifecycleEnd fires once per caller,
1320+
// duplicating browser driver tab-close IPC.
1321+
const entry = createRunEntry({
1322+
expectsCompletionMessage: false,
1323+
});
1324+
const runSubagentAnnounceFlow = vi.fn(async () => true);
1325+
1326+
const controller = createLifecycleController({
1327+
entry,
1328+
runSubagentAnnounceFlow,
1329+
});
1330+
1331+
const completeParams = {
1332+
runId: entry.runId,
1333+
endedAt: 4_000,
1334+
outcome: { status: "ok" as const },
1335+
reason: SUBAGENT_ENDED_REASON_COMPLETE,
1336+
triggerCleanup: true,
1337+
};
1338+
1339+
await Promise.all([
1340+
controller.completeSubagentRun(completeParams),
1341+
controller.completeSubagentRun(completeParams),
1342+
]);
1343+
1344+
expect(
1345+
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
1346+
).toHaveBeenCalledTimes(1);
1347+
expect(entry.browserCleanupDispatchedAt).toBeTypeOf("number");
1348+
});
1349+
1350+
it("drains the retire + announce tail for a duplicate completion held behind a slow first browser cleanup", async () => {
1351+
// The dispatch flag dedupes only the browser tab-close IPC. A duplicate
1352+
// completion caller must still reach retireRunModeBundleMcpRuntime and
1353+
// startSubagentAnnounceCleanupFlow while the first caller's cleanup
1354+
// promise is still pending, so a slow browser driver cannot strand
1355+
// completion delivery behind it.
1356+
const entry = createRunEntry({
1357+
expectsCompletionMessage: true,
1358+
});
1359+
const runSubagentAnnounceFlow = vi.fn(async () => true);
1360+
const controller = createLifecycleController({ entry, runSubagentAnnounceFlow });
1361+
1362+
let releaseFirstCleanup: (() => void) | undefined;
1363+
let firstCleanupEntered: (() => void) | undefined;
1364+
const firstCleanupEnteredPromise = new Promise<void>((resolve) => {
1365+
firstCleanupEntered = resolve;
1366+
});
1367+
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd.mockImplementationOnce(
1368+
() => {
1369+
firstCleanupEntered?.();
1370+
return new Promise<void>((resolve) => {
1371+
releaseFirstCleanup = resolve;
1372+
});
1373+
},
1374+
);
1375+
1376+
const completeParams = {
1377+
runId: entry.runId,
1378+
endedAt: 4_000,
1379+
outcome: { status: "ok" as const },
1380+
reason: SUBAGENT_ENDED_REASON_COMPLETE,
1381+
triggerCleanup: true,
1382+
};
1383+
1384+
// First caller takes the dispatch flag and parks inside the cleanup wrapper.
1385+
const firstCompletion = controller.completeSubagentRun(completeParams);
1386+
await firstCleanupEnteredPromise;
1387+
1388+
// Second caller observes the flag set, skips the cleanup wrapper, and must
1389+
// still drain the retire + announce tail without waiting on the first
1390+
// caller's still-pending cleanup.
1391+
await controller.completeSubagentRun(completeParams);
1392+
1393+
expect(
1394+
browserLifecycleCleanupMocks.cleanupBrowserSessionsForLifecycleEnd,
1395+
).toHaveBeenCalledTimes(1);
1396+
expect(bundleMcpRuntimeMocks.retireSessionMcpRuntimeForSessionKey).toHaveBeenCalled();
1397+
expect(runSubagentAnnounceFlow).toHaveBeenCalled();
1398+
1399+
// Release the held first cleanup so the first caller can settle too.
1400+
releaseFirstCleanup?.();
1401+
await expect(firstCompletion).resolves.toBeUndefined();
1402+
});
13141403
});

src/agents/subagent-registry-lifecycle.ts

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,20 +1247,29 @@ export function createSubagentRegistryLifecycleController(params: {
12471247
return;
12481248
}
12491249

1250-
try {
1251-
const cleanupBrowserSessions =
1252-
params.cleanupBrowserSessionsForLifecycleEnd ??
1253-
(await loadCleanupBrowserSessionsForLifecycleEnd());
1254-
await cleanupBrowserSessions({
1255-
sessionKeys: [entry.childSessionKey],
1256-
onWarn: (msg) => params.warn(msg, { runId: entry.runId }),
1257-
});
1258-
} catch (error) {
1259-
params.warn("failed to cleanup browser sessions for completed subagent", {
1260-
error: buildSafeLifecycleErrorMeta(error),
1261-
runId: maskRunId(completeParams.runId),
1262-
childSessionKey: maskSessionKey(entry.childSessionKey),
1263-
});
1250+
// registerSubagentRun fires both an in-process listener and a gateway
1251+
// waitForSubagentCompletion RPC; both can reach this point for the same
1252+
// runId in embedded mode. Dedupe only the browser driver tab-close IPC
1253+
// with a sync check-then-set. The retire + announce tail below must still
1254+
// run for every caller, so a slow or held first browser cleanup cannot
1255+
// strand a duplicate caller's completion behind it.
1256+
if (entry.browserCleanupDispatchedAt === undefined) {
1257+
entry.browserCleanupDispatchedAt = Date.now();
1258+
try {
1259+
const cleanupBrowserSessions =
1260+
params.cleanupBrowserSessionsForLifecycleEnd ??
1261+
(await loadCleanupBrowserSessionsForLifecycleEnd());
1262+
await cleanupBrowserSessions({
1263+
sessionKeys: [entry.childSessionKey],
1264+
onWarn: (msg) => params.warn(msg, { runId: entry.runId }),
1265+
});
1266+
} catch (error) {
1267+
params.warn("failed to cleanup browser sessions for completed subagent", {
1268+
error: buildSafeLifecycleErrorMeta(error),
1269+
runId: maskRunId(completeParams.runId),
1270+
childSessionKey: maskSessionKey(entry.childSessionKey),
1271+
});
1272+
}
12641273
}
12651274

12661275
try {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@ export function createSubagentRunManager(params: {
594594
endedReason: undefined,
595595
pauseReason: undefined,
596596
endedHookEmittedAt: undefined,
597+
browserCleanupDispatchedAt: undefined,
597598
wakeOnDescendantSettle: undefined,
598599
outcome: undefined,
599600
execution: {

src/agents/subagent-registry.types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ export type SubagentRunRecord = {
105105
completion?: SubagentCompletionState;
106106
/** Set after the subagent_ended hook has been emitted successfully once. */
107107
endedHookEmittedAt?: number;
108+
/** Set after cleanupBrowserSessionsForLifecycleEnd has been dispatched once. */
109+
browserCleanupDispatchedAt?: number;
108110
/** Durable outbox marker for parent/external completion delivery. */
109111
delivery?: SubagentCompletionDeliveryState;
110112
attachmentsDir?: string;

0 commit comments

Comments
 (0)