Summary
completeSubagentRun can be invoked in parallel from two completion paths for the same runId in embedded mode:
- In-process listener on
phase: "end" — src/agents/subagent-registry.ts (via onAgentEvent).
- Gateway
waitForSubagentCompletion RPC — src/agents/subagent-registry-run-manager.ts.
registerSubagentRun pairs both unconditionally. Both callers hit cleanupBrowserSessionsForLifecycleEnd(...) at src/agents/subagent-registry-lifecycle.ts for the same childSessionKey, so the wrapper runs twice.
Scope
Actual CDP browserCloseTab IPC is already idempotent: extensions/browser/src/browser/session-tab-registry.ts's takeTrackedTabsForSessionKeys drains trackedTabsBySession.delete(sessionKey), and closeTrackedBrowserTabsForSessions short-circuits with tabs.length === 0 on the second call.
The remaining duplication is at the wrapper layer:
cleanupBrowserSessionsForLifecycleEnd wrapper call (runs twice)
normalizeSessionKeys + runBestEffortCleanup (runs twice)
plugin-sdk facade dispatch (runs twice; resolution itself is cached)
onWarn logging path in the non-empty window
Why it matters
- Sibling race
48042c3875 fix(agents): avoid duplicate subagent ended hook loads already introduced endedHookEmittedAt in the same file for the same dual-dispatch pattern. Extending that pattern to browser cleanup keeps the dedup surface consistent.
- Closes a defense-in-depth gap if the bundled browser extension ever removes or replaces the take-and-drain idempotency.
- Trims wrapper overhead on every embedded subagent completion.
Reproduction
Promise.all([completeSubagentRun(p), completeSubagentRun(p)]) for the same runId + triggerCleanup: true calls cleanupBrowserSessionsForLifecycleEnd twice without a guard. See accompanying PR's regression test.
Proposed fix
Add a dedicated dispatch flag browserCleanupDispatchedAt?: number on SubagentRunRecord and a sync check-then-set guard in completeSubagentRun that matches the endedHookEmittedAt pattern. Reset the flag on rearm alongside endedHookEmittedAt in subagent-registry-run-manager.ts.
PR: coming.
[AI-assisted]
Summary
completeSubagentRuncan be invoked in parallel from two completion paths for the samerunIdin embedded mode:phase: "end"—src/agents/subagent-registry.ts(viaonAgentEvent).waitForSubagentCompletionRPC —src/agents/subagent-registry-run-manager.ts.registerSubagentRunpairs both unconditionally. Both callers hitcleanupBrowserSessionsForLifecycleEnd(...)atsrc/agents/subagent-registry-lifecycle.tsfor the samechildSessionKey, so the wrapper runs twice.Scope
Actual CDP
browserCloseTabIPC is already idempotent:extensions/browser/src/browser/session-tab-registry.ts'stakeTrackedTabsForSessionKeysdrainstrackedTabsBySession.delete(sessionKey), andcloseTrackedBrowserTabsForSessionsshort-circuits withtabs.length === 0on the second call.The remaining duplication is at the wrapper layer:
cleanupBrowserSessionsForLifecycleEndwrapper call (runs twice)normalizeSessionKeys+runBestEffortCleanup(runs twice)plugin-sdkfacade dispatch (runs twice; resolution itself is cached)onWarnlogging path in the non-empty windowWhy it matters
48042c3875 fix(agents): avoid duplicate subagent ended hook loadsalready introducedendedHookEmittedAtin the same file for the same dual-dispatch pattern. Extending that pattern to browser cleanup keeps the dedup surface consistent.Reproduction
Promise.all([completeSubagentRun(p), completeSubagentRun(p)])for the samerunId+triggerCleanup: truecallscleanupBrowserSessionsForLifecycleEndtwice without a guard. See accompanying PR's regression test.Proposed fix
Add a dedicated dispatch flag
browserCleanupDispatchedAt?: numberonSubagentRunRecordand a sync check-then-set guard incompleteSubagentRunthat matches theendedHookEmittedAtpattern. Reset the flag on rearm alongsideendedHookEmittedAtinsubagent-registry-run-manager.ts.PR: coming.
[AI-assisted]