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
11 KiB
任务书 · 非终态轮出口统一与访谈死路根治(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:51–05: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 走到:
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 等死。
硬红线
- 不得放宽上一轮的收紧。
deliveryCapability、引擎 ceiling 交集、strictAliasedBoolean、四个阈值常量(MIN_SEPARATION_LEAD=8、MIN_STANDALONE_*=3/2、MIN_ACCEPTANCE_*=3/2)一律不动。本轮修的是"没有出路",不是"门太严"。任何让事故 case 变成canAdopt: true的改动都是错的。 - 不得修引擎 receipt 的语义。
acceptance_allowed与diagnostic_quality自相矛盾是引擎侧问题,本轮只做记录,不在 TS 侧"修正"引擎输出。 - 不得用「让用户重开一个 case」当作出路。 死路必须在当前 case 内解决。
- 不得靠模型正文兜底。 问题槽仍由服务端拥有;不得引入正文正则、问号检测或字符串匹配来判断"是不是已经问过了"。
- 不得修改既有行为断言 —— 除非该断言锁住的正是本轮要修的死路本身。配额:错误行为组 ≤ 3 组、断言 ≤ 6 条,超出立即停下汇报。机械性版本身份同步不计入(口径见 BUG-459)。
- 推 staging 前必须
./node_modules/.bin/tsc --noEmit通过。不要用npx tsc(新建 worktree 未npm install时会装到空包tsc@2.0.4)。 tests/rectification-*.test.ts不得低于基线 714,skill-registry.test.ts保持 16,且fail=0。- 不得改
.gitea/workflows/**。不得在有未提交改动的工作树上切分支。不得自行把 staging 提升到 main。
让步顺序:用户永远有下一步 > 不得放行不该放行的采用 > 功能与测试不回归 > 代码整洁。
开工前置
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"),而应按优先级转向:
- holdout 可问(
holdoutValidation === "not_started",即oos_blind_prompts非空或有保留事件)→ 走 holdout 验证 - 仍可收集(存在未使用的领域线索,如
oos_blind_prompts的 family / health_pressure)→ 回到收集 nakshatra_boundary.near_boundary === true且带 A/B 选项 → 该边界题是合法的下一问- 以上都没有 → 才允许停在区间交付,且必须同时给出一个明确的收尾出口(用户可主动停止并拿到区间),不得留空
注意红线 1:转向不改变 canAdopt。事故 case 转向后仍应是 canAdopt: false + 有下一问。
任务 C(P1)· 服务端问题写进 turn 历史
上一轮 D-1 让服务端 focus 成为唯一问题源,副作用是问题不进消息历史:turns 里只剩「接下来请点选下面这一问。」这类占位,用户滚回去看不到问过什么。
这是上一版任务书的规格疏漏 —— 我写了"唯一问题源",没写"问题也要进历史",两者并不冲突。
修法:服务端 focus 仍是唯一来源,但把它的 prompt 由服务端写入 turn 文本。不是让模型复述(会漂移),是服务端直接写。占位文案「接下来请点选下面这一问。」应被真实问题取代。
验收标准
cd frontend && ./node_modules/.bin/tsc --noEmitexit 0npx tsx --test tests/rectification-*.test.ts不低于 714 且fail=0;skill-registry.test.ts16/16- 任务 0 的三条不变量全绿,且覆盖四种 action
- 事故回归实证:用
645ba774的真实形状跑,断言canAdopt === false且current_question非空,把前后输出贴进 PR - 上一轮的不变量(引擎上限、证据不足不放行、并列不采用、holdout unavailable 不放行、overlay 只收紧)仍然全绿
- 任务 A 的"新增分支不会绕过闸门"机制说明写进 PR
- 贴出修复后 turn 历史里问题文案的实际样子(任务 C)
交付前必须说明
- 逐条列出改动的既有断言(红线 5),每条写原值与为什么原值是错的
- 本任务书的根因来自静态溯源 + 事故 case 快照分析,作者无 staging 凭据,未在真实环境验证。你若同样无凭据,不要声称已验证。
- 修复后请在
docs/BUG_HISTORY.md追加记录,并在复发自明确指向 BUG-456 —— 这是同一类缺陷的第二次出现,旧防线(给单个分支补调用)为何失效必须写清楚。