# TASK · 三处把系统故障说成别的东西(校正轮次链路) - 日期:2026-09-15 - 基线 commit:`origin/staging` @ `6b3248bf` - 执行分支:`codex/rectification-failure-attribution-20260915` - 独占文件:`frontend/src/app/api/rectification/agent/route.ts`、`frontend/src/lib/rectification-agentic/v9/turn-intent-classifier.ts`、`frontend/src/lib/rectification-agentic/v9/engine-client.ts`、`frontend/src/lib/rectification-agentic/v9/agent-run.ts`、`frontend/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.ts` 的 `POST` 里,`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 } ``` `classifyTurnIntentWithRetry`(`turn-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.ts` → `readEngineJson`: ``` if (!response.ok) { ... throw new RectificationEngineError("engine_request_failed", message); } ``` 所有非 2xx 一视同仁。而 Python 侧 `jyotish_api_server.py` 的 `except HeavyComputeBusy` 明确返回 **429 + `Retry-After` 头 + `ERR_COMPUTE_BUSY`**,语义是「现在满了,过几秒再来」,不是「坏了」。重算闸门 `api_heavy_compute_gate.py` 并发默认 2、饱和 fail-fast 不排队——**这是设计,不要改它**,要改的是调用侧。 失败之后:`rectification-v9-tools.ts` 的 `autoRescoreAfterEvidenceChange` 整个包在 `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_stream`、`evidence_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、用户原文、模型原文、JWT**(BUG-715 防复发 + AGENTS §8)。 4. 超时改动后,「两次尝试总预算 < 路由 `maxDuration`」必须由一条测试断言钉死,而不是靠注释。 5. 超时后若本轮已盖戳 `open_question`,仍不得把整轮打成空 `run_timeout`(BUG-388 防复发,现状行为,保持)。 6. `frontend/src/app/page.tsx` 一行不许动(1951/2000,AGENTS §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.ts` 的 `readEngineJson` 改为按原因产出判别码:`busy`(429)/ `http_error` / `timeout` / `bad_payload`,并读取 `Retry-After`。每一种非 ok 打一条 `console.warn`(路径、状态码或错误名、耗时)。对齐 BUG-715 在 `chart-view-engine.ts` 里已经落地的那套形状,不要另发明一套。 `scoreAndPersistCurrentEvidence` / `autoRescoreAfterEvidenceChange` 链路上:`busy` 按 `Retry-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` 从路由模块读取而不是重写一遍字面量(`agent` 与 `regenerate` 两条路由都要覆盖)。 - 验收:`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-522(522 里写明的既有缺口本单关闭)。 - 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. 开工前置命令 ```bash 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 ``` 验收命令: ```bash ./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 / 724**(BUG-721 留给 engine-memoization 单)。开工时核对当时的实际最大号;同日四单并行,先落库者先占号,冲突时顺延并在进度记录里写明。 ## 10. 不在本单范围 - 分类改用便宜快模型:**产品 2026-09-15 已决定不改,分类继续用会话选定的贵模型**,不另开单(见 §4.6)。 - 并发闸门、`_SWISSEPH_LOCK`、加机器。 - 引擎本身的耗时(见 `TASK-rectification-engine-memoization-20260915`)。 - 请求内 Case 档案缓存(见 `TASK-rectification-request-dossier-cache-20260915`,串行在本单之后)。