fix(subagent): complete product provider lifecycle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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` 等普通环境变量值仍然可用。
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user