@@ -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} ) ;
0 commit comments