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

11 KiB
Raw Blame History

TASK · 校正会话流式时整条对话每帧重建,已结算消息每帧重跑 Markdown

  • 日期:2026-09-15
  • 基线 commitorigin/staging @ 6b3248bf
  • 执行分支:codex/rectification-settled-render-split-20260915
  • 独占文件:frontend/src/components/rectification-agentic-chat.tsx、新建的行组件文件、frontend/src/components/chat-message-row.tsxfrontend/tests/ 下新增用例
  • 与本日其它三单无文件重叠,可并行
  • 规模:抽一个行组件 + 把逐消息派生搬进去 + 稳住回调身份。不改任何交互行为、不改任何文案。

1. 事故实证

frontend/src/components/rectification-agentic-chat.tsx1 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

渲染出的 ChatMessageRowchat-message-row.tsx没有 memo,它内部的 ChatMessageContent已结算消息走的是 renderProse(spoken, renderMarkdown) 这条没有任何记忆化的分支(StableMarkdownPrefix 的 memo 只覆盖流式那一条)。ChatMessageActionsonFeedback / 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-249Home() 2 723 行、useCallbackuseMemo 各为 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.tsxRectificationMessageEntry),§1 列的那五项派生全部搬进组件内部计算,不再由容器每帧算好再传下去。

组件的 props 只允许:

  • message(已结算行在流式期间身份稳定)
  • 标量:busyreadonlyregeneratingcanRegenerateliveQuestion 所依赖的那几个 id 与布尔、choiceNoncesavedTimecopied
  • 该行自己的 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 已经在用的模式:把 submitChoicesubmitStopcopyMessageregenerateMessage、feedback 切换等放进一个 ref 容器,每帧只更新 ref 的 .current,组件内部通过 actionsRef.current.xxx(...) 调用。不要逐个加 useCallback 去追依赖数组——那条路在本文件的 state 规模下一定会漏。

  • 验收:源码合同断言新组件的 props 里没有任何函数类型字段(actionsRef 除外)。

5.3 给 ChatMessageRowmemo

chat-message-row.tsxChatMessageRow 目前是裸函数。加 memo 后咨询面同样受益。注意它的 afterAnswerReactNode prop——由 5.1 保证它在新组件内部构造,从而与该行自身的 props 同源。

  • 验收:咨询面既有测试(home-streaming-render-split.test.tschat-markdown-split.test.ts 等)全绿,无断言改动。

5.4 结算态 Markdown 也要记忆化

chat-message-content.tsxstreaming === 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. 开工前置命令

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

验收命令:

./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 是全站壳的问题,另议)
  • 校正面任何交互缺陷(本单零行为变化)