Merge final Claude failure facts into Codex layer

This commit is contained in:
pku-xht
2026-08-18 05:33:13 +08:00
6 changed files with 125 additions and 29 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-claude-code/README.md
README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07
README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd
README.md: 4a289236aca01b01fe8ff48c09776052729f6e52
README.zh.md: 482e125136f9f9b7749112eb2130c52e383bb0b3
@@ -10,7 +10,7 @@ This package registers the fixed `claude-code` subagent provider. Each accepted
The SDK receives the exact concatenated text task. The provider iterates the complete SDK message stream and accepts only a `result` message with `subtype: "success"`, `is_error: false`, and a nonblank `result`, followed by normal iterator completion. Every failure still maps to `error`: the four error subtypes in Agent SDK 0.3.220 retain their exact category, an error-marked or blank success becomes `invalid-success`, a missing result becomes `missing-result`, an unclassified query failure becomes `unknown`, and an early CLI exit becomes `process-exit`. The diagnostic also names the current `query-start`, `query-run`, `process`, or `teardown` stage and independently includes an observed exit code and signal. The provider produces neither `max-tokens` nor `refusal`.
Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains only on the internal cause chain. Result failure and independent teardown failure remain separate.
Local cancellation wins the result race and maps to `aborted` without a failure diagnostic. `dispose()` is idempotent: it aborts the run, asks the SDK query to close, invokes the shared process-tree termination escalation, and waits for whole-tree exit. SDK graceful close expresses protocol intent; the subprocess handle remains the authority for process quiescence. Startup and teardown rejections expose the same fixed safe stage and process facts through their Error message, while the original product or Host error remains on the internal cause chain and in the Provider's Host log. Result failure and independent teardown failure remain separate.
## Native settings and interaction
@@ -10,7 +10,7 @@
SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK 消息流,而且只接受满足以下条件的 `result` 消息:其 `subtype: "success"``is_error: false``result` 非空白,之后迭代器还须正常结束。所有失败仍映射为 `error`Agent SDK 0.3.220 的四种错误子类型保留准确类别;标记为错误或内容空白的成功消息成为 `invalid-success`;缺失结果成为 `missing-result`;未分类的 query 失败成为 `unknown`CLI 提前退出成为 `process-exit`。诊断还会注明当前 `query-start``query-run``process``teardown` 阶段,并分别保留已观测到的退出码与信号。该提供方不会产生 `max-tokens``refusal`
本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链。结果失败与独立的清理失败仍彼此分离。
本地取消会在结果竞态中胜出并映射为 `aborted`,且不附带失败诊断。`dispose()`(资源释放)具有幂等性:它会中止此次运行、请求 SDK query 关闭、调用共享的进程树逐级终止机制,并等待整棵进程树退出。SDK 的优雅关闭只表达协议意图;进程是否完全停稳仍以子进程句柄为准。启动与清理拒绝会在 Error 消息中公开同样固定的安全阶段和进程事实,而原始产品或 Host 错误只保留在内部 cause 链与提供方的 Host 日志中。结果失败与独立的清理失败仍彼此分离。
## 原生设置与交互
@@ -98,7 +98,12 @@ class ClaudeCodeProvider implements SubagentProvider {
'subagent-claude-code: request was aborted before SDK startup',
)
}
throw claudeCodeStartupFailure(error)
const failure = claudeCodeStartupFailure(error)
this.ctx.logger.warn(
'subagent-claude-code: child start failed: %o',
failure,
)
throw failure
}
const spec: ClaudeCodeRunSpec = {
cwd,
@@ -160,7 +160,7 @@ export interface ClaudeCodeRunSpec {
readonly disposeGraceMs: number
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
/** Host diagnostic sink for a product failure kept outside model-visible text. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
@@ -224,11 +224,13 @@ export function successfulResult(message: SDKResultMessage): string {
* iterator completion.
* @param query - published official SDK query.
* @param onPermissionDenied - records a safe fact when the SDK reports native denial.
* @param onResult - records that the SDK supplied a terminal result message.
* @returns the completed shared result.
*/
export async function consumeClaudeQuery(
query: AsyncIterable<SDKMessage>,
onPermissionDenied?: () => void,
onResult?: () => void,
): Promise<SubagentResult> {
let answer: string | undefined
for await (const message of query) {
@@ -237,6 +239,7 @@ export async function consumeClaudeQuery(
continue
}
if (message.type !== 'result') continue
onResult?.()
answer = successfulResult(message)
}
if (answer === undefined) {
@@ -394,6 +397,13 @@ export async function startClaudeCodeRun(
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
const reportFailure = (error: Error): void => {
try {
spec.onError?.(error, 'error')
} catch {
// Host diagnostic logging cannot replace the product failure.
}
}
let child: SubprocessHandle | undefined
let query: Query | undefined
@@ -435,6 +445,7 @@ export async function startClaudeCodeRun(
}
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
// Let child.done publish a concurrently observed exit before classification.
await Promise.resolve()
const startupOutcome = managedProcess?.outcome
const startupFacts = {
@@ -453,10 +464,12 @@ export async function startClaudeCodeRun(
} catch (disposeError: unknown) {
const failure = startupFailure()
const cleanupFailure = thrown(disposeError)
throw new AggregateError(
const aggregate = new AggregateError(
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
reportFailure(aggregate)
throw aggregate
}
} else if (query !== undefined) {
try {
@@ -467,10 +480,12 @@ export async function startClaudeCodeRun(
stage: 'teardown',
category: 'unknown',
}, thrown(disposeError))
throw new AggregateError(
const aggregate = new AggregateError(
[failure, cleanupFailure],
`${failure.message}; ${cleanupFailure.message}`,
)
reportFailure(aggregate)
throw aggregate
}
}
try {
@@ -478,11 +493,14 @@ export async function startClaudeCodeRun(
} catch {
throw new Error('subagent-claude-code: request was aborted before SDK startup')
}
throw startupFailure()
const failure = startupFailure()
reportFailure(failure)
throw failure
}
const publishedQuery = query
const publishedChild = child
let receivedResult = false
const result = settleRunResult({
attempt: async () => {
try {
@@ -493,19 +511,29 @@ export async function startClaudeCodeRun(
'denied',
'Claude Code denied the request before an interactive prompt',
))
}, () => {
receivedResult = true
})
} catch (error: unknown) {
const processOutcome = managedProcess?.outcome
const facts = error instanceof ClaudeCodeFailure
? { ...error.facts, outcome: processOutcome }
: processOutcome === undefined
? { stage: 'query-run', category: 'unknown' } as const
: {
stage: 'process',
category: 'process-exit',
outcome: processOutcome,
} as const
let facts: ClaudeCodeFailureFacts
if (error instanceof ClaudeCodeFailure) {
facts = { ...error.facts, outcome: processOutcome }
} else if (processOutcome !== undefined && !receivedResult) {
facts = {
stage: 'process',
category: 'process-exit',
outcome: processOutcome,
}
} else {
facts = {
stage: 'query-run',
category: 'unknown',
outcome: processOutcome,
}
}
prependFailureDiagnostic(facts)
// Keep the SDK category and cause; the diagnostic adds later process facts.
throw error instanceof ClaudeCodeFailure
? error
: new ClaudeCodeFailure(facts, thrown(error))
@@ -525,9 +553,14 @@ export async function startClaudeCodeRun(
signal: request.signal,
onAbort,
requestCancel,
teardown: () => disposeClaudeCodeChild(
publishedQuery,
publishedChild,
),
teardown: async () => {
try {
await disposeClaudeCodeChild(publishedQuery, publishedChild)
} catch (error: unknown) {
const failure = thrown(error)
reportFailure(failure)
throw failure
}
},
})
}
@@ -415,8 +415,17 @@ describe('task admission and package contracts', () => {
expect(queryMock).not.toHaveBeenCalled()
resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH'))
await expect(ctx.subagents.start('claude-code', request()))
const missingExecutable = ctx.subagents.start('claude-code', request())
await expect(missingExecutable)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(missingExecutable).rejects.not.toThrow('claude missing from PATH')
expect(warn).toHaveBeenCalledWith(
'subagent-claude-code: child start failed: %o',
expect.any(Error),
)
expect(warn.mock.calls[0]?.[1]).toMatchObject({
cause: expect.objectContaining({ message: 'claude missing from PATH' }),
})
expect(queryMock).not.toHaveBeenCalled()
const resolutionAbort = new AbortController()
@@ -914,14 +923,29 @@ describe('run publication, cancellation, and settlement', () => {
})
it('fails closed when iteration rejects after a result', async () => {
const fixture = fakeRun(
[success('partial final')],
new Error('iterator boom'),
)
const run = await startClaudeCodeRun(request(), fixture.spec)
const child = fakeChild()
const outcome = { exitCode: 31, signal: null } as const
async function* stream(): AsyncGenerator<SDKMessage, void> {
yield success('partial final')
child.settle(outcome)
await Promise.resolve()
throw new Error('iterator boom')
}
queryMock.mockImplementation(({ options }) => {
options.spawnClaudeCodeProcess!(sdkSpawnOptions())
return Object.assign(stream(), { close: vi.fn() }) as unknown as Query
})
const run = await startClaudeCodeRun(request(), {
cwd: '/workspace',
executable: '/native/claude',
permissionMode: DEFAULT_CLAUDE_CODE_PERMISSION_MODE,
env: {},
disposeGraceMs: 5,
spawn: () => child.handle,
})
await expect(run.result).resolves.toEqual({
output: [],
diagnostic: expectedFailureDiagnostic('query-run', 'unknown'),
diagnostic: expectedFailureDiagnostic('query-run', 'unknown', outcome),
stopReason: 'error',
})
await run.dispose()
@@ -1141,14 +1165,25 @@ describe('run publication, cancellation, and settlement', () => {
queryMock.mockImplementationOnce(() => {
throw new Error('query failed before resource creation')
})
const queryFailureOnError = vi.fn()
const queryFailure = startClaudeCodeRun(request(), {
...unused.spec,
onError: queryFailureOnError,
})
await expect(queryFailure)
.rejects.toThrow(expectedFailureDiagnostic('query-start', 'unknown'))
await expect(queryFailure).rejects.not.toThrow(
'query failed before resource creation',
)
expect(queryFailureOnError).toHaveBeenCalledWith(
expect.any(Error),
'error',
)
expect(queryFailureOnError.mock.calls[0]?.[0]).toMatchObject({
cause: expect.objectContaining({
message: 'query failed before resource creation',
}),
})
const spawned = fakeChild()
const spawnSpecs: SubprocessSpawnSpec[] = []
@@ -1225,6 +1260,29 @@ describe('query and process disposal', () => {
})
})
it('reports a published teardown failure to the Host diagnostic sink', async () => {
const fixture = fakeRun([success('exact answer')])
const onError = vi.fn()
const run = await startClaudeCodeRun(request(), {
...fixture.spec,
onError,
})
await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' })
fixture.close.mockImplementationOnce(() => {
throw new Error('SECRET_TOKEN close failure')
})
await expect(run.dispose()).rejects.toThrow(
expectedFailureDiagnostic('teardown', 'unknown', {
exitCode: 0,
signal: null,
}),
)
expect(onError).toHaveBeenCalledWith(expect.any(Error), 'error')
expect(onError.mock.calls[0]?.[0]).toMatchObject({
cause: expect.objectContaining({ message: 'SECRET_TOKEN close failure' }),
})
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
let disposed = false