Files
Jyotisha/docs/tasks/TASK-rectification-failure-attribution-20260915.md
T
Jesse_ChenandClaude Opus 5 a8d29d1b6c docs(tasks): 记录产品两项决定(分类用会话模型 / 年份采样不改)
产品 2026-09-15 对审计单里挂着的两个待拍板项给出结论,两项都是「不改」:

- 意图分类继续用会话选定的模型,不引入便宜快模型,模型目录不新增
  「工具模型」角色。failure-attribution 单 §4.6 立为决策记录,§10 从
  「待产品拍板后另开单」改为已决定不做;本单只改归因,不改用哪个模型。
- 只给年份的事件继续采满 12 个月,降采样不做、研究单也不立。
  engine-memoization 单 §4.2 / §11 同步,并写明后续不得以性能为由重提。

两条都加了「要重提必须先拿到产品新的授权」,避免下一轮被当成遗漏又提一次。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 17:17:29 +00:00

16 KiB
Raw Blame History

TASK · 三处把系统故障说成别的东西(校正轮次链路)

  • 日期:2026-09-15
  • 基线 commitorigin/staging @ 6b3248bf
  • 执行分支:codex/rectification-failure-attribution-20260915
  • 独占文件:frontend/src/app/api/rectification/agent/route.tsfrontend/src/lib/rectification-agentic/v9/turn-intent-classifier.tsfrontend/src/lib/rectification-agentic/v9/engine-client.tsfrontend/src/lib/rectification-agentic/v9/agent-run.tsfrontend/src/lib/rectification-activity-labels.ts
  • route.ts 由本单独占。 TASK-rectification-request-dossier-cache-20260915 也要碰同一文件,必须串行在本单之后
  • 与 engine-memoization 单、settled-render-split 单无文件重叠,可并行

1. 三条缺陷的共同点

都不是算错,是归因错:后端出了故障,但对用户显示成「你没说清楚」「已经记下了」或者干脆断流。用户没有任何线索知道要重试,也不知道刚才那句话有没有算数。

# 用户看到 实际发生 预占 BUG
1 「我不太确定这句是不是在回答上面的问题」 意图分类器两次都异常,这句话里的经历直接丢弃 BUG-722
2 「记下了:2016 年 3 月……」但范围一动不动 引擎 429(算不过来),重算静默失败 BUG-723
3 等三五分钟,无错误码断流 两次尝试 420 秒 > 路由预算 240 秒 BUG-724

2. 事故实证

2.1 BUG-722 · 分类器失败被说成用户表达不清

route.tsPOST 里,action === "message" 且当前焦点是点选题parseAgentChoiceCopy(focus.expectedAnswerSchema) 非空)时:

const intent = await classifyTurnIntentWithRetry(resolvedModel, {...});
const classified = intent.classified;
...
if (!classified || classified.intent === "unclear") {
  const narration = RECTIFICATION_USER_COPY.unclearFocusReply;   // 「我不太确定这句是不是在回答上面的问题——点个选项,或者换个说法都行。」
  const turn = await persistV9DeterministicTurn(...);            // 本轮落库
  return completedMessageResponse(narration, ...);               // 直接返回,不进 Agent
}

classifyTurnIntentWithRetryturn-intent-classifier.ts)的契约是:两次尝试都抛异常 → 返回 { classified: null, expectedWrite: "unknown" }。也就是说 classified === null 表示「模型没答上来」,classified.intent === "unclear" 表示「用户确实说不清」,这是两件性质完全相反的事,路由把它们合并进了同一条分支。

后果:模型超时、供应商 5xx、网络抖动时,用户被回一句质疑他表达能力的话;这一轮正常落库,但没有任何证据写入尝试,他刚讲的那件事就此消失,只能自己再说一遍。

同一文件里采集题分支(isCollectFocusSchema 为真)已经处理对了:classified 为 null 时只记 collectIntent = "unclassified",不短路,继续往下进 Agent,由 expectedWrite = "unknown" 的 fail-open 守卫接管。这是 BUG-643 的成果。点选题分支没有跟上。

第三处同类:decision.nextAction === "ask_candidate_discriminator" 分支里的 classifyRectificationTurnIntent 包在 try { } catch { classified = null } 里,随后落 RECTIFICATION_USER_COPY.choicePrompt。危害小(那条路本来就要出卡),但归因同样错,一并处理。

