Quellcode durchsuchen

test(subagent): preserve ACP readiness budgets and teardown ownership

_Kerman vor 3 Wochen
Ursprung
Commit
71e96aebae
1 geänderte Dateien mit 43 neuen und 25 gelöschten Zeilen
  1. 43 25
      packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

+ 43 - 25
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -74,9 +74,10 @@ function expectedPermission(policy: 'allow' | 'reject', requestKind: string, dec
  * Poll until `file` exists (the mock touches it once its prompt is in flight),
  * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the
  * subprocess cold-start is variable, and a fixed sleep both flakes and
- * slows the suite. Fails loud if the child never signals readiness.
+ * slows the suite. The caller supplies its effective test budget so child
+ * startup retains the lane's allowance under concurrent coverage.
  */
-async function waitForFile(file: string, timeoutMs = 5000): Promise<void> {
+async function waitForFile(file: string, timeoutMs: number): Promise<void> {
   const deadline = Date.now() + timeoutMs
   while (!existsSync(file)) {
     if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`)
@@ -724,7 +725,7 @@ describe('dsh-subagent-acp', () => {
     await run.dispose()
   })
 
-  it('aborting the required signal cancels a running child', async () => {
+  it('aborting the required signal cancels a running child', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-'))
     const readyFile = join(tmp, 'ready')
     try {
@@ -733,7 +734,7 @@ describe('dsh-subagent-acp', () => {
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
       // Wait until the child's prompt is in flight (condition, not a sleep),
       // then cancel — so we exercise the mid-run session/cancel path.
-      await waitForFile(readyFile)
+      await waitForFile(readyFile, task.timeout)
       controller.abort('test')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -900,7 +901,7 @@ describe('dsh-subagent-acp', () => {
     await realChild?.done
   })
 
-  it('reports only the safe teardown failure when cancelled startup rollback fails', async () => {
+  it('reports only the safe teardown failure when cancelled startup rollback fails', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-cancelled-rollback-'))
     const ready = join(tmp, 'ready')
     const go = join(tmp, 'go')
@@ -923,7 +924,7 @@ describe('dsh-subagent-acp', () => {
         },
         onError: (error) => { errors.push(error.message) },
       })
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort()
       writeFileSync(go, 'go')
       const error = await starting.catch((cause: unknown) => cause)
@@ -940,7 +941,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => {
+  it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async ({ task }) => {
     // The child traps SIGTERM and keeps its event loop alive, so a graceful
     // term alone would hang dispose forever. With a short grace, dispose must
     // escalate to SIGKILL and return once the process is actually gone.
@@ -964,7 +965,7 @@ describe('dsh-subagent-acp', () => {
       // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a
       // sleep) — otherwise SIGTERM races the trap install and the default handler
       // terminates the child, never exercising the escalation.
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       // Don't await result (the child hangs). Dispose must still return promptly
       // via the SIGKILL escalation — bound it so a regression (no escalation)
       // fails loud instead of hanging the suite.
@@ -977,7 +978,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => {
+  it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async ({ task }) => {
     // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears
     // down on connection close, NOT on a signal) — and it has no SIGTERM handler.
     // Its EOF teardown can itself await a signal-trapping grandchild (a bash
@@ -1011,7 +1012,7 @@ describe('dsh-subagent-acp', () => {
       const run = await startAcpRun(request(), spec)
       // Wait until the child is fully booted with its prompt in flight (its ACP
       // stdin reader is attached), so dispose's stdin EOF reaches a live child.
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await run.dispose()
       // dispose returned via the natural-exit tier — the EOF-driven flush landed
       // despite taking longer than the SIGTERM grace.
@@ -1021,7 +1022,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('terminates a child that ignores EOF using the host platform semantics', async () => {
+  it('terminates a child that ignores EOF using the host platform semantics', async ({ task }) => {
     // POSIX uses the catchable SIGTERM tier and records the marker. Windows has
     // no distinct graceful signal, so disposal skips directly to forced exit.
     const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
@@ -1048,7 +1049,7 @@ describe('dsh-subagent-acp', () => {
         },
       }
       run = await startAcpRun(request(), spec)
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await run.dispose()
       const outcome = await child!.done
       expect(outcome.signal).toBeNull()
@@ -1071,7 +1072,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('rejects after cleanup when the signal aborts during newSession', async () => {
+  it('rejects after cleanup when the signal aborts during newSession', async ({ task }) => {
     // Gate the child at newSession: it signals `ready` and blocks until `go`.
     // We cancel WHILE newSession is pending (sessionId still undefined, so the
     // backend cannot send session/cancel) — the `cancelled` flag alone must
@@ -1083,23 +1084,40 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' })
       const controller = new AbortController()
       const starting = ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready) // newSession is now in flight, sessionId undefined
-      controller.abort('early')
-      writeFileSync(go, 'go') // let newSession resolve
-      await expect(starting).rejects.toThrow('aborted before the ACP child started')
+      // Observe rejection before readiness; cleanup still owns an unpublished
+      // child if readiness or an assertion fails.
+      const settled = starting.then(
+        run => ({ kind: 'started' as const, run }),
+        (error: unknown) => ({ kind: 'failed' as const, error }),
+      )
+      try {
+        await waitForFile(ready, task.timeout) // newSession is now in flight, sessionId undefined
+        controller.abort('early')
+        writeFileSync(go, 'go') // let newSession resolve
+        await expect(starting).rejects.toThrow('aborted before the ACP child started')
+      } finally {
+        controller.abort('test cleanup')
+        writeFileSync(go, 'go')
+        try {
+          const outcome = await settled
+          if (outcome.kind === 'started') await outcome.run.dispose()
+        } finally {
+          await ctx.fiber.dispose()
+        }
+      }
     } finally {
       rmSync(tmp, { recursive: true, force: true })
     }
   })
 
-  it('bridges the request signal to a session/cancel mid-run', async () => {
+  it('bridges the request signal to a session/cancel mid-run', async ({ task }) => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-'))
     const readyFile = join(tmp, 'ready')
     try {
       const controller = new AbortController()
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile })
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(readyFile)
+      await waitForFile(readyFile, task.timeout)
       controller.abort()
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -1357,7 +1375,7 @@ describe('dsh-subagent-acp', () => {
     expect(errors).toEqual([rawMessage])
   })
 
-  it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => {
+  it.skipIf(process.platform === 'win32')('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async ({ task }) => {
     // Same trap scenario as the direct startAcpRun escalation test, but the
     // graces arrive via the PLUGIN CONFIG through the registered provider — so a
     // regression that stops threading config into AcpRunSpec (falling back to
@@ -1379,7 +1397,7 @@ describe('dsh-subagent-acp', () => {
         disposeGraceMs: 150,
       })
       const run = await ctx.subagents.start('acp', request())
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       await expect(Promise.race([
         run.dispose(),
         new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }),
@@ -1587,7 +1605,7 @@ describe('dsh-subagent-acp', () => {
     await run.dispose()
   })
 
-  it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => {
+  it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async ({ task }) => {
     // The child hangs, we cancel, and instead of answering the child exits hard
     // — the pending prompt RPC rejects. With a cancel already requested, the
     // backend's catch path must settle `aborted` (the failure is the cancel
@@ -1598,7 +1616,7 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready })
       const controller = new AbortController()
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort('crash it')
       const result = await run.result
       expect(result.stopReason).toBe('aborted')
@@ -1609,7 +1627,7 @@ describe('dsh-subagent-acp', () => {
     }
   })
 
-  it('settles aborted on signal even when the child IGNORES session/cancel', async () => {
+  it('settles aborted on signal even when the child IGNORES session/cancel', async ({ task }) => {
     // The signal contract requires `result` to settle `aborted`. A child that hangs
     // its prompt AND ignores session/cancel must not wedge the parent — the
     // backend's own cancel-settle path resolves `aborted` without the child's
@@ -1620,7 +1638,7 @@ describe('dsh-subagent-acp', () => {
       const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready })
       const controller = new AbortController()
       const run = await ctx.subagents.start('acp', request('p', controller.signal))
-      await waitForFile(ready)
+      await waitForFile(ready, task.timeout)
       controller.abort('test')
       // Bound it: a regression (cancel only notifies the child, which ignores it)
       // would hang result forever — fail loud instead of stalling the suite.