feat(workflow): agent 失败自动重试一次(dead 或非 abort throw)

- hooks.agent 包装 invokeBackend:第一次 dead 或非 abort throw → 重试一次
- WorkflowAbortedError(kill)不重试——是用户意图
- registry.resolve 配置错(AdapterNotFoundError 等)在 try 外直接上抛,不走重试——
  配置问题重试无意义且掩盖 bug
- 重试仍失败:dead 保持 dead;throw 降级 dead(不击穿 workflow,
  与 parallel/pipeline null-on-error 契约一致)
- budget 不重复扣:dead 不 addOutputTokens,重试 ok 才扣一次
- 新增 7 项 hooks 层重试测试 + 1 项 service 层降级测试

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-14 11:11:33 +08:00
parent ef4d22f496
commit bd470b5ad4
3 changed files with 169 additions and 16 deletions

View File

@@ -123,6 +123,110 @@ test('agent dead → null', async () => {
expect(await hooks.agent('hi')).toBeNull()
})
// 重试dead 或 非 abort throw 都给一次重试机会WorkflowAbortedErrorkill不重试。
// 重试仍失败dead 保持 deadthrow 降级为 dead不击穿 workflowhooks.agent 返 null
test('agent dead → 重试一次成功 → ok', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return calls === 1
? { kind: 'dead' as const }
: {
kind: 'ok' as const,
output: 'recovered',
usage: { outputTokens: 5 },
}
},
})
expect(await hooks.agent('p')).toBe('recovered')
expect(calls).toBe(2)
})
test('agent dead → 重试仍 dead → 最终 nulldead 保持 dead', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return { kind: 'dead' as const }
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(2)
})
test('agent 非 abort throw → 重试一次成功 → ok', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
if (calls === 1) throw new Error('transient network')
return {
kind: 'ok' as const,
output: 'recovered',
usage: { outputTokens: 3 },
}
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBe('recovered')
expect(calls).toBe(2)
})
test('agent 非 abort throw → 重试仍 throw → 降级 dead返 null不击穿 workflow', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
throw new Error('persistent')
},
loggerWarn: () => {},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(2)
})
test('agent throw WorkflowAbortedError → 不重试,直接 rethrowkill 不容许重试)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
throw new WorkflowAbortedError()
},
})
await expect(hooks.agent('p')).rejects.toBeInstanceOf(WorkflowAbortedError)
expect(calls).toBe(1)
})
test('agent ok → 不重试calls=1省一次 backend 往返)', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return {
kind: 'ok' as const,
output: 'first-try',
usage: { outputTokens: 1 },
}
},
})
expect(await hooks.agent('p')).toBe('first-try')
expect(calls).toBe(1)
})
test('agent skipped → 不重试(用户主动 skip不重试', async () => {
let calls = 0
const { hooks } = buildCtx({
runner: async () => {
calls++
return { kind: 'skipped' as const }
},
})
expect(await hooks.agent('p')).toBeNull()
expect(calls).toBe(1)
})
test('agent journal 命中时不调用 runner', async () => {
let called = 0
const { emitter } = createBufferingEmitter()

View File

@@ -154,9 +154,40 @@ export function makeHooks(
: {}),
}
: null
const result = registry
? await registry.resolve(params).run(params, adapterCtx!)
: await ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
// resolve 在 try 外配置错AdapterNotFoundError 等)直接上抛,不走重试——
// 这是 workflow 配置问题而非 backend 临时故障,重试无意义且掩盖 bug。
const adapter = registry ? registry.resolve(params) : null
const invokeBackend = (): Promise<AgentRunResult> =>
adapter
? adapter.run(params, adapterCtx!)
: ctx.ports.agentRunner.runAgentToResult(params, ctx.host)
// 失败一次自动重试deadterminal API error after retries或 非 abort 抛错
// 都给一次重试机会WorkflowAbortedErrorkill不重试——是用户意图。
// 重试仍失败dead 保持 deadthrow 降级为 dead不让一个 agent 击穿 workflow
// budget 不重复扣dead 不 addOutputTokens重试 ok 才扣一次(最终 ok 时)。
let result: AgentRunResult
try {
result = await invokeBackend()
if (result.kind === 'dead') {
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" returned dead; retrying once`,
)
result = await invokeBackend()
}
} catch (e) {
if (e instanceof WorkflowAbortedError) throw e
ctx.ports.logger.warn?.(
`agent "${label ?? `#${agentId}`}" threw (${(e as Error).message}); retrying once`,
)
try {
result = await invokeBackend()
} catch (e2) {
if (e2 instanceof WorkflowAbortedError) throw e2
// 重试仍抛:降级 dead保持 workflow 继续hooks.agent 返 null
result = { kind: 'dead' }
}
}
if (result.kind === 'ok') {
ctx.resources.budget.addOutputTokens(result.usage.outputTokens)
}