From cfcecbf0c7fa1f8a33de1f280cced62cda43b2bb Mon Sep 17 00:00:00 2001 From: pku-xht Date: Sat, 15 Aug 2026 19:11:24 +0800 Subject: [PATCH] fix(subagent): stabilize Codex permission diagnostics --- ...code-and-codex-subagent-backends.i18n.yaml | 4 +- ...claude-code-and-codex-subagent-backends.md | 2 +- ...ude-code-and-codex-subagent-backends.zh.md | 2 +- ...bagent-one-shot-background-tasks.i18n.yaml | 4 +- ...duct-subagent-one-shot-background-tasks.md | 2 +- ...t-subagent-one-shot-background-tasks.zh.md | 2 +- packages/subagent/subagent-codex/src/run.ts | 23 ++++-- packages/subagent/subagent-codex/src/wire.ts | 56 +++++++++----- .../tests/subagent-codex.spec.ts | 75 ++++++++++++++++++- 9 files changed, 139 insertions(+), 31 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 597b078939..777fff4e2a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.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/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 49c3e3fc6a99cae23b606f5a680320307c79d08c -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: dc3a0737b9cfe00a850697ca482fad2743105058 +2026-08-04-claude-code-and-codex-subagent-backends.md: f65c0626ad22db8f3e7d2a543c7aa87e58df54d4 +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 97ac527b8e89cc07d65aa28102ba43d648b1b64c diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 49c3e3fc6a..f65c0626ad 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile-selected mode and the shared failure diagnostic. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [production-install exclusion decision](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md) owns their explicit Profile installation and host-plane placement, the [product one-shot background decision](2026-08-12-product-subagent-one-shot-background-tasks.md) owns the model-visible scheduling choice, and the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile-selected mode and diagnostic production. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection remains deployment configuration. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools use `backgroundMode: 'one-shot'` and `maxDepth: 'provider-managed'`: the consumer keeps foreground collection as the default and may place the same run in the generic Job runtime, while recursion policy stays with the out-of-process product. Every call creates a fresh product process and a non-resumable product conversation. `ctx.subagents` owns named-request resolution and paired lifecycle events; `dsh-tool-subagent` owns model-visible scheduling and foreground-versus-Job adaptation; `ctx.jobs` and `dsh-tool-jobs` own Job ids, state, output, controls, notices, and parent-owner cancellation; each product provider owns native result mapping, while `dsh-subprocess` owns credential scrubbing, process-tree termination, and whole-tree exit observation. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index dc3a0737b9..97ac527b8e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责 Claude Code 的 Profile 模式选择与共享失败诊断。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[生产安装排除决策](../simplification/2026-08-12-production-dsh-excludes-product-subagent-providers.md)负责显式 Profile 安装与 host plane(宿主平面)放置,[产品一次性后台任务决策](2026-08-12-product-subagent-one-shot-background-tasks.md)负责模型可见的调度选择,[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)则负责各产品提供方的 Profile 模式选择与诊断生产。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择仍属于部署配置。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具使用 `backgroundMode: 'one-shot'` 与 `maxDepth: 'provider-managed'`:消费方默认在前台收集结果,也可把同一次运行放入通用 Job 运行时,而递归策略仍由进程外产品负责。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。`ctx.subagents` 负责具名请求解析与成对生命周期事件;`dsh-tool-subagent` 负责模型可见的调度以及前台与 Job 适配;`ctx.jobs` 和 `dsh-tool-jobs` 负责 Job id、状态、输出、控制、通知与父级 owner 取消;各产品提供方负责原生结果映射,`dsh-subprocess` 则负责凭证清洗、进程树终止以及整棵进程树的退出观测。 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml index b6cad9b39f..cec2cc269a 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.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/feature/2026-08-12-product-subagent-one-shot-background-tasks.md -2026-08-12-product-subagent-one-shot-background-tasks.md: 9aeccfadbad0d8f44ac2c294c4008b672f855027 -2026-08-12-product-subagent-one-shot-background-tasks.zh.md: 74a0a614847543aff5f88cc5696246a76f2bb72f +2026-08-12-product-subagent-one-shot-background-tasks.md: 248bb943f8ee46a7050c373b6b7c3f7dec65d566 +2026-08-12-product-subagent-one-shot-background-tasks.zh.md: d6867a97561e7efbe2b152b6c45991553393b4e7 diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md index 9aeccfadba..248bb943f8 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.md @@ -14,7 +14,7 @@ Exposing background execution must not add a product session, product-specific j Production `dsh` does not install the optional product providers. A Profile that opts in installs and mounts `dsh-subagent-codex`, `dsh-subagent-claude-code`, or both once on the host plane. The `standard`, `code`, and `cordis` Agent Presets configure the corresponding dormant tool rows with `backgroundMode: one-shot`; removing a row's `disabled` field exposes the existing optional `run_in_background` argument to agents composed from that preset. Omission or `false` waits in the foreground; explicit `true` returns a parent-owned Job id after synchronous Job preflight and registration, without waiting for provider startup or completion. -The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns Claude Code's Profile configuration and diagnostic production. +The [generic one-shot background adapter](2026-07-08-background-subagent-tasks.md) owns background registration and settlement. It starts the same [`SubagentRun`](2026-06-21-subagent-capability-seam.md), uses a Job-owned cancellation signal across provider startup and execution, waits for `run.result` and `run.dispose()`, maps the terminal result and optional safe diagnostic into the Job, and lets `job_output`, `job_list`, `job_kill`, and the existing completion notice expose that state. The [product provider decision](2026-08-04-claude-code-and-codex-subagent-backends.md) continues to own native protocols, answer selection, local cancellation, and process-tree quiescence; the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) owns each product Provider's Profile configuration and diagnostic production. This scheduling decision adds no provider configuration, service interface, event, wire field, persistence format, or product identifier. A Provider may define its own Profile configuration independently; foreground and background still differ only in which existing consumer waits for the same one-shot run. diff --git a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md index 74a0a61484..d6867a9756 100644 --- a/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-12-product-subagent-one-shot-background-tasks.zh.md @@ -14,7 +14,7 @@ Codex 与 Claude Code 提供方已经能够运行一项自包含任务并返回 生产 `dsh` 不安装可选产品提供方。选择启用产品集成的 Profile 会安装 `dsh-subagent-codex`、`dsh-subagent-claude-code` 或两者,并在 host plane(宿主平面)各挂载一次。`standard`、`code` 与 `cordis` Agent Preset 使用 `backgroundMode: one-shot` 配置相应的休眠工具行;删除某一行的 `disabled` 字段后,现有可选参数 `run_in_background` 会向由该 preset 组装的 agent 公开。省略该参数或传入 `false` 时会在前台等待;显式传入 `true` 时会在同步完成 Job 预检与登记后返回由父级拥有的 Job id,而不会等待提供方启动或完成。 -[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责 Claude Code 的 Profile 配置与诊断生产。 +[通用 one-shot 后台适配器](2026-07-08-background-subagent-tasks.md)负责后台登记与结算。它会启动同一个 [`SubagentRun`](2026-06-21-subagent-capability-seam.md),让 Job 自有的取消信号覆盖提供方启动与执行,等待 `run.result` 和 `run.dispose()`,把终态结果与可选安全诊断映射进 Job,并由 `job_output`、`job_list`、`job_kill` 与现有完成通知公开该状态。[产品提供方决策](2026-08-04-claude-code-and-codex-subagent-backends.md)继续负责原生协议、答案选择、本地取消与进程树完全停稳;[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.md)负责各产品提供方的 Profile 配置与诊断生产。 本调度决策不新增提供方配置、服务接口、事件、协议字段、持久化格式或产品标识符。提供方可以独立定义自己的 Profile 配置;前台与后台的区别仍然只在于由哪个现有消费方等待同一个 one-shot 运行。 diff --git a/packages/subagent/subagent-codex/src/run.ts b/packages/subagent/subagent-codex/src/run.ts index 086b6a588b..94183802f1 100644 --- a/packages/subagent/subagent-codex/src/run.ts +++ b/packages/subagent/subagent-codex/src/run.ts @@ -154,21 +154,27 @@ export async function startCodexRun( spec.permissionMode, ) const onStderr = (chunk: Buffer | string): void => { - process.stderr.write(chunk) wire.observeStderr(chunk.toString()) } const onStderrError = (): void => { // Stderr observation is auxiliary. JSON-RPC and child.done remain the // only terminal authorities if the diagnostic stream itself fails. } + const onHostStderrError = (): void => { + // Host stderr is an observation sink, not a child-run failure authority. + } child.stderr?.on('data', onStderr) child.stderr?.on('error', onStderrError) + process.stderr.on('error', onHostStderrError) + child.stderr?.pipe(process.stderr, { end: false }) const disposeProcess = async (): Promise => { try { await disposeCodexChild(wire, child) } finally { + child.stderr?.unpipe(process.stderr) child.stderr?.off('data', onStderr) child.stderr?.off('error', onStderrError) + process.stderr.off('error', onHostStderrError) } } @@ -214,10 +220,17 @@ export async function startCodexRun( const collectOutput = (): ContentBlock[] => wire.collectOutput() const result: Promise = settleRunResult({ - attempt: () => Promise.race([ - wire.runTurn(texts, runAbort.signal), - processFailure, - ]), + attempt: async () => { + try { + return await Promise.race([ + wire.runTurn(texts, runAbort.signal), + processFailure, + ]) + } catch (error: unknown) { + await new Promise((resolve) => { setImmediate(resolve) }) + throw error + } + }, collectOutput, collectDiagnostic: () => wire.collectDiagnostic(), cancelled: () => runAbort.signal.aborted, diff --git a/packages/subagent/subagent-codex/src/wire.ts b/packages/subagent/subagent-codex/src/wire.ts index 9fe5036d53..6e617891b1 100644 --- a/packages/subagent/subagent-codex/src/wire.ts +++ b/packages/subagent/subagent-codex/src/wire.ts @@ -47,6 +47,21 @@ const STDERR_SIGNATURE_TAIL_CHARS = Math.max( ...STDERR_PERMISSION_SIGNATURES.map(signature => signature.text.length), ) - 1 +function stderrSignatureTail(value: string): string { + for ( + let length = Math.min(STDERR_SIGNATURE_TAIL_CHARS, value.length) + ; length > 0 + ; length -= 1 + ) { + const tail = value.slice(-length) + if (STDERR_PERMISSION_SIGNATURES.some(signature => + tail.length < signature.text.length && signature.text.startsWith(tail))) { + return tail + } + } + return '' +} + function object(value: unknown, label: string): JsonObject { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new Error(`subagent-codex: app-server returned invalid ${label}`) @@ -318,7 +333,7 @@ export class CodexAppServerWire { if (latest !== undefined) { this.recordDiagnostic(latest.request, latest.decision, latest.reason) } - this.stderrTail = observed.slice(-STDERR_SIGNATURE_TAIL_CHARS) + this.stderrTail = stderrSignatureTail(observed) } /** Detach JSON-RPC listeners and reject outstanding requests. Idempotent. */ @@ -399,6 +414,26 @@ export class CodexAppServerWire { ) } + private recordDeclinedItem(item: JsonObject): boolean { + if (item.type === 'commandExecution' && item.status === 'declined') { + this.recordDiagnostic( + 'command execution', + 'declined', + 'Codex declined the command under the selected permission mode', + ) + return true + } + if (item.type === 'fileChange' && item.status === 'declined') { + this.recordDiagnostic( + 'file change', + 'declined', + 'Codex declined the file change under the selected permission mode', + ) + return true + } + return false + } + private handleServerRequest(method: string, params: JsonObject): Promise { try { switch (method) { @@ -475,28 +510,15 @@ export class CodexAppServerWire { if (this.turnId === undefined) { if (this.turnCompleted !== undefined) { this.observePendingTurnId(id) + const item = object(params.item, 'item/completed item') + if (this.recordDeclinedItem(item)) return this.earlyTurnNotifications.push({ method, params }) } return } if (id !== this.turnId) return const item = object(params.item, 'item/completed item') - if (item.type === 'commandExecution' && item.status === 'declined') { - this.recordDiagnostic( - 'command execution', - 'declined', - 'Codex declined the command under the selected permission mode', - ) - return - } - if (item.type === 'fileChange' && item.status === 'declined') { - this.recordDiagnostic( - 'file change', - 'declined', - 'Codex declined the file change under the selected permission mode', - ) - return - } + if (this.recordDeclinedItem(item)) return if (item.type !== 'agentMessage') return const text = typeof item.text === 'string' ? item.text diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index a50b9b488a..0fd2642d52 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -868,7 +868,7 @@ describe('CodexAppServerWire', () => { it('does not reapply an old stderr signature after a newer request diagnostic', async () => { const { child, wire } = await initializeWire() - wire.observeStderr('approval policy is Never; reject command') + wire.observeStderr('recorded sandbox violation:') const result = wire.runTurn(['task'], new AbortController().signal) const turnStart = await child.peer.nextMethod('turn/start') child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) @@ -891,6 +891,36 @@ describe('CodexAppServerWire', () => { wire.close() }) + it('keeps a newer request diagnostic after replaying an older early item', async () => { + const { child, wire } = await initializeWire() + const result = wire.runTurn(['task'], new AbortController().signal) + const turnStart = await child.peer.nextMethod('turn/start') + child.peer.send({ + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { type: 'fileChange', status: 'declined' }, + }, + }) + await nextTask() + child.peer.send({ + id: 'newer-command-request', + method: 'item/commandExecution/requestApproval', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + availableDecisions: ['cancel'], + }, + }) + await child.peer.nextResponse('newer-command-request') + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(result).resolves.toMatchObject({ stopReason: 'completed' }) + expect(wire.collectDiagnostic()).toContain('request: command approval') + wire.close() + }) + it('fails the run on unknown requests or wrong request association', async () => { for (const serverRequest of [ { @@ -1222,11 +1252,37 @@ describe('run lifecycle and quiescence', () => { await run.dispose() }) + it('drains queued stderr before settling a failed published run', async () => { + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const { child, run, turnStart } = await publishRun() + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + child.peer.send(turnCompleted('failed', 'turn-1', 'thread-1', { + message: 'fixture terminal failure', + codexErrorInfo: 'badRequest', + })) + setImmediate(() => { + child.stderr.write('approval policy is Never; reject command') + }) + await expect(run.result).resolves.toEqual({ + output: [], + diagnostic: 'Codex unattended decision (mode: never; request: command execution; decision: denied): Codex rejected an escalation because the selected policy never asks for approval', + stopReason: 'error', + }) + await run.dispose() + write.mockRestore() + }) + it('forwards stderr while extracting only a fixed safe permission signature', async () => { const child = fakeChild() const forwarded: string[] = [] + let writes = 0 const write = vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { forwarded.push(String(chunk)) + writes += 1 + if (writes === 1) { + setImmediate(() => { process.stderr.emit('drain') }) + return false + } return true }) const { run, turnStart } = await publishRun(child) @@ -1243,11 +1299,28 @@ describe('run lifecycle and quiescence', () => { stopReason: 'error', }) expect(forwarded.join('')).toContain('SECRET_TOKEN') + expect(writes).toBe(2) await run.dispose() expect(child.stderr.listenerCount('data')).toBe(0) write.mockRestore() }) + it('contains host stderr errors without changing run settlement', async () => { + const child = fakeChild() + const initialErrorListeners = process.stderr.listenerCount('error') + const { run, turnStart } = await publishRun(child) + child.peer.respond(turnStart, { turn: { id: 'turn-1' } }) + expect(process.stderr.listenerCount('error')).toBeGreaterThan(initialErrorListeners) + process.stderr.emit('error', new Error('host stderr broke')) + child.peer.send(agentMessage('answer', 'final_answer'), turnCompleted('completed')) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'answer' }], + stopReason: 'completed', + }) + await run.dispose() + expect(process.stderr.listenerCount('error')).toBe(initialErrorListeners) + }) + it('rejects before spawn when pre-aborted and rolls back startup failures', async () => { const controller = new AbortController() controller.abort()