Files
Jyotisha/docs/tasks/TASK-rectification-settled-render-split-20260915.md
T
Jesse_ChenandClaude Opus 5 e4f9f3ee0f docs(tasks): 校正链路审计四单(引擎记忆化 / 故障归因 / 渲染拆分 / 档案缓存)
只读审计 origin/staging @ 6b3248bf 后出的四份任务书:

- engine-memoization(BUG-721,纯 Python 可并行):一次重算 45% CPU 在重复
  算同一份 Shadbala,过境盘按候选算了 2196 次(应 36),鉴别探针算两遍,
  _cached_rows 是死代码。本机等价实验 3358 → 1604 ms,candidate_scores 与
  decision_receipt 逐字相同。只做记忆化,不改算法。
- failure-attribution(BUG-722/723/724,独占 route.ts):分类器失败被说成
  用户说不清且丢证据;引擎 429 被当成引擎坏、不重试不打日志(复发自
  BUG-715);attempt 210s × 2 > maxDuration 240s(复发自 BUG-059)。
- settled-render-split(BUG-725,前端可并行):校正会话流式每帧重渲整条
  对话并重跑已结算消息的 Markdown;BUG-473 的咨询面拆分没有跟过来。
- request-dossier-cache(BUG-726,串行在 failure-attribution 之后):一轮
  取 3.44 次整份 Case 档案,改成写即失效的请求作用域缓存,零调用点改动。

纯文档推送,不触发门禁、不发布镜像、不部署。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-15 17:13:52 +00:00