关联记录BUG-643(分类器 null 不得回退到关键词;collectIntent=unclassified 就是那一单加的)、BUG-635(证据轮只说「记下了」却没写入)、BUG-522(其记录末尾原文写着「意图分类器超时仍是既有缺口,本单不修」——这条缺口从 2026-09-04 挂到今天)。

2.2 BUG-723 · 引擎「忙」被当成引擎「坏」

engine-client.tsreadEngineJson

if (!response.ok) {
  ... throw new RectificationEngineError("engine_request_failed", message);
}

所有非 2xx 一视同仁。而 Python 侧 jyotish_api_server.pyexcept HeavyComputeBusy 明确返回 429 + Retry-After 头 + ERR_COMPUTE_BUSY,语义是「现在满了,过几秒再来」,不是「坏了」。重算闸门 api_heavy_compute_gate.py 并发默认 2、饱和 fail-fast 不排队——这是设计,不要改它,要改的是调用侧。

失败之后:rectification-v9-tools.tsautoRescoreAfterEvidenceChange 整个包在 try/catch 里,返回 { status: "failed", errorCode, openQuestion: null }不打任何日志。这个结果进 record-evidence-batch 的 projection 交给模型,但系统提示词明确要求「工具执行保持静默……不叙述工具或内部状态」,所以模型不会说。于是:证据入库成功 → 重算静默失败 → 模型照常写「记下了:2016 年 3 月……」→ 范围一动不动 → 也没有下一问被盖戳。

这正是历史上反复出现的「说记下了但范围没变」的一个来源,而且它只在两个人同时校正时出现,本机永远复现不了。

复发自 BUG-715(星盘页同一天刚修完同一个错误)。BUG-715 的防复发原文:「引擎调用不得用 catch {} 吞掉原因,失败必须留服务端日志且用户文案按原因分档。」那一单只改了 chart-view-engine.ts,校正链路的 engine-client.ts 是同样的写法,没有被扫到。

2.3 BUG-724 · 超时预算自相矛盾

位置
route.ts export const maxDuration 240 s
regenerate/route.ts export const maxDuration 240 s
agent-run.ts RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS 210 s
agent-run.ts MAX_ATTEMPTS 2

单次尝试 210 s < 240 s,满足 BUG-388 防复发的字面要求(「attempt 超时必须小于路由 maxDuration」)。但两次加起来 420 s > 240 s,违反 BUG-059 的防复发:「两次模型尝试的总预算必须显式小于路由 maxDuration」。

只要发生一次重试(empty_streamevidence_not_written 都会触发),这一轮必然撞上路由预算或边缘代理超时,用户等三五分钟拿到一个连错误码都没有的断流。

另有一处隐患:RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS = 210_000 在仓库里定义了两遍——agent-run.ts:123(服务端真正用的)和 rectification-activity-labels.ts:40(前端「即将超时」提示用的)。改一个不改另一个,提示时机就会漂。

复发自 BUG-059关联 BUG-388(把 105 s 提到 210 s、把 120 s 提到 240 s 的那一单,它的防复发只写了单次尝试,没写总预算,所以这次没拦住)。

3. 根因

三条共用一个根因:故障的机器语义在传递过程中被压平。分类器把「抛异常」和「答了 unclear」压成同一个 null;引擎客户端把 429/500/超时/坏 JSON 压成同一个 engine_request_failed;运行器把「单次尝试的预算」当成「整轮的预算」。压平之后,上层再想按原因分档就没有信息可用了。

4. 决策记录

产品 2026-09-15 授权本单,并明确以下口径:

  1. 分类器失败必须让用户知道「是我们这边的事」,并且要能把这句话捡回来。 允许的做法是提示重试;不允许用年份正则、关键词表或任何模式匹配去猜用户意图——这是 BUG-643 的防复发红线,本单不推翻。
  2. 429 不是错误,是排队信号。 允许在重算链路上按 Retry-After 做有限次退避重试。退避重试仍失败时,必须让用户知道这次没有重算,不得让模型继续说「记下了」而范围不动。具体文案由执行方按 frontend/docs/VOICE.md 拟,评审在验收轮。
  3. 超时按「整轮一个预算」重构,不是简单调数字。 不接受把 attempt 砍到 110 s——那会把 BUG-388 重新打开(带引擎重算的轮次实测就要超过 105 s)。也不接受把 maxDuration 提到 430 s——让用户等七分钟不是产品。
  4. 并发闸门(默认 2)、_SWISSEPH_LOCK、fail-fast 不排队三项一律不动。 那是主机只有 2 vCPU 的保护,不是 bug。吞吐问题由 TASK-rectification-engine-memoization-20260915 解决。
  5. 不改计费口径。 现在这三条失败路径都发生在 billing.reserve() 之前或走 release,用户不扣点;改完必须仍然不扣点。
  6. 意图分类继续用会话选定的模型,不引入便宜快模型。 产品 2026-09-15 明确决定:分类用贵的。因此模型目录不新增「工具模型」角色,classifyRectificationTurnIntent / classifyTurnIntentWithRetry 继续走 resolveSessionLanguageModel 的返回值。本单不得以「省钱 / 提速」为由改模型选择;后续轮次要重提必须先拿到产品新的授权。本单只改归因,不改用哪个模型

