tied_first 并列到顶时,persistNextInterviewIfIdle 因刷新尝试「落库失败仍回报 已尝试」而在第二次计算时判交付并播出结论,GET 重算又回到 collect_evidence 且 清零交付能力(BUG-680)。交付话术闸门认 completed_with_range/provisional_range, 交付卡闸门只认 ADOPT_OUTCOMES,能说不能画(BUG-681)。缺口状态机没有终态, 兜底印「没有拿到下一个问题」(BUG-682)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
14 KiB
TASK · 播了交付结论却回到采集态:卡不出、题没有、只剩「没有拿到下一个问题」 — 2026-09-14
- 基线:
origin/staging@b50a8f94(staging 已部署该 SHA,含 BUG-673~679 的修复)。 - 分支:
codex/rectification-delivery-vs-collect-20260914,worktree.worktrees/rectification-delivery-vs-collect-20260914。 - 关联:BUG-674(同源:POST 与 GET 对同一状态判定不一致)、BUG-675、BUG-678、BUG-590、BUG-587。
- 串行:改
refresh-discriminator-probes.ts、rectification-decision.ts、rectification-candidate-result.ts、rectification-surface-state.ts。开工前git log --oneline origin/staging -20复核无同文件在途分支。
1. 事故实证(产品负责人 staging 真机,2026-09-14,部署 SHA b50a8f94)
用户看到的
跳过最后一道定向补事题(家人)之后,助手播出完整的交付结论:
再问下去也分不开 04:53 和 05:00,没有年份的分盘题不再问,所以这一轮不再往下问。
现在给的范围是 04:48 到 05:07,代表分钟为 04:53。采用后没有还能核对的前事,
之后新建对话即按此时间排盘,对不上可改选。
我按你说的经历认真分析过了,下面是这次的结果。
然后没有交付卡、没有下一题,界面只剩:
目前范围 04:48–05:07,再说一件带年月的事就能继续
没有拿到下一个问题。
快照实证(同一会话 GET,产品负责人提供,已脱敏)
"interview": {
"type": "ask_fact_collection", "session_outcome": "collect_evidence",
"can_offer_range": false, "can_adopt": false, "selection_allowed": false, "propose_allowed": false,
"precision_stage": "collect_events", "stop_reason": "tied_first",
"representative_time": "04:53", "credible_range": ["04:48","05:07"]
},
"current_question": null, "choice_card": null,
"next_user_action": { "id": "explain_current_window", … }
活跃候选 posterior:04:53 = 16、05:00 = 16、05:06 = 15、04:51 = 14、04:59 = 13(前两名完全并列,probability 也都是 0.2592219508007118)。
这组字段只能由一条分支产生
evaluateCandidateSeparation:lead = 16 − 16 = 0 → tiedForFirst = true、sufficient = false。
classifyStop(core/rectification-decision.ts:237):if (separation.tiedForFirst) return { kind: "exhausted", reason: "tied_first" }。
decideRectification 的 !separation.sufficient 分支:
if (stillNeedNarrowing(input)) {
return collect(separation, holdout, range, probe, waitToNarrowCapability(capability), stopReason);
}
if (stopClass?.kind === "exhausted") {
return completeWithRange(separation, holdout, range, "exhausted", capability, stopClass.reason);
}
collect() 给出 session_outcome: collect_evidence、precision_stage: collect_events、can_offer_range:false,waitToNarrowCapability() 把 canAdopt / selectionAllowed / proposeAllowed 全清零,stopReason 原样带出 tied_first —— 与快照逐字段吻合。结论:GET 侧 stillNeedNarrowing(input) === true,即 refreshExhausted === false || targetedCollectExhausted === false。
为什么 POST 那一轮却交付了
交付话术出自 persistNextInterviewIfIdle(answer-choice.ts:1645 一带,由 agent-run.ts:621 在本轮没有持久化问题时调用)。它的算法是:
let decision = decideFromDossier(dossier, …); // ① 第一次算:还要继续收窄
const refreshed = await refreshDatedDiscriminatorPoolIfNeeded({…});
if (refreshed.refreshed || refreshed.attemptRecorded) {
dossier = { …dossier, latestResult: { …latest, decisionReceipt: overlay.decisionReceipt ?? … } };
decision = decideFromDossier(dossier, …); // ② 第二次算:refreshExhausted 变真 → 交付
}
而 refreshDatedDiscriminatorPoolIfNeeded 在写不进库时仍然回报"已尝试":
// frontend/src/lib/rectification-agentic/v9/refresh-discriminator-probes.ts:568
await persistRefreshAttempt({ … }); // ← 返回值 boolean 被丢弃
return { dossier: …, state: attempted, refreshed: false, attemptRecorded: true };
persistRefreshAttempt 用 expectedRevision: previous.revision 写 inference state;本轮的选择题回答刚刚把 revision 推进过,这里很容易撞版本冲突 → catch → return false → 落库失败。但调用方不看返回值,attemptRecorded: true 照常让第 ② 次计算把 refreshExhausted 判为真 → stillNeedNarrowing 变假 → 走 completeWithRange("exhausted", tied_first) → deliveryNarrationAllowed 放行 → 播出交付结论。
下一次 GET 读的是库里的 receipt,没有这条 refresh attempt → refreshExhausted 回到 false → 又回到 collect()。同一状态,POST 说交付,GET 说继续收集。
第二层:就算 GET 判成交付,卡也不会出
// core/rectification-decision.ts:126
ADOPT_OUTCOMES = { adopt_representative, provisional_range_user_stopped, awaiting_confirmation, validated_range }
// rectification-candidate-result.ts:426
canShowRectificationSelectionCards = selectionAllowed && canAdopt
&& rectificationDecisionReceiptAllowsAdoption(receipt)
&& ADOPT_OUTCOMES.has(sessionOutcome);
// core/rectification-decision.ts:148
deliveryNarrationAllowed = publicCanAdopt(decision)
|| (selectionAllowed === true && nextAction === "offer_provisional_range");
completed_with_range 与 provisional_range —— 也就是"问不下去了,给你区间"这两种正常收尾 —— 不在 ADOPT_OUTCOMES 里,而话术闸门认这两种。区间交付卡只在 rectification-agentic-chat.tsx:1844 一处渲染,挂在 showSelectionCards 之下,没有第二条路径。所以只要 canAdopt 为假,就必然"有结论、没有卡"。
第三层:这个状态没有终态,兜底成一句报错
// rectification-surface-state.ts:381 interviewCollectWaiting
if (sessionOutcome === "collect_evidence" && !stopReason) return true;
return stopReason === "insufficient_dated_events" || "insufficient_domains" || "insufficient_events";
当前组合是「没有题 + collect_evidence + stopReason = tied_first」→ 三个条件都不满足 → 不是 collect_waiting、不是 persisted_question、不是 verified_idle → 掉到重试耗尽的 unavailable → 印出「没有拿到下一个问题」。
2. 根因
- BUG-680:刷新尝试落库失败仍按"已尝试"推进决策(
attemptRecorded: true无视persistRefreshAttempt的返回值),使 POST 与 GET 对同一状态给出相反结论;叠加stillNeedNarrowing排在stopClass === "exhausted"之前,"并列到顶(tied_first)"这个终局信号被"还有收窄手段没用完"无条件压过。 - BUG-681:交付话术闸门与交付卡闸门不是同一套,
completed_with_range/provisional_range能说不能画。 - BUG-682:问题缺口状态机没有"已交付 / 没有更多可问"的终态,兜底文案是「没有拿到下一个问题」这种报错口吻。
3. 决策记录
| 决策 | 内容 |
|---|---|
| D1 | 落库失败不得当成已尝试。 refreshDatedDiscriminatorPoolIfNeeded 的 attemptRecorded 必须等于 persistRefreshAttempt 的真实返回值;写失败时按"未尝试"继续(本轮不交付、继续给定向补事或诚实说明),不得靠内存态播交付。 |
| D2 | 并列到顶是终局,不被收窄手段压过。 decideRectification 的 !separation.sufficient 分支里,stopClass?.kind === "exhausted" && reason === "tied_first" 的判断提到 stillNeedNarrowing 之前:候选前两名分数完全并列时,再多的定向补事也分不开,应当直接交付区间。其余 exhausted 原因维持现有顺序(不扩大改动面)。 |
| D3 | 能说就能画。 把 completed_with_range 与 provisional_range 纳入交付卡可见集合(新增一个 DELIVERY_OUTCOMES,卡片闸门用它,ADOPT_OUTCOMES 仍只管"可采用"语义),使"播了交付话术"与"出了交付卡"永远同真同假。卡在这两种 outcome 下按只读区间卡呈现:可看候选、可选采用(若 selection_allowed),不得反过来把 can_adopt 提权。 |
| D4 | 补终态。 缺口状态机新增 delivered:current_question === null 且 outcome ∈ 交付集合(或 stop_reason 属于 tied_first / user_uncertainty_too_high 这类终局)时,显示区间与出口说明,禁止再出现「没有拿到下一个问题」。 |
| D5 | 不动数据库结构与迁移、不动 deploy/** 与 workflow、不动 page.tsx、不新增依赖。不得为了让状态"看起来对"而放宽 can_adopt / confirmation_allowed 或任何置信度边界(§8 红线)。 |
4. 硬红线
tsc --noEmit0 错;npm run lint0 error;npm test失败清单与基线逐条一致(本机基线 27 条,全是无 Docker / DB / rsync 缺口;工作树缺.venv软链会多一条 workflow YAML 假红);next build通过且/仍○ Static(Turbopack 拒软链node_modules);首屏 gzip ±2%。- 新增断言必须是行为断言,不得用
readFileSync+ 正则匹配源码。 - 不得新增第二套交付卡渲染路径。
- 交付话术与交付卡必须由同一个判据驱动;任何新增分支都要同时过这两处。
5. 任务分解
任务 1 · 刷新尝试落库失败不得当成已尝试(BUG-680,P0)
refresh-discriminator-probes.ts:568一带:接住persistRefreshAttempt的返回值,attemptRecorded用真实结果;失败时console.warn(JSON.stringify({ event: "rectification_refresh_attempt_persist_failed", case_id, candidate_set_id, answer_count }))。core/rectification-decision.ts:按 D2 把tied_first的终局判断提到stillNeedNarrowing之前。- 验收标准:
- 行为单测:
persistRefreshAttempt抛错(模拟版本冲突)时refreshDatedDiscriminatorPoolIfNeeded返回attemptRecorded: false,且随后的决策不是交付。 - 行为单测(回归核心):同一份 dossier + inference state(前两名 posterior 并列 16/16,
refresh_attempts为空),persistNextInterviewIfIdle的决策与decideFromDossier的决策必须一致——要么都交付,要么都继续收集。这条是本单的防复发锚点。 - 行为单测:并列到顶时
decideRectification返回completeWithRange,stop_reason = tied_first,can_offer_range = true。
- 行为单测:
任务 2 · 能说就能画(BUG-681,P0)
core/rectification-decision.ts:新增DELIVERY_OUTCOMES(=ADOPT_OUTCOMES∪{ completed_with_range, provisional_range }),导出。rectification-candidate-result.ts:426:canShowRectificationSelectionCards改用DELIVERY_OUTCOMES;canShowRectificationReadonlyRange相应排除交付 outcome(避免同屏既出卡又出"再说一件…")。- 验收标准:
- 行为单测:outcome =
completed_with_range、selection_allowed = true、can_adopt = false时,canShowRectificationSelectionCards为真,canShowRectificationReadonlyRange为假。 - 行为单测:
deliveryNarrationAllowed为真的每一种 outcome,canShowRectificationSelectionCards都为真(用表驱动遍历所有 outcome,锁死"同真同假")。
- 行为单测:outcome =
任务 3 · 补终态(BUG-682,P1)
rectification-surface-state.ts:rectificationQuestionGapState新增delivered分支(排在collectWaiting之前),条件:无题 + outcome ∈ 交付集合,或stop_reason ∈ { tied_first, user_uncertainty_too_high }。- 组件按该状态显示区间与出口说明(文案对照
frontend/docs/VOICE.md,不得出现「没有拿到下一个问题」「请稍候」)。 - 验收标准:行为单测——该组合下
rectificationQuestionGapState返回delivered,不是unavailable;普通"题没取到"仍返回unavailable(不得误伤修复入口)。
任务 4 · 记录
docs/BUG_HISTORY.md:BUG-680 / 681 / 682 三条resolved。BUG-680 的防复发必须写明:任何"是否已尝试/已耗尽"的标志,落库失败时一律按未完成处理;POST 与 GET 对同一状态的决策必须一致,并有一条对拍测试守着。CHANGELOG.md一行;PROGRESS-rectification-delivery-vs-collect-20260914.md;docs/testing/真机清单(要覆盖:并列到顶时给出区间交付卡;不再出现「没有拿到下一个问题」;同屏不再既出卡又出"再说一件带年月的事")。
6. 让步顺序
- 任务 1、任务 2 必做——缺任一条用户都会看到"有结论没有卡"。
- 任务 3 若文案需要产品确认,可先用既有区间文案,把待确认项写进进度记录,但不得保留「没有拿到下一个问题」。
- D2 的顺序调整若牵动其他用例,最小改动只对
tied_first生效,其余exhausted原因保持原样。
7. 开工前置命令
git fetch origin --prune
git worktree add -b codex/rectification-delivery-vs-collect-20260914 \
.worktrees/rectification-delivery-vs-collect-20260914 origin/staging
cd .worktrees/rectification-delivery-vs-collect-20260914
ln -s /workspace/Jyotisha/.venv .venv
cd frontend && npm ci
按 AGENTS.md §5,开工前用「tied_first」「没有拿到下一个问题」「交付卡」「refresh attempt」「stillNeedNarrowing」检索 docs/BUG_HISTORY.md,至少读完 BUG-674、BUG-678、BUG-590、BUG-587 四条。
8. BUG 编号起点
- 起点 BUG-680(当前最大号 679,开工时以
docs/BUG_HISTORY.md实际最大号为准)。