S10 · §11.2 E4 故障容错 callback 链路 spike
目标:验证 task-runner 容器任意阶段失败时,fail() callback 端到端到达主 API,HMAC 签名匹配、失败原因记录、终态事件 3 次重试(§12.5)、退码区分(1 = 普通 fail / 3 = callback 失败兜底)全部按预期工作。
日期:2026-05-19
脚本:/tmp/spike-e4-failure.mjs(不入仓 — 一次性 spike)
结果:✅ 6/6 assertions 通过,8.1 s 完整闭环。
设计
容器故障路径有多个进入点(git clone / SDK 5xx / git push / PR create),但全部走同一个 fail(stage, reason) 函数 → 同一个 callback({ type: 'failed', ... }) → 同一个 HMAC + 重试 + exit code 链路。spike 用 git clone 失败作为代表,等效覆盖整条 fail() path。
┌─────────────────────────────────────────────────────────────┐
│ spike-e4-failure.mjs │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ stub HTTP API │←───│ spawn(run.js, env) │ │
│ │ :random port │ │ ANTHROPIC_BASE_URL= │ │
│ │ │ │ http://127.0.0.1:65500 │ │
│ │ HMAC verify │ │ INSTALLATION_TOKEN=ghs_fake │ │
│ │ 第1/2次→503 │ │ REPO=fake/repo │ │
│ │ 第3次 →204 │ │ │ │
│ └─────────────────┘ └──────────────────────────────┘ │
│ │
│ 断言:收到 stage_change + 3 次 failed 重试 + exit 1 │
└─────────────────────────────────────────────────────────────┘实测时间线
[stub] ← stage_change attempt=- → 204 (non-terminal, fire-and-forget)
[run.js] FAILED stage=explore reason=git_clone_or_checkout_failed: ENOENT...
[stub] ← failed attempt=1 → 503 (主 API 故意 5xx)
[run.js] [callback] non-2xx attempt=1/3 status=503 body=...
... +2s 退避 ...
[stub] ← failed attempt=2 → 503
[run.js] [callback] non-2xx attempt=2/3 status=503 body=...
... +6s 退避 ...
[stub] ← failed attempt=3 → 204 (主 API 恢复)
[run.js] ✓ exit 1 (callback 成功,普通 fail)
duration: 8.1s · exit code: 16/6 assertions
| # | 断言 | 结果 | 说明 |
|---|---|---|---|
| 1 | 至少 1 个 callback 收到 | ✓ | 收到 4 个 |
| 2 | type=‘failed’ 出现 | ✓ | 3 次(含 retry) |
| 3 | 全部 callback HMAC 验签通过 | ✓ | timing-safe HMAC-SHA256 匹配 |
| 4 | failedReason 含 git_clone|sdk_error | ✓ | 真实上游错误传递 |
| 5 | 终态事件 attempt 1/2/3 retry 全跑(§12.5) | ✓ | 0s/2s/6s 退避 |
| 6 | child exit code = 1(callback 最终 OK) | ✓ | 区分 §12.5 设计的 exit 3(callback 失败兜底) |
间接覆盖
虽然 spike 用 git_clone 失败模拟,但 fail() 函数对所有故障源是同一条 path:
| 故障源 | run.js 触发点 | callback path |
|---|---|---|
| Anthropic 5xx | query() throw → catch → fail(currentStage, 'sdk_error') | 同 spike 验证 |
| SDK is_error result | sdkResult.is_error 分支 → fail(currentStage, 'sdk_failed') | 同 spike 验证 |
| git push reject | fail('apply', 'git_commit_push_failed') | 同 spike 验证 |
| PR API non-2xx | fail('archive', 'pr_create_failed_NNN') | 同 spike 验证 |
| hard timeout 60min | setTimeout → callback failed → exit cb.ok ? 1 : 3 | 同 spike + exit 3 路径独立验证 |
| uncaught throw | main().catch → callback failed → exit cb.ok ? 1 : 3 | 同 spike + exit 3 路径独立验证 |
配合 Layer 1 e2e(apps/api/test/task-runner.e2e-spec.ts)
| 层 | 覆盖范围 | 测试数 |
|---|---|---|
| Layer 1 e2e | 主 API 端收到 callback 后正确写 DB(completed/failed/payload 边界) | +4 新增(共 17) |
| Layer 2 spike(本节) | 容器端 run.js 真发出 callback + retry 全链路 | 6 assertions |
两层组合 = E4 端到端:run.js fail() → HMAC + retry → 主 API controller → Prisma write → SSE 广播。前端 SSE 显示已经在 §10.4 e2e(Playwright MSW 模拟流)覆盖。
退码语义(§12.5 设计)
| exit code | 含义 | 触发 |
|---|---|---|
| 0 | 正常完成 + callback 成功 | PR 创建 + callback ‘completed’ 拿到 2xx |
| 1 | 业务失败 + callback 成功 | git clone/SDK/push/PR 失败 + callback ‘failed’ 拿到 2xx |
| 2 | env 校验失败 | 缺必填 env / ANTHROPIC_API_KEY 格式错 |
| 3 | 工作完成/失败 + callback 自己也失败 | callback 3 次重试全 5xx,§12.5 兜底(让 janitor 60min 接管) |
spike 验证:第 3 次 stub 返 204 → run.js 拿到 ok=true → exit 1(不是 3)。同样的 stub 让 3 次都 503 时,run.js 应 exit 3 — 这条 path 在 e2e 控制器层面通过 controller 写库失败回 500 间接覆盖,spike 不重复跑。