# Proposal M12: renderer 状态层审查(IPC 契约化后做) > 来源:问题.md M12(行 290-294)、附录 D(行 403-408) > 状态:proposed > 提出:Roundtable 2026-04-20(Linus + Karpathy + Steve Jobs) > Owner: TBD > 估计工时:3-4 周 > Blocking dependencies:**M2(IPC 契约化)必须先完成** ## 问题陈述 104 个 IPC channel(见 M2)基本都在给 renderer 推状态: - profile snapshot - task list - run events - browser actions - approvals - messages - … renderer 如何 debounce、merge、避免 stale read,**本次 review 未深入**——但缺乏契约层的前提下几乎必然有一致性 bug。 典型问题猜测(需 M2 完成后验证): 1. **Race condition**:两个 IPC response 几乎同时到达,后到覆盖先到,但语义上应该合并 2. **Stale read**:renderer 发 request → main 返回 → 中间 main 已经又写了一次 → renderer 拿到 stale snapshot 3. **过度 refetch**:renderer 没有合适的 cache,每次切换页面都重 fetch(浪费 IPC round trip) 4. **Event dedup**:同一个 run event 被多个订阅路径接收,渲染时重复展示 5. **Optimistic update 回滚**:renderer 本地 optimistic 更新,但 main 拒绝后没有正确回滚 ## 根因分析 当前未知——本 proposal 承认**review 没覆盖 renderer**。 Linus 视角推测:104 个 channel × N 种 payload shape × 若干订阅者 = 巨大的状态空间。没有类型契约(M2 未完成)时,renderer 层的一致性问题**无法静态证明也无法系统性测试**。M2 是前置条件——契约化后才能逐个 channel audit。 Karpathy 视角补充:renderer 的 UI state 错乱直接让用户看到 "agent 跑着但 UI 显示闲置" 或 "approval 已批但列表还在" 等典型 bug。这种 bug 很难用 unit test 捕捉,需要 e2e 验证 + state invariant checking。 ## 设计方案 本 proposal 是**审查方案**而不是修复方案——具体修法要等审查结果。 ### 审查范围 `src/renderer/` 全部代码,重点: 1. **状态管理库**:目前是 zustand(从 `package.json` 看到依赖) 2. **IPC 订阅点**:`useEffect` 里调 `window.api.invoke` / 订阅 event 的位置 3. **数据合并逻辑**:event 到达时如何 merge 到 store 4. **缓存策略**:哪些数据缓存、TTL 多少、如何失效 5. **Optimistic update**:哪些 action 先改 UI 再等 ack ### 审查 checklist | 项 | 检查内容 | |----|---------| | 1 | 每个 zustand store 的 state shape 是否 type-safe(借助 M2 的 IpcOutput 类型) | | 2 | 每个 IPC invoke 的 response 处理,是否考虑 race(后到请求覆盖先到请求) | | 3 | 每个 event subscription,是否 dedup(同一 event 不同路径接收) | | 4 | Optimistic update 的回滚逻辑完备性 | | 5 | 组件 unmount 时 subscription cleanup | | 6 | 跨 route 切换时 store 是否 reset(避免显示上一个 company 的数据) | | 7 | 长列表的 virtualization(react-virtual 已在依赖里,实际使用覆盖度) | | 8 | Loading / error / empty 三种边界态覆盖 | ### 预期产出 审查结束后产出 `docs/proposals/M12-renderer-findings.md`,按严重度列出发现的具体问题,每条给修复 proposal(可能演化为独立 proposal)。 ### 审查方法 1. **静态**: - grep `window.api.invoke` 找所有 call site - grep `useEffect` + IPC 调用 找订阅点 - 对照 M2 的 IpcChannels 一一核对 shape 2. **动态**: - 启动 app,开 React DevTools 看 zustand store 状态 - 触发典型场景(agent run、approval flow、task update),观察 state transition - 故意制造 race(背景 run 发 event + 前景用户交互) 3. **e2e**: - 用 Playwright(tests/e2e 已配置)写 invariant 检查脚本: - 任何时刻 UI 显示的 "pending approvals" 数量 = main 进程 DB 查询结果 - 任何时刻 UI 显示的 "active runs" = DB runs where status=running ## Migration 计划(审查后) 审查阶段: 1. **Phase 1(1 周)**:静态扫描 + 形成 findings 列表 2. **Phase 2(1 周)**:动态测试 + Playwright invariant script 3. **Phase 3(1 周)**:按严重度归类,高严重度的 findings 进入后续 sprint 修复 4. **Phase 4(1-2 周)**:根据 findings 可能派生出新的 proposal(例如"renderer-state-race-fixes") ## 风险与回滚 | 风险 | 触发条件 | 回滚方法 | |------|---------|---------| | 审查范围失控 | 发现问题太多,sprint 无法覆盖 | 按严重度分批,每个 sprint 处理一类 | | 修复引入新 bug | 动 renderer state 逻辑 | 每类修复独立 PR + e2e 验证 | | M2 没完成就启动 | 契约化未完成导致审查无类型依据 | 不做,等 M2 | ## 验证标准(审查阶段) 1. **覆盖率**:grep 可见所有 `window.api.invoke` 都被审查 2. **Findings 数**:产出具体 finding 列表(预估 20-40 条) 3. **E2E invariant**:至少 5 条关键 invariant 被 playwright 脚本覆盖 4. **修复 backlog**:高优先 finding 进入下个 sprint ## 不在范围 - **不做 renderer 架构重写**:只是审查 + 针对性修复 - **不换状态管理库**:zustand 保留 - **不改 UI 设计**:只审数据流 - **不重构 route 层**:url routing 逻辑独立 - **不做 A/B 测试设施**:UI 实验独立 proposal ## 参考文件清单 - `src/renderer/`(全部,待审查) - `src/preload/index.ts`(IPC bridge) - `src/main/ipc-*.ts`(main 侧 handler) - `tests/e2e/playwright.config.ts`(e2e 测试配置) - M2 契约表(前置依赖) ## 附:为什么现在放一个"审查 proposal" 而不是修复 proposal 本次代码 review(2026-04-20)**明确跳过了 renderer 深入**——review report 也坦白了这点。列 M12 不是占位,是承认"这里有未知的坑"。 **第一性原理**:不能在没契约(M2)的前提下审 renderer 状态——审的结论无法落成 executable 测试。必须等 M2 完成后,renderer 层的问题才有具体形状。 M12 也是 IPC-related 所有 proposal 的收官——M2 完成 + M12 审查完 + 修复完,整个 main ↔ renderer 的契约面才算治理完整。 Karpathy 视角:Agent 的 UI 呈现错误("agent 跑完了但还在 spinner")直接伤害产品 perception——用户不会分辨是 LLM bug 还是 state bug,一律认为"这 agent 不可靠"。所以 renderer state 治理的优先级实际比列 M 级更高,但依赖链强迫它排在后面。 ## 附:审查输出模板 审查结束后 `docs/proposals/M12-renderer-findings.md` 的结构: ```markdown # M12 Findings () ## 🔴 Critical findings ### F-01: <具体问题> - 文件:src/renderer/xxx.tsx:<行号> - 重现:<最小重现步骤> - 影响:<用户层面影响> - 建议修复:<简短描述> ## 🟠 Major findings ... ## 🟡 Minor findings ... ## Covered e2e invariants 1. 2. ... ``` ## 附:gating matrix | Gate | 条件 | 放行判断人 | |------|------|----------| | G1 | M2 IPC 契约化完成 | Linus | | G2 | 审查 checklist 8 项全覆盖 | Owner | | G3 | Findings 列表产出并分类(≥20 条) | Linus | | G4 | e2e invariant 脚本 ≥5 条可运行 | Karpathy | | G5 | 高优先 findings 进入下 sprint 修复 | Owner | ## 附:关键 invariant 候选 e2e playwright 要覆盖的状态不变式: 1. UI "pending approvals" count = DB approvals where status = 'pending' 2. UI "active runs" = DB runs where status = 'running' 3. Task card 显示的 phase = DB tasks.phase(B2 完成后) 4. Agent message 未读数 = DB agent_message_reads 未读行数 5. 某 company 被切换后,UI 不应残留前一 company 数据 6. Approval 批准后,UI 应在 1s 内从 pending 列表消失 7. Task 从 todo → in_progress 时,spinner 应立即显示 8. Browser action 执行中,main 进程可见 subprocess status = running ## 附:为什么现在不做 proposal 只做审查 当前 review 团队(Linus + Karpathy + Jobs)**没有覆盖 renderer**——贸然给出修复 proposal 等于凭想象出方案。本 proposal 诚实承认这是"审查准备"阶段,具体修复待发现。 ## 附:为什么不选其他方案 **方案 A(当前 proposal)**:先审查后修复,M2 做前置。 **方案 B(一次性 renderer 重写)**:风险极大——renderer 代码量不小,重写周期长,期间 UI 完全不可用。 **方案 C(按路由逐 page 重写)**:逻辑上可行,但 findings 可能跨 page(例如多个 page 用同一个 bug store),按 page 重写解决不了。 方案 A 是发现问题 → 针对性修复的稳妥路径。 ## 附:常见 renderer state 反模式(待审查时排查) 审查重点观察以下 pattern: 1. **useEffect 里 invoke 没 AbortController**:组件 unmount 但 promise 仍 resolve,导致 "setState on unmounted component" 2. **event listener 没 cleanup**:`ipcRenderer.on("xxx", h)` 返回的 listener 没在 `useEffect` 清理 3. **派生 state 存 store**:本可以 derive 的数据写入 store,引入一致性问题 4. **Optimistic update 没 rollback**:`store.setState({ ... })` 后 invoke 失败,UI 保持错误态 5. **同一 data 多次 fetch**:切换 tab 每次重 fetch,没有 cache 或 cache 失效策略不当 6. **Route 切换 store 不 reset**:上一个 company 的数据残留到当前 company 视图 7. **Event dedup 缺失**:同一 run_event 被多个路径转发到 renderer,渲染时重复 8. **List 未 virtualize**:大列表渲染全量 DOM,滚动卡顿 审查时对每个 store 和每个大组件逐项核查。 ## 附:playwright e2e invariant 脚本骨架 ```ts // tests/e2e/invariants.spec.ts(M12 产出) test("pending approvals UI = DB", async ({ page, electronMain }) => { await page.goto("/approvals"); const uiCount = await page.locator("[data-testid=pending-approval]").count(); const dbCount = await electronMain.evaluate(() => db.approvals.count({ status: "pending" }) ); expect(uiCount).toBe(dbCount); }); test("active runs UI = DB", async ({ page, electronMain }) => { await page.goto("/runs"); const uiRunning = await page.locator("[data-status=running]").count(); const dbRunning = await electronMain.evaluate(() => db.runs.count({ status: "running" }) ); expect(uiRunning).toBe(dbRunning); }); // ... 至少 5 条 invariant ``` ## 附:renderer store 梳理(待 M2 完成后形成清单) M2 完成后,M12 审查第一步:把每个 zustand store 列出来 + 它订阅的 IPC channel 映射,形成: | Store | 订阅 channel | 更新策略 | 发现的问题 | |-------|------------|---------|----------| | agentsStore | `agents/list` + `agents/updated` event | ? | 待审 | | tasksStore | `tasks/list` + `tasks/updated` event | ? | 待审 | | ... | ... | ... | ... | ## 附:e2e harness 运行频率 - PR 触发:快速 invariant(< 30s 跑完的子集) - 夜间 build:全量 invariant + soak test(5 分钟持续交互) - Release gate:全量 + manual QA pass M12 审查结束后产出的 invariant 脚本会同时塞进这三层。