Files
Jyotisha/docs/tasks/TASK-rectification-open-retitles-session-20260915.md
T
Jesse_ChenandClaude Fable 5 3e58e85f8f docs(tasks): P0 — tie-break entry destroys the delivery card; session list lies about rectification
两组缺陷,分两单。

会话列表单追加 BUG-700/701:全仓只有 append_consultation_question 这个
咨询 RPC 写 chat_sessions.updated_at,校正的 turn 走自己的表从不推进,于是
校正会话的 updated_at 冻结在创建时刻——连着几小时答题也不动位置。叠加
BUG-699 的改名,列表变成「名字是今天、排序是旧日期」。而 resolveBootstrap-
SessionSelection 的 ?c= 只比对当前已加载那一页(SESSION_PAGE_SIZE = 40),
翻不到就判 missing、打「该对话不存在或已被删除」并清掉 URL。查过了没有任何
删除路径被触发,会话大概率仍在库里。

新单 BUG-702~704:applyLiveCandidateOffer 遇到任何未答的 choice/collect_spoken
就把 candidateOffer 从每一条消息上剥掉,而 requestTieBreak 必然写入这样一道题
——点卡上的按钮必然丢卡。按钮亮不亮看 tieBreakPersonalityAvailable(只看
followup),题能不能画成选择题看 buildChoiceCard 的 styleOptions.ok /
canRenderYearlessChoice,两个判据不一致,于是按钮亮着、点开是裸题,卡又没了,
两条路都断。交付旁白还写了 VOICE 明令禁止的「相对支持度」,并在自称不再问
分盘题的同一屏提供分盘题入口。

产品重申 BUG-686 的原意:卡上不要参考题按钮,出卡前收集完,以卡结算。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155nFCgCHtoA7jhSDGmZmMu
2026-09-15 07:44:40 +00:00

19 KiB
Raw Blame History

TASK · P0:校正会话在列表里「名字是今天、位置是旧日期、刷新后报已删除」(BUG-699/700701

  • 日期:2026-09-15
  • 基线 commitorigin/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.tsopenRectificationCase,构造 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 已经查出来了,pinnedarchivedAt 也确实从它继承——titleupdatedAt 却没有。作者知道这条路径会跑在已存在的会话上,只是漏了两个字段。

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.tspersistSessionupdate 分支写的字段是:

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.mdBUG-553 | 历史列表只按置顶排,改名收藏后旧会话跳到最顶resolveda1956deb)。它的防复发写的是:

元数据 PATCH 不得写 updated_atupdatedAt 只由对话活动推进。 侧栏历史排序必须置顶 + updatedAt 倒序。

「打开一个历史会话」不是对话活动。BUG-553 修的是元数据 PATCH 与客户端改名/收藏/归档/换模型/换资料五条路径,没有覆盖校正的 open 路径,那条路径当时也已经存在。防复发是对的,但没有任何测试或类型约束去执行它,所以另一条路径照样违反。

BUG-553 也是「命名+排序」一起坏,用户这次的描述与当时几乎一字不差。


2.6 追加实证(2026-09-15 第二次回报):另外两条独立缺陷

用户随后回报:「刷新之后也恢复不到这个 session,返回『该对话不存在或已删除』,左边 session 列表也没有。」URL 是 /?c=6cf7653b-c33d-4f1d-b4c4-bb190eed03ff,会话标题是「9月15日 · 生时校正 15:19」(uniquifySessionTitle 追加了时分,说明当天已有同名标题——即 §2.1 的改名已经发生过不止一次)。

查证后没有找到任何删除路径grep -rn 'method: "DELETE"' frontend/src 里只有一处作用于会话,是 use-session-management.ts 的显式删除动作;服务端 DELETE /api/sessions/[id] 也只被它调用。会话大概率仍在库里。真正让它「消失」的是下面两条。

BUG-700|校正活动从不推进 chat_sessions.updated_at,会话边用边下沉

全仓只有一处写 chat_sessions.updated_at

frontend/supabase/migrations/20260901010000_append_consultation_question.sql

updated_at = clock_timestamp()

这是普通咨询的 RPC。校正的 turn 走 /api/rectification/cases/[caseId]/turns 与自己的表,不碰 chat_sessions.updated_atgrep -rn "chat_sessions" src/lib/rectification-agentic/v9/ 无命中)。

于是一条校正会话的 updated_at 冻结在创建时刻。用户连着几小时在里面答题,它在侧栏的位置纹丝不动。

和 §2.1 的改名叠加,结果是列表自相矛盾

  • 显示的名字:今天(被 open 改的)
  • 实际的排序与分组:创建那天(updated_at 从未推进)

「一大堆 15 号的记录但实际上都不是 15 号的」正是这个——名字全变成今天,位置还在各自的旧日期。

BUG-553 的防复发「updatedAt 只由对话活动推进」在这里被反向违反:校正答题是不折不扣的对话活动,却不推进。

BUG-701?c= 找不到就报「已被删除」,从不问服务端

frontend/src/lib/chat-session-url.tsresolveBootstrapSessionSelection

