Files
Jyotisha/docs/tasks/TASK-rectification-explicit-open-20261023.md
T

135 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 清理时)。