149 lines
11 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 · 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown
- 日期:2026-09-15
- 基线 commit`origin/staging` @ `6b3248bf`
- 执行分支:`codex/rectification-settled-render-split-20260915`
- 独占文件:`frontend/src/components/rectification-agentic-chat.tsx`、新建的行组件文件、`frontend/src/components/chat-message-row.tsx``frontend/tests/` 下新增用例
- 与本日其它三单**无文件重叠**,可并行
- 规模:抽一个行组件 + 把逐消息派生搬进去 + 稳住回调身份。**不改任何交互行为、不改任何文案。**
---
## 1. 事故实证
`frontend/src/components/rectification-agentic-chat.tsx`1 973 行,`useState` 14 个,`useCallback` 12 个,**`useMemo` 0 个,`memo` 0 个**。
消息列表是直接内联在组件体里的 `messages.map((message) => {...})`(在 `<div className="message-list">` 内),而且 map 体里每条消息都要做非平凡的派生:
- `{...message}` 展开出一个新的 `displayedMessage`
- `rectificationTimelineRows({ trace, receipt, activity, settled })` —— 每次都新建数组
- `vargaSentenceFromMethods(message.completedReceipt?.methods)`
- `choiceCardFromQuestion(question, ...)` —— 每次都新建对象
- 为带题的消息构造 `afterAnswer` 整棵 JSX(含 `RectificationChoiceCard`
渲染出的 `ChatMessageRow``chat-message-row.tsx`**没有 `memo`**,它内部的 `ChatMessageContent` 对**已结算**消息走的是 `renderProse(spoken, renderMarkdown)` 这条没有任何记忆化的分支(`StableMarkdownPrefix` 的 memo 只覆盖流式那一条)。`ChatMessageActions``onFeedback` / `onCopy` / `onRegenerate` 全是内联箭头函数,`submitChoice` / `submitStop` / `copyMessage` / `regenerateMessage` 也不在那 12 个 `useCallback` 里,每次渲染都是新身份。
流式输出这一侧是对的:`createStreamFrameBuffer` 已经把每个 `answer.delta` 合并成**每个动画帧最多提交一次**(BUG-473 的成果,该单影响面里就写着本文件)。但每一帧的那一次 `setMessages(current => current.map(...))` 会重渲整个列表 —— 于是每秒约 60 次,把整条会话里**每一条早已结算的消息**连同它的 Markdown 全部重算一遍。
校正会话天生就长:采集 + 探针 + 交付,二三十轮很常见。会话越长越卡,而且卡在最不该卡的时候——正在出结果的那一轮。
## 2. 根因
BUG-473 修的是「每个网络事件都提交一次」,它在本文件里落地了 `stream-frame-buffer`。但同一单在**咨询面**还做了第二件事:`chat-transcript.tsx` 把「已结算的历史」和「正在流的那一条」拆成两个 `memo` 组件(`SettledMessageList` + `HistoryMessageEntry`,以及 `LatestAssistantEntry`),历史整体只渲染一次。
**校正面只继承了前一半。** BUG-473 的两条防复发(「流式状态必须经 `stream-frame-buffer` 提交」「流式期间的 Markdown 渲染必须走前缀/尾块切分」)都只约束**正在流的那一行**,没有一条要求给**已结算的行**留记忆化边界,所以这半边缺失没有被任何断言拦住;`home-streaming-render-split.test.ts` 也只覆盖咨询面的那几个组件。
同类前史:BUG-249`Home()` 2 723 行、`useCallback``useMemo` 各为 0,每次击键重渲整个组件)。同一个形态,换了个组件。
## 3. 决策记录
产品 2026-09-15 授权本单,口径:
1. **纯性能重构,零行为变化。** 交付后校正会话的每一个可见行为——消息顺序、题目嵌入位置、选择卡可点性、跳过提示、交付卡出现时机、重新生成、复制、点赞点踩、停止——必须与改前逐字一致。本单不修任何已知交互缺陷,发现了写进进度记录。
2. **不改文案、不改动效、不改间距。** 因此本轮**不需要**更新 `frontend/DESIGN.md`(AGENTS §7.5 的触发条件是「改 UI」,纯记忆化不算)。若执行中确实动了任何可见样式,则必须同提交更新 `DESIGN.md`
3. **不启用 React Compiler。** BUG-260 的结论是它在本仓对目标组件静默放弃且无法证明生效;本单用显式 `memo` 解决,不碰 `next.config.ts`
4. **不做「把流式文本从 `messages` 里搬出去」的大改。** 咨询面走的是 `streamingText` 独立 state 的路子,校正面把流式文本写在 `messages` 里。改成前者是更彻底的方案,但会动到快照回填、重新生成、题目绑定等一大片逻辑,风险与本单不匹配。**逐行 `memo` 就够**`.map` 每帧产生新数组不影响 memo,因为 memo 比的是**每一行自己的 props**,而已结算消息的对象身份在流式期间是稳定的(每帧只有直播那一行的对象被替换)。
## 4. 硬红线
1. `frontend/src/app/page.tsx` 一行不许动(1951/2000AGENTS §6)。
2. 不得新写第二个聊天输入框、第二套滚动跟随、第二套加载动画(AGENTS §6)。滚动锚点仍是 `useConversationScrollAnchor` + `JumpToLatestButton`,本单不得改它们的调用方式。
3. 测试总数不得低于开工时 `origin/staging` 的实测;改任何既有断言必须写「原值 / 新值 / 原因」三栏(AGENTS §7.3)。
4. `next build``/` 仍须 `○ Static`,首屏 JS gzip 变化在 ±2 % 内(上次实测 584 413 B)。
5. 不得用 `React.lazy` + `Suspense` 承载 MarkdownBUG-250 的结论:promise 落定后仍需一次重渲,可能露出一帧 fallback)。
6. 不得顺手升级依赖、不得顺手修不在本单里的 warning。
## 5. 任务分解
### 5.1 抽出逐消息行组件并加 `memo`
`messages.map` 的整个 body 搬进一个新文件里的 `memo` 组件(建议 `rectification-message-entry.tsx``RectificationMessageEntry`),§1 列的那五项派生全部搬进组件内部计算,不再由容器每帧算好再传下去。
组件的 props 只允许:
- `message`(已结算行在流式期间身份稳定)
- 标量:`busy``readonly``regenerating``canRegenerate``liveQuestion` 所依赖的那几个 id 与布尔、`choiceNonce``savedTime``copied`
- 该行自己的 `feedback` 值(**不是整张 feedback map**
- 一个 `actionsRef`(见 5.2
不允许把每帧新建的对象/数组直接当 props 传下去(时间轴行、choice card、`afterAnswer` JSX 都属此列)。
- 验收:源码合同断言 `rectification-agentic-chat.tsx` 里不再直接调用 `rectificationTimelineRows` / `vargaSentenceFromMethods` / `choiceCardFromQuestion`
- 验收:源码合同断言新组件由 `memo(` 包裹。
### 5.2 用 `actionsRef` 稳住回调身份
照搬 `chat-transcript.tsx` 已经在用的模式:把 `submitChoice``submitStop``copyMessage``regenerateMessage`、feedback 切换等放进一个 ref 容器,每帧只更新 ref 的 `.current`,组件内部通过 `actionsRef.current.xxx(...)` 调用。不要逐个加 `useCallback` 去追依赖数组——那条路在本文件的 state 规模下一定会漏。
- 验收:源码合同断言新组件的 props 里没有任何函数类型字段(`actionsRef` 除外)。
### 5.3 给 `ChatMessageRow` 加 `memo`
`chat-message-row.tsx``ChatMessageRow` 目前是裸函数。加 `memo` 后咨询面同样受益。注意它的 `afterAnswer``ReactNode` prop——由 5.1 保证它在新组件内部构造,从而与该行自身的 props 同源。
- 验收:咨询面既有测试(`home-streaming-render-split.test.ts``chat-markdown-split.test.ts` 等)全绿,无断言改动。
### 5.4 结算态 Markdown 也要记忆化
`chat-message-content.tsx``streaming === false` 走的 `renderProse(spoken, renderMarkdown)` 没有任何缓存。给结算态也套一层按文本内容记忆的组件(`StableMarkdownPrefix` 已经是这个形状,可直接复用或抽成共用组件)。这一条独立于 5.1,即使行组件的 memo 因为某个 prop 抖动而失效,它也能兜住最贵的那部分。
- 验收:新增断言——同一段文本连续渲染两次,Markdown 解析器只被调用一次(用可计数的桩 renderer)。
### 5.5 渲染次数回归测试
`frontend/tests/home-streaming-render-split.test.ts` 的手法,为校正面补一份:复用 `src/lib/home-streaming-render-probe.ts` 的计数器(在新组件里调 `noteSettledRowRender()`),按帧手工驱动渲染,断言**已结算行的渲染次数不随流式帧数增长**。
必须照搬那份测试已经做对的两件事:
- 由测试手工驱动渲染次数,不假装 `renderToString` 能反映真实 memo 命中(本仓测试是字符串渲染,数不了重渲染次数——BUG-617 记录里已经写过这条);
- 同时跑一份「未拆分」对照,断言拆分后的计数**严格小于**对照,证明这个取证方法不是恒为真。
- 验收:新测试在改动前跑是红的、改动后是绿的(执行方需在进度记录里贴出这两次运行结果)。
### 5.6 Bug 历史
同一变更内写进 `docs/BUG_HISTORY.md`,预占 **BUG-725**。必须写明:**关联 BUG-473**(它的影响面包含本文件,但两条防复发都只约束正在流的那一行),以及 BUG-249(同形态前史)。防复发升级为:**任何流式会话面都必须为已结算消息保留记忆化边界,并由一条按帧驱动的渲染计数断言钉死;新增会话面必须同时补这条断言。**
## 6. 让步顺序
1. 5.4(结算态 Markdown 记忆化)最先做——最小、最独立,单独就能拿走最贵的一块。
2. 5.1 + 5.2 是主体,必须一起做(只加 `memo` 不稳住回调身份等于没加)。
3. 5.3 可以砍,砍了在进度记录里写明。
4. 5.5 **不得砍**——没有渲染计数断言的性能改动一律视为未通过(这正是本次缺口能存在这么久的原因)。
5. 5.6 不得砍。
## 7. 开工前置命令
```bash
git fetch origin --prune
git worktree add -b codex/rectification-settled-render-split-20260915 \
.worktrees/rectification-settled-render-split-20260915 origin/staging
cd .worktrees/rectification-settled-render-split-20260915/frontend
git status -sb | head -1
npm ci
```
验收命令:
```bash
./node_modules/.bin/tsc --noEmit
npm run lint # 0 error
npx tsx --test tests/rectification-*.test.ts tests/home-streaming-render-split.test.ts \
tests/chat-markdown-split.test.ts tests/stream-frame-buffer.test.ts
npx tsx --test tests/*.test.ts # 与基线逐条比对失败清单(无 Docker 时数据库套件照常红)
npm run build # `/` 仍须 ○ Static
```
## 8. 真人验收欠账
本仓没有浏览器环境,「长会话流式时是否还卡」只能由产品在真实环境确认。执行方必须在 `docs/testing/` 下留一份可照做的条目,至少包含:开一个已有二十轮以上的校正会话 → 发一条会触发长回答的消息 → 观察流式期间页面是否还有明显掉帧、历史消息是否闪烁。
## 9. BUG 编号起点
基线 `6b3248bf` 上最大号 **BUG-720**。本单预占 **BUG-725**。同日四单并行(721 / 722724 / 725 / 726),开工时核对实际最大号,冲突顺延并在进度记录写明。
## 10. 不在本单范围
- 把流式文本从 `messages` 搬进独立 state(§3.4 已否决,风险不匹配)
- React Compiler(§3.3
- 首屏包体积(584 KB gzip 是全站壳的问题,另议)
- 校正面任何交互缺陷(本单零行为变化)