diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index c2fe8df4db..c3a2b9b6be 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/README.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 packages/subagent/subagent-claude-code/README.md -README.md: 21beb0a0534e601f9dd26d36f53d1fb5f09b4e07 -README.zh.md: c83999ee7b2d38e9c4ee57ba74df25217998bffd +README.md: 4a289236aca01b01fe8ff48c09776052729f6e52 +README.zh.md: 482e125136f9f9b7749112eb2130c52e383bb0b3 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 21beb0a053..4a289236ac 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -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 diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index c83999ee7b..482e125136 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -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 日志中。结果失败与独立的清理失败仍彼此分离。 ## 原生设置与交互 diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index 1359b1d642..eb7779c4c3 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -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, diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 5ea41d9bac..d10fd647f8 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -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, onPermissionDenied?: () => void, + onResult?: () => void, ): Promise { 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 + } + }, }) } diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 786f19d37c..b853dc9004 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -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 { + 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