if (query.sessionId && input.listedIds.includes(query.sessionId)) {  }
return { sessionId: input.defaultSessionId, urlAction: "replace-clear", missing: true,  };

listedIds 只是当前已加载那一页的 id。missing: true 会让 page.tsx 打出 SESSION_MISSING_NOTICE"该对话不存在或已被删除"),并且把 URL 里的 ?c= 清掉。

而列表是游标分页的:frontend/src/lib/session-cursor.tsSESSION_PAGE_SIZE = 40GET /api/sessionsupdated_at desc, id desc 取 40 条(置顶另算)。被 BUG-700 压在旧日期上的校正会话,只要前面攒了 40 条更新的,首屏就加载不到它。

「不在已加载的这一页」被当成了「已被删除」,还顺手把用户唯一能回去的 URL 抹了。这是本次「找不到 session」最直接的原因,也是三条里最容易修的。

3. 决策记录(产品已授权)

  1. 打开已有会话不得改标题、不得改 updatedAt 只有新建才算首次命名。
  2. 已经被改坏的历史标题要修回,见任务 3。修不回真实日期的,宁可退回中性标题,也不要留一个错的日期。
  3. 本单只改校正 open 路径与随之而来的守卫,不重构会话列表。
  4. 校正答题必须推进 updated_at(BUG-700)。它和普通咨询一样是对话活动,BUG-553 的规则本来就该覆盖它。
  5. ?c= 指向的会话不在已加载页时,必须先问服务端再下结论(BUG-701)。确认服务端也没有,才允许说「已被删除」;在此之前不得清掉 URL 里的 ?c=

4. 硬红线

  1. 不得修改 sortSessions / groupSessionsByRecency / recencyKeyForfrontend/src/lib/session-groups.ts)。排序口径是对的,坏的是喂给它的数据。
  2. 不得让 persistSession 开始写 updated_at——那正是 BUG-553 修掉的东西。
  3. 不得改 use-consultation-run.ts 的三处标题守卫(§2.4,它们是对的)。
  4. tsc --noEmit 0 错;npm run lint 0 error
  5. 测试总数不得低于基线;改既有断言写「原值 / 新值 / 原因」三栏。
  6. 全量 npm test 的失败清单必须与基线 ff5023a2 逐条一致(Linux 上基线是 27 条环境失败)。
  7. 任务 3 的数据修补不得猜测日期,不得凭 created_at 以外的来源编造。

5. 任务分解

任务 1 · BUG-699:打开已有会话不再改名、不再 bump

1.1 frontend/src/hooks/use-rectification-surface.tsopenRectificationCasetitleupdatedAt 改为优先继承 existing,与 pinned / archivedAt 同一写法:

title: existing?.title ?? resolveSessionTitle("生时校正", undefined, {  }),
updatedAt: existing?.updatedAt ?? timestamp(),

1.2 existing 不存在时(真正的新建)才走 resolveSessionTitletimestamp(),行为不变。

1.3 顺带确认 messages: [] 这一行:merged 无条件把本地消息清空,随后才 hydrateRectificationCase。校正面自己从 turns 渲染,所以界面上可能看不出问题,但 persistSessionupdate 分支不写 messages,服务端不受影响。确认后在 PROGRESS 里写结论;若确实无害就保持原样,不要顺手改。

验收标准

  • 打开一条已有的历史校正会话:侧栏标题不变、分组不变、位置不变。
  • 新建校正:标题仍是「{今天} · 生时校正」,出现在「今天」最上面。
  • 从首页校正卡打开一个已存在的可续校正:同样不改名、不移位。

任务 2 · 把防复发从「文字约定」变成「测试」

BUG-553 的防复发只写在 Bug 记录里,没有任何东西执行它,所以又被另一条路径违反。本轮补上:

2.1 新增 frontend/tests/session-open-preserves-identity.test.ts(名字可调),至少断言:

  • openRectificationCaseexisting 命中时产出的会话,titleupdatedAt 全等于 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 resolveSessionTitleoptions.at 默认 new Date() 是墙钟。加一行注释说明:它只能用于新建;任何已有会话的重算都必须显式传入该会话的创建时间,否则日期会变成今天。

验收标准:新测试通过;把 1.1 的继承改回无条件重算,新测试必须变红(执行方本地验一次,结论写进 PROGRESS,不要把破坏提交上去)。

任务 3 · 修回已经被改坏的历史标题

3.1 先量损坏面。 写一个只读的核对脚本或 SQL(不改数据),统计 chat_sessionssession_type = 'birth_time_rectification'title 匹配 ^\d{1,2}月\d{1,2}日\s*·\s*生时校正(\s+\d{2}:\d{2})?$、但标题里的月日与 created_at 的本地月日不一致的行数。数字写进 PROGRESS。

3.2 修补规则,按可靠性排序,不得猜

  1. created_at 可用 → 标题重写为 created_at 本地日期的「M月D日 · 生时校正」。这是唯一真实来源。
  2. created_at 不可用 → 退回中性标题「生时校正」,不带日期。宁可没有日期,也不要错的日期。

