fix(subagent): complete product provider lifecycle

This commit is contained in:
pku-xht
2026-08-04 22:18:35 +08:00
parent d780902679
commit 32f829c4e6
21 changed files with 216 additions and 301 deletions
@@ -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 packages/subagent/subagent-codex/README.md
README.md: ce1c66427b562c08af06320f012f28b9e125ac45
README.zh.md: bef47586db77c70bec741629d8579ba0e2efba1e
README.md: d7293a0ef37e4ec0f0cf983c254f9e22f830fcd8
README.zh.md: 110953312162e146f01ef037a40d2f70b136850c
+1 -1
View File
@@ -23,7 +23,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite process-tree termination grace in milliseconds; the final exit proof is bounded at twice this value. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
@@ -23,7 +23,7 @@
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 进程树终止宽限期,须为正有限值,单位为毫秒;最终退出确认的等待时间上限为该值的两倍。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值;随后资源释放会等待整棵进程树退出。 |
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
+7 -22
View File
@@ -11,9 +11,9 @@ import { randomUUID } from 'node:crypto'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
doubledGraceWindow,
settleRunResult,
subprocessRunHandle,
thrownError,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
@@ -31,7 +31,7 @@ export interface CodexRunSpec {
readonly cwd: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace and final tree-exit bound. */
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
@@ -39,11 +39,6 @@ export interface CodexRunSpec {
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/**
* Validate and preserve the one-shot task before crossing the process seam.
* @param prompt - task content accepted from the shared subagent service.
@@ -71,12 +66,10 @@ export function textTask(prompt: readonly ContentBlock[]): string[] {
* subprocess owner to prove it is gone.
* @param wire - private app-server protocol connection.
* @param child - shared-service handle that owns the process tree.
* @param graceMs - termination grace used to bound final exit observation.
*/
export async function disposeCodexChild(
wire: CodexAppServerWire,
child: SubprocessHandle,
graceMs: number,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
@@ -89,14 +82,7 @@ export async function disposeCodexChild(
// A concurrently closed stdin does not change tree ownership below.
}
child.terminate()
const exitWindow = doubledGraceWindow(graceMs)
try {
if (!(await child.waitForExit(exitWindow.signal))) {
throw new Error('subagent-codex: app-server process tree did not exit within its dispose window')
}
} finally {
exitWindow.cancel()
}
await child.waitForExit()
await child.done
}
@@ -127,15 +113,14 @@ export async function startCodexRun(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
)
const disposeProcess = (): Promise<void> =>
disposeCodexChild(wire, child, spec.disposeGraceMs)
const disposeProcess = (): Promise<void> => disposeCodexChild(wire, child)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-codex: app-server exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
(error: unknown) => Promise.reject(thrownError(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
@@ -160,14 +145,14 @@ export async function startCodexRun(
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
[thrownError(error), thrownError(disposeError)],
'subagent-codex: startup failed and app-server cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
throw thrown(error)
throw thrownError(error)
}
const collectOutput = (): ContentBlock[] => wire.collectOutput()
@@ -91,7 +91,6 @@ class ProtocolPeer {
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly waitForExitResult?: boolean
readonly doneError?: Error
}
@@ -134,9 +133,6 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
if (options.waitForExitResult !== undefined) {
return options.waitForExitResult
}
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
@@ -964,19 +960,6 @@ describe('run lifecycle and quiescence', () => {
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('reports both startup and rollback failures', async () => {
const child = fakeChild({ waitForExitResult: false, exitOnTerminate: false })
const starting = startCodexRun(
request(),
runSpec(child, { disposeGraceMs: 1 }),
)
const initialize = await child.peer.nextMethod('initialize')
child.peer.respond(initialize, { userAgent: '' })
await expect(starting).rejects.toThrow(
'startup failed and app-server cleanup also failed',
)
})
it('keeps overlapping runs isolated', async () => {
const first = fakeChild()
const second = fakeChild()
@@ -1047,41 +1030,25 @@ describe('disposeCodexChild', () => {
const child = fakeChild()
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
const end = vi.spyOn(child.toChild, 'end')
await disposeCodexChild(wire, child.handle, 100)
await disposeCodexChild(wire, child.handle)
expect(end).toHaveBeenCalled()
expect(child.terminate).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledWith()
})
it('accepts fractional and larger-than-Node grace windows', async () => {
for (const graceMs of [0.25, Number.MAX_VALUE]) {
const child = fakeChild()
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(disposeCodexChild(wire, child.handle, graceMs))
.resolves.toBeUndefined()
const signal = vi.mocked(child.waitForExit).mock.calls[0]?.[0]
expect(signal?.aborted).toBe(false)
}
})
it('chains a doubled grace window beyond one Node timer segment', async () => {
vi.useFakeTimers()
try {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
const disposal = disposeCodexChild(
wire,
child.handle,
1_073_741_823.75,
)
const rejected = expect(disposal)
.rejects.toThrow('did not exit within its dispose window')
await vi.advanceTimersByTimeAsync(2_147_483_647)
await vi.advanceTimersByTimeAsync(1)
await rejected
} finally {
vi.useRealTimers()
}
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
let disposed = false
const disposal = disposeCodexChild(wire, child.handle).then(() => {
disposed = true
})
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(disposed).toBe(false)
child.settle()
await disposal
expect(disposed).toBe(true)
})
it('contains a concurrently closed stdin error', async () => {
@@ -1090,7 +1057,7 @@ describe('disposeCodexChild', () => {
vi.spyOn(child.toChild, 'end').mockImplementation(() => {
throw new Error('already closed')
})
await expect(disposeCodexChild(wire, child.handle, 100))
await expect(disposeCodexChild(wire, child.handle))
.resolves.toBeUndefined()
})
@@ -1100,34 +1067,26 @@ describe('disposeCodexChild', () => {
doneError: new Error('spawn failed'),
})
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(disposeCodexChild(wire, child.handle, 100))
await expect(disposeCodexChild(wire, child.handle))
.resolves.toBeUndefined()
expect(child.terminate).not.toHaveBeenCalled()
expect(child.waitForExit).not.toHaveBeenCalled()
})
it('fails when the tree misses the release window or done rejects', async () => {
{
const child = fakeChild({
exitOnTerminate: false,
})
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(disposeCodexChild(wire, child.handle, 1))
.rejects.toThrow('did not exit within its dispose window')
}
it('reports direct-child observer failure and accepts absent stdin', async () => {
{
const child = fakeChild({
doneError: new Error('close observer failed'),
})
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(disposeCodexChild(wire, child.handle, 1))
await expect(disposeCodexChild(wire, child.handle))
.rejects.toThrow('close observer failed')
}
{
const child = fakeChild()
const handle = { ...child.handle, stdin: undefined }
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(disposeCodexChild(wire, handle, 1)).resolves.toBeUndefined()
await expect(disposeCodexChild(wire, handle)).resolves.toBeUndefined()
}
})
})