5. 硬红线

  1. 不得引入任何关键词/正则/词表兜底去替代分类器(BUG-643 防复发)。
  2. 不得放宽 expectedWrite 守卫、不得改 evidence_not_written 的重试语义、不得用确定性业务模板伪装成模型生成成功(BUG-059 防复发)。
  3. 引擎调用失败必须留服务端日志,只含路径、状态码或错误名、耗时;不得含出生资料、案例 ID、用户原文、模型原文、JWTBUG-715 防复发 + AGENTS §8)。
  4. 超时改动后,「两次尝试总预算 < 路由 maxDuration」必须由一条测试断言钉死,而不是靠注释。
  5. 超时后若本轮已盖戳 open_question,仍不得把整轮打成空 run_timeout(BUG-388 防复发,现状行为,保持)。
  6. frontend/src/app/page.tsx 一行不许动(1951/2000AGENTS §6)。
  7. 不得顺手升级依赖、不得顺手修不在本单里的 warning。

6. 任务分解

6.1 分类器区分「模型没答上来」与「用户说不清」

classifyTurnIntentWithRetry 的返回值加一个判别字段(例如 outcome: "classified" | "unclear" | "classifier_unavailable"),classifier_unavailable 专指两次尝试都抛异常。classified: null 仍然保留给调用方兼容,但路由不再据此分档。

route.ts 三处改为:

  • outcome === "unclear" → 维持现状,回 unclearFocusReply
  • outcome === "classifier_unavailable" → 走一条新文案(大意:这边没接上,把刚才那句再发一次就行),并且这一轮不得被记成用户已经答过当前焦点;焦点保持 active,下一次重发能正常进入同一条路径。
  • 同一改动覆盖点选题分支、ask_candidate_discriminator 分支的 try/catch。采集题分支已经正确,只需保证它的 collectIntent 语义不被本次重构改坏。

另加一条服务端日志(只含 case 前缀无关的机器码与耗时,不含原文),让这类失败在日志里可数。

  • 验收:rectification-turn-intent-classifier.test.ts 新增断言——分类器抛异常两次时 outcome === "classifier_unavailable";模型正常返回 intent: "unclear"outcome === "unclear";两者的路由回复文案不同。
  • 验收:源码合同断言 route.ts 不存在 !classified || classified.intent === "unclear" 这种合并判断。
  • 验收:classifier_unavailable 路径不写任何证据、不推进焦点状态、不扣点。

6.2 引擎失败按原因分档

engine-client.tsreadEngineJson 改为按原因产出判别码:busy429/ http_error / timeout / bad_payload,并读取 Retry-After。每一种非 ok 打一条 console.warn(路径、状态码或错误名、耗时)。对齐 BUG-715 在 chart-view-engine.ts 里已经落地的那套形状,不要另发明一套。

scoreAndPersistCurrentEvidence / autoRescoreAfterEvidenceChange 链路上:busyRetry-After(上限取一个显式常量,建议 ≤ 2 次、总退避 ≤ 6 s,写成具名常量并在测试里钉死)退避重试;仍失败时把失败原因显式带回 projection,并让本轮的主持人正文告诉用户这次没有重算、经历已经记下、稍后会再比一次。

  • 验收:新增测试,桩出 429 + Retry-After: 2、500、超时、坏 JSON 四种,断言四种分别产出不同判别码、各有可区分日志、只有 429 触发退避重试。
  • 验收:断言退避总时长有上限,且上限是具名常量不是字面量散落。
  • 验收:busy 重试成功后,rescore.status 必须是 completed,与从未失败过的那条路径逐字相同。
  • 验收:busy 最终失败时,用户可见正文里必须出现「这次没有重新比较」这一语义(具体文案对照 frontend/docs/VOICE.md),且不得出现「范围在收窄」这类进度句。

