fix(subagent): close Codex provider review findings

This commit is contained in:
pku-xht
2026-08-04 17:13:38 +08:00
parent 9d65894314
commit 36b562e4e9
6 changed files with 48 additions and 93 deletions

View File

@@ -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/proposed/feature/2026-08-04-claude-code-and-codex-subagent-backends.md
2026-08-04-claude-code-and-codex-subagent-backends.md: f8f9e10231874b6f97eba98594c952a86efd6901
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 60437a1bc6d8d3c3c9677b4a466ce6a7122fa4fc
2026-08-04-claude-code-and-codex-subagent-backends.md: 37f45f844c9411af0467397272649533ed4d44cc
2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dcd7b90231bdaed47435c27deef413d20f0b7f28

View File

@@ -14,7 +14,7 @@ The product integrations must not become second owners for task text, cwd, cance
The harness provides two sibling one-shot providers behind two fixed model-facing tools. `subagent_codex` selects the `codex` provider, and `subagent_claude_code` selects the `claude-code` provider. Each tool accepts only a standalone text task and binds its provider at deployment time; product selection and background execution are not model arguments.
The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains part of this proposal and will use Claude Agent SDK 0.3.220 with its bundled Claude Code 2.1.220 CLI. This Note remains proposed until both siblings and their combined evidence are present.
The Codex provider is implemented against Codex 0.146.0. The Claude Code provider remains unimplemented. This Note remains proposed until both siblings and their combined evidence are present.
Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation.
@@ -47,11 +47,7 @@ An unpublished startup failure closes the wire, terminates the acquired process
## Claude Code provider
The Claude Code sibling follows the same fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries. It will call the official Agent SDK's `query()` and use its `spawnClaudeCodeProcess` hook to pass the SDK-provided command, arguments, cwd, environment, and forwarded signal into `dsh-subprocess` without rewriting them.
The SDK will continue to own the Claude protocol and graceful `Query.close()` intent, while `dsh-subprocess` owns the actual CLI process tree and exit proof. Publication waits until both the SDK query and real CLI handle are controllable. A strict successful `SDKResultMessage` becomes `completed` only after asynchronous iteration ends normally; local cancellation becomes `aborted`, and every other result, iterator failure, protocol failure, or process failure becomes `error`.
The package will expose the same two configuration concerns, `env` and `disposeGraceMs`. It will keep native settings and login under Claude Code's authority, disable unattended `AskUserQuestion`, omit interactive callbacks, and create no plugin-owned product session or account state.
The Claude Code sibling is not yet implemented. Its product version, official integration, terminal mapping, product-specific configuration, interaction policy, and evidence are not fixed by this intermediate proposal. Its eventual implementation must preserve the shared fixed-name, standalone-task, parent-cwd, shared-result, and managed-tree boundaries above before this Note can become implemented.
## Evidence contract
@@ -59,11 +55,11 @@ Each product owns branch-complete package tests, a required real-product spec, a
The Codex evidence pins `@openai/codex@0.146.0` and `codex-cli 0.146.0`. Its real-product spec observes the exact Bearer key, original task, byte-exact final answer, unattended command rejection with no file side effect, local cancellation, and whole-tree exit. Its Loader snapshot fixes the no-background tool schema, exact tool call and result, complete persisted parent Session, product request, and pre-teardown quiescence. The npm package is a development dependency for reproducible evidence; production still supplies `codex` on `PATH`.
The combined contract is complete only when the Claude sibling has equivalent real SDK and bundled-CLI evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract.
The combined contract is complete only when the Claude sibling has equivalent real-product evidence and one assembled Loader run proves both fixed tools coexist without changing the common subagent contract.
## Alternatives considered
**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible process protocols and cannot prove native configuration, tools, approvals, result semantics, or teardown. The providers use app-server and the official Agent SDK instead.
**Direct model HTTP, `codex exec`, or a hand-written Claude CLI protocol.** These paths bypass the products' official extensible integration surfaces and cannot prove native configuration, tools, approvals, result semantics, or teardown. Each provider uses its official product integration instead.
**A shared product-process helper package.** The existing subagent and subprocess seams already own every shared task, result, environment, and process-tree concern. A new helper would duplicate ownership before the two products demonstrate a missing common contract, so each private adapter calls the existing seams directly.

