[Bug]: Markdown cache still keyed by a row id that moves between hosts / Markdown 缓存仍以会随宿主迁移的 row id 为键
Verification baseline(复核基线)
- Release tag:
desktop-v1.38.6 = e2145b031deef603d9ec1acc9d30f9a1e1ac9325(2026-09-11)
- PR 分支基线:
upstream/main-v2 = fbc8a8f36(领先 v1.38.6 共 17 个提交)
- 复核方式:下方每一条 file:line 均以
git show desktop-v1.38.6:<path> 逐项核对过;对 main-v2 的差异另在本节末尾标注。
main-v2 相对 v1.38.6 的差异(已核查):transcriptStore.ts:212 在 main-v2 上引入了第三种 id 形态 ——
const id = m.messageId && (m.role === "assistant" || m.role === "user")
? `m:${m.messageId}`
: `he:${rec.entryId}`;
id 体系还在继续分化(v1.38.6 只有 he:,main-v2 又加了 m:),而 live 侧的 p3/c1/e12 与工具侧的 he:<entryId>:tc<N> 两版都存在。结论不受影响,反而更强:每一种新形态都是「又一个随上下文变化的 id」,而缓存键正挂在它们身上。
TL;DR(中文摘要)
问题:#9573 报的「缓存永不命中」被 #9565 以「统一缓存键」修掉了一半 —— 上游把键从 he: 前缀改为无条件的 cacheKey={item.id},消除了 h<seq> 与 he: 这一种具体错配,但没有改变 item.id 本身会随渲染宿主迁移而变化这件事。
根因:item.id 不是身份,而是「哪条渲染路径在读它」的函数。
| 来源 |
id 形态 |
位置 |
| live(控制器持有) |
单字母前缀 + 序号(u7 / p3 / c1 / s2 / e12) |
useController.ts:945 / :1853 / :1855 / :1870 / :1975 |
| history(store 投影) |
he:<entryId> |
transcriptStore.ts:221 |
| 工具调用 |
toolCallId 优先,否则 he:<entryId>:tc<N> |
transcriptStore.ts:201 / :310 |
上游代码自己就在按前缀区分这两类:
// Message.tsx:804
data-history-restore={item.id.startsWith("h") ? "" : undefined} data-entrance={item.id}
触发迁移的时机:切换会话 / 上滑翻页 / 应用重启 / 合并 recovery 副本(entryId 空间整体换掉) / remote-serve 水合 —— 任意一次之后,item.id 换掉,cacheKey 随之换掉,整条 transcript 的解析结果全部作废。
为什么现在提:hydrateHistoryApply.ts:227-234 专门写了「按签名匹配、保留 live id」的对账逻辑 —— 说明 id churn 上游已知,但缓解只做在了水合这一条路径上;切会话、翻页、重启、副本合并仍然换 id。
方案方向:缓存键挂在内容上,而不是挂在身份上。解析结果是源文本的纯函数(worker 唯一输入就是文本,revision 已是其 FNV-1a 指纹),所以 md:${revision} 就足以定位条目,且天然跨宿主、跨会话切换、跨副本合并命中。
优先级:P2(体验问题,不崩溃;但每次宿主迁移都代价一次全量重解析,长会话体感明显)。
Background
#9573 修掉了什么、没修掉什么
修掉了:h<seq> 行不再因为不是 he: 前缀而被 cachedBlocks 直接判为无 entryId。现在 Message.tsx:817 对每个 assistant 行无条件传 cacheKey={item.id},MarkdownHistory 用 cacheKey ?? entryId 解析键 —— 于是 h<seq> 行有键了,与 he: 行不再互斥。
没修掉:item.id 仍然不是稳定身份。它随「这条消息现在由哪个宿主渲染」而变:
- live 阶段(控制器持有、正在流式或刚完成):id 来自
useController 的 reducer,是短前缀 + 序号。
- history 阶段(保存后重新载入、翻页、重启、水合并入历史):id 来自
transcriptStore.convertRecord,是 he:<entryId>。
两者是不同的字符串。所以 cacheKey={item.id} 只是把「一种具体错配」换成了「一类结构性错配」:任何一次宿主迁移都会换键 → 全量 miss → 全量重解析。
Code path (file:line, desktop-v1.38.6)
| 环节 |
文件:行 |
说明 |
| id 被判定为「会变」 |
components/Message.tsx:804 |
data-history-restore={item.id.startsWith("h") ? "" : undefined} —— 上游自己用前缀区分 live / history |
| live id 生成 |
lib/useController.ts:945 / :1853 / :1855 / :1870 / :1975
|
id: \${idPrefix}${seq}`/p${s.seq}/c${s.seq}/s${s.seq}/e${s.seq}` |
| history id 生成 |
lib/transcriptStore.ts:221 |
const id = \he:${rec.entryId}`` |
| 工具 id 生成 |
lib/transcriptStore.ts:201 / :310
|
itemIdForToolCall(tc.id, \he:${rec.entryId}:tc${callIndex}`)→return tcId || fallback` |
| id churn 已知的旁证 |
lib/hydrateHistoryApply.ts:227-234 |
按 itemSignature 匹配后返回 liveItems 的 id,只为让水合不换 id |
| legacy id 已换过代 |
__tests__/transcript-rows.test.ts:438 |
eq(historyEntryIdForItemId("h3-2"), undefined, "legacy item ids carry no entry") |
| 解析值与文本一一对应 |
lib/transcriptSelectionText.ts:29 / :32
|
revision = contentRevision(sourceText);命中校验 cached.source === sourceText
|
| 只在非流式使用 |
components/Markdown.tsx:467 |
if (streaming || legacyMode) return committedView; |
Reproduction
- 打开一个较长的本地会话(history 行 id 为
he:<entryId>),滚到顶部让历史分页加载完 —— 此时 Markdown 已解析并进缓存。
- 切到另一个会话,再切回来。
- 观察:切换后整条 transcript 重新走 worker 解析(长会话上表现为可见的明文 fallback → 逐块渲染)。
更明显的一种:对某个有 recovery 副本的会话执行「合并副本 / 以副本为主线」后切回 —— entryId 空间整体换掉,he:<entryId> 全变,缓存必然全废。
(这一条对我们尤其重要:我们用 recovery 副本恢复历史,合并会整体换 id 空间。)
Proposed direction
键应当是内容的函数,而不是行身份的函数。
// lib/transcriptMarkdownCache.ts
private key(_entryId: string | undefined, revision: number): string {
return `md:${revision}`; // revision 已是 source text 的 FNV-1a 指纹
}
为什么这是本质解:
- worker 是纯函数 —— 唯一输入
sourceText,输出 blocks / selectionText / selectionRevision 全部只依赖它。
revision 已经足够定位条目 —— 它就是 contentRevision(sourceText)。
- 命中校验仍然存在 —— 读取时仍比对
cached.source === text,FNV-1a 碰撞或陈旧写入都会被挡下。
MarkdownHistory 只在非流式使用(Markdown.tsx:467),所以 revision 稳定,不存在"流式期间每帧换键"的问题。
- 相同文本共享一条是正确的:相同 markdown 必然解析相同。
随之可以去掉两道守卫 —— 它们存在的唯一理由就是"没有稳定 id 时跳过缓存":
MarkdownHistory.tsx 的 if (stableCacheKey && initial.result)
transcriptSelectionText.ts 的 entryId ? … : undefined
我们在 fork 里已按此实现并验证(Linearl/DeepSeek-Reasonix,提交 624c90442;fork 基线 desktop-v1.38.3,PR 已 rebase 到 upstream/main-v2 @ fbc8a8f36 / v1.38.6 e2145b031):改 3 个文件,tsc --noEmit 干净,npm run build 全 PASS,markdown-history.test.tsx 56 passed。愿意提 PR 到 upstream/main-v2。
如果维护者更倾向保留身份键,那也可以只做兜底:key = identity ?? md:${revision} —— 但那只恢复了命中率,没有修正"键曾经是身份"这个概念错误,且仍会经过一次"先问身份"的分支。我们建议直接改为内容键。
Impact / risk
- 影响面:任何宿主迁移(切会话 / 翻页 / 重启 / 副本合并 / 水合)后的首次渲染。长会话上是可感知的等待。
- 风险:低。缓存读取仍做
source 比对;LRU 字节预算与 pin 语义不变;entryId 参数保留在签名里(调用方无需改动)。
- 行为变化:内容相同的两条不同消息将共享一条缓存条目 —— 这是正确的,且降低内存占用。
[Bug]: Markdown cache still keyed by a row id that moves between hosts / Markdown 缓存仍以会随宿主迁移的 row id 为键
Verification baseline(复核基线)
desktop-v1.38.6=e2145b031deef603d9ec1acc9d30f9a1e1ac9325(2026-09-11)upstream/main-v2=fbc8a8f36(领先 v1.38.6 共 17 个提交)git show desktop-v1.38.6:<path>逐项核对过;对main-v2的差异另在本节末尾标注。main-v2相对 v1.38.6 的差异(已核查):transcriptStore.ts:212在main-v2上引入了第三种 id 形态 ——id 体系还在继续分化(v1.38.6 只有
he:,main-v2又加了m:),而 live 侧的p3/c1/e12与工具侧的he:<entryId>:tc<N>两版都存在。结论不受影响,反而更强:每一种新形态都是「又一个随上下文变化的 id」,而缓存键正挂在它们身上。TL;DR(中文摘要)
问题:#9573 报的「缓存永不命中」被 #9565 以「统一缓存键」修掉了一半 —— 上游把键从
he:前缀改为无条件的cacheKey={item.id},消除了h<seq>与he:这一种具体错配,但没有改变item.id本身会随渲染宿主迁移而变化这件事。根因:
item.id不是身份,而是「哪条渲染路径在读它」的函数。u7/p3/c1/s2/e12)useController.ts:945/:1853/:1855/:1870/:1975he:<entryId>transcriptStore.ts:221toolCallId优先,否则he:<entryId>:tc<N>transcriptStore.ts:201/:310上游代码自己就在按前缀区分这两类:
触发迁移的时机:切换会话 / 上滑翻页 / 应用重启 / 合并 recovery 副本(entryId 空间整体换掉) / remote-serve 水合 —— 任意一次之后,
item.id换掉,cacheKey随之换掉,整条 transcript 的解析结果全部作废。为什么现在提:
hydrateHistoryApply.ts:227-234专门写了「按签名匹配、保留 live id」的对账逻辑 —— 说明 id churn 上游已知,但缓解只做在了水合这一条路径上;切会话、翻页、重启、副本合并仍然换 id。方案方向:缓存键挂在内容上,而不是挂在身份上。解析结果是源文本的纯函数(worker 唯一输入就是文本,
revision已是其 FNV-1a 指纹),所以md:${revision}就足以定位条目,且天然跨宿主、跨会话切换、跨副本合并命中。优先级:P2(体验问题,不崩溃;但每次宿主迁移都代价一次全量重解析,长会话体感明显)。
Background
desktop-v1.38.6。a9031345f,多轮思考时间线与键体系重构)。components/MarkdownHistory.tsx、lib/transcriptMarkdownCache.ts、lib/transcriptStore.ts、lib/useController.ts、lib/transcriptSelectionText.ts、lib/hydrateHistoryApply.ts。#9573 修掉了什么、没修掉什么
修掉了:
h<seq>行不再因为不是he:前缀而被cachedBlocks直接判为无 entryId。现在Message.tsx:817对每个 assistant 行无条件传cacheKey={item.id},MarkdownHistory用cacheKey ?? entryId解析键 —— 于是h<seq>行有键了,与he:行不再互斥。没修掉:
item.id仍然不是稳定身份。它随「这条消息现在由哪个宿主渲染」而变:useController的 reducer,是短前缀 + 序号。transcriptStore.convertRecord,是he:<entryId>。两者是不同的字符串。所以
cacheKey={item.id}只是把「一种具体错配」换成了「一类结构性错配」:任何一次宿主迁移都会换键 → 全量 miss → 全量重解析。Code path (file:line,
desktop-v1.38.6)components/Message.tsx:804data-history-restore={item.id.startsWith("h") ? "" : undefined}—— 上游自己用前缀区分 live / historylib/useController.ts:945/:1853/:1855/:1870/:1975id: \${idPrefix}${seq}`/p${s.seq}/c${s.seq}/s${s.seq}/e${s.seq}`lib/transcriptStore.ts:221const id = \he:${rec.entryId}``lib/transcriptStore.ts:201/:310itemIdForToolCall(tc.id, \he:${rec.entryId}:tc${callIndex}`)→return tcId || fallback`lib/hydrateHistoryApply.ts:227-234itemSignature匹配后返回 liveItems 的 id,只为让水合不换 id__tests__/transcript-rows.test.ts:438eq(historyEntryIdForItemId("h3-2"), undefined, "legacy item ids carry no entry")lib/transcriptSelectionText.ts:29/:32revision = contentRevision(sourceText);命中校验cached.source === sourceTextcomponents/Markdown.tsx:467if (streaming || legacyMode) return committedView;Reproduction
he:<entryId>),滚到顶部让历史分页加载完 —— 此时 Markdown 已解析并进缓存。更明显的一种:对某个有 recovery 副本的会话执行「合并副本 / 以副本为主线」后切回 ——
entryId空间整体换掉,he:<entryId>全变,缓存必然全废。(这一条对我们尤其重要:我们用 recovery 副本恢复历史,合并会整体换 id 空间。)
Proposed direction
键应当是内容的函数,而不是行身份的函数。
为什么这是本质解:
sourceText,输出blocks/selectionText/selectionRevision全部只依赖它。revision已经足够定位条目 —— 它就是contentRevision(sourceText)。cached.source === text,FNV-1a 碰撞或陈旧写入都会被挡下。MarkdownHistory只在非流式使用(Markdown.tsx:467),所以 revision 稳定,不存在"流式期间每帧换键"的问题。随之可以去掉两道守卫 —— 它们存在的唯一理由就是"没有稳定 id 时跳过缓存":
MarkdownHistory.tsx的if (stableCacheKey && initial.result)transcriptSelectionText.ts的entryId ? … : undefined我们在 fork 里已按此实现并验证(
Linearl/DeepSeek-Reasonix,提交624c90442;fork 基线desktop-v1.38.3,PR 已 rebase 到upstream/main-v2@fbc8a8f36/ v1.38.6e2145b031):改 3 个文件,tsc --noEmit干净,npm run build全 PASS,markdown-history.test.tsx56 passed。愿意提 PR 到upstream/main-v2。如果维护者更倾向保留身份键,那也可以只做兜底:
key = identity ?? md:${revision}—— 但那只恢复了命中率,没有修正"键曾经是身份"这个概念错误,且仍会经过一次"先问身份"的分支。我们建议直接改为内容键。Impact / risk
source比对;LRU 字节预算与pin语义不变;entryId参数保留在签名里(调用方无需改动)。