6.3 超时改成整轮一个预算

runV9AgentTurn 里引入一个整轮 deadline(建议 RECTIFICATION_RUN_BUDGET_MS,取值必须显式小于 maxDuration,例如 225 s),单次尝试仍保留 210 s 上限,但实际超时取 min(单次上限, 剩余预算)。重试条件从 attemptNumber < MAX_ATTEMPTS 改为 attemptNumber < MAX_ATTEMPTS && 剩余预算 ≥ 最小可用尝试时长(同样具名常量)。预算不够重试时,走现有的 hostFallbackUsed 优雅路径,而不是启动一次注定被砍断的尝试。

同时把 RECTIFICATION_AGENT_ATTEMPT_TIMEOUT_MS 收敛成一处定义rectification-activity-labels.ts 从那一处 import,消除两份 210_000 漂移的可能。

  • 验收:新增断言 RECTIFICATION_RUN_BUDGET_MS < maxDuration,且 maxDuration 从路由模块读取而不是重写一遍字面量(agentregenerate 两条路由都要覆盖)。
  • 验收:rectification-v9-stream.test.ts 新增用例——第一次尝试耗尽大部分预算后返回 retryable,运行器不得发起第二次尝试,必须走 host fallback 并给出可见正文。
  • 验收:源码合同断言全仓只有一个 210_000 的定义点。
  • 验收:已盖戳 open_question 的超时轮仍然落题干、不空失败(BUG-388 现状行为回归)。

6.4 三条 Bug 历史

同一变更内写进 docs/BUG_HISTORY.md,编号连续,每条都要有「复发自 / 关联记录」:

  • BUG-722:关联 BUG-643、BUG-635、BUG-522522 里写明的既有缺口本单关闭)。
  • BUG-723复发自 BUG-715,说明 715 的防复发为什么没覆盖到 engine-client.ts(那一单的范围写死在星盘页三个文件里)。防复发要升级成仓库级:任何调用 Python 引擎的客户端都不得把非 2xx 压平成单一错误码,429 必须单独成档。
  • BUG-724复发自 BUG-059,说明 BUG-388 的防复发只约束了单次尝试、没约束总预算,因此没拦住。

7. 让步顺序

  1. 6.1 必须做,它是唯一会丢用户数据的一条。
  2. 6.3 次之,改动最小、风险最低,而且它是重试链路的前提。
  3. 6.2 的「分档 + 日志」必须做;「按 Retry-After 退避重试」可以砍到下一轮,但那样的话用户可见的「这次没有重算」提示不得砍——宁可不重试也不许静默。
  4. 6.4 不得砍。

8. 开工前置命令

git fetch origin --prune
git worktree add -b codex/rectification-failure-attribution-20260915 \
  .worktrees/rectification-failure-attribution-20260915 origin/staging
cd .worktrees/rectification-failure-attribution-20260915/frontend
git status -sb | head -1
npm ci

验收命令:

./node_modules/.bin/tsc --noEmit
npm run lint
npx tsx --test tests/rectification-turn-intent-classifier.test.ts \
  tests/rectification-v9-stream.test.ts \
  tests/rectification-answer-choice.test.ts \
  tests/rectification-spoken-collect.test.ts \
  tests/rectification-unwritten-evidence.test.ts \
  tests/rectification-v9-agent.test.ts \
  tests/chart-view-engine.test.ts
npx tsx --test tests/*.test.ts     # 与基线逐条比对失败清单
npm run build                      # `/` 仍须 ○ Static,首屏 gzip ±2%

9. BUG 编号起点

基线 6b3248bf 上最大号 BUG-720。本单预占 BUG-722 / 723 / 724BUG-721 留给 engine-memoization 单)。开工时核对当时的实际最大号;同日四单并行,先落库者先占号,冲突时顺延并在进度记录里写明。

10. 不在本单范围

  • 分类改用便宜快模型:产品 2026-09-15 已决定不改,分类继续用会话选定的贵模型,不另开单(见 §4.6)。
  • 并发闸门、_SWISSEPH_LOCK、加机器。
  • 引擎本身的耗时(见 TASK-rectification-engine-memoization-20260915)。
  • 请求内 Case 档案缓存(见 TASK-rectification-request-dossier-cache-20260915,串行在本单之后)。