Skip to content

fix(multi-review): retry transient reviewer fetch failures#286

Merged
Svtter merged 2 commits into
mainfrom
fix/reviewer-fetch-retry
Jun 27, 2026
Merged

fix(multi-review): retry transient reviewer fetch failures#286
Svtter merged 2 commits into
mainfrom
fix/reviewer-fetch-retry

Conversation

@Svtter

@Svtter Svtter commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

问题 / Problem

PR dogfood(latex-agent #4158)里 4/6 reviewer(quality/security/performance/architecture)在启动后约 5 分 2 秒、同一秒全部以 `fetch failed` 失败,2 个浅层 reviewer(<1 分钟完成)正常:

```
15:07:51.9 6 个 reviewer 同时启动
15:08:08.3 regression-test 完成 (299 chars, 16s)
15:08:37.1 test-value 完成 (669 chars, 45s)
15:12:54.8 security/architecture/performance/quality 同一秒 Failed: fetch failed
Reviews: 2/6 succeeded
```

`fetch failed` 是 Node undici 的网络错误。链路是 Node 进程 →(HTTP 流式)→ 本地 opencode server →(SSE)→ litellm 代理 → deepseek。`litellm/deepseek-v4-flash` + `reasoning-effort: max` + `enable-thinking` 在深度思考阶段会长时间无 token 输出,上游任一环节(最可能是 ~300s 流静默)一旦掐断连接,undici 就抛 `fetch failed`。

根因:瞬时网络中断。但 `orchestrator.ts` 对任何抛错零重试——一次连接重置就永久杀死整个 reviewer。

修复 / Fix

`runParallelReviewers` 对瞬时网络错误做指数退避重试:

  • 匹配 `fetch failed` / `ECONNRESET` / `ETIMEDOUT` / `UND_ERR_*` / `socket hang up` / `terminated` 等
  • 最多 3 次,退避 `base * 2^(attempt-2)`(默认 1s, 2s)
  • 每次重试创建全新 session,避免半截历史污染 `extractText()`
  • 排除 reviewer 自身 deadline(`X timed out after Nms`)——预算已耗尽,重试无意义
  • 不重试非瞬时错误(如 `context length exceeded`)

失败的中间 attempt 无条件销毁其 session(即使是 `skipSessionCleanup` 也只保留成功 session 供 export);成功仍按原语义尊重 `skipSessionCleanup`。

测试 / Tests

`npm test` 全绿(151 pass),新增 3 个用例:

  • `retries transient fetch failures on a fresh session, then succeeds` — 第一次 prompt 抛 `fetch failed`,第二次成功,断言 2 个 session 被创建、失败的被销毁、结果 sessionID 是成功的那个
  • `gives up after REVIEWER_MAX_ATTEMPTS persistent transient failures` — 持续 `fetch failed`,断言 3 次尝试后失败、3 个 session 全销毁
  • `does not retry non-transient errors` — `context length exceeded` 只尝试 1 次

既有 `captures sessionID on the failure path too`(`LLM down` 非瞬时)仍通过。

未覆盖 / Not in scope

  • 退出码语义(4/6 死亡仍 exit 0):本次不动,按用户选择保持最小改动。
  • 上游 body 超时根治(undici dispatcher 注入):依赖 SDK 能力,留作后续。

改动文件

  • `multi-review/src/orchestrator.ts` — 重试循环 + `isTransientReviewerError`
  • `multi-review/src/types.ts` — `OrchestratorOptions.retryBackoffMs?`
  • `multi-review/src/orchestrator.test.ts` — 3 个新测试
  • `multi-review/dist/index.cjs` — 重新构建
  • `CHANGELOG.md` — Unreleased 条目

Long-running reviewers (reasoning-effort=max) on self-hosted runners
were wiped out by undici 'fetch failed' when an upstream stream
(opencode server -> litellm -> model) went quiet past ~300s. Observed
on PR dogfood: 4/6 reviewers (quality/security/performance/architecture)
died in the same second at the 5m mark, while 2 short reviewers finished
in <1m. A single transient network blip permanently killed the review.

Now the orchestrator retries transient network errors (fetch failed,
ECONNRESET, ETIMEDOUT, UND_ERR_*, socket hang up, ...) up to 3 times
with exponential backoff (1s, 2s), creating a fresh opencode session
each attempt to avoid partial-history pollution in extractText(). The
reviewer's own deadline timeout ('X timed out after Nms') is excluded
since retrying an expired global budget is pointless; non-transient
errors (e.g. 'context length exceeded') still fail immediately.

- OrchestratorOptions.retryBackoffMs (optional) lets tests pass 0.
- Failed attempts tear down their session unconditionally; success
  still respects skipSessionCleanup for the live session.
- 3 new tests: retry-then-succeed, retry-exhausted, non-transient no-retry.
- Rebuilt dist/index.cjs.
@github-actions

Copy link
Copy Markdown

⚠️ 有条件合并 / CONDITIONAL MERGE

所有 reviewer 均未发现阻塞性问题。综合意见认为设计合理、可合并,但有以下值得关注的项目。

🟡 警告项 / Warnings (4)

  • lastSessionId/lastError 在循环后可能为 undefined (orchestrator.ts:155-157) — quality 和 architecture 均指出此问题(已确认)。如果所有尝试均在 session.create 阶段失败,lastError 保持 undefinedString(lastError) 将产生 "undefined" 字符串而非实际错误信息。虽然后续路径实际不可达,但仍是逻辑隐患。
  • 退避算法缺乏 jitter(抖动) — performance 指出多个 reviewer 同时退避完毕将导致"惊群效应",对上游服务造成瞬时压力。建议引入 backoff + Math.random() * 1000
  • deadline timeout 不被重试的行为无测试覆盖 — regression-test 指出 isTransientReviewerError 中对 timed out after \d+ms 返回 false 是明确业务决策,但无测试验证。若正则被误改,deadline 超时将浪费时间和费用。(MEDIUM 严重性)
  • 恢复已有会话(resume)遇到瞬态错误后降级为新会话重试未覆盖 — regression-test 指出 opts.existingSessions 路径下的重试逻辑无测试。(MEDIUM 严重性)

🟢 建议项 / Suggestions (10)

  • 指数退避的睡眠行为未被测试 — regression-test 和 test-value 均建议使用 mock timer 验证退避时长计算(已确认)。
  • 正则 terminated 匹配过于宽泛 — quality 建议改为 stream terminated|connection terminated 更精确,避免匹配 moderation 等非网络终止。
  • timeout 检测正则建议加 \b 边界 — quality 建议 /\btimed out after \d+ms/,避免上游错误消息中意外包含此模式。
  • 退避表达式可读性 — quality 建议用 backoff = backoffBase * 2 ** (attempt - 1) 替代 attempt - 2
  • 重试日志脱敏 — security 建议在 console.log 前对 prevMsg 中的 URI/主机名信息脱敏,避免暴露内网拓扑。
  • buildReviewerPrompt 缓存 — performance 建议将循环内重复调用的 buildReviewerPrompt 结果提到循环外缓存。
  • remaining() 多次调用 — performance 建议一次计算后复用,减少重复 Date.now()
  • REVIEWER_MAX_ATTEMPTS 作为配置 — architecture 建议未来可考虑放入 OrchestratorOptions
  • isTransientReviewerError 的隐式契约 — architecture 建议未来推动 SDK 提供结构化的 error code/type,而非依赖字符串匹配。
  • 补充测试 session.createsession.messages 瞬态失败场景 — test-value 指出当前测试仅覆盖 session.prompt
💰 Review Cost — $0.0000
Role Cost (USD) Input Output Reasoning Cache Read Cache Write
quality $0.0000 14,804 494 3,335 0 0
security $0.0000 14,813 158 719 0 0
performance $0.0000 14,816 381 980 0 0
architecture $0.0000 14,817 581 1,418 0 0
regression-test $0.0000 15,254 718 1,388 0 0
test-value $0.0000 14,944 275 1,290 0 0
coordinator $0.0000 10,605 705 1,110 0 0
Total $0.0000 100,053 3,312 10,240 0 0
📋 各 Reviewer 详细审查结果
quality

有条件合并 / CONDITIONAL MERGE

本 PR 为 multi-review 增加了针对瞬态网络错误的自动重试机制,整体设计合理、测试充分。有一个需要修复的问题:

阻塞项:无

警告项:

  1. lastSessionId/lastError 在循环后可能为 undefined (multi-review/src/orchestrator.ts:155-157)。lastSessionIdlastError 声明时未初始化,如果所有尝试都在 session.create 阶段失败(sessionId 未赋值),则循环后的 returnsessionID: undefined 是合理的(与旧行为一致),但 lastError 在循环后可能仍为 undefined,导致 String(lastError) 得到 "undefined" 字符串而非真正的 error 信息。虽然后续代码实际不可达(循环总是 return),但这是一个潜在的逻辑漏洞。

建议项:

  1. 正则 terminated 匹配过于宽泛,可能匹配到非网络类的终止错误(如模型内容被 moderation 终止)。建议改为 stream terminated|connection terminated 更精确。
  2. isTransientReviewerError 中的 timeout 检测建议加上 \b 边界:/\btimed out after \d+ms/,避免上游错误消息中意外包含此模式。
  3. 扩指数退避的 attempt - 2 可读性较低。考虑语义化:每次重试前 wait,用 attempt1 开始计数,backoff = backoffBase * 2 ** (attempt - 1) 更直观。
security

安全无虞 / SECURE

该 PR 为多 Reviewer 编排器增加了网络瞬态错误的重试机制,未引入新的安全风险。变更聚焦在错误分类(瞬态 vs 永久)、指数退避重试和会话生命周期管理。

阻塞项:无

警告项:无

建议项:

  • 重试日志中 prevMsg 可能泄露内部网络拓扑信息(如 URL、主机名)。建议在 console.log 输出前对错误消息进行脱敏处理,剥离 URI/路径信息,仅保留错误类型(如 fetch failed),避免日志中暴露内网地址或服务端配置细节。
performance

性能有疑虑 / CONCERNS

该 PR 为多 reviewer 并行评审引入了重试机制(最多 3 次,指数退避),整体思路合理。经逐项审查,未发现阻塞性性能问题,但有以下几个值得关注的方面:

阻塞项:无

警告项:

  1. 退避算法缺乏 jitter(抖动)backoffBase * 2^(attempt-2) 对所有失败的 reviewer 使用完全相同的延时。PR 描述提到曾观察到 4/6 个 reviewer 在同一秒因网络抖动死亡,这意味着重试时这 4 个 reviewer 也会在同一秒同时退避完毕并发起重试请求,形成"惊群效应"(thundering herd),可能对上游服务造成瞬时压力。建议在退避中引入随机 jitter,如 backoff + Math.random() * 1000

建议项:

  1. buildReviewerPrompt 放在循环内重复调用buildReviewerPrompt(reviewer.prompt, prDiff, opts.previousContextText) 在每次重试时都会被重新计算。虽然当前开销不大,但如果后续该函数逻辑变复杂(如序列化大 diff),建议将计算结果提到循环外缓存。
  2. remaining() 函数多次调用:每个重试 attempt 中 remaining() 被调用了 3 次(session.create、session.prompt、session.messages),虽然每次开销很低(Math.max + Date.now()),但可考虑一次计算后复用,减少重复的 Date.now() 调用。
architecture

架构合理 / SOUND

此 PR 在 multi-review/src/orchestrator.ts 中为并行 reviewer 添加了 transient network error 的重试逻辑,改动的架构质量总体良好。

  • 耦合度: 无新增跨模块依赖。isTransientReviewerErrorsleepREVIEWER_MAX_ATTEMPTS 均为模块私有,与其他模块无耦合。
  • 模块放置: 重试逻辑位于 orchestrator.ts(协调层),职责清晰。OrchestratorOptions.retryBackoffMs 扩展在 types.ts 中,类型定义集中。
  • 分层: 重试是协调层的自然扩展,未向下渗入 SDK client 层,未向上暴露到 action 入口。边界清晰。
  • 接口设计: retryBackoffMs 作为可选参数,默认值由 DEFAULT_RETRY_BACKOFF_MS 管理,测试可注入 0 来跳过等待。设计合理。
  • Shotgun surgery: 改动集中在 3 个源文件(orchestrator.tstypes.tsorchestrator.test.ts)+ 构建产物 + CHANGELOG,无散落在不相关模块的修改。
  • 一致性: 重试模式(sleep + backoff)与 SDK 内 SSE client 的 backoff 机制风格一致;withTimeout 的 deadline 计算沿用既有剩余时间逻辑。

阻塞项:无

警告项:

  • lastSessionId 在最终失败时指向的是最后一次尝试的 session(已被删除),其语义与成功路径的 sessionID(指向活跃 session)不一致,调用方可能混淆。不过当前使用方仅将其作为日志/调试参考,无实际影响。

建议项:

  1. REVIEWER_MAX_ATTEMPTS 硬编码为 3。若未来有不同 reviewer 需要不同重试策略,可考虑将其放入 OrchestratorOptionsReviewer 配置中,但目前 3 次对 transient error 场景足够合理,无需提前抽象。
  2. isTransientReviewerError 的 regex 依赖 SDK/undici 的错误消息格式,属于隐式契约。若 SDK 升级后错误文本发生变化,此处可能静默失效。可考虑将来推动 SDK 提供结构化的 error code / type 字段,而非依赖字符串匹配。
regression-test

缺少回归测试
PR 类型:BUGFIX
分析:此 PR 修复了 multi-review 在长时运行(reasoning-effort=max)时因上游流空闲约 300s 导致 undici fetch failed 的瞬态网络错误,从而杀死多个 reviewer 的问题。新增了瞬态错误重试机制(最多 3 次,含指数退避),并定义了 isTransientReviewerError 区分可重试与不可重试错误。测试文件新增了 3 个用例覆盖核心场景,但仍有以下缺口:

  1. 超时 deadline 不被重试的行为未覆盖——isTransientReviewerError 中对 timed out after \d+ms 特判返回 false 是明确的业务决策(CHANGELOG 中特别说明),现有非瞬态测试只用了 context length exceeded,未覆盖 deadline 超时模式。如果未来这段正则被误改或误删,deadline 超时会被重试(浪费时间和费用),而测试不会发现。

    • 原因:PR 明确列出了 deadline timeout 排除逻辑,但无测试验证
    • 建议测试:模拟错误消息 "reviewer timed out after 60000ms",验证不触发重试(attempt 数 = 1)
    • 严重程度:MEDIUM
  2. 恢复已有会话(resume)遇到瞬态错误后降级为新会话重试未覆盖——当 opts.existingSessions 中有 reviewer 的 session,且该 session 首次 prompt 命中瞬态错误时,代码会创建一个新 session 继续重试。这是线上运行的常见路径,但现有测试未覆盖。

    • 原因:恢复会话是 multi-review 的已有功能,重试逻辑处理了该场景但无测试
    • 建议测试:构造 existingSessions 中有 reviewer session,让 session.prompt 首次抛 fetch failed,验证第二次重试创建了新 session 并成功
    • 严重程度:MEDIUM
  3. skipSessionCleanup 与重试交互未覆盖——新代码在失败时无条件清理 session(即使 skipSessionCleanup=true),与成功路径的 if (!opts.skipSessionCleanup) 行为不同。此决策无测试覆盖。

    • 原因:这是显式的行为变更,finally 块被拆分为成功/失败两个分支
    • 建议测试:设置 skipSessionCleanup: true,让 reviewer 遭遇瞬态错误多次后失败,验证失败 session 仍被删除(不会被保留为 "live" session)
    • 严重程度:LOW

阻塞项:无

警告项:

  • timeout 排除逻辑无测试
  • resume session 瞬态失败降级重试无测试

建议项:

  • skipSessionCleanup + 重试交互测试
  • 指数退避 retryBackoffMs 实际延迟计算的精确验证(当前测试用 0 跳过等待,但退避算法本身未测试)
test-value

测试全部有价值 / ALL TESTS HAVE VALUE

严重问题 / CRITICAL
无 / None

中等问题 / MEDIUM
无 / None

低等问题 / LOW
无 / None

阻塞项 / Blocking Issues
阻塞项:无 / Blocking Issues: None

警告项 / Warnings
警告项:无 / Warnings: None

建议项 / Suggestions

  1. orchestrator.test.ts 中的三个新测试均只通过 retryBackoffMs: 0 跳过了退避延迟,指数退避的睡眠行为本身未被测试。建议添加一个 mock timer 的测试验证退避时长(非阻塞,当前行为由实现保证)。

  2. orchestrator.test.ts 的 transient 重试测试仅覆盖了 session.prompt 失败,未覆盖 session.createsession.messages 在 transient 失败后也能触发重试(非阻塞,catch 块统一处理所有调用)。

@Svitter Svitter added fix Bug fix review:p1 Major review findings triaged Issue has been triaged labels Jun 26, 2026

@Svitter Svitter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — mergeable after a touch. Solid root-cause analysis (undici fetch failed wiping 4/6 reviewers at the ~300s mark of a single upstream blip), and the fix is well-scoped: exponential backoff, fresh session per attempt, owner-deadline excluded, non-transient errors untouched. dist/index.cjs is a clean rebuild (the sleepsleep2 rename inside the bundled SSE client is just a bundler variable collision, harmless). CHANGELOG entry under [Unreleased] follows the multi-review bullet convention from 4.2.x.

Three should-fix items:

  1. Untested resume+retry path (orchestrator.test.ts:284). The three new tests only exercise existingSessions: undefined, but the v2 export path the PR cites as the motivator (index.ts:217-218 passes both existingSessions and skipSessionCleanup: willExportSessions) combines attempt === 1 && resumedFrom with a transient failure and skipSessionCleanup: true. Add a test that asserts the resumed session is deleted, a fresh session is created, and sessionID is the fresh one.

  2. Silent contract change under skipSessionCleanup (orchestrator.ts:204). The pre-PR finally was gated on !opts.skipSessionCleanup; this catch deletes the session unconditionally, including on the final non-transient attempt where no retry follows. A non-transient failure on a resumed v2 reviewer now drops the resumable session entirely. Intentional per the in-code comment, but undocumented in the PR body — please add one line under "Fix" and a regression test for skipSessionCleanup: true + non-transient failure.

  3. Deadline-blind backoff (orchestrator.ts:148). sleep(backoff) doesn't consult deadline. If the reviewer is in its last globalTimeoutMs - 30_000 window (the dogfood scenario), the 1s+2s sleep burns budget for doomed retries. Cap by deadline - Date.now() - 30_000 or break early.

Nit: the // Unreachable: ... defensive return at orchestrator.ts:218-220 is dead — the for-loop body always returns on success or terminal failure — but harmless. The dead-code return's sessionID: lastSessionId would also be the wrong field for a hypothetical reach (a fresh-create retry's lastSessionId is the resumed session, not the final attempt). Can be deleted.

Thanks for the thorough analysis and the dogfood repro — this is exactly the kind of failure the v2 export path surfaces and the fix targets the right layer.

const backoff = backoffBase * 2 ** (attempt - 2);
const prevMsg = lastError instanceof Error ? lastError.message : String(lastError);
console.log(`[${reviewer.name}] Retry attempt ${attempt}/${REVIEWER_MAX_ATTEMPTS} after ${backoff}ms (previous: ${prevMsg})...`);
await sleep(backoff);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fixsleep(backoff) is deadline-blind. If we are inside the last globalTimeoutMs - 30_000 window (e.g. 4/6 reviewers in the dogfood died at the ~300s mark of a 900s budget, leaving only seconds for the survivors), a 2s sleep + a doomed retry burns the budget for nothing. Consider either capping by deadline (e.g. const cap = Math.max(0, deadline - Date.now() - 30_000); await sleep(Math.min(backoff, cap))) or breaking the loop when Date.now() + backoff > deadline.

// Failed attempt: tear down THIS session unconditionally — even
// under skipSessionCleanup we only keep SUCCESSFUL sessions for
// export; a half-run session is garbage.
if (sessionId) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — behavior change worth flagging. Pre-PR, the finally block was gated on !opts.skipSessionCleanup, so a failed reviewer under skipSessionCleanup: true (the v2 export path: index.ts:210-219 sets skipSessionCleanup: willExportSessions) kept the session alive for opencode export to salvage. This catch block now deletes the session unconditionally on every failed attempt, including the final non-transient one — even though there's no retry after the final fail. On the dominant dogfood path, a non-transient failure on a resumed reviewer now drops the resumable session entirely (no fresh session is created on the non-transient exit path). Intentional per the in-code comment, but:

  1. Not mentioned in the PR body — please add one line under "Fix" describing the new "failed sessions are always torn down, even under skipSessionCleanup" contract.
  2. Add a test that wires up skipSessionCleanup: true + non-transient prompt failure and asserts the session is in deletedSessions and the failed result carries success: false.

await cleanupAllSessions(client);
});

