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

11 KiB
Raw Blame History

任务书 · 非终态轮出口统一与访谈死路根治(2026-09-01)

基线:origin/staging @ 857f9dc9

上一轮(9f011194 + 4b133177)把「证据不足却允许采用」堵住了,这部分有效且必须保留。但只做了"不给错的",没做"给对的出路",结果是用户走完 7 条证据、答完全部区分题,最后卡在死路上,既不能采用也没有下一个问题

对用户而言这不是改进:从「拿到一个错误结果」变成「拿不到任何结果」。本轮的目标只有一个 —— 任何非终态轮结束后,用户都必须有下一步可走。


事故实证

真实 case 645ba774-67fb-4d6f-b842-f59e4d97adc0Skill 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: falsemargin_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 路径没有任何非终态兜底

persistNextInterviewIfIdleensureNonTerminalTurnExit全部调用点都在 message / opening 分支内:

route.ts:438  persistNextInterviewIfIdle   message 预检分支)
route.ts:513  persistNextInterviewIfIdle   message 预检分支)
route.ts:678  persistNextInterviewIfIdle   action === "message" || action === "opening"
route.ts:689  ensureNonTerminalTurnExit    (同上)

isStructuredChoiceaction === "answer_choice" || "stop_and_review",见 route.ts:173、249)在第 249 行独立分支里处理完就 return一个兜底都不经过。事故最后一步正是用户点选项:applyRectificationChoicepersistNextInterviewAfterChoice 没能建出 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 = nulldecideRectification 走到:

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_promptsfamilyhealth_pressure 两条线一次都没用过。canAskHoldoutdecision-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=8MIN_STANDALONE_*=3/2MIN_ACCEPTANCE_*=3/2)一律不动。本轮修的是"没有出路",不是"门太严"。任何让事故 case 变成 canAdopt: true 的改动都是错的。
  2. 不得修引擎 receipt 的语义。 acceptance_alloweddiagnostic_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 不得低于基线 714skill-registry.test.ts 保持 16,且 fail=0
  8. 不得改 .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 的 actionopeningmessageanswer_choicestop_and_review。用组合枚举,不要手挑用例。

再补两条:

  • probe 耗尽不得成为死路discriminatorProbe === nullseparation.sufficient === false 且非 userStoppednextAction 属于收集类或 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=0skill-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 —— 这是同一类缺陷的第二次出现,旧防线(给单个分支补调用)为何失效必须写清楚。