docs(tasks): skipped-health holdout dead end + exit gate fix brief (BUG-626/627)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
This commit is contained in:
Jesse_Chen
2026-09-09 14:16:01 +00:00
co-authored by Claude Fable 5.1
parent 6008c07c81
commit 350fd13426
@@ -0,0 +1,60 @@
# TASK · 健康题答「记不清」后职业题一答完就断:holdout 把跳过的健康线换个名字再问,撞焦点 id 静默失败;出口闸门把"穷尽"当成已交付(2026-09-09
- 基线:`origin/staging` @ `6008c07c`BUG-623625 已合入)
- 分支:`codex/rectification-skipped-health-deadend-20260909`,基于 `origin/staging`
- 执行方:coding agent;验收:Claude
- 涉及文件:`frontend/src/lib/rectification-agentic/v9/method-followup.ts``holdoutFollowupFor` L16761697、`declinedDomains` L509)、`v9/answer-choice.ts``persistNextInterviewAfterChoice``duplicate_focus` 分支、`inspectNonTerminalTurnExit` L17391753、`ensureNonTerminalTurnExit`)、`v9/agent-run.ts`(idle 异常吞掉后的处理 L555–575)、`frontend/src/components/rectification-agentic-chat.tsx`("没有拿到下一个问题 / 重新加载")、`frontend/src/app/api/rectification/cases/[caseId]/...`(重载改为修复)
- BUG 编号起点:**BUG-626**`docs/BUG_HISTORY.md` 当前最大 BUG-625
- 优先级:**P1**(真实用户,流程在最后一步断掉,只剩"没有拿到下一个问题"和一个无效的重新加载)
## 1. 事故实证(2026-09-09 真实用户截图 + 转录;只写结构)
顺序:学业 → 感情 → D9 风格 → 2018.04 事业 → 2024.08 迁居 → 家人「没有」→ 事业事件 → D10 风格 → 财务事件 → 2024.08 财务 → 2025.09 财务 → 健康题点「记不清」→ 职业题答完("已完成 2 步",正文一句复述)→ **"没有拿到下一个问题。[重新加载]"**,没有卡、没有旁白、没有题。时间轴 14:33–14:57,三个实心点。
## 2. 根因
### 2.1 BUG-626P1):跳过的健康线在 holdout 里没被识别,再问一次时撞上刚跳过的焦点 id
1. 「记不清」把健康采集焦点标 `skipped``declined_skipped_topics` 里这条的 `target_domain`**`health`**DB 约束只有 `health`,没有 `health_pressure``persistableFocusDomain``health_pressure` 映成 `health`)。
2. 职业答完后计划层走 `pendingHoldout = holdoutFollowupFor(input, declined)``declined``declinedDomains(topics)` 得到 `{family, health}`holdout 候选来自 `oos_blind_prompts`,其 `domain`**`health_pressure`**`!declined.has("health_pressure")` 为真 → 把刚跳过的健康线当"没用过的线"再问一遍。BUG-590 只给 `occupied` 做了 health / health_pressure 归并,没给 `declined` 做。
3. 这道 holdout 采集题的 `questionId``collect:health_pressure:collect_method_evidence`,与刚被跳过的焦点同 id → `focus_idempotency_conflict` → BUG-591 后非 retry 不再 `:next`,返回 `duplicate_focus`
4. `persistNextInterviewAfterChoice` 对 collect 类 followup:状态不是 `created / already_open` 就转 `persistExhaustionCollect`;那里 `exhaustionSpokenCollectFollowup` 为空(健康按 `declinedHealth` 已封)、`canAdopt` 为假(决策仍是 holdout / 区分)→ 只剩门槛句路径。截图里连门槛句都没有,说明这条链上某处返回了空旁白或抛了异常被 `agent-run` L565 的 `catch` 吞掉(日志有 `persist interview before collect attach failed`)。执行方开工先从 staging 日志确认是哪一种,两种都要修。
### 2.2 BUG-627P1):出口闸门把"穷尽"当成"已交付"
`inspectNonTerminalTurnExit``satisfied` 包含 `exhausted``!remainingCollect && blockingMethodsCovered && !canAdopt && !accepted`)。穷尽只是一个状态,不是用户能看到的出口;本例正是穷尽为真、却没有任何问题 / 卡 / 句子落库,闸门照样放行,`ensureNonTerminalTurnExit` 不修复。BUG-558 的防复发写的是"穷尽必须有 `rectification_exhaustion_collect` 日志且写出 terminalNote",这个闸门把它绕过去了。
### 2.3 P2:客户端"重新加载"只重取快照
`questionGap === "unavailable"` 的按钮只 `refetchQuestion()`;服务端没有新东西,点多少次都一样。
## 3. 决策记录
1. **holdout 的 `declined` 与 `occupied` 一样做 health / health_pressure 归并**`declinedHealth` 口径),跳过的领域本会话不再作 holdout 问。
2. **`duplicate_focus` 不得静默。** `persistNextInterviewAfterChoice``duplicate_focus` 时记 `rectification_focus_duplicate` 日志,并**必定**走 `persistExhaustionCollect``persistExhaustionCollect` 在三条分支(继续采集 / 采用 / 门槛句)之外不得返回空旁白——门槛句为空时用 `RECTIFICATION_USER_COPY.noCandidatesGate` 或范围句兜底,`terminalNote: true`
3. **出口闸门只认可见载体。** `satisfied` = 有活动问题 / 已采用 / 已确认 / 本轮已落库带 `terminalNote` 的 host turn / `publicCanAdopt``exhausted``satisfied` 里删掉。穷尽且无载体 → `ensureNonTerminalTurnExit``persistExhaustionCollect` 并把结果写成 host turn(现有 `persistExhaustionGateTurn`)。
4. **idle 异常不再吞。** `agent-run` L565 的 `catch` 记日志后,仍调用 `ensureNonTerminalTurnExit`(闸门已在路由里,但要保证异常路径也到得了它)。
5. **"重新加载"改为"修复"。** 客户端按钮改调 `POST /api/rectification/cases/[caseId]/repair-exit`(新路由,只做 `ensureNonTerminalTurnExit` + 返回快照),文案"接着问";连续两次仍无载体才显示"暂时接不上,请新建一次校正"。不新增迁移。
6. 不动采用门、确认门、各道门槛、四选项合同;Skill 不 bump。
## 4. 任务分解
- 4.1 BUG-626:决策 1、2。用例——账本七领域 + 健康 `skipped`topic `target_domain=health`+ oos 含 `health_pressure``holdoutFollowupFor` 为 null;同形状进 `persistNextInterviewIfIdle` → 返回非空 `hostNarration` 且(`canAdopt` 时)`terminalNote`mock `persistCollectFocus` 返回 `duplicate_focus` → 仍有非空旁白。
- 4.2 BUG-627:决策 3、4。用例——穷尽为真、无活动焦点、无 host turn → `ensureNonTerminalTurnExit` 写出 host turn;有 terminalNote turn 时不重复写。
- 4.3 决策 5:路由 + 客户端;`rectification-agentic-entry` 合同:unavailable 态按钮文案与请求路径。
- 4.4 记录:`docs/BUG_HISTORY.md` BUG-626(复发关联 BUG-558 / BUG-590 决策 4 / BUG-591 / BUG-605)、BUG-627(关联 BUG-558 防复发条款);`CHANGELOG.md``PROGRESS-…``docs/testing/rectification-scenarios-20260907.md` 加"健康题点『记不清』后职业答完必须出卡或门槛句,不得出现『没有拿到下一个问题』"。
## 5. 让步顺序
4.1 决策 1 一行改动先部署;4.1 决策 2、4.2 必做;4.3 可后置为 P2;4.4 不可省。
## 6. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-skipped-health-deadend-20260909 .worktrees/rectification-skipped-health-deadend-20260909 origin/staging
cd .worktrees/rectification-skipped-health-deadend-20260909
ln -s /workspace/Jyotisha/frontend/node_modules frontend/node_modules
cd frontend && ./node_modules/.bin/tsc --noEmit; npm run lint 2>&1 | tail -1
ls tests/rectification-*.test.ts | grep -v database | xargs npx tsx --test 2>&1 | grep -E "^# (tests|pass|fail)"
```