From 350fd13426cccf6f1f3c5ca86ef8d803a5014248 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 9 Sep 2026 14:16:01 +0000 Subject: [PATCH] docs(tasks): skipped-health holdout dead end + exit gate fix brief (BUG-626/627) Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P --- ...ication-skipped-health-deadend-20260909.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md diff --git a/docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md b/docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md new file mode 100644 index 00000000..f9fb7249 --- /dev/null +++ b/docs/tasks/TASK-rectification-skipped-health-deadend-20260909.md @@ -0,0 +1,60 @@ +# TASK · 健康题答「记不清」后职业题一答完就断:holdout 把跳过的健康线换个名字再问,撞焦点 id 静默失败;出口闸门把"穷尽"当成已交付(2026-09-09) + +- 基线:`origin/staging` @ `6008c07c`(BUG-623~625 已合入) +- 分支:`codex/rectification-skipped-health-deadend-20260909`,基于 `origin/staging` +- 执行方:coding agent;验收:Claude +- 涉及文件:`frontend/src/lib/rectification-agentic/v9/method-followup.ts`(`holdoutFollowupFor` L1676–1697、`declinedDomains` L509)、`v9/answer-choice.ts`(`persistNextInterviewAfterChoice` 的 `duplicate_focus` 分支、`inspectNonTerminalTurnExit` L1739–1753、`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-626(P1):跳过的健康线在 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-627(P1):出口闸门把"穷尽"当成"已交付" + +`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)" +```