Skip to content

Commit abbb467

Browse files
committed
fix(agents): scope browser-cleanup dispatch guard to the cleanup wrapper only
ClawSweeper re-review flagged [P2] "Keep duplicate completions on the finalization path": the browserCleanupDispatchedAt guard returned from completeSubagentRun entirely, so a duplicate completion caller skipped retireRunModeBundleMcpRuntime and startSubagentAnnounceCleanupFlow, not just the browser-cleanup wrapper. On pre-fix main the duplicate caller still ran that tail because its own cleanupBrowserSessionsForLifecycleEnd returns immediately once takeTrackedTabsForSessionKeys has drained the tabs. Scope the check-then-set guard to the cleanupBrowserSessions wrapper only. retireRunModeBundleMcpRuntime (idempotent) and startSubagentAnnounceCleanupFlow (single-caller via beginSubagentCleanup) now run for every completion caller, restoring pre-fix tail behavior while still firing the tab-close IPC once. Add a held-first-cleanup regression test: with the first caller parked inside a pending cleanup promise, a second completeSubagentRun must still reach retireSessionMcpRuntimeForSessionKey and runSubagentAnnounceFlow. [AI-assisted]
1 parent 0985d70 commit abbb467

2 files changed

Lines changed: 75 additions & 21 deletions

File tree

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,4 +1346,58 @@ describe("subagent registry lifecycle hardening", () => {
13461346
).toHaveBeenCalledTimes(1);
13471347
expect(entry.browserCleanupDispatchedAt).toBeTypeOf("number");
13481348
});
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+
});
13491403
});

src/agents/subagent-registry-lifecycle.ts

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1249,27 +1249,27 @@ export function createSubagentRegistryLifecycleController(params: {
12491249

12501250
// registerSubagentRun fires both an in-process listener and a gateway
12511251
// waitForSubagentCompletion RPC; both can reach this point for the same
1252-
// runId in embedded mode. Guard with a sync check-then-set so the browser
1253-
// driver tab-close IPC only fires once per completion.
1254-
if (entry.browserCleanupDispatchedAt !== undefined) {
1255-
return;
1256-
}
1257-
entry.browserCleanupDispatchedAt = Date.now();
1258-
1259-
try {
1260-
const cleanupBrowserSessions =
1261-
params.cleanupBrowserSessionsForLifecycleEnd ??
1262-
(await loadCleanupBrowserSessionsForLifecycleEnd());
1263-
await cleanupBrowserSessions({
1264-
sessionKeys: [entry.childSessionKey],
1265-
onWarn: (msg) => params.warn(msg, { runId: entry.runId }),
1266-
});
1267-
} catch (error) {
1268-
params.warn("failed to cleanup browser sessions for completed subagent", {
1269-
error: buildSafeLifecycleErrorMeta(error),
1270-
runId: maskRunId(completeParams.runId),
1271-
childSessionKey: maskSessionKey(entry.childSessionKey),
1272-
});
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+
}
12731273
}
12741274

12751275
try {

0 commit comments

Comments
 (0)