openRectificationCase 构造 merged 时无条件 title: resolveSessionTitle(…) 与 updatedAt: timestamp()。同一个对象里 pinned 和 archivedAt 是从 existing 继承 的,说明作者知道这条路径会跑在已存在的会话上,只是漏了这两个字段。 resolveSessionTitle 的日期来自 new Date(),于是 9/12 建的会话被改名成 「9月15日 · 生时校正」,同名时再追加时:分。 两种损坏持久性不同,修复时要分开:persistSession 的 update 分支写 title 但不写 updated_at(BUG-553 的正确遗产),所以标题是永久写坏服务端,排序 只坏在客户端 state、刷新自行恢复。已坏的历史标题按 created_at 修回,取不到 就退回不带日期的中性标题,不猜。 复发自 BUG-553——它的防复发「updatedAt 只由对话活动推进」只是 Bug 记录里 的一句话,没有任何测试执行,且当时只覆盖元数据 PATCH 与五条客户端路径, 漏了当时就存在的校正 open 路径。本单要求把那句话变成测试。 咨询侧三处 resolveSessionTitle 都有 messages.length === 0 && 通用标题的守卫, 不受影响,明令不得顺手改。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
13 KiB
TASK · P0:打开历史校正会话会把它改名成今天、并顶到列表最前(BUG-699,复发自 BUG-553)
- 日期:2026-09-15
- 基线 commit:
origin/staging@ff5023a2(= staging 当前部署) - 执行分支:
codex/rectification-open-retitles-session-20260915 - 工作树:
.worktrees/rectification-open-retitles-session-20260915 - 严重度:P0。用户已经因此找不到自己的会话,且标题损坏是写进服务端、不可自动恢复的。
1. 用户现象(原话)
我现在根本找不到那条 session 了。这个历史对话列表的排序和命名都有问题。之前的比如 12 号的我刚点开,里面就成 15 号的了。然后一大堆 15 号的记录,但实际上都不是 15 号的。
2. 事故实证
行号会漂移,定位以符号名为准。核对于 origin/staging@ff5023a2。
2.1 根因(单点,已定位)
frontend/src/hooks/use-rectification-surface.ts → openRectificationCase,构造 merged 的那一段:
const existing = sessions.find((session) => session.id === opened.sessionId);
const merged: ChatSession = {
id: opened.sessionId,
title: resolveSessionTitle("生时校正", undefined, { // ← 无条件重算
entrypoint: "birth_time_rectification",
existingTitles: sessions.map((session) => session.title),
}),
…
updatedAt: timestamp(), // ← 无条件盖成此刻
pinned: existing?.pinned ?? false, // ← 这两个保留了
archivedAt: existing?.archivedAt ?? null, // ←
…
};
setSessions((current) => [merged, ...current.filter((s) => s.id !== merged.id)]);
void persistSession(merged).catch(() => {}); // ← 写回服务端
existing 已经查出来了,pinned 和 archivedAt 也确实从它继承——title 和 updatedAt 却没有。作者知道这条路径会跑在已存在的会话上,只是漏了两个字段。
而 resolveSessionTitle 的日期来自墙钟:
frontend/src/lib/agent-reply.ts
const at = options.at ?? new Date();
…
return uniquifySessionTitle(datedSessionTitle(at, "生时校正"), existingTitles, at);
// datedSessionTitle: `${at.getMonth() + 1}月${at.getDate()}日 · ${suffix}`
所以打开一条 9 月 12 日建的校正会话,标题被算成「9 月 15 日 · 生时校正」。若当天已经有同名的,uniquifySessionTitle 再追加 时:分,于是列表里出现一堆「9月15日 · 生时校正」「9月15日 · 生时校正 14:32」……
2.2 两种损坏的持久性不同(修复时必须分开对待)
frontend/src/hooks/use-session-management.ts → persistSession 的 update 分支写的字段是:
title, theme, model_id, chart_profile_id, chart_profile_name, chart_profile_role
不含 updated_at(这是 BUG-553 的修复结果,正确,不要动)。因此:
| 损坏 | 范围 | 刷新后 |
|---|---|---|
| 标题被改成今天 | 写进服务端 chat_sessions.title |
不恢复。永久损坏。 |
| 排到列表最前 / 落进「今天」分组 | 只改客户端 sessions state 的 updatedAt |
自行恢复(服务端 updated_at 未被写坏) |
这条区分是本单最重要的事实:排序是暂时的,命名是永久的。 已经被改坏的历史标题需要一次数据修补,见任务 3。
2.3 入口覆盖面
openRectificationCase 有三个调用方,三个都会触发:
openRectificationSession(exactSessionId)—— 点侧栏历史校正会话,就是用户踩到的那条openRectificationFromHomepage()—— 首页校正卡- 第三个
"new"入口 —— 新建
只有 "new" 该重算标题与 updatedAt;另外两个作用在已存在的会话上。
2.4 普通咨询会话不受影响(不要顺手改)
use-consultation-run.ts 的三处 resolveSessionTitle 都有守卫:
- 两处是
session.messages.length === 0 && isGenericSessionTitle(session.title)—— 只对全新空会话生效。 - 一处只在模型返回了非通用标题时调用,走的是
clipTitle(modelTitle)分支,根本到不了带日期的分支。
本单只改校正这一条路径。
2.5 这是 BUG-553 的复发
docs/BUG_HISTORY.md → BUG-553 | 历史列表只按置顶排,改名收藏后旧会话跳到最顶(resolved,a1956deb)。它的防复发写的是:
元数据 PATCH 不得写
updated_at。updatedAt只由对话活动推进。 侧栏历史排序必须置顶 +updatedAt倒序。
「打开一个历史会话」不是对话活动。BUG-553 修的是元数据 PATCH 与客户端改名/收藏/归档/换模型/换资料五条路径,没有覆盖校正的 open 路径,那条路径当时也已经存在。防复发是对的,但没有任何测试或类型约束去执行它,所以另一条路径照样违反。
BUG-553 也是「命名+排序」一起坏,用户这次的描述与当时几乎一字不差。
3. 决策记录(产品已授权)
- 打开已有会话不得改标题、不得改
updatedAt。 只有新建才算首次命名。 - 已经被改坏的历史标题要修回,见任务 3。修不回真实日期的,宁可退回中性标题,也不要留一个错的日期。
- 本单只改校正 open 路径与随之而来的守卫,不重构会话列表。
4. 硬红线
- 不得修改
sortSessions/groupSessionsByRecency/recencyKeyFor(frontend/src/lib/session-groups.ts)。排序口径是对的,坏的是喂给它的数据。 - 不得让
persistSession开始写updated_at——那正是 BUG-553 修掉的东西。 - 不得改
use-consultation-run.ts的三处标题守卫(§2.4,它们是对的)。 tsc --noEmit0 错;npm run lint0 error。- 测试总数不得低于基线;改既有断言写「原值 / 新值 / 原因」三栏。
- 全量
npm test的失败清单必须与基线ff5023a2逐条一致(Linux 上基线是 27 条环境失败)。 - 任务 3 的数据修补不得猜测日期,不得凭
created_at以外的来源编造。
5. 任务分解
任务 1 · BUG-699:打开已有会话不再改名、不再 bump
1.1 frontend/src/hooks/use-rectification-surface.ts → openRectificationCase:title 与 updatedAt 改为优先继承 existing,与 pinned / archivedAt 同一写法:
title: existing?.title ?? resolveSessionTitle("生时校正", undefined, { … }),
updatedAt: existing?.updatedAt ?? timestamp(),
1.2 existing 不存在时(真正的新建)才走 resolveSessionTitle 与 timestamp(),行为不变。
1.3 顺带确认 messages: [] 这一行:merged 无条件把本地消息清空,随后才 hydrateRectificationCase。校正面自己从 turns 渲染,所以界面上可能看不出问题,但 persistSession 的 update 分支不写 messages,服务端不受影响。确认后在 PROGRESS 里写结论;若确实无害就保持原样,不要顺手改。
验收标准
- 打开一条已有的历史校正会话:侧栏标题不变、分组不变、位置不变。
- 新建校正:标题仍是「{今天} · 生时校正」,出现在「今天」最上面。
- 从首页校正卡打开一个已存在的可续校正:同样不改名、不移位。
任务 2 · 把防复发从「文字约定」变成「测试」
BUG-553 的防复发只写在 Bug 记录里,没有任何东西执行它,所以又被另一条路径违反。本轮补上:
2.1 新增 frontend/tests/session-open-preserves-identity.test.ts(名字可调),至少断言:
openRectificationCase在existing命中时产出的会话,title与updatedAt全等于existing的值。existing未命中时才调用resolveSessionTitle。
2.2 新增一条跨文件的源码契约,锁住 BUG-553 的那句防复发:frontend/src/hooks/ 与 frontend/src/lib/ 里,任何构造 ChatSession 且可能作用于已有会话的地方,不得无条件写 updatedAt: timestamp()。
具体形式执行方定(可以是"timestamp() 出现在 ChatSession 字面量里时,同一字面量必须出现 existing?.updatedAt ?? 或注释豁免标记"这类文本断言)。要求是:下一个人再写一条这样的路径时会被测试拦下,而不是又只留一句 Bug 记录里的话。若实在写不出稳定的文本契约,退到 2.1 并在 PROGRESS 说明缺口。
2.3 resolveSessionTitle 的 options.at 默认 new Date() 是墙钟。加一行注释说明:它只能用于新建;任何已有会话的重算都必须显式传入该会话的创建时间,否则日期会变成今天。
验收标准:新测试通过;把 1.1 的继承改回无条件重算,新测试必须变红(执行方本地验一次,结论写进 PROGRESS,不要把破坏提交上去)。
任务 3 · 修回已经被改坏的历史标题
3.1 先量损坏面。 写一个只读的核对脚本或 SQL(不改数据),统计 chat_sessions 里 session_type = 'birth_time_rectification' 且 title 匹配 ^\d{1,2}月\d{1,2}日\s*·\s*生时校正(\s+\d{2}:\d{2})?$、但标题里的月日与 created_at 的本地月日不一致的行数。数字写进 PROGRESS。
3.2 修补规则,按可靠性排序,不得猜:
created_at可用 → 标题重写为created_at本地日期的「M月D日 · 生时校正」。这是唯一真实来源。created_at不可用 → 退回中性标题「生时校正」,不带日期。宁可没有日期,也不要错的日期。
3.3 形式:写成一次性迁移还是运维脚本,由执行方按 deploy/README.md 的既有做法定,但:
- 必须幂等。
- 必须只动匹配上的行,不得批量覆盖。
- 必须先在 staging 跑,把影响行数贴进 PROGRESS,再由产品决定要不要在生产跑(生产当前停在
7b620c7a,是否受影响需单独确认,见 §8)。 - 用户自己手动改过的标题(不匹配 3.1 的正则)一律不动。
验收标准:staging 上跑完,3.1 的核对脚本返回 0 行;随机抽 3 条核对标题日期等于 created_at 本地日期。
任务 4 · 测试与文档
4.1 基线:
cd .worktrees/rectification-open-retitles-session-20260915/frontend
npm ci
npm test 2>&1 | tail -20
4.2 文档:
docs/BUG_HISTORY.md新增 BUG-699,字段齐全。「复发自」写BUG-553,并在根因里写明 BUG-553 的防复发为何没拦住——它只是一句话,没有任何测试执行它,且当时只覆盖了元数据 PATCH 与五条客户端路径,漏了校正 open。回到 BUG-553 记录的「防复发」末尾补一句指向 BUG-699 与新测试。frontend/DESIGN.md:在会话列表/Copy 一节补一句——会话标题在创建时定名,打开已有会话不得改名;updatedAt只由对话活动推进,并指向新测试。CHANGELOG.md:一条日期 + 一句话标题 + 要点(含"历史校正标题已修回")。docs/testing/rectification-open-identity-20260915.md:真机/浏览器清单——打开 3 条不同日期的历史校正,确认标题、分组、位置都不变;新建一条确认仍按今天命名。docs/tasks/PROGRESS-rectification-open-retitles-session-20260915.md。
6. 让步顺序
- 任务 2.2 的跨文件契约若写不出稳定形式,退到 2.1 的单元测试并把缺口写进 PROGRESS。1.1 的修复不让。
- 任务 3 若
created_at在相当比例的行上不可用,先只修created_at可用的部分,其余列成清单交回产品决定,不要擅自退回中性标题以外的任何猜测。 - 任务 1.3(
messages: [])若查出确有问题,不要在本单顺手修,另开一条,本单只记录。 - 绝不让步:不得改
sortSessions/ 分组口径;不得让persistSession写updated_at;不得改咨询侧三处标题守卫;数据修补必须幂等且不猜日期。
7. 开工前置命令
cd /workspace/Jyotisha
git status -sb | head -1
git fetch origin --prune
git worktree add -b codex/rectification-open-retitles-session-20260915 \
.worktrees/rectification-open-retitles-session-20260915 origin/staging
cd .worktrees/rectification-open-retitles-session-20260915/frontend
npm ci
npm test 2>&1 | tail -20
./node_modules/.bin/tsc --noEmit
npm run lint
交付:git push origin HEAD:staging,推完核对远端 SHA。
8. 需要产品确认 / 本单未覆盖
| 项 | 说明 |
|---|---|
| 生产是否受影响 | 生产停在 7b620c7a(2026-08-16)。该提交里 use-rectification-surface 是否已有这段 merged 构造,需执行方 git show 7b620c7a:frontend/src/hooks/use-rectification-surface.ts 核对后写进 PROGRESS。若是,任务 3 的修补要不要在生产跑由产品定。 |
| 用户丢失的那条会话 | 服务端 updated_at 没坏,刷新后排序会回到真实位置;只是标题写着今天的日期。可以按位置(在「最近 7 天」里 12 号那一段)找回。 |
| BUG-698 | 已由 TASK-settings-dialog-size-and-nav-20260915.md 占用,本单从 BUG-699 起。 |