# TASK · 说「帮我做生时纠正」只回一句「可在历史对话里接着做」(issue #103,2026-10-23) ## 基线 - 写本单时 `origin/staging` 为 `dd5ae8d03`。开工时以当时的 `origin/staging` 为准。 - 执行方:Grok(Windows + Docker),分支 `codex/rectification-explicit-open-20261023`,只推这个分支。 - BUG 编号:开工时用 `grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1` 核对,从下一个号起。写本单时的最大号是 BUG-1420。 - **本单不需要动表**(理由见决策记录 D3)。 ## 事故实证 **现象**:产品负责人在 staging 对话里说「请你帮我做一下生时纠正」,只收到一句「可在历史对话里接着做。」,没有卡片、链接或按钮,校正进不去。该账户的出生时间显示「已采用」。 **链路**(文件、行号以开工时为准,按符号定位): 1. **识别**:模型调用 `offer-birth-time-rectification` 并传 `explicitRequest: true`(`frontend/src/mastra/consultation-tools.ts`)。 2. **判定**:`decideRectificationOffer`(`src/lib/rectification-in-chat-server.ts`)读取 `getRectificationEntrySummary(...).hasResumableCase`,再进入 `decideRectificationOfferWith`(`src/lib/rectification-in-chat.ts`),得到 `case_in_progress`。 3. **「可续做」的状态集合**:`RESUMABLE_CASE_STATUSES`(`src/lib/contracts/rectification-entry.ts`)与 SQL 函数 `agentic_rectification_resumable_statuses()`(`20260812010000_agentic_rectification_v9_runtime.sql`)都包含 `candidate_accepted`。 - 但确认门实际上永远不开(Bug 历史:`confirmation_allowed=false`,「本会话以代表性时间收口」),`candidate_accepted` 在现实里就是终点。 - 结果:**每个采用过时间的用户都会被永久判为「有进行中的校正」**。明说要校正进不去;普通对话里的校正提议卡也再不会出现。 4. **找那次校正的会话**:`finishExplicitRectificationRequest`(`consultation-tools.ts`)调用 `loadResumableSessionId`,实现在 `src/app/api/consult/route.ts`。它直接 `from("agentic_rectification_cases").select("session_id")` 读表。 - 而 `20260814010000_immutable_skill_registry.sql` 写了 `revoke all on table public.agentic_rectification_cases from service_role;`,之后没有任何迁移把权限授回。`20260904010000_read_report_candidate_range.sql` 的注释也写明:直接读表的权限保持撤销,只能走 RPC。 - 自托管环境下,admin 客户端以 `set local role service_role` 运行,所以这次查询**必然**报 permission denied。`if (error || !data) return null` 把错误吞掉,也没有日志。 - **结果:即使是真正没做完的校正,也永远续不上,必然落到那一句话。** - 单测没拦住的原因:测试里的 `loadResumableSessionId` 是注入的假函数,从来没有碰过真库。 5. **兜底**:拿到 null 后写成 `kind: "refused"` + `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`。`stream-agent-response.ts` 只在 `new` / `resume` 时下发 `rectificationDirectOpen`,所以前端只剩一句话。 ## 根因 1. **找会话的方式错了**:绕开 RPC 直接读一张对服务角色撤销了权限的表,失败又被静默吞掉。 2. **「进行中」的口径错了**:把一个实际上的终点状态(`candidate_accepted`)算作「进行中」,这个口径同时挡住了明说入口和提议卡。 3. **兜底设计错了**:失败时只给一句话,没有任何能操作的出口。 ## 决策记录 | # | 决定 | 来源 | | --- | --- | --- | | D1 | **已采用(`candidate_accepted`)的校正不算「进行中」**。用户明说要校正时,直接开一次新的;普通对话里的提议卡也按同一口径恢复出现 | 产品 2026-10-22 选 C | | D2 | 删掉「可在历史对话里接着做」这个兜底:**说了要校正,就一定打开一个能操作的校正** | 产品 2026-10-22 | | D3 | **只改「入口判定」的口径,不改共享的 `RESUMABLE_CASE_STATUSES` 和 SQL 函数**。理由是那个集合还被采用后补证据、重新出卡、暂停恢复、Skill 升级(不在集合内会被当成 `case_terminal`)、改资料触发器(置 `needs_rebaseline`)、以 `session` 打开时的 resumed / readonly 判定共同使用;从里面删掉 `candidate_accepted` 会让旧会话里的采用后核对题变成只读、Skill 升级报错。按 AGENTS.md §5「修在根因上」,根因是入口口径,不是状态机。因此本单**不动表** | Claude 规划 | | D4 | 真正没做完的校正(`draft` / `collecting_evidence` / `candidate_ready` / `needs_rebaseline` / `paused`)仍然续做,但改为**经 RPC** 拿到会话号(现成的 `getRectificationCase(accounting, userId, caseId)`,`src/lib/rectification-agentic/v9/case-service.ts`,返回值带 `sessionId`)。RPC 失败时打日志,并改为直接新开一次。数据库早已允许同一用户并行多个校正(BUG-177 删了唯一索引,`rectification-v9-database.test.ts` 已验证) | Claude 规划 | **推翻的旧决策**: - BUG-1306 修复段原话「进行中的 case 不新开:查到 `agentic_rectification_cases.session_id` 就打开那次,查不到就说可以在历史对话里接着做」:本单推翻「查不到就说去历史接着做」,并改为经 RPC 查找。 - BUG-1306 10-12 决策日志「服务端本人 / 开关 / 进行中 case / 价格判定不变」中的「进行中 case」:口径改为不含 `candidate_accepted`。 - BUG-1248 提议卡条件「无进行中 case」:沿用,但口径同上。 - 与 BUG-177「不得用存在 resumable Case 阻止新 Case」一致,本单是在对话入口上重申这一点。 ## 硬红线 1. 不改 `RESUMABLE_CASE_STATUSES`、`agentic_rectification_resumable_statuses()` 及其它 SQL;不新增迁移。 2. 非本人、开关关闭、没有价格这三种拒绝,行为不变。 3. 扣点规则不变:开始答第一题时才扣。 4. 日志不得含任何个人资料,只记 `requestId`、错误类别和 caseId 是否存在。 5. 旧会话里已采用的那次校正,采用后核对题在**新校正还没采用前**必须照常能答。新校正采用了不同的时间之后,旧那次的核对题不得报错或弹出错误提示,按只读处理即可(`segmentSelection` 为 null 时的界面状态)。 6. 改既有断言必须写「原值 / 新值 / 原因」三栏。按 AGENTS.md §7 第 8 条,先 grep 源码文本合同测试,同一轮里跟着改。 ## 任务 ### T1 入口口径:已采用不算进行中 - 新增一个只给入口用的判定,名称执行方定,例如 `blocksNewRectification(status)`:命中 D4 列的五种状态返回 true,`candidate_accepted` 返回 false。 - `getRectificationEntrySummary` 返回的 `latestResumable.status` 如果是 `candidate_accepted`,入口就视为「没有进行中的校正」。 - `decideRectificationOffer`(明说入口和普通提议卡共用)改用这个判定。 - 已知边界:summary 只返回最近活动的一次可续做校正。如果最近一次是已采用的、更早还有一次没做完的,本单按「没有进行中的」处理,直接新开。数据库允许并行,用户在历史对话里仍能找到那次。在进度记录里写明这一点。 **验收** - 单测:最近一次是 `candidate_accepted` → 明说要校正得到 `new`,提议卡可以出现;最近一次是 `collecting_evidence` → 得到 `resume`。 ### T2 经 RPC 找会话,删掉兜底 - `loadResumableSessionId` 改为调用 `getRectificationCase(accounting, userId, caseId)`,取 `sessionId`。不再直接读表。 - `finishExplicitRectificationRequest` 的分支: - 有进行中的校正且拿到 `sessionId` → `kind: "resume"`; - 有进行中的校正,但 RPC 失败或拿不到 `sessionId` → 打日志(红线 4),然后 `kind: "new"`; - 没有进行中的校正 → `kind: "new"`。 - 删除常量 `RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY`,以及 `consultation-tools.ts` 里给模型的「去历史对话接着做」说明;同步删掉 `frontend/docs/VOICE.md` 里的对应口径。 - 清理 `case-status.ts` 里已经过期的 `MAX_RESUMABLE_CASES_PER_USER = 1`:注释说它「镜像 DB 部分唯一索引」,但索引已被 BUG-177 删除。先 grep 引用;删除前确认没有业务逻辑依赖,测试里的断言按三栏改。 **验收** - 单测:RPC 抛错 → `new`,并且有一条日志调用(不含个人资料)。 - **真库测试**(`test:db`):用服务角色调 `loadResumableSessionId` 的新实现,能拿到一个进行中校正的 `sessionId`。旧实现在同样条件下会 permission denied,这条测试就是当初缺的防线。 ### T3 旧会话不报错 - 准备一个已采用的旧校正,再开一个新校正,并让新校正采用不同的时间。然后打开旧会话: - 采用后核对题显示为只读; - 不弹错误; - 控制台没有未处理的异常。 - 用交互测试或组件测试覆盖。 ### T4 文档 - `docs/BUG_HISTORY.md` 新增一条:关联 BUG-1306、BUG-1248、BUG-177。根因写三条(读表权限、口径、兜底);防复发写 T2 的真库测试和 T1 的口径单测。 - `CHANGELOG.md` 写一行用户可感知的变化。 - `docs/testing/rectification-explicit-open-20261023.md` 真机清单: 1. 已采用过时间的账户,在对话里说「帮我做生时纠正」,应直接进入新的校正; 2. 一个答到一半的校正,从对话里说要校正,应接回那次; 3. 普通对话问到时机类问题时,提议卡会出现(已采用的账户)。 ## 让步顺序 1. 先砍 T3 的自动化测试,改为写进真机清单。 2. T1、T2、真库测试都不可让。 ## 开工前置命令 ```bash git fetch origin --prune git worktree add -b codex/rectification-explicit-open-20261023 .worktrees/rectification-explicit-open-20261023 origin/staging cd .worktrees/rectification-explicit-open-20261023 && git status -sb grep -oE "BUG-[0-9]+" docs/BUG_HISTORY.md | sort -t- -k2 -n | tail -1 cd frontend && npm ci && ./node_modules/.bin/tsc --noEmit && npm run lint ./node_modules/.bin/tsx --test tests/rectification-explicit-entry-20261012.test.ts tests/rectification-in-chat-20261007.test.ts tests/rectification-v9-contracts.test.ts # 记基线 ``` ## 验收分工 | 谁 | 跑什么 | | --- | --- | | Grok 自跑 | `tsc --noEmit`;`npm run lint`;上面三个测试文件加新增测试;`npm run test:db`,与 `tools/agent-loop/windows-known-failures.txt` 逐条比对;`npm run build` | | Claude 验收时跑 | 前端全量 `npm test`,与基线逐条对比;复核没有新迁移、`RESUMABLE_CASE_STATUSES` 和 SQL 函数未改;复核日志不含个人资料 | | 环境缺口 | 真实模型会不会在用户明说时调用工具,Claude 这边没有模型凭据,留给真机清单第 1 步 | 现有会受影响的断言(改时按三栏写): - `rectification-explicit-entry-20261012.test.ts` - 断言 `RESUME_ELSEWHERE_COPY` 原文(约第 129–132 行); - 「an in-progress case resumes that session, or says to continue in history」(约第 243 行),其中 loader 返回 null 或抛错时期望 `refused`; - 普通调用 note 匹配 `/历史对话/`; - 约第 695–698 行的源码文本合同(`openRectificationFromChat(...)` 恰好出现 1 次)。 - `rectification-in-chat-20261007.test.ts`:`hasResumableCase` 为 yes 时期望 `case_in_progress`(约第 107–115 行),需要补「最近一次是已采用」的分支。 - `rectification-v9-contracts.test.ts`:`MAX_RESUMABLE_CASES_PER_USER === 1`(T2 清理时)。