3.3 形式:写成一次性迁移还是运维脚本,由执行方按 deploy/README.md 的既有做法定,但:

  • 必须幂等
  • 必须只动匹配上的行,不得批量覆盖。
  • 必须先在 staging 跑,把影响行数贴进 PROGRESS,再由产品决定要不要在生产跑(生产当前停在 7b620c7a,是否受影响需单独确认,见 §8)。
  • 用户自己手动改过的标题(不匹配 3.1 的正则)一律不动

验收标准staging 上跑完,3.1 的核对脚本返回 0 行;随机抽 3 条核对标题日期等于 created_at 本地日期。

任务 5 · BUG-700:校正活动推进 updated_at

5.1 校正写入 turn(以及采用、停止这类推进会话状态的动作)时,同步把该会话的 chat_sessions.updated_at 推到当前时间。

实现位置由执行方按既有架构定——优先复用 append_consultation_question 那种服务端 RPC 内部顺带更新的做法,不要让客户端 persistSession 开始写 updated_at(§4 红线 2)。

5.2 只有真正的对话活动才推进:写 turn、提交选择、采用、停止。打开、刷新、读取快照、改名、收藏、归档一律不推进BUG-553 的规则,继续有效)。

5.3 已经冻结的历史数据:校正会话的 updated_at 若明显早于其最后一条 turn 的时间,按最后一条 turn 的时间回填。与任务 3 同一次数据修补里做,同样要求幂等、只动匹配行、先在 staging 报影响行数。

验收标准

  • 在一条旧校正会话里答一道题,侧栏里它移动到「今天」最上面。
  • 打开同一条会话但不答题,位置不变。
  • 回填后,抽查 3 条校正会话的 updated_at 等于其最后一条 turn 的时间。

任务 6 · BUG-701?c= 找不到时先问服务端

6.1 resolveBootstrapSessionSelectionlistedIds 未命中时,不得直接判定 missing。改为返回一个「需要向服务端确认」的状态,由 page.tsxGET /api/sessions/{id} 单条查询:

  • 服务端有 → 把这条会话并进本地列表并选中它,URL 保持 ?c= 不变。
  • 服务端 404 → 这时才是真的没有,打 SESSION_MISSING_NOTICE、清 ?c=
  • 请求失败(网络 / 5xx)→ 既不选中也不宣告删除,保留 ?c= 并给可重试的提示。不得把一次网络抖动说成「已被删除」。

6.2 文案复核:SESSION_MISSING_NOTICE 现在是「该对话不存在或已被删除」。只有在服务端确实 404 时才允许出现这句。

6.3 顺带确认侧栏:会话确实存在但在后面的分页里时,侧栏的无限滚动(app-sidebar.tsxsession-list-sentinel + loadMoreSessions)能把它翻出来。若选中了一条不在已加载页的会话,侧栏应当能正确高亮它——查一下这条是否成立,结论写进 PROGRESS;不成立就单独记一条,不要在本单顺手改

验收标准

  • 构造一条排在第 40 条之后的会话,用它的 ?c= 直接打开:能正常进入,URL 不被清,不出现「已被删除」。
  • 服务端确实 404 的 id:仍然给出「该对话不存在或已被删除」并清 ?c=
  • 单条查询失败时:不宣告删除,?c= 保留。
  • 新增测试覆盖上面三条分支。

任务 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-700 / BUG-701,字段齐全。BUG-700 的「复发自」同样写 BUG-553——它的规则说 updatedAt 只由对话活动推进,而校正答题就是对话活动却没推进,是同一条规则的另一半没落实。BUG-701 写清「不在已加载页」被当成「已删除」的判定错误。「复发自」写 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. 让步顺序

  1. 任务 2.2 的跨文件契约若写不出稳定形式,退到 2.1 的单元测试并把缺口写进 PROGRESS。1.1 的修复不让。
  2. 任务 3 若 created_at 在相当比例的行上不可用,先只修 created_at 可用的部分,其余列成清单交回产品决定,不要擅自退回中性标题以外的任何猜测。
  3. 任务 1.3messages: [])若查出确有问题,不要在本单顺手修,另开一条,本单只记录。
  4. 绝不让步:不得改 sortSessions / 分组口径;不得让 persistSessionupdated_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. 需要产品确认 / 本单未覆盖

说明
生产是否受影响 生产停在 7b620c7a2026-08-16)。该提交里 use-rectification-surface 是否已有这段 merged 构造,需执行方 git show 7b620c7a:frontend/src/hooks/use-rectification-surface.ts 核对后写进 PROGRESS。若是,任务 3 的修补要不要在生产跑由产品定。
用户丢失的那条会话 服务端 updated_at 没坏,刷新后排序会回到真实位置;只是标题写着今天的日期。可以按位置(在「最近 7 天」里 12 号那一段)找回。
BUG 编号 BUG-698 由 TASK-settings-dialog-size-and-nav-20260915.md 占用。本单占 BUG-699 / 700 / 701TASK-rectification-tiebreak-card-loss-20260915.mdBUG-702704