View File

@@ -14,7 +14,7 @@ Status: proposed
harness 在两个固定的面向模型工具背后提供两个一次性兄弟提供方。`subagent_codex` 选择 `codex` 提供方,`subagent_claude_code` 选择 `claude-code` 提供方。每个工具只接受独立文本任务,并在部署时绑定其提供方;产品选择与后台执行都不作为模型参数。
Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍属于本提案的一部分,将使用 Claude Agent SDK 0.3.220 及其捆绑的 Claude Code 2.1.220 CLI命令行界面。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。
Codex 提供方基于 Codex 0.146.0 实现。Claude Code 提供方仍未实现。在两个兄弟提供方及其组合证据全部具备之前,本 Agent Note 将保持提案状态。
这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动时功能,并传递父会话 cwd但不会复制父级对话。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。
@@ -47,11 +47,7 @@ fixed tool → shared subagent service → product provider → official product
## Claude Code 提供方
Claude Code 兄弟提供方遵循同样的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界。它将调用官方 Agent SDK 的 `query()`,并使用其 `spawnClaudeCodeProcess` 钩子,将 SDK 提供的命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`
SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭意图;`dsh-subprocess` 则负责实际的 CLI 进程树与退出证明。只有在 SDK query 和真实 CLI 句柄均可控后才会发布运行。只有严格表示成功的 `SDKResultMessage` 才会在异步迭代正常结束后成为 `completed`;本地取消成为 `aborted`,其他任何结果、迭代器失败、协议失败或进程失败都成为 `error`
该包会公开相同的两个配置项:`env``disposeGraceMs`。它会继续让 Claude Code 负责原生设置与登录,禁用无人值守的 `AskUserQuestion`,不提供交互式回调,也不会创建由插件负责的产品会话或账户状态。
Claude Code 兄弟提供方尚未实现。其中间提案不固定产品版本、官方接入方式、终态映射、产品特定配置、交互策略或证据。它的最终实现必须保留上文所述的固定名称、独立任务、父级 cwd、共享结果和受管进程树边界本 Agent Note 才能进入 implemented 状态
## 证据契约
@@ -59,11 +55,11 @@ SDK 将继续负责 Claude 协议,并通过 `Query.close()` 表达优雅关闭
Codex 证据锁定 `@openai/codex@0.146.0``codex-cli 0.146.0`。其真实产品测试会观测确切的 Bearer 密钥、原始任务、逐字节完全一致的最终回答、不会产生文件副作用的无人值守命令拒绝、本地取消以及整棵进程树退出。其 Loader 快照锁定不支持后台执行的工具 schema、确切的工具调用与结果、完整的已持久化父会话、产品请求以及清理前的完全停稳状态。该 NPM 包是用于复现证据的开发依赖;生产环境仍提供 `codex`,并通过 `PATH` 解析。
只有在 Claude 兄弟提供方具备同等的真实 SDK 与捆绑 CLI 证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。
只有在 Claude 兄弟提供方具备同等的真实产品证据,并且一次组装后的 Loader 运行证明两个固定工具可以共存且无需更改通用 subagent 契约时,组合契约才算完整。
## 曾考虑的替代方案
**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展进程协议,无法证明原生配置、工具、审批、结果语义或资源清理。提供方改为使用 app-server 与官方 Agent SDK
**直接模型 HTTP、`codex exec` 或手写的 Claude CLI 协议。** 这些路径会绕过产品的官方可扩展接入面,无法证明原生配置、工具、审批、结果语义或资源清理。每个提供方都使用对应产品的官方接入方式
**共享产品进程辅助包。** 现有 subagent 与子进程 seam 已负责围绕任务、结果、环境和进程树的全部共享职责。在两个产品尚未证明通用契约存在缺口时,新辅助包只会造成责任重复,因此各自的私有适配器会直接调用现有 seam。

View File

@@ -116,13 +116,11 @@ export async function startCodexRun(
graceMs: spec.disposeGraceMs,
env: spec.env,
})
if (child.stdin === undefined || child.stdout === undefined) {
child.terminate()
await child.waitForExit()
throw new Error('subagent-codex: subprocess implementation dropped a piped protocol stream')
}
const wire = new CodexAppServerWire(child.stdout, child.stdin)
const wire = new CodexAppServerWire(
child.stdout as NonNullable<SubprocessHandle['stdout']>,
child.stdin as NonNullable<SubprocessHandle['stdin']>,
)
const disposeProcess = (): Promise<void> =>
disposeCodexChild(wire, child, spec.disposeGraceMs)
@@ -137,15 +135,10 @@ export async function startCodexRun(
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const flags = { cancelled: false }
const runAbort = new AbortController()
let settleCancellation!: () => void
const cancellation = new Promise<void>((resolve) => { settleCancellation = resolve })
const requestCancel = (): void => {
if (flags.cancelled) return
flags.cancelled = true
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-codex: run cancelled locally'))
settleCancellation()
wire.interrupt()
}
const onAbort = (): void => { requestCancel() }
@@ -165,7 +158,7 @@ export async function startCodexRun(
'subagent-codex: startup failed and app-server cleanup also failed',
)
}
if (flags.cancelled) {
if (runAbort.signal.aborted) {
throw new Error('subagent-codex: request was aborted before app-server startup')
}
throw thrown(error)
@@ -174,15 +167,11 @@ export async function startCodexRun(
const collectOutput = (): ContentBlock[] => wire.collectOutput()
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([
wire.runTurn(texts, runAbort.signal, () => flags.cancelled),
wire.runTurn(texts, runAbort.signal, () => runAbort.signal.aborted),
processFailure,
cancellation.then((): SubagentResult => ({
output: collectOutput(),
stopReason: 'aborted',
})),
]),
collectOutput,
cancelled: () => flags.cancelled,
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,

View File

@@ -83,9 +83,8 @@ export class CodexAppServerWire {
readonly method: string
readonly params: JsonObject
}> = []
private readonly finalAnswers: string[] = []
private readonly unphasedAnswers: string[] = []
private started = false
private lastFinalAnswer: string | undefined
private lastUnphasedAnswer: string | undefined
private closed = false
constructor(
@@ -101,14 +100,16 @@ export class CodexAppServerWire {
this.fail(thrown(error))
}
})
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
// Pipe errors can race protocol closure and process teardown. Retain both
// error listeners for the lifetime of their per-run streams so no late
// EPIPE or read failure becomes an unhandled EventEmitter error.
output.on('error', this.onOutputError)
}
/** Start reading app-server frames. */
start(): void {
if (this.started) return
this.started = true
this.input.on('error', this.onInputError)
this.input.on('end', this.onInputEnd)
this.transport.start()
}
@@ -166,16 +167,11 @@ export class CodexAppServerWire {
signal: AbortSignal,
cancelled: () => boolean,
): Promise<SubagentResult> {
if (this.threadId === undefined) {
throw new Error('subagent-codex: cannot start a turn before thread/start')
}
if (this.turnCompleted !== undefined) {
throw new Error('subagent-codex: this one-shot wire already started its turn')
}
const completion = deferred<JsonObject>()
this.turnCompleted = completion
const threadId = this.threadId as string
const response = object(await this.guarded(this.transport.request('turn/start', {
threadId: this.threadId,
threadId,
input: texts.map(text => ({ type: 'text', text, text_elements: [] })),
}, signal), signal), 'turn/start response')
const turn = object(response.turn, 'turn/start turn')
@@ -216,9 +212,7 @@ export class CodexAppServerWire {
* @returns the selected final or nullable-phase text block, if any.
*/
collectOutput(): ContentBlock[] {
const selected = this.finalAnswers.length > 0
? this.finalAnswers.at(-1)
: this.unphasedAnswers.at(-1)
const selected = this.lastFinalAnswer ?? this.lastUnphasedAnswer
return selected !== undefined && selected.trim().length > 0
? [{ type: 'text', text: selected }]
: []
@@ -228,7 +222,6 @@ export class CodexAppServerWire {
close(): void {
if (this.closed) return
this.closed = true
this.input.off('error', this.onInputError)
this.input.off('end', this.onInputEnd)
this.transport.close()
}
@@ -249,6 +242,10 @@ export class CodexAppServerWire {
this.fail(error)
}
private readonly onOutputError = (error: Error): void => {
this.fail(error)
}
private readonly onInputEnd = (): void => {
this.fail(new Error('subagent-codex: app-server protocol stream closed'))
}
@@ -338,9 +335,9 @@ export class CodexAppServerWire {
? item.text
: (() => { throw new Error('subagent-codex: app-server returned an invalid agent message') })()
if (item.phase === 'final_answer') {
this.finalAnswers.push(text)
this.lastFinalAnswer = text
} else if (item.phase === null) {
this.unphasedAnswers.push(text)
this.lastUnphasedAnswer = text
} else if (item.phase !== 'commentary') {
throw new Error(`subagent-codex: app-server returned an unknown agent message phase ${JSON.stringify(item.phase)}`)
}

View File

@@ -90,8 +90,6 @@ class ProtocolPeer {
interface FakeChildOptions {
readonly pid?: number
readonly stdin?: boolean
readonly stdout?: boolean
readonly exitOnTerminate?: boolean
readonly waitForExitResult?: boolean
readonly doneError?: Error
@@ -161,8 +159,8 @@ function fakeChild(options: FakeChildOptions = {}): FakeChild {
})
const handle: SubprocessHandle = {
pid: options.pid ?? 1234,
stdin: options.stdin === false ? undefined : toChild,
stdout: options.stdout === false ? undefined : fromChild,
stdin: toChild,
stdout: fromChild,
stderr: undefined,
collected: {},
done,
@@ -334,7 +332,6 @@ describe('CodexAppServerWire', () => {
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
expect(wire.collectOutput()).toEqual([])
wire.start()
wire.start()
const initializing = wire.initialize(new AbortController().signal)
const initialize = await child.peer.nextMethod('initialize')
@@ -451,27 +448,6 @@ describe('CodexAppServerWire', () => {
}
})
it('rejects a turn before thread publication and a second one-shot turn', async () => {
const child = fakeChild()
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
await expect(wire.runTurn(['task'], new AbortController().signal, () => false))
.rejects.toThrow('before thread/start')
const initialized = await initializeWire()
const first = initialized.wire.runTurn(
['task'],
new AbortController().signal,
() => false,
)
await initialized.child.peer.nextMethod('turn/start')
await expect(initialized.wire.runTurn(
['again'],
new AbortController().signal,
() => false,
)).rejects.toThrow('already started')
initialized.wire.close()
await expect(first).rejects.toThrow('transport closed')
})
it('fails closed for empty output, malformed messages, phases, and terminal status', async () => {
const scenarios: Array<{
readonly frames: JsonObject[]
@@ -757,6 +733,17 @@ describe('CodexAppServerWire', () => {
await expect(pending).rejects.toThrow('stdout broke')
wire.close()
}
{
const child = fakeChild()
const wire = new CodexAppServerWire(child.handle.stdout!, child.handle.stdin!)
wire.start()
const pending = wire.initialize(new AbortController().signal)
await child.peer.nextMethod('initialize')
child.toChild.emit('error', new Error('stdin broke'))
await expect(pending).rejects.toThrow('stdin broke')
wire.close()
child.toChild.emit('error', new Error('late stdin close'))
}
})
})
@@ -918,16 +905,6 @@ describe('run lifecycle and quiescence', () => {
)
})
it('rejects a missing protocol stream after reaping the unpublished child', async () => {
for (const options of [{ stdin: false }, { stdout: false }]) {
const child = fakeChild(options)
await expect(startCodexRun(request(), runSpec(child)))
.rejects.toThrow('dropped a piped protocol stream')
expect(child.terminate).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledTimes(1)
}
})
it('keeps overlapping runs isolated', async () => {
const first = fakeChild()
const second = fakeChild()