docs(rectification): add non-terminal exit task brief
Independent Staging Quality Gate / validate (push) Successful in 13m36s
Independent Staging Quality Gate / publish (push) Successful in 10m51s

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
This commit is contained in:
Jesse_Chen
2026-08-31 19:18:25 +00:00
parent 857f9dc9b9
commit 15877069fc
@@ -0,0 +1,169 @@
# 任务书 · 非终态轮出口统一与访谈死路根治(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** —— 这是同一类缺陷的第二次出现,旧防线(给单个分支补调用)为何失效必须写清楚。