Files
Jyotisha/TASK-rectification-nonterminal-exit-20260901.md
T
Jesse_Chen 15877069fc
Independent Staging Quality Gate / validate (push) Successful in 13m36s
Independent Staging Quality Gate / publish (push) Successful in 10m51s
docs(rectification): add non-terminal exit task brief
The previous pass closed the "adoptable on one event" hole but left no
forward path: case 645ba774 ends with can_adopt false and
current_question null, so the user is stuck with neither a result nor a
next question. Two causes: answer_choice never reaches
ensureNonTerminalTurnExit or persistNextInterviewIfIdle, all of whose
call sites sit in the message/opening branches; and once every probe is
answered or dropped, decideRectification returns offer_provisional_range
before the holdout branch, ignoring unused oos_blind prompts and the
nakshatra boundary question.

Same defect class as BUG-456, which was fixed by patching one branch
rather than gating every exit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVapmh2oGNyr6ECHKjPJY8
2026-08-31 19:18:25 +00:00

170 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 任务书 · 非终态轮出口统一与访谈死路根治(2026-09-01)
基线:`origin/staging` @ `857f9dc9`
上一轮(`9f011194` + `4b133177`)把「证据不足却允许采用」堵住了,这部分有效且必须保留。但只做了"不给错的",没做"给对的出路",结果是**用户走完 7 条证据、答完全部区分题,最后卡在死路上,既不能采用也没有下一个问题**。
对用户而言这不是改进:从「拿到一个错误结果」变成「拿不到任何结果」。本轮的目标只有一个 —— **任何非终态轮结束后,用户都必须有下一步可走。**
---
## 事故实证
真实 case `645ba774-67fb-4d6f-b842-f59e4d97adc0`Skill `10.0.14`,7 条已确认证据、3 个领域、6 个区分题已作答。最终状态:
```
case.status : collecting_evidence ← 没有收尾
completed_at : null
current_question : null ← 没有下一问
choice_card : null
interview.type : offer_provisional_range
can_adopt : false
selection_allowed : false
```
界面显示「当前没有可回答的问题,正在等待服务端更新」,然后永远等下去。
**注意:这次 `can_adopt: false` 是正确的,不要去改它。** 引擎自己的门也没过:`diagnostic_quality.passed: false``margin_percent: 4.476`(最低 10)、`date_sensitivity_retention_rate: 0.2857`、候选跨度 04:5105:15 共 24 分钟、`overall_confidence: "low"`。引擎 receipt 里 `acceptance_allowed: true` 与它自己的 `diagnostic_quality.passed: false` 自相矛盾,那是引擎侧的问题,**本轮不修引擎,也不得因此放宽 TS 侧收紧**。
---
## 两个根因
### 根因 1 · `answer_choice` 路径没有任何非终态兜底
`persistNextInterviewIfIdle``ensureNonTerminalTurnExit` 的**全部**调用点都在 `message` / `opening` 分支内:
```
route.ts:438 persistNextInterviewIfIdle message 预检分支)
route.ts:513 persistNextInterviewIfIdle message 预检分支)
route.ts:678 persistNextInterviewIfIdle action === "message" || action === "opening"
route.ts:689 ensureNonTerminalTurnExit (同上)
```
`isStructuredChoice``action === "answer_choice" || "stop_and_review"`,见 route.ts:173、249)在第 249 行独立分支里处理完就 `return`,**一个兜底都不经过**。事故最后一步正是用户点选项:`applyRectificationChoice``persistNextInterviewAfterChoice` 没能建出 focus,此后没有任何补救,`current_question` 永久为 null。
这与 **BUG-456** 是同一类缺陷(非终态轮结束时未保证服务端状态)。BUG-456 修了 opening,漏了 answer_choice —— 因为当时的修法是"给某个分支补一次调用",而不是"给所有出口建一道闸"。
### 根因 2 · probe 耗尽后决策链提前 return,不转向收集
事故 case 的 `inference_state.probes` 共 11 条:
- 4 条带年份的 `dasha_boundary` / `dasha_activation` —— **全部已在 `answered_probes` 中**
- 7 条 `varga_contrast` —— **全部因 `yearless_ungrounded_contrast` 进了 `dropped_probes`**
于是 `discriminatorProbe = null``decideRectification` 走到:
```ts
if (!separation.sufficient) {
if (probe && !userStopped) return discriminateOrExhaust(...);
return completeWithRange(separation, holdout, range, "offer"); // ← 停在这里
}
if (holdout === "not_started" && !userStopped) {
return holdoutValidation(separation, range); // ← 永远到不了
}
```
**但这个 case 明明还有路可走:**
- `oos_blind_prompts``family``health_pressure` 两条线一次都没用过。`canAskHoldout``decision-from-dossier.ts:134`)正是以 `oosBlindPrompts.length > 0` 判定 holdout 为 `not_started` —— 也就是说 holdout 验证**本来可以问**,只是决策链在 `!separation.sufficient` 处提前返回,根本走不到那个分支。
- `nakshatra_boundary.near_boundary: true`,且带有现成的 A/B 选项与 `user_meaning`
「候选分不开」且「没有区分题可问」的正确出口是**回去收集新证据或做 holdout 验证**,不是停在 `offer_provisional_range` 等死。
---
## 硬红线
1. **不得放宽上一轮的收紧。** `deliveryCapability`、引擎 ceiling 交集、`strictAliasedBoolean`、四个阈值常量(`MIN_SEPARATION_LEAD=8``MIN_STANDALONE_*=3/2``MIN_ACCEPTANCE_*=3/2`)一律不动。本轮修的是"没有出路",不是"门太严"。任何让事故 case 变成 `canAdopt: true` 的改动都是错的。
2. **不得修引擎 receipt 的语义。** `acceptance_allowed``diagnostic_quality` 自相矛盾是引擎侧问题,本轮只做记录,不在 TS 侧"修正"引擎输出。
3. **不得用「让用户重开一个 case」当作出路。** 死路必须在当前 case 内解决。
4. **不得靠模型正文兜底。** 问题槽仍由服务端拥有;不得引入正文正则、问号检测或字符串匹配来判断"是不是已经问过了"。
5. 不得修改既有行为断言 —— 除非该断言锁住的正是本轮要修的死路本身。**配额:错误行为组 ≤ 3 组、断言 ≤ 6 条**,超出立即停下汇报。机械性版本身份同步不计入(口径见 BUG-459)。
6. 推 staging 前必须 `./node_modules/.bin/tsc --noEmit` 通过。**不要用 `npx tsc`**(新建 worktree 未 `npm install` 时会装到空包 `tsc@2.0.4`)。
7. `tests/rectification-*.test.ts` 不得低于基线 **714**`skill-registry.test.ts` 保持 16,且 `fail=0`
8. 不得改 `.gitea/workflows/**`。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:**用户永远有下一步 > 不得放行不该放行的采用 > 功能与测试不回归 > 代码整洁**。
## 开工前置
```bash
git fetch origin --prune
git worktree add -b codex/rectification-nonterminal-exit-20260901 \
../.worktrees/rectification-nonterminal-exit-20260901 origin/staging
```
`docs/research/pre_work_error_ledger.md`,跑 `scripts/pre_work_check.py`,读 `frontend/AGENTS.md`。在 `docs/BUG_HISTORY.md` 检索 **BUG-456**(同类缺陷)与 **BUG-459**
**行号只是线索,按符号名定位。**
---
## 任务 0(门控)· 先写不变量,先让它红
`frontend/tests/rectification-decision-authority.test.ts` 增加**本轮的核心不变量**
> **任何非终态轮结束后,`current_question` 非空 与 `canAdopt` 为真,二者必居其一。**
这一条同时挡住 BUG-456、本次死路,以及将来任何新增路径上的同类问题。它是本轮唯一真正的防线 —— 任务 A/B/C 都只是补洞,只有它能防止下次换个路径再漏。
必须覆盖**全部四种**推进 case 的 action`opening``message``answer_choice``stop_and_review`。用组合枚举,不要手挑用例。
再补两条:
- **probe 耗尽不得成为死路**:`discriminatorProbe === null``separation.sufficient === false` 且非 `userStopped``nextAction` 属于收集类或 holdout 验证类,**不得**是 `offer_provisional_range` 终点态。
- **事故 case 回归**:用 `645ba774` 的真实形状(7 证据 / 3 领域 / 6 题已答 / 11 probe 全部已答或被丢弃 / `oos_blind_prompts` 剩 family、health_pressure)断言 `canAdopt === false` **且** 有下一问。
跑一遍,确认这三条是红的。**全绿说明测试没写对,重写,不要往下走。**
---
## 任务 A(P0)· 所有非终态轮出口统一兜底
不要再"给 `answer_choice` 分支也补一次调用" —— 那正是 BUG-456 的修法,也正是这次复发的原因。
`ensureNonTerminalTurnExit` 提升为 **route 的公共出口闸**:任何成功且非终态的轮次,无论 action 是什么、无论走确定性路径还是 agent 路径,返回前都必须经过它。`read_only` 保持无副作用,显式排除并注释理由。
实现上建议用统一的收尾包装,而不是在每个 `return` 前手工插一行 —— 后者一定会在下次新增分支时再漏。请在 PR 里说明你如何保证"新增分支不会绕过这道闸"。
---
## 任务 B(P0)· probe 耗尽必须转向,不得停在原地
调整 `decideRectification``!separation.sufficient` 分支的出口:当 `probe === null` 且非 `userStopped` 时,不得直接 `completeWithRange(..., "offer")`,而应按优先级转向:
1. holdout 可问(`holdoutValidation === "not_started"`,即 `oos_blind_prompts` 非空或有保留事件)→ 走 holdout 验证
2. 仍可收集(存在未使用的领域线索,如 `oos_blind_prompts` 的 family / health_pressure)→ 回到收集
3. `nakshatra_boundary.near_boundary === true` 且带 A/B 选项 → 该边界题是合法的下一问
4. 以上都没有 → 才允许停在区间交付,且**必须**同时给出一个明确的收尾出口(用户可主动停止并拿到区间),不得留空
注意红线 1:转向不改变 `canAdopt`。事故 case 转向后仍应是 `canAdopt: false` + 有下一问。
---
## 任务 C(P1)· 服务端问题写进 turn 历史
上一轮 D-1 让服务端 focus 成为唯一问题源,副作用是**问题不进消息历史**:`turns` 里只剩「接下来请点选下面这一问。」这类占位,用户滚回去看不到问过什么。
这是上一版任务书的规格疏漏 —— 我写了"唯一问题源",没写"问题也要进历史",两者并不冲突。
修法:服务端 focus 仍是唯一来源,但把它的 prompt **由服务端写入 turn 文本**。不是让模型复述(会漂移),是服务端直接写。占位文案「接下来请点选下面这一问。」应被真实问题取代。
---
## 验收标准
1. `cd frontend && ./node_modules/.bin/tsc --noEmit` exit 0
2. `npx tsx --test tests/rectification-*.test.ts` 不低于 714 且 `fail=0``skill-registry.test.ts` 16/16
3. 任务 0 的三条不变量全绿,且覆盖四种 action
4. **事故回归实证**:用 `645ba774` 的真实形状跑,断言 `canAdopt === false` **且** `current_question` 非空,把前后输出贴进 PR
5. 上一轮的不变量(引擎上限、证据不足不放行、并列不采用、holdout unavailable 不放行、overlay 只收紧)**仍然全绿**
6. 任务 A 的"新增分支不会绕过闸门"机制说明写进 PR
7. 贴出修复后 turn 历史里问题文案的实际样子(任务 C)
## 交付前必须说明
- 逐条列出改动的既有断言(红线 5),每条写原值与为什么原值是错的
- 本任务书的根因来自静态溯源 + 事故 case 快照分析,作者无 staging 凭据,**未在真实环境验证**。你若同样无凭据,不要声称已验证。
- 修复后请在 `docs/BUG_HISTORY.md` 追加记录,并在 `复发自` 明确指向 **BUG-456** —— 这是同一类缺陷的第二次出现,旧防线(给单个分支补调用)为何失效必须写清楚。