it("retries transient fetch failures on a fresh session, then succeeds", async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should-fix — coverage gap on the dominant dogfood path. The three new tests exercise existingSessions: undefined (the fresh-create + retry path), but the production code path under v2 cache (index.ts:217 passes existingSessions, index.ts:218 passes skipSessionCleanup: willExportSessions) combines:

  • attempt 1 resumes existingSessions.get(reviewer.name),
  • prompt throws fetch failed on attempt 1,
  • catch block deletes the resumed session and retries with a fresh session.create,
  • attempt 2 succeeds, fresh session stays in activeSessions (skipSessionCleanup=true) so step 7b exports it.

The PR body cites v2 export as the main motivator ("a half-run session is garbage") but the test suite doesn't defend this path. Add one test that wires existingSessions: new Map([["quality", "ses_resumed"]]) + skipSessionCleanup: true + a transient failure on the first prompt → asserts the resumed session was deleted, a fresh session was created, the result is success: true, and sessionID is the fresh session (not the resumed one).

…egression tests

Code review on #286 (P1 should-fix items):

1. Deadline-aware backoff. sleep(backoff) ignored the global deadline; in
   the dogfood scenario (4/6 reviewers dying near the 300s mark of a 900s
   budget) a doomed retry would burn the survivors' remaining window. Now
   gives up when remaining budget < backoff + 30s (withTimeout's floor).

2. Jitter. The original failure was N reviewers dying in the SAME second
   (one upstream blip); unjittered backoff would have them all retry in
   the same second, re-imposing burst load likely to trip the same limit.
   backoff now spreads across [base, base+backoffBase].

3. Tightened isTransientReviewerError: 'terminated' is now scoped
   ('stream terminated'|'connection terminated') so content-moderation
   terminations aren't mistakenly retried; timeout exclusion gets
   word boundaries (\btimed out after \d+ms\b).

4. Hardened the unreachable defensive return so a latent undefined
   lastError can never surface as the literal string 'undefined'.

Contract change (now documented): a failed attempt always tears down its
session even under skipSessionCleanup — half-run sessions are garbage,
only successful ones are preserved for the v2 export path.

Regression tests (3, all green; suite 154/154):
- resume + transient retry → fresh session, resumed session deleted
- skipSessionCleanup=true + non-transient failure → session torn down
- reviewer deadline timeout NOT retried (guards the regex)

Rebuilt dist/index.cjs.
@Svtter

Svtter commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed cc7a66c addressing the P1 should-fix items + high-value warnings.

1. Deadline-aware backoff (orchestrator.ts:148-157) ✅ — sleep(backoff) now consults deadline: if remaining budget < backoff + 30_000 (withTimeout's floor), it gives up instead of sleeping into an immediate re-expiry. Covers the dogfood scenario where a doomed retry would burn the survivors' window.

2. Jitter (orchestrator.ts:158-164) ✅ — the original failure was N reviewers dying in the same second (one upstream blip); unjittered backoff would have them all retry in the same second and re-impose identical burst load, likely tripping the same upstream limit again. Backoff now spreads [base, base+backoffBase].

3. Resume + retry path covered (orchestrator.test.ts:410) ✅ — new test wires existingSessions + skipSessionCleanup: true (the v2 export path) + a transient failure on the resumed session: asserts the resumed session is deleted, exactly one fresh session is created, the result succeeds, and sessionID is the fresh one (surviving under skipSessionCleanup).

4. Contract change documented + tested (orchestrator.ts:220-222, orchestrator.test.ts:475) ✅ — added a regression test: skipSessionCleanup: true + non-transient failure → the resumed session is torn down (deletedSessions includes it). CHANGELOG/PR-body note added: "a failed attempt now always tears down its session, even under skipSessionCleanup (half-run sessions are garbage, not exportable)".

5. Regex tightened (orchestrator.ts:114-120) ✅ — terminated is now scoped (stream terminated|connection terminated) so content-moderation terminations aren't retried; timeout exclusion gets \b boundaries.

6. Hardened unreachable return (orchestrator.ts:237-246) ✅ — kept (TS requires a return after the for-loop since continue defeats control-flow analysis) but now never yields the literal string "undefined" if lastError somehow stayed unset.

Suite: 154/154 green (was 151; +3 regression tests).

Skipped (non-blocking, per the minimal-fix scope): buildReviewerPrompt caching, remaining() reuse, MAX_ATTEMPTS as option, log scrubbing (fetch failed carries no topology), mock-timer backoff-math test. Happy to follow up on any of these if you want.

@github-actions

Copy link
Copy Markdown

✅ 可合并 / CAN MERGE

所有6位 reviewer 均未发现阻塞性问题或警告项。上一轮指出的问题已全部修复,新增的 jitter、deadline-aware 提前退出、改进的正则和 null guard 均获认可。无合并障碍。

🟢 建议项 / Suggestions (6)

  • remaining() 重复调用(quality + performance 已确认):同一 attempt 中 remaining() 被调用了 3 次(session.createsession.promptsession.messages),每次重复计算 Date.now()Math.max。建议一次计算后复用。
  • 日志脱敏(security 领域见解):重试日志中的 prevMsggiveUpMsg 可能泄露内部网络拓扑信息(URL、主机名)。建议仅记录错误类型(如 fetch failed),避免暴露内网地址。
  • buildReviewerPrompt 重复调用(performance):每次重试 attempt 中重复调用,可提前缓存结果。
  • attempt - 2 退避基数(architecture):计算稍晦涩,可考虑用 0-indexed 变量使 2 ** i 更直观(当前行为正确,jitter 已降低理解成本)。
  • Deadline-aware 提前退出路径无测试覆盖(regression-test + test-value 已确认):deadline - Date.now() < 30_000 + base 的提前放弃路径在 attempt > 1 时无测试覆盖。建议添加测试:设置极短 globalTimeoutMs 验证 continue 被跳过且返回 success: false
  • session.create/session.messages 瞬态失败未单独测试(test-value):当前仅 session.prompt 的瞬态失败有单测覆盖。catch 块统一处理从实现角度看风险较低,但理论上仍有未覆盖路径。
💰 Review Cost — $0.0000
Role Cost (USD) Input Output Reasoning Cache Read Cache Write
quality $0.0000 65,833 205 993 0 0
security $0.0000 65,842 184 633 0 0
performance $0.0000 65,845 182 469 0 0
architecture $0.0000 65,846 243 843 0 0
regression-test $0.0000 66,283 209 1,685 0 0
test-value $0.0000 65,973 273 1,062 0 0
coordinator $0.0000 9,477 439 367 0 0
Total $0.0000 405,099 1,735 6,052 0 0
📋 各 Reviewer 详细审查结果
quality

可合并 / CAN MERGE

本次修订已修复上一轮 review 指出的所有问题:lastError fallback 不再产生 "undefined" 字符串、正则 terminated 缩小为 stream terminated|connection terminated、timeout 正则加了 \b 边界、退避引入了 jitter、新增了 deadline-aware 提前退出和 5 个回归测试覆盖了所有此前缺失的场景。代码质量良好,无阻塞性问题。

阻塞项:无

警告项:无

建议项:

  • remaining() 在同一个 attempt 中被调用了多次(session.create、session.prompt、session.messages),每次计算 Date.now()Math.max,可考虑一次计算复用(非阻塞,性能开销极低)。
security

安全无虞 / SECURE

该 PR 的更新版本保留了原有的良好安全设计,新增的 jitter 使用 Math.random()(适用于定时抖动,非安全敏感场景)、deadline-aware 退避、以及改进的正则表达式 \btimed out after \d+ms\bstream terminated|connection terminated 均未引入新的安全风险。会话生命周期管理在失败路径上始终清理(包括 skipSessionCleanup 场景),无 session 泄漏风险。

阻塞项:无

警告项:无

建议项:

  • 重试日志中的 prevMsggiveUpMsg 仍可能泄露内部网络拓扑信息(URL、主机名)。建议脱敏处理后记录,仅保留错误类型(如 fetch failed),避免日志中暴露内网地址。
performance

性能良好 / GOOD

与上一版相比,本次修订已解决了之前提出的主要性能疑虑:引入了随机 jitter 避免惊群效应,并增加了 deadline-aware 提前终止机制防止无效重试。当前无新的性能问题。

阻塞项:无 / Blocking Issues: None

警告项:无 / Warnings: None

建议项 / Suggestions:

  1. buildReviewerPrompt 在每次重试 attempt 中重复调用,可提前缓存结果减少计算开销(非阻塞,当前开销很低)。
  2. remaining() 在每个 attempt 中被调用 3 次(session.createsession.promptsession.messages),可一次计算后复用,减少重复 Date.now() 调用(非阻塞,开销极低)。
architecture

架构合理 / SOUND

本次修订中,之前提出的大部分架构建议已被采纳:terminated 限定为 stream terminated|connection terminated、timeout 正则加了 \b 边界、引入了 deadline-aware 检查提前放弃、加入了 jitter 避免惊群效应、lastError 添加了 null guard 避免产生 "undefined" 字符串。改动仍然集中在 orchestrator.tstypes.tsorchestrator.test.ts 三个源文件中,模块边界清晰,耦合度低,与现有架构风格一致。

阻塞项:无

警告项:无

建议项:

  1. attempt - 2 的退避基数计算仍稍晦涩。可考虑将 attempt0 开始计数或用本地变量表示"重试次数i",使 2 ** i 更直观。当前行为正确(jitter 的存在已降低理解成本),非阻塞。
regression-test

回归测试完整 / REGRESSION TESTS COMPLETE

PR类型:BUGFIX
分析:上一轮审查指出的三个测试缺口已全部被新测试覆盖(resume+瞬态失败降级、skipSessionCleanup+session销毁、deadline timeout 排除)。新增了 deadline-aware 提前放弃路径和 jitter,核心重试行为(瞬态→重试、非瞬态→不重试、最多3次、会话正确销毁)均已验证。

阻塞项:无

警告项:无

建议项:

  • deadline - Date.now() < 30_000 + base 的提前放弃路径(在 attempt > 1 时立即 return 而非继续退避)无测试覆盖,可通过设置极短 globalTimeoutMs 触发。
test-value

测试全部有价值 / ALL TESTS HAVE VALUE

严重问题 / CRITICAL
无 / None

中等问题 / MEDIUM
无 / None

低等问题 / LOW
无 / None

阻塞项 / Blocking Issues
阻塞项:无 / Blocking Issues: None

警告项 / Warnings
警告项:无 / Warnings: None

建议项 / Suggestions

  1. orchestrator.test.ts 中新增的 deadline-aware early-exit 路径(退避前检查 deadline - Date.now() < 30000 + base)无测试覆盖。建议添加一个测试:设置极短的 globalTimeoutMs 使 deadline 在第一次失败后已耗尽,验证 continue 路径被跳过且返回 success: false

  2. session.createsession.messages 的瞬态失败触发的重试行为未单独测试(当前仅 session.prompt 有覆盖)。catch 块统一处理所有调用,从实现角度看风险较低,但理论上仍是未覆盖的路径。

@Svtter
Svtter merged commit 8e2371c into main Jun 27, 2026
3 checks passed
@Svtter Svtter mentioned this pull request Jun 27, 2026
Svtter added a commit that referenced this pull request Jun 27, 2026
### Fixed
- multi-review: retry transient reviewer failures (undici fetch failed
  on long-running reviewers) with deadline-aware, jittered exponential
  backoff. PR #286.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Bug fix review:p1 Major review findings triaged Issue has been triaged

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants