Sfoglia il codice sorgente

Merge pull request #3668 from deepseek-harness/fix/windows-subagent-teardown-ci

test(ci): synchronize Windows waiters and complete teardown
Tianyi Cui 2 settimane fa
parent
commit
4ac4e3d014

+ 2 - 2
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md
-2026-09-05-read-only-session-migration-preparation.md: c343ca457184554f4b47a0795dcb33b8b07e9d39
-2026-09-05-read-only-session-migration-preparation.zh.md: 3a283259729eda6a01fa4208cac5399f45978fac
+2026-09-05-read-only-session-migration-preparation.md: ab23e7e61dcdf0762cae6185de5fd16c4070fcbf
+2026-09-05-read-only-session-migration-preparation.zh.md: 89377d4d84776bebbc6d2ca6acea92ac15f74df3

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running.
+A new read or write open joins the existing entry only when its source path and revision still match. `waitWithAbort()` races each caller's AbortSignal against the shared Promise without forwarding that signal to shared work. The backend-owned controller is aborted only when the last waiter leaves while preparation is still running. The cancellation test pauses the physical read and observes two registered waiters before aborting one caller; an event-loop yield alone cannot establish admission after asynchronous path and revision lookup.
 
 Completed results enter the existing bounded `coldLogMemo`. The `StoredLog` discriminant separates published current state from `PreparedStoredLog`, whose `publication` field binds current logical events to their matching publication operation. A query followed by Agent resume therefore reuses the same Decode and migration result. The in-flight map owns only running work; it is not a second completed-result cache.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-09-05-read-only-session-migration-preparation.zh.md

@@ -59,7 +59,7 @@ interface MigrationPreparation {
 }
 ```
 
-新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。
+新的 read/write open 只有在 source path 与 revision 仍匹配时才加入已有 entry。`waitWithAbort()` 让每个 caller 的 AbortSignal 与 shared Promise 竞争,但不会把 caller signal 传给共享工作。只有最后一个 waiter 在 preparation 仍运行时离开,backend-owned controller 才会 abort。取消测试暂停物理读取,并在取消一个 caller 前观察到两个已注册的 waiter;仅让出一次事件循环不能证明异步路径与 revision 查找后的加入已经完成。
 
 完成结果进入既有 bounded `coldLogMemo`。`StoredLog` 判别字段把已发布 current state 与 `PreparedStoredLog` 分开,后者的 `publication` 字段把 current logical events 与匹配的 publication operation 绑定,使 query 后紧接的 Agent resume 复用同一次 Decode 与 migration。In-flight map 只拥有运行中的工作,不是第二个 completed-result cache。
 

+ 6 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md
+2026-09-07-subagent-teardown-test-budgets.md: 4fe83c421383aa768ffa0d33520407ed8d099d14
+2026-09-07-subagent-teardown-test-budgets.zh.md: 1487351d498c9d0ef9eb6c2e83e47425ac82b9c9

+ 28 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.md

@@ -0,0 +1,28 @@
+# Agent Note: Subagent teardown tests inherit their execution lane budgets
+
+Status: implemented
+
+English | [中文](2026-09-07-subagent-teardown-test-budgets.zh.md)
+
+## Problem
+
+The [Windows coverage run](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34085536250/job/101628739668) reports two teardown failures despite granting tests and hooks 90 seconds. The ACP ignored-EOF test races disposal against its own five-second timer. The real Codex test overrides the hook budget with 30 seconds. Neither deadline tests a product latency guarantee. The Codex body has already observed process-tree exit before its hook fails; the log does not identify whether context disposal, HTTP closure, or temporary-directory removal exceeded the hook budget.
+
+## Decision
+
+The [ACP test](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) awaits disposal under the execution lane’s test budget, then checks the actual child outcome. Failure cleanup awaits disposal and child completion before removing the private directory. A deferred exit observation proves that disposal cannot finish merely because termination was requested. The production EOF and termination grace periods remain unchanged.
+
+The [Codex test](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) inherits the execution lane’s hook budget. Cleanup captures its contexts, HTTP fixtures, and temporary roots before its first asynchronous wait, so an overdue hook cannot drain resources registered by another test. It preserves context-disposal, server-closure, and directory-removal ordering, waits for every captured disposer, and attempts the remaining cleanup stages after a rejection. Collected errors identify each failing stage or path and retain their causes; cleanup reports them only after all captured resources have been attempted.
+
+The [native Windows CI decision](../process/2026-08-08-native-windows-pull-request-ci.md) continues to own lane scheduling and budgets. This change only removes conflicting local deadlines and strengthens resource-lifetime assertions; it does not establish a Windows process-kill or filesystem defect.
+
+## Alternatives considered
+
+- Increase production grace periods or filesystem retries: the failures do not demonstrate incorrect product timing or exhausted removal retries.
+- Replace local deadlines with larger constants: that would still override future lane budgets.
+- Return from cleanup immediately after requesting termination: that would permit children or sockets to outlive the fixture.
+- Serialize coverage: unrelated tests need not lose concurrency to accommodate two local deadline overrides.
+
+## Consequences
+
+The lane timeout remains a bound on hangs. Focused tests verify observed child completion and cleanup ownership instead of host termination speed. Native Windows runs remain necessary for taskkill, process-exit delivery, and NTFS removal evidence; passing macOS tests cannot prove those mechanisms. No model-visible output, Session fixture, production timeout, or CI routing changes.

+ 28 - 0
.agents/notes/implemented/testing/2026-09-07-subagent-teardown-test-budgets.zh.md

@@ -0,0 +1,28 @@
+# Agent Note: 子代理清理测试继承执行通道的时间预算
+
+Status: implemented
+
+[English](2026-09-07-subagent-teardown-test-budgets.md) | 中文
+
+## 问题
+
+[Windows 覆盖率运行](https://github.com/deepseek-harness/deepseek-harness/actions/runs/34085536250/job/101628739668) 为测试和钩子提供 90 秒预算,却报告了两个清理失败。ACP 忽略 EOF 测试让清理与自设的五秒定时器竞争。真实 Codex 测试将钩子预算覆盖为 30 秒。这两个期限都不用于验证产品延迟保证。Codex 测试正文在钩子失败前已经观察到进程树退出;日志未指出究竟是上下文释放、HTTP 关闭还是临时目录删除超出了钩子预算。
+
+## 决策
+
+[ACP 测试](../../../../packages/subagent/subagent-acp/tests/subagent-acp.spec.ts) 在执行通道的测试预算内等待清理完成,然后检查真实子进程的结果。失败清理先等待释放和子进程完成,再删除私有目录。延迟的退出观察证明,清理不能仅因已请求终止而完成。生产环境的 EOF 与终止宽限期保持不变。
+
+[Codex 测试](../../../../packages/subagent/subagent-codex/tests/real-product.spec.ts) 继承执行通道的钩子预算。清理在第一次异步等待前取得其上下文、HTTP 夹具和临时根目录,因此超时钩子不能取走其他测试注册的资源。清理保留上下文释放、服务器关闭、目录删除的顺序,等待所有已取得的释放操作,并在拒绝后继续尝试其余清理阶段。收集的错误指出各自失败的阶段或路径并保留原始原因;只有全部已取得资源都尝试清理后才报告错误。
+
+[原生 Windows CI 决策](../process/2026-08-08-native-windows-pull-request-ci.zh.md) 继续负责通道调度和预算。本次改动仅移除冲突的局部期限并加强资源生命周期断言;它并不证明 Windows 进程终止或文件系统存在缺陷。
+
+## 曾考虑的替代方案
+
+- 增加生产环境宽限期或文件系统重试次数:这些失败不能证明产品时序错误或删除重试耗尽。
+- 用更大的常量替换局部期限:这样仍会覆盖未来的通道预算。
+- 请求终止后立即结束清理:这样会允许子进程或套接字存活超过夹具的生命周期。
+- 将覆盖率测试串行化:无关测试不应为两个局部期限覆盖而失去并发能力。
+
+## 后果
+
+通道超时仍为挂起提供时间上限。定向测试验证观察到的子进程完成和清理所有权,而不是宿主机终止速度。taskkill、进程退出通知和 NTFS 删除仍需原生 Windows 运行提供证据;macOS 测试通过不能证明这些机制。模型可见输出、Session 夹具、生产环境超时与 CI 路由均不变。

+ 21 - 9
packages/session/session-persistence-jsonl/tests/jsonl.spec.ts

@@ -793,17 +793,29 @@ describe('JsonlSessionPersistence: immutable format generations', () => {
     const controller = new AbortController()
     const reason = new Error('first historical waiter cancelled')
 
+    const internals = ctx.sessionPersistence as unknown as {
+      migrationPreparations: Map<SessionId, { waiters: number }>
+    }
     const first = ctx.sessionPersistence.open(header.id, 'read', { signal: controller.signal })
     const second = ctx.sessionPersistence.open(header.id, 'read')
-    await pause.entered
-    await scheduler.yield()
-    controller.abort(reason)
-    await expect(first).rejects.toBe(reason)
-    pause.release()
-    const handle = await second
-    expect((await handle.read()).events).toEqual([])
-    expect(readTally.bySuffix.get(sourcePath)).toBe(1)
-    await handle.close()
+    const settled = Promise.allSettled([first, second])
+    try {
+      await pause.entered
+      // Both callers must join the preparation before either caller leaves it.
+      await expect.poll(() => internals.migrationPreparations.get(header.id)?.waiters).toBe(2)
+      controller.abort(reason)
+      await expect(first).rejects.toBe(reason)
+      pause.release()
+      const handle = await second
+      expect((await handle.read()).events).toEqual([])
+      expect(readTally.bySuffix.get(sourcePath)).toBe(1)
+    } finally {
+      controller.abort(reason)
+      pause.release()
+      for (const result of await settled) {
+        if (result.status === 'fulfilled') await result.value.close()
+      }
+    }
   })
 
   it('cancels shared historical preparation after its last waiter leaves', async () => {

+ 76 - 9
packages/subagent/subagent-acp/tests/subagent-acp.spec.ts

@@ -1,4 +1,4 @@
-import { describe, expect, it } from 'vitest'
+import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import Loader from '@deepseek-ai/cordis-plugin-loader'
 import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
@@ -294,6 +294,57 @@ describe('disposeAcpChild (the backend-owned teardown ladder over seam verbs)',
     expectHostTermination(outcome, 'SIGKILL')
   })
 
+  it('waits for observed tree exit after the EOF grace and termination request', async () => {
+    vi.useFakeTimers()
+    const exited = Promise.withResolvers<boolean>()
+    const stdin = new PassThrough()
+    const calls: string[] = []
+    const child: SubprocessHandle = {
+      pid: 123,
+      stdin,
+      stdout: undefined,
+      stderr: undefined,
+      collected: {},
+      done: exited.promise.then(() => ({ exitCode: 1, signal: null })),
+      terminate: () => { calls.push('terminate') },
+      waitForExit: (signal?: AbortSignal) => {
+        if (signal === undefined) {
+          calls.push('wait for exit')
+          return exited.promise
+        }
+        calls.push('wait for EOF')
+        return new Promise((resolve) => {
+          signal.addEventListener('abort', () => { resolve(false) }, { once: true })
+        })
+      },
+    }
+    let disposal: Promise<void> | undefined
+    try {
+      let disposed = false
+      disposal = disposeAcpChild(child, 150).then(() => { disposed = true })
+      expect(stdin.writableEnded).toBe(true)
+      await vi.advanceTimersByTimeAsync(149)
+      expect(calls).toEqual(['wait for EOF'])
+      await vi.advanceTimersByTimeAsync(1)
+      expect(calls).toEqual(['wait for EOF', 'terminate', 'wait for exit'])
+      // Advancing the clock cannot stand in for the process owner's exit proof.
+      await vi.advanceTimersByTimeAsync(10_000)
+      expect(disposed).toBe(false)
+      exited.resolve(true)
+      await disposal
+      expect(disposed).toBe(true)
+    } finally {
+      exited.resolve(true)
+      try {
+        await vi.runAllTimersAsync()
+        await disposal
+      } finally {
+        stdin.destroy()
+        vi.useRealTimers()
+      }
+    }
+  })
+
   it('observes a spawn-level rejection and returns without a process to reap', async () => {
     const child = spawnSubprocess({
       argv: [process.execPath, '--input-type=module', '--eval', ''],
@@ -859,6 +910,8 @@ describe('dsh-subagent-acp', () => {
     const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-'))
     const ready = join(tmp, 'ready')
     const sigterm = join(tmp, 'sigterm')
+    let child: SubprocessHandle | undefined
+    let run: Awaited<ReturnType<typeof startAcpRun>> | undefined
     try {
       const spec: AcpRunSpec = {
         command: process.execPath,
@@ -872,18 +925,32 @@ describe('dsh-subagent-acp', () => {
         // Tiny EOF grace so the ignored-EOF window elapses quickly.
         disposeEofGraceMs: 150,
         disposeGraceMs: 2000,
-        spawn: spawnSubprocess,
+        spawn: (spec) => {
+          child = spawnSubprocess(spec)
+          return child
+        },
       }
-      const run = await startAcpRun(request(), spec)
+      run = await startAcpRun(request(), spec)
       await waitForFile(ready)
-      // Bound it so a hang fails loud rather than stalling the suite.
-      await expect(Promise.race([
-        run.dispose(),
-        new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }),
-      ])).resolves.toBeUndefined()
+      await run.dispose()
+      const outcome = await child!.done
+      expect(outcome.signal).toBeNull()
+      if (process.platform === 'win32') {
+        expect(outcome.exitCode).not.toBeNull()
+        expect(outcome.exitCode).not.toBe(0)
+      } else {
+        expect(outcome.exitCode).toBe(0)
+      }
       expect(existsSync(sigterm)).toBe(process.platform !== 'win32')
     } finally {
-      rmSync(tmp, { recursive: true, force: true })
+      try {
+        await run?.dispose()
+      } finally {
+        child?.terminate()
+        await child?.waitForExit()
+        await child?.done
+        rmSync(tmp, { recursive: true, force: true })
+      }
     }
   })
 

+ 157 - 0
packages/subagent/subagent-codex/tests/real-product-cleanup.spec.ts

@@ -0,0 +1,157 @@
+import { existsSync, mkdtempSync, rmSync } from 'node:fs'
+import { createServer } from 'node:http'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { expect, it, vi } from 'vitest'
+import { cleanupRealProduct } from './real-product-cleanup.ts'
+
+it.each(['context', 'HTTP fixture'] as const)('attributes %s cleanup failures without losing the cause', async (stage) => {
+  const cause = new Error('fixture failure')
+  const fail = (): Promise<void> => Promise.reject(cause)
+  await expect(cleanupRealProduct({
+    contexts: stage === 'context' ? [{ fiber: { dispose: fail } }] : [],
+    fixtures: stage === 'HTTP fixture' ? [{ close: fail }] : [],
+    roots: [],
+  })).rejects.toMatchObject({
+    message: stage === 'context'
+      ? 'Codex test context disposal failed'
+      : 'Codex test HTTP fixture closure failed',
+    cause,
+  })
+})
+
+it('attributes root removal failures to the owned path', async () => {
+  const root = 'invalid\0root'
+  await expect(cleanupRealProduct({ contexts: [], fixtures: [], roots: [root] }))
+    .rejects.toMatchObject({
+      message: `Codex test temporary root removal failed: ${root}`,
+      cause: { code: 'ERR_INVALID_ARG_VALUE' },
+    })
+})
+
+it('closes its real HTTP server and removes its root after context disposal rejects', async () => {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-codex-cleanup-rejected-'))
+  const server = createServer()
+  const close = (): Promise<void> => new Promise((resolve, reject) => {
+    server.close((error) => {
+      if (error) reject(error)
+      else resolve()
+    })
+  })
+  try {
+    await new Promise<void>((resolve, reject) => {
+      server.once('error', reject)
+      server.listen(0, '127.0.0.1', resolve)
+    })
+    const cause = new Error('context disposal failed')
+    await expect(cleanupRealProduct({
+      contexts: [{ fiber: { dispose: () => Promise.reject(cause) } }],
+      fixtures: [{ close }],
+      roots: [root],
+    })).rejects.toMatchObject({ cause })
+    expect(server.listening).toBe(false)
+    expect(existsSync(root)).toBe(false)
+  } finally {
+    if (server.listening) await close()
+    rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
+  }
+})
+
+it('removes sibling roots after an earlier root removal fails', async () => {
+  const root = mkdtempSync(join(tmpdir(), 'dsh-codex-cleanup-sibling-'))
+  try {
+    await expect(cleanupRealProduct({ contexts: [], fixtures: [], roots: ['invalid\0root', root] }))
+      .rejects.toHaveProperty('cause.code', 'ERR_INVALID_ARG_VALUE')
+    expect(existsSync(root)).toBe(false)
+  } finally {
+    rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
+  }
+})
+
+it.each(['context', 'HTTP fixture'] as const)('joins pending %s cleanup after a sibling rejects', async (stage) => {
+  const release = Promise.withResolvers<undefined>()
+  const cause = new Error('sibling cleanup failed')
+  const fail = (): Promise<void> => Promise.reject(cause)
+  const pending = (): Promise<undefined> => release.promise
+  const laterFixture = vi.fn(async () => {})
+  let settled = false
+  const cleanup = cleanupRealProduct({
+    contexts: stage === 'context' ? [{ fiber: { dispose: fail } }, { fiber: { dispose: pending } }] : [],
+    fixtures: stage === 'context' ? [{ close: laterFixture }] : [{ close: fail }, { close: pending }],
+    roots: [],
+  }).catch((error: unknown) => {
+    settled = true
+    return error
+  })
+  try {
+    await new Promise(resolve => setImmediate(resolve))
+    expect(settled).toBe(false)
+    expect(laterFixture).not.toHaveBeenCalled()
+    release.resolve(undefined)
+    await expect(cleanup).resolves.toMatchObject({ cause })
+    if (stage === 'context') expect(laterFixture).toHaveBeenCalledOnce()
+  } finally {
+    release.resolve(undefined)
+    await cleanup
+  }
+})
+
+it('reports failures from every cleanup stage together', async () => {
+  const contextCause = new Error('context failed')
+  const fixtureCause = new Error('fixture failed')
+  await expect(cleanupRealProduct({
+    contexts: [{ fiber: { dispose: () => { throw contextCause } } }],
+    fixtures: [{ close: () => { throw fixtureCause } }],
+    roots: ['invalid\0root'],
+  })).rejects.toMatchObject({
+    name: 'AggregateError',
+    errors: [
+      { message: 'Codex test context disposal failed', cause: contextCause },
+      { message: 'Codex test HTTP fixture closure failed', cause: fixtureCause },
+      { cause: { code: 'ERR_INVALID_ARG_VALUE' } },
+    ],
+  })
+})
+
+it('keeps resources registered during pending cleanup for their own cleanup', async () => {
+  const oldRoot = mkdtempSync(join(tmpdir(), 'dsh-codex-cleanup-old-'))
+  const nextRoot = mkdtempSync(join(tmpdir(), 'dsh-codex-cleanup-next-'))
+  const releaseContext = Promise.withResolvers<undefined>()
+  const oldContext = { fiber: { dispose: vi.fn(() => releaseContext.promise) } }
+  const nextContext = { fiber: { dispose: vi.fn(async () => {}) } }
+  const oldFixture = { close: vi.fn(async () => {
+    expect(existsSync(oldRoot)).toBe(true)
+  }) }
+  const nextFixture = { close: vi.fn(async () => {}) }
+  const resources: Parameters<typeof cleanupRealProduct>[0] = {
+    contexts: [oldContext], fixtures: [oldFixture], roots: [oldRoot],
+  }
+  const cleanup = cleanupRealProduct(resources)
+  try {
+    expect(oldContext.fiber.dispose).toHaveBeenCalledOnce()
+    expect(oldFixture.close).not.toHaveBeenCalled()
+    resources.contexts.push(nextContext)
+    resources.fixtures.push(nextFixture)
+    resources.roots.push(nextRoot)
+    releaseContext.resolve(undefined)
+    await cleanup
+
+    expect(oldFixture.close).toHaveBeenCalledOnce()
+    expect(existsSync(oldRoot)).toBe(false)
+    expect(nextContext.fiber.dispose).not.toHaveBeenCalled()
+    expect(nextFixture.close).not.toHaveBeenCalled()
+    expect(existsSync(nextRoot)).toBe(true)
+    expect(resources).toEqual({ contexts: [nextContext], fixtures: [nextFixture], roots: [nextRoot] })
+
+    await cleanupRealProduct(resources)
+    expect(nextContext.fiber.dispose).toHaveBeenCalledOnce()
+    expect(nextFixture.close).toHaveBeenCalledOnce()
+    expect(existsSync(nextRoot)).toBe(false)
+    expect(resources).toEqual({ contexts: [], fixtures: [], roots: [] })
+  } finally {
+    releaseContext.resolve(undefined)
+    await cleanup
+    rmSync(oldRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
+    rmSync(nextRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
+  }
+})

+ 43 - 0
packages/subagent/subagent-codex/tests/real-product-cleanup.ts

@@ -0,0 +1,43 @@
+import { rm } from 'node:fs/promises'
+import type { Context } from '@deepseek-ai/cordis'
+import type { ResponsesFixture } from './responses-fixture.ts'
+
+interface RealProductResources {
+  contexts: { fiber: Pick<Context['fiber'], 'dispose'> }[]
+  fixtures: Pick<ResponsesFixture, 'close'>[]
+  roots: string[]
+}
+
+/**
+ * Dispose Codex test contexts and HTTP fixtures before removing their files.
+ * Captures all registries before awaiting, so later tests retain their resources.
+ * Attempts every captured cleanup before reporting failures.
+ * @param resources - mutable registries of resources owned by the test.
+ */
+export async function cleanupRealProduct(resources: RealProductResources): Promise<void> {
+  const contexts = resources.contexts.splice(0)
+  const fixtures = resources.fixtures.splice(0)
+  const roots = resources.roots.splice(0)
+  const failures: Error[] = []
+  const contextOutcomes = await Promise.allSettled(contexts.map(async ctx => ctx.fiber.dispose()))
+  for (const outcome of contextOutcomes) {
+    if (outcome.status === 'rejected') {
+      failures.push(new Error('Codex test context disposal failed', { cause: outcome.reason }))
+    }
+  }
+  const fixtureOutcomes = await Promise.allSettled(fixtures.map(async fixture => fixture.close()))
+  for (const outcome of fixtureOutcomes) {
+    if (outcome.status === 'rejected') {
+      failures.push(new Error('Codex test HTTP fixture closure failed', { cause: outcome.reason }))
+    }
+  }
+  for (const root of roots) {
+    try {
+      await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
+    } catch (cause) {
+      failures.push(new Error(`Codex test temporary root removal failed: ${root}`, { cause }))
+    }
+  }
+  if (failures.length === 1) throw failures[0]
+  if (failures.length > 1) throw new AggregateError(failures, 'Codex test cleanup failed')
+}

+ 2 - 8
packages/subagent/subagent-codex/tests/real-product.spec.ts

@@ -7,7 +7,6 @@ import {
   readFileSync,
   writeFileSync,
 } from 'node:fs'
-import { rm } from 'node:fs/promises'
 import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
 import { delimiter, dirname, join, resolve } from 'node:path'
@@ -31,6 +30,7 @@ import {
   type ResponsesBehavior,
   type ResponsesFixture,
 } from './responses-fixture.ts'
+import { cleanupRealProduct } from './real-product-cleanup.ts'
 
 const execFileAsync = promisify(execFile)
 const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
@@ -47,13 +47,7 @@ const roots: string[] = []
 const fixtures: ResponsesFixture[] = []
 const contexts: Context[] = []
 
-afterEach(async () => {
-  await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
-  await Promise.all(fixtures.splice(0).map(fixture => fixture.close()))
-  for (const root of roots.splice(0)) {
-    await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
-  }
-}, 30_000)
+afterEach(() => cleanupRealProduct({ contexts, fixtures, roots }))
 
 interface RealHarness {
   readonly ctx: Context