Skip to content

perf(web-shell): derive the session workflow projection once and share it across surfaces (#10865) - #11237

Merged
wenshao merged 3 commits into
QwenLM:mainfrom
now-ing:perf/web-shell-session-projection-10865
Sep 23, 2026
Merged

wenshao merged 3 commits into
QwenLM:mainfrom
now-ing:perf/web-shell-session-projection-10865

Conversation

@now-ing

@now-ing now-ing commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

perf(web-shell): derive the session workflow projection once per render

Fixes #10865

What this PR does

One session workflow projection per render, shared by every surface. App builds the projection once (memoized on the same inputs the cockpit already receives) and hands the same object to the cockpit, the artifact-panel inspector, and the graph embedded in the cockpit. Each surface accepts an optional projection prop and falls back to deriving from raw props, so standalone mounts (ToolApproval, TasksStatusMessage, isolated tests) are unchanged.

To make the sharing possible without a circular import, the task-execution lookups PlanExecutionView owned — the index, its walkers, the node-state / attention / active-agent derivations — move to a shared taskExecutionIndex module that both the projection (session-workflow-model) and the graph now depend on. PlanExecutionView re-exports them, so every existing import path keeps working.

The projection now carries what the graph used to recompute privately:

  • taskIndex — the single createTaskExecutionIndex build; the graph's executions read live status through it instead of raising their own;
  • unassignedTools — tools whose todo_id is missing or outside the plan, previously collected in the graph and dropped by the projection;
  • the graph's grouping (toolsByTodo), node states, counts and its dependents map now come from the projection — the dependents map used to be a third copy of the same blockedBy walk (after the projection's own and the one perf(web-shell): derive the session workflow projection once #10871 replaced in the inspector).

What remains graph-local in PlanExecutionView is genuinely graph-specific: the topological layering, the topology serialization and the edge budget — still one memoized derivation that hover cannot re-run.

Why it's needed

Follow-up to #10871 (merged). That PR made each derivation cheap — one index build per projection instead of one per todo and per tool — and memoized each surface's own derivation. What it left in place is that a single render still ran that derivation three times: the cockpit, the inspector (auto-opened beside it, App.tsx → ArtifactPanel.tsx) and the embedded graph each built their own projection, each with its own index and its own copies of the grouping, node-state, count and dependents walks. The issue's remaining acceptance criterion is exactly this: one projection for the cockpit, the inspector and the embedded graph per render.

Wiring the projection through PlanExecutionView also removes the last semantic duplication inside the graph: its derived block re-derived an equivalent set from raw props inline, so any future divergence between the projection and the graph had two places to happen. They now cannot disagree — they read one object.

Reviewer Test Plan

How to verify

The behavioural guarantees are pinned by tests rather than by inspection:

  • session-workflow-surfaces.test.tsx — mounts the cockpit (with its embedded graph) and the inspector together:
    • with the app-level shared projection, neither surface re-derives anything: spies on buildSessionWorkflowProjection and createTaskExecutionIndex both count 0 extra calls, and both surfaces render their content from the shared object;
    • a standalone cockpit tree — no projection passed — derives exactly one projection and one task index for the whole render (the embedded graph reuses them);
    • a standalone inspector likewise derives exactly one.
  • PlanExecutionView.derivation.test.tsx:
    • hovering a node flips data-focused (the re-render happened) while layerPlanTodos and JSON.stringify call counts stay flat — no topological re-sort, no topology re-serialization per hover;
    • three window resizes inside one frame schedule one requestAnimationFrame, and that frame runs one measure pass (one getBoundingClientRect batch over the graph container and its nodes, not one per schedule call).
  • session-workflow-model.index.test.ts (from perf(web-shell): derive the session workflow projection once #10871) — the projection still builds the task index exactly once; its mock now points at the extracted taskExecutionIndex module, which is what the projection actually imports.

Sanity of the spies: temporarily making PlanExecutionView ignore the passed-in projection turns the surfaces suite red (1 extra projection + 1 extra index where 0 are expected, and 2 where 1 is expected for the standalone tree) — the counts fail on reintroduction, not just on absence.

Manual check if you prefer: enable experimental.sessionWorkflow, open the Workflow cockpit with a plan that has dependencies and agent steps, and confirm the graph, the inspector summary, the progress strip and the unassigned bucket all read the same values as before — the change is intended to be invisible. Everything in the existing PlanExecutionView, SessionWorkflowCockpit, SessionWorkflowInspector and session-workflow-model suites stays green, including the CSS source-shape guards.

Not covered / out of scope

getAgentToolsForPlan's full-message rescan (issue problem 7) is untouched — it is memoized at the App level on the same message list and is a separate, self-contained change if it is wanted.


中文说明

本 PR 做了什么

每次渲染只推导一份 session workflow projection,全部 surface 共享。App 层用 useMemo 构建一次(输入与 cockpit 现有 props 相同),把同一个对象传给 cockpit、artifact 面板里的 inspector、以及 cockpit 内嵌的依赖图。每个 surface 都接受可选的 projection prop,未传入时回退为从原始 props 自行推导——独立挂载场景(ToolApproval、TasksStatusMessage、隔离的测试)行为不变。

为了让共享不产生循环依赖,原先由 PlanExecutionView 持有的 task-execution 查询(索引本身、各类遍历、节点状态 / attention / 活跃 agent 推导)抽取到共享模块 taskExecutionIndex,projection(session-workflow-model)与依赖图都改为依赖它。PlanExecutionView 对这些导出做了 re-export,所有既有 import 路径不受影响。

projection 现在携带图内此前各自重算的内容:

  • taskIndex —— 唯一一次 createTaskExecutionIndex 构建;图内的执行列表直接经由它读实时状态,不再自建;
  • unassignedTools —— todo_id 缺失或指向计划外的工具,此前只在图内收集、projection 直接丢弃;
  • 图的分组(toolsByTodo)、节点状态、各类计数、dependents 映射全部改从 projection 读取——dependents 映射此前是同一 blockedBy 遍历的第三份拷贝(projection 自身一份、perf(web-shell): derive the session workflow projection once #10871 在 inspector 里替换掉的那份之外又一份)。

PlanExecutionView 里保留的只剩真正图专属的部分:拓扑分层、拓扑序列化与边数预算——仍是单份 memo 化推导,hover 不会重跑。

为什么需要

这是 #10871(已合并)的后续。那个 PR 让每次推导变便宜了——每个 projection 只建一次索引而不是每个 todo、每个 tool 各建一次——并把每个 surface 各自的推导 memo 化。它遗留的问题是:一次渲染仍会把这套推导跑三遍——cockpit、inspector(进入 cockpit 时自动在旁边打开)与内嵌图各建一份 projection,各自带自己的索引和各自的分组 / 节点状态 / 计数 / dependents 遍历。issue 剩下的验收标准正是这一点:cockpit、inspector 与内嵌图每次渲染共享一个 projection。

把 projection 接进 PlanExecutionView 还消除了图内部最后一处语义重复:它的派生块此前用原始 props 内联重推一份等价集合,projection 与图之间未来任何行为分歧都有两个可以发生的地方;现在它们读同一个对象,不可能不一致。

审查者验证计划

行为保证由测试钉住,而非人工检查:

  • session-workflow-surfaces.test.tsx —— 同时挂载 cockpit(含内嵌图)与 inspector:
    • 传入 App 层共享 projection 时,两个 surface 都不再额外推导:对 buildSessionWorkflowProjection 与 createTaskExecutionIndex 的 spy 计数均为 0,且两个 surface 都基于共享对象正常渲染;
    • 不传 projection 的独立 cockpit 树——整棵树一次渲染只推导恰好一份 projection 和一个 task index(内嵌图复用);
    • 独立 inspector 同样恰好一份。
  • PlanExecutionView.derivation.test.tsx:
    • hover 一个节点翻转 data-focused(确实发生了 re-render),而 layerPlanTodos 与 JSON.stringify 的调用计数保持不变——hover 不重跑拓扑排序、不重新序列化拓扑;
    • 同一帧内三次 window resize 只调度一个 requestAnimationFrame,该帧只跑一轮 measure(对图容器和各节点各读一次 getBoundingClientRect,而不是每次调度一轮)。
  • session-workflow-model.index.test.ts(perf(web-shell): derive the session workflow projection once #10871 引入)—— projection 每次仍然只建一次 task index;其 mock 路径已改为指向抽取后的 taskExecutionIndex 模块(projection 实际 import 的模块)。

spy 的锋利度验证:临时让 PlanExecutionView 无视传入的 projection,surfaces 套件立刻变红(共享场景多出 1 次 projection + 1 次 index,独立树场景 2 次而非 1 次)——计数在回退引入时会失败,而不是只对"不存在"敏感。

偏好手动验证的话:开启 experimental.sessionWorkflow,打开 Workflow cockpit(计划带依赖与 agent 步骤),确认图、inspector 摘要、进度条与 unassigned 分组的读数与之前完全一致——本变更应当是不可见的。现有 PlanExecutionView、SessionWorkflowCockpit、SessionWorkflowInspector、session-workflow-model 套件全部保持绿色,包括 CSS 源形状守卫测试。

未覆盖 / 范围之外

getAgentToolsForPlan 的全消息重扫(issue 问题 7)未动——它在 App 层以同一消息列表为依赖做了 memoize,若需要是一个独立、内聚的后续变更。

…er (QwenLM#10865)

The cockpit, the workflow inspector and the graph embedded in the cockpit
each derived their own copy of the session workflow projection for a single
render. The app now builds one projection per render and hands the same
object to every surface; each surface keeps a raw-props fallback so
standalone mounts (ToolApproval, TasksStatusMessage, isolated tests) still
work unchanged.

To make the sharing possible without a circular import, the task-execution
lookups PlanExecutionView owned (the index, its walkers, the node-state and
active-agent derivations) move to a shared taskExecutionIndex module that
both the projection and the graph depend on. The projection now carries
its task index and the unassigned-tools bucket, so the embedded graph reads
grouping, node states, counts and dependents from the shared object
instead of recomputing them, and its dependents map comes from the
projection's own derivation rather than a third copy of the blockedBy walk.

Pinned by tests: a mount of cockpit + inspector + embedded graph with the
shared projection derives nothing extra (spy on buildSessionWorkflowProjection
and createTaskExecutionIndex); a standalone cockpit tree or inspector
derives exactly one projection and one index; hovering a node re-renders
without re-running layerPlanTodos or the topology serialization; a
same-frame resize storm coalesces to one measure per animation frame.

Fixes QwenLM#10865
@wenshao

wenshao commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Verification report — built and run locally against a real daemon

I rebuilt this PR in an isolated worktree and drove it through a real qwen serve daemon with a real browser, side by side with its merge base, to answer three questions before merging: does the perf claim hold in the running app, does anything change for the user, and do the review findings still stand at 1688c31.

Short answer: the change delivers what it says and is behaviourally invisible — but every one of the 11 open review findings reproduces, and the branch no longer merges cleanly.

Rig — how the numbers below were produced
  • Worktrees at PR head 1688c31 and at the merge base 1a73f5b (git merge-base of the PR and main), each with a full npm run build. macOS 25.6, Node 24.18.1.
  • One real daemon for both arms: node scripts/dev.js serve --port 4180 --workspace <scratch> from the PR tree, experimental.sessionWorkflow: true, tools.todoWrite.enabled: true, approval mode plan.
  • The model is a scripted OpenAI endpoint (the repo's own integration-tests/fake-openai-server.ts) so the session is deterministic: a Plan-mode plan of 8 steps with blockedBy dependencies → exit_plan_mode → approved in the UI → 3 real sub-agents launched with todo_id. These are real sub-agent processes and real daemon agent tasks, not fixtures.
  • Two Vite dev servers against that one daemon — head on :5191, base on :5192 — so the two arms differ only in the client code under review.
  • Counters: one statement added at the top of buildSessionWorkflowProjection and of createTaskExecutionIndex (and at the top of each of the three surface components) incrementing a global. Identical shape in both arms; nothing else was touched.

1. The perf claim holds, and the UI is unchanged

cockpit base vs head

derivation counts

Scenario (component render counts identical in both arms) base 1a73f5b head 1688c31
Whole session runs, no workflow surface mounted 0 projections / 0 index builds 23 / 23
Workflow inspector alone (4 inspector renders) 2 / 2 2 / 2
Enter the cockpit (cockpit 2 + inspector 2 + graph 4 renders) 4 / 6 2 / 2
One real transcript update, all three surfaces mounted (8 + 8 + 8 renders) 4 / 6 2 / 2
Hover storm, 10 pointer events (graph re-renders twice) 0 / 0 0 / 0

With the cockpit, its embedded graph and the inspector all mounted, a transcript update derives the projection 2× instead of 4× and builds the task index 2× instead of 6×, at identical render counts. Hover was already clean at the merge base (#10871) and stays clean. The two cockpits render the same values, so this is invisible to the user, as intended.

Two smaller measurements worth recording:

  • New idle work. The App-level memo runs on every todos/tools/tasks change even with every workflow surface closed — 23 builds over one session where the base did none. At that shape (8 todos, 0 tools, 24 tasks) a build costs 5.3 µs (2000 builds in 10.7 ms), i.e. ~0.12 ms per session. A design note, not a regression.
  • The standalone transcript path pays a little more. TasksStatusMessage / ToolApproval mount PlanExecutionView with no shared projection, and the fallback now builds the full projection instead of the graph's leaner inline block. On a 40-step / 80-tool / 240-task fixture, 30 updates with a fresh tasks identity: head 7.74 / 8.12 / 7.92 ms per update vs base 7.41 / 7.80 / 7.50 ms — consistently ~4–5% slower (3/3 warm repetitions). A fraction of a frame; noting it, not blocking on it.

2. Repo gates

Check (run in the head worktree) Result
vitest run (packages/web-shell) 290 files / 6692 tests, 1 failed
Same suite at the merge base 288 files / 6687 tests, same 1 failed
tsc -p packages/web-shell/tsconfig.json --noEmit clean
eslint packages/web-shell clean
prettier --check on the touched directories clean

The single failure is App.test.tsx > task activity key > releases a detached terminal when its persisted session is evicted at 5313 ms against vitest's 5 s default — it fails identically at the merge base (5270 ms), so it is load-induced and pre-existing, not caused by this PR.

3. The 11 open review findings — all reproduce

mutation battery

Each mutant was applied to the PR head and the suites that claim to cover the behaviour were run (components/workflow + PlanExecutionView*: 10 files / 67 tests, green unmutated).

Finding How I re-tested it Result
Dead counts.layers counter asserted counts.layers > 0 after mount red → counter is provably dead
TranscriptRenderModeProvider with no value ran the file React logs The value prop is required for the <Context.Provider> on every run
ResizeObserver half uncovered new ResizeObserver(scheduleMeasure) → new ResizeObserver(measure) survived 67/67
No collocated test for the new 398-line module ls no taskExecutionIndex.test.ts
Module header states a false module-graph property read PlanExecutionView.tsx:25-26 it imports session-workflow-model at head
unassignedTools "outside the plan" unasserted narrowed the push to the missing-id case survived 67/67
Test 1 is order-coupled vitest --sequence.shuffle.tests, seeds 1/2/3/7 2 of 4 seeds red on correct code
App's pass-down is simulated, never exercised deleted both projection= sites in App.tsx survived 67/67; App.test.tsx has 0 references to the three symbols
The combined tree the test pins cannot occur queried the DOM in the real cockpit inspector renders data-testid="workflow-canvas-detail", i.e. the canvas branch the test does not mount
Freshness of the adopted projection unpinned deps [sharedProjection, tasks, todos, tools] → [tasks, todos, tools] survived 67/67
Measured per mount, not per render cockpit memo → bare sharedProjection ?? build(…) survived 67/67

One correction to the review. The dead counts.layers counter does not leave the "hover must not re-run the topological layering" criterion unguarded. I replaced the graph's useMemo with an inline IIFE so hover really does re-derive: the file goes red — on its neighbour, the JSON.stringify spy (expected 1 to be +0). Since the layering and the serialization live in the same memo, the live assertion already fails whenever the dead one would have. So that finding is a redundancy defect to clean up, not a hole in the acceptance criterion.

The other ten are real gaps in the new suites. The two I would want closed before merge are the App-level pass-down (the headline contract of the PR, currently asserted nowhere — an accidental revert of two lines in App.tsx ships green) and the freshness dep list (a one-token narrowing of a dep list this diff wrote, whose symptom is a frozen graph while the transcript streams).

4. The branch no longer merges

mergeStateStatus: DIRTY — 4 conflicting regions in PlanExecutionView.tsx, and the conflict is semantic rather than textual. Since the merge base, main took:

Everything above was measured on 1688c31 as submitted; the rebase will need its own pass.

5. Not covered

macOS only (no Windows/Linux run); web-shell package suites only (no full-repo run, no Playwright e2e/visual suites); the post-rebase tree was not exercised. One thing a reviewer reproducing this will hit: after a full page reload the workflow surfaces show "This session has no structured workflow yet", because the replayed transcript does not carry rawOutput.sessionWorkflow. That happens identically on both arms and is unrelated to this PR.

Verdict

The perf work is correct, measurable in the real app, and user-invisible. I am happy to merge it once (a) it is rebased on main with stepNumberByTodo folded in, and (b) the App-level pass-down and the adopting-memo freshness get real assertions. The remaining findings are worth a follow-up sweep but should not hold the rebase hostage.

中文说明

验证报告 —— 本地真实环境实测

我在隔离 worktree 里重建了本 PR,并连同它的 merge base 一起,接到真实 qwen serve daemon + 真实浏览器里跑,目的是在合并前回答三个问题:性能收益在运行中的应用里是否成立、对用户是否有行为变化、以及 1688c31 上那 11 条评审意见是否仍然成立。

结论:改动确实做到了它声称的事,且对用户不可见;但 11 条未解决的评审意见全部复现,并且分支已经无法干净合并。

装置

  • 分别检出 PR head 1688c31 与 merge base 1a73f5b(PR 与 main 的 git merge-base),各自完整 npm run build。macOS 25.6、Node 24.18.1。
  • 两臂共用一个真实 daemon:node scripts/dev.js serve --port 4180 --workspace <scratch>,来自 PR 树;experimental.sessionWorkflow: true、tools.todoWrite.enabled: true、审批模式 plan。
  • 模型用仓库自带的 integration-tests/fake-openai-server.ts 脚本化:Plan 模式生成 8 步带 blockedBy 依赖的计划 → exit_plan_mode → 在界面上批准 → 派出 3 个带 todo_id 的真实子智能体。这些是真的子进程和真的 daemon agent task,不是夹具。
  • 两个 Vite dev server 接同一个 daemon —— head 在 :5191、base 在 :5192,两臂只差被审查的客户端代码。
  • 计数:在 buildSessionWorkflowProjection、createTaskExecutionIndex 以及三个 surface 组件的函数体首行各加一条自增语句,两臂形状完全一致,其余一行未动。

1. 性能收益成立,界面无变化

场景(两臂组件渲染次数完全相同) base 1a73f5b head 1688c31
整个会话跑完,未挂载任何 workflow surface 0 次 projection / 0 次索引 23 / 23
只打开 Workflow inspector(inspector 渲染 4 次) 2 / 2 2 / 2
进入 cockpit(cockpit 2 + inspector 2 + graph 4 次渲染) 4 / 6 2 / 2
cockpit/inspector/graph 全挂载时的一次真实 transcript 更新(8+8+8 次渲染) 4 / 6 2 / 2
hover 风暴,10 次指针事件(图重渲染 2 次) 0 / 0 0 / 0

cockpit、内嵌图与 inspector 同时挂载时,一次 transcript 更新的 projection 推导从 4 次降到 2 次、task 索引构建从 6 次降到 2 次,而渲染次数完全一致。hover 在 merge base 上就已经干净(#10871),本 PR 保持干净。两臂 cockpit 读数一致,对用户不可见 —— 正是预期。

另外两个值得记录的测量:

  • 新增的空转开销:App 层的 memo 在 todos/tools/tasks 变化时都会跑,即使所有 workflow surface 都没打开 —— 一个会话里跑了 23 次,base 是 0 次。该形状(8 todos、0 tools、24 tasks)下单次 5.3 µs(2000 次共 10.7 ms),整场约 0.12 ms。属于设计备注,不是回归。
  • 独立 transcript 路径略变贵:TasksStatusMessage / ToolApproval 挂载 PlanExecutionView 时没有共享 projection,回退分支现在构建完整 projection,而不是图内更精简的内联块。40 步 / 80 工具 / 240 task 的夹具、30 次带全新 tasks 身份的更新:head 7.74 / 8.12 / 7.92 ms/次,base 7.41 / 7.80 / 7.50 ms/次 —— 稳定慢约 4–5%(3/3 次热身后重复)。只有一帧的零头,记录但不作为阻塞。

2. 仓库门禁

检查(在 head worktree 里跑) 结果
vitest run(packages/web-shell) 290 文件 / 6692 用例,1 失败
同一套件在 merge base 上 288 文件 / 6687 用例,同一条失败
tsc -p packages/web-shell/tsconfig.json --noEmit 通过
eslint packages/web-shell 通过
对改动目录 prettier --check 通过

唯一失败是 App.test.tsx > task activity key > releases a detached terminal when its persisted session is evicted,耗时 5313 ms 超过 vitest 默认 5 秒;在 merge base 上同样失败(5270 ms),属负载诱发的既有问题,与本 PR 无关。

3. 11 条评审意见 —— 全部复现

每个变异体都打在 PR head 上,然后跑声称覆盖该行为的套件(components/workflow + PlanExecutionView*:10 文件 / 67 用例,未变异时全绿)。

意见 复测方式 结果
counts.layers 计数器是死的 断言 counts.layers > 0 红 → 证实计数器恒为 0
TranscriptRenderModeProvider 未传 value 直接跑该文件 每次运行 React 都打印 The value prop is required for the <Context.Provider>
ResizeObserver 那一半无覆盖 new ResizeObserver(scheduleMeasure) → (measure) 存活 67/67
新增 398 行模块无同目录测试 ls 确实没有 taskExecutionIndex.test.ts
模块头注释陈述的模块图性质为假 读 PlanExecutionView.tsx:25-26 head 上它确实 import 了 session-workflow-model
unassignedTools 的「计划外」分支无断言 把 push 收窄到缺 id 的情况 存活 67/67
测试 1 存在顺序耦合 vitest --sequence.shuffle.tests,种子 1/2/3/7 4 个种子里 2 个在正确代码上变红
App 的传递是模拟的、从未被真正执行 删掉 App.tsx 里两处 projection= 存活 67/67;App.test.tsx 对这三个符号零引用
测试钉住的组合树在生产中不会出现 在真实 cockpit 里查 DOM inspector 渲染的是 data-testid="workflow-canvas-detail",即测试没挂的 canvas 分支
采纳后的 projection 新鲜度未钉住 依赖 [sharedProjection, tasks, todos, tools] → [tasks, todos, tools] 存活 67/67
只按挂载测量,而非按渲染 cockpit 的 memo 换成裸表达式 存活 67/67

对评审的一处更正。 死掉的 counts.layers 计数器并没有让「hover 不得重跑拓扑分层」这条验收标准失去守卫。我把图的 useMemo 换成内联 IIFE,让 hover 真的重跑推导:该文件变红了 —— 红在它旁边那条 JSON.stringify 断言上(expected 1 to be +0)。因为分层与序列化在同一个 memo 里,死计数器本该抓到的情况,活着的那条断言已经会抓到。所以这条属于「该清理的冗余」,不是覆盖漏洞。

其余十条都是新套件的真实缺口。合并前我希望先补上的是两条:App 层的传递(这是本 PR 的核心契约,目前无处断言 —— 误删 App.tsx 两行也能全绿过)和新鲜度依赖列表(本 diff 自己新写的依赖列表,收窄一个 token 的症状是 transcript 还在流、图却冻住)。

4. 分支已无法干净合并

mergeStateStatus: DIRTY —— PlanExecutionView.tsx 里 4 处冲突,且是语义冲突而非文本冲突。自 merge base 以来 main 合入了:

以上所有测量都是在提交时的 1688c31 上做的;rebase 之后需要重新过一遍。

5. 未覆盖

仅 macOS(未跑 Windows/Linux);仅 web-shell 包的套件(未跑全仓,未跑 Playwright e2e / 视觉套件);未验证 rebase 之后的树。另外,复现时会遇到一个现象:整页刷新后 workflow 面板显示 "This session has no structured workflow yet",因为回放的 transcript 不带 rawOutput.sessionWorkflow。两臂表现一致,与本 PR 无关。

结论

这项性能工作是正确的、在真实应用里可测量、且对用户不可见。满足两点后我乐意合并:(a) 基于 main rebase 并把 stepNumberByTodo 收进来;(b) 给 App 层传递与采纳 memo 的新鲜度补上真实断言。其余意见值得后续统一清理,但不必卡住这次 rebase。

@wenshao

wenshao commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /resolve

Resolve the PlanExecutionView.tsx conflict between the shared session-workflow projection refactor and main's dependency-navigation/gating work: derive stepNumberByTodo in the surviving graph-local memo, and restore the toolFormatting/toolClassification imports main's new node-face and subagent-details gating code needs.
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

Qwen Code resolved the merge conflicts and pushed the branch update.

Root cause. Since the PR last merged main (base 1a73f5bff6), main landed #10938 (navigable workflow dependencies) and #11434 (gate subagent details), rewriting the same regions of PlanExecutionView.tsx as this PR's #10865 refactor: the PR moved every task-execution derivation out of the component's big useMemo into the shared projection; #10938 added a new derivation (stepNumberByTodo) inside that memo plus node-face code calling helpers the PR extracted. Only this file conflicted.

Semantic, not adjacent. Both sides changed the same memo. The resolution keeps the PR's projection structure and re-adds main's step-number derivation to the surviving graph-local memo:

const { stepNumberByTodo, topology, … } = useMemo(() => {
  const stepNumberByTodo = new Map(
    todos.map((todo, index) => [todo.id, index + 1]),
  );
  const topology = …; // unchanged PR code
  return { stepNumberByTodo, topology, … };
}, [projection, todos]);

Imports: kept the PR's taskExecutionIndex/projection imports, added main's getSubagentDetailsUnavailableReason, and restored isSubAgentToolCall — the refactor dropped it but main's auto-merged node-face agentCount needs it. getAgentDisplayStatus/isAgentCancelled stay dropped; their consumer executionStatus now lives in taskExecutionIndex.ts.

Load-bearing.

  • stepNumberByTodo must remain index + 1 over the same todos prop the inspector's stepNumberById numbers; feat(web-shell): make Session Workflow dependencies navigable and quiet its chrome #10938 requires graph, inspector and chips to name a step identically.
  • The memo's deps stay [projection, todos]; the added derivation reads only todos.
  • Verified: merged-vs-PR-head contains exactly main's added lines (imports aside), merged-vs-main exactly the PR's change; the four other overlapping auto-merges equal the PR-side diff; every importer (incl. main's PlanExecutionView.test.tsx) resolves against the merged re-export surface.

Not verified. No build/typecheck/tests run. Two non-conflicted dependencies for CI: (1) main's PlanExecutionView.test.tsx now exercises the new node face against the projection-fed component; (2) PR-only taskExecutionIndex.ts imports getAgentDisplayStatus/isAgentCancelled from toolFormatting.ts, which main changed in #11434/#11977 — exports confirmed present, behavior is CI's call.

中文说明

根因:自 PR 上次合并 main(基点 1a73f5bff6)后,main 合入 #10938(依赖可导航)与 #11434(子代理详情门控),与 PR 的 #10865 重构改写了 PlanExecutionView.tsx 的同一区域:PR 把组件大 useMemo 中的任务执行派生全部移入共享 projection,而 #10938 恰在该 memo 内新增 stepNumberByTodo 派生,并在节点面板调用 PR 已移出的辅助函数。唯一冲突文件。

语义冲突:解决保留 PR 的 projection 结构,把 main 的编号派生放回幸存的图形局部 memo(见上方代码)。导入同时保留两侧:PR 的 taskExecutionIndex/projection 导入、main 的 getSubagentDetailsUnavailableReason,并恢复 isSubAgentToolCall(重构删掉了它,但 main 新的 agentCount 需要);getAgentDisplayStatus/isAgentCancelled 不再导入,其使用者 executionStatus 已在 taskExecutionIndex.ts。

关键约束:stepNumberByTodo 必须保持对同一 todos prop 按 index + 1 编号,与 inspector 的 stepNumberById 一致(#10938 要求三个界面编号相同);memo 依赖保持 [projection, todos]。已核对:合并结果相对 PR head 恰为 main 新增行,相对 main 恰为 PR 的改动;其余四个重叠自动合并文件与 PR 侧 diff 一致;所有导入方(含 PlanExecutionView.test.tsx)均可解析。

未验证:未运行构建/类型检查/测试。两处交由 CI:① main 的 PlanExecutionView.test.tsx 将用 PR 的 projection 组件测新节点面板;② PR 新增的 taskExecutionIndex.ts 依赖被 main(#11434/#11977)改过的 toolFormatting.ts,导出确认存在,行为以 CI 为准。

wenshao pushed a commit to wenshao/qwen-code that referenced this pull request Sep 18, 2026
@wenshao

wenshao commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

Verification report, round 2 — the merged head 6c4484138c

After my first report, @qwen-code /resolve merged main into this branch and pushed 6c44841, closing with "Not verified. No build/typecheck/tests run." This round rebuilds that merged tree and puts it back in front of a real qwen serve daemon in a real browser, next to its main parent, to answer exactly what was left open: is the resolution equivalent, does the perf claim survive the merge, and do the repo gates pass.

Short answer: the resolution is correct — I checked it in both directions and in the running app — the win is intact at 4→2 / 6→2 with identical render counts and byte-identical UI output, and typecheck, lint, format, build and the package suites all pass. The two review gaps I called blocking last time are untouched, because the merge did not change a single test file.

Rig — how the numbers below were produced
  • Worktrees at the merged head 6c44841 and at its main parent e5969d6 (= 6c44841^2, i.e. the same tree without the PR), each installed with pnpm 11.24.0 and fully built. macOS 25.6, Node 24.18.1.
  • One real daemon, from the head tree: node scripts/dev.js serve --port 4180 --workspace <scratch>, isolated QWEN_HOME, experimental.sessionWorkflow: true, tools.todoWrite.enabled: true, Plan mode.
  • The model is the repo's own scripted endpoint (integration-tests/fake-openai-server.ts), so the session is deterministic: todo_write with an 8-step blockedBy plan → exit_plan_mode → approved in the browser → three real background sub-agents launched with todo_id, each held at a gate I release from outside. Real daemon agent tasks and real sub-agent processes, not fixtures.
  • Two Vite dev servers against that one daemon — head on :5191, base on :5192 — so the two arms differ only in the client code under review, reading the same session.
  • Counters: one statement at the top of buildSessionWorkflowProjection, of createTaskExecutionIndex, and of each of the three surface components, incrementing a global that the page reports. Identical in both arms, reverted before the gates ran (git status clean).
  • Every comparison below was taken with the session frozen (all three agents finished), so both arms see identical data.

1. The conflict resolution is equivalent — checked from both sides

merge audit

merged − main is byte-identical to the PR's own change on nine of the ten files, including all three test files. On PlanExecutionView.tsx — the only file that conflicted — the entire difference is its import block: the resolution keeps isSubAgentToolCall (the refactor had dropped it; main's new node-face agentCount needs it) and keeps the multi-line toolFormatting import that main extended. Read the other way, merged − PR head is main's own 342-line change plus exactly those import lines. Every marker main added in #10938 and #11434 survives, and data-plan-input — the one main deleted — is still gone.

Both semantic joints the resolution had to invent hold:

  • stepNumberByTodo is character-identical to main's derivation, over the same todos prop the inspector numbers from, feeding the same five consumption sites, with todos in the surviving memo's dependency list. feat(web-shell): make Session Workflow dependencies navigable and quiet its chrome #10938's requirement that the graph, the inspector and the chips name a step identically is preserved — and I checked that in the running app, not only on paper.
  • dependentsByTodo now comes from the shared projection instead of a local blockedBy walk. Both dedup with a Set, drop self-references and drop ids that name no step, in todos order.

2. In the running app: the win is intact, the UI is unchanged

cockpit parity

derivation counts

Scenario Surface renders
cockpit / inspector / graph
base e5969d6 head 6c44841
Enter the cockpit — cockpit, its embedded graph and the inspector all mounted (3 repetitions per arm) 2 / 2 / 4 — identical in both arms 4 projections / 6 index builds 2 / 2
Workflow inspector alone 0 / 4 / 0 2 / 2 2 / 2
Hover storm, 10 pointer events over graph nodes 0 / 0 / 2 0 / 0 0 / 0
Session page load with no workflow surface mounted (3 repetitions) 0 / 0 / 0 0 / 0 13 / 13

With all three surfaces mounted, the merged head derives the projection 2× instead of 4× and builds the task index 2× instead of 6×, at identical render counts — the same result my first report measured before the merge. Hover was already clean at the base and stays clean.

And it is invisible to the user. The cockpit's rendered text is the same on both arms — 1080 characters, checksum 1442004915 — including main's step numbers 1…8 and every "Depends on" chip. The two screenshots above are the same session, side by side.

The one real cost is unchanged and still small: the App-level memo runs on every todos/tools/tasks change even with every workflow surface closed — 13 builds per session load where the base does none. At this session's shape (8 todos / 0 tools / 3 agent tasks) a build measures 2.8–4.0 µs (4×4000 iterations in the live page), so ~40 µs per load. A design note, not a regression.

3. Repo gates — the ones the resolve bot skipped

gates

Typecheck, ESLint, Prettier and the whole-repo build all pass on the merged tree. The web-shell suite is 328 files / 8672 tests with 3 failures, and none of them belongs to this PR:

  • build-artifact.test.ts › keeps the transcript entry a fraction of the interactive entry — 3/3 red on both arms. It is a byte cap: 1 341 442 (head) vs 1 339 305 (base) against a 1 300 000 ceiling, so it is already over on main.
  • AddMenu.test.tsx › returns keyboard focus to the trigger after Escape — flaky; red in the full run on both arms, green in 2 of 3 re-runs.
  • BranchPickerPopover.test.tsx › resets the remotes view after a workspace switch — flaky; did not reproduce once in three re-runs.

Worth recording about that first one: two byte budgets are breached on this machine — the document export renderer (1 976 702 vs a 1 930 000 cap) and the transcript entry above — and both are breached by main alone. My workspace was installed with pnpm; CI installs with npm ci against package-lock.json, where the same build is green (main's nightly e2e lane builds fine) and neither budget runs on pull requests. This PR's own share is +1 802 B and +2 137 B. Not a blocker and not this PR's doing — but if anyone tightens those caps later, this is the arithmetic.

One caveat on method: a first full run while this machine was at load average 150–250 reported 43 failures on head; re-run at load 60 it was 3. The numbers above are the low-load run plus three targeted repetitions per arm.

4. The two blocking review gaps are unchanged

mutation battery

Mutation applied to 6c44841 What it breaks for a user Result
Delete both App-level pass-down sites (projection: sessionWorkflowProjection, and projection={sessionWorkflowProjection} — 2 lines) Every surface silently goes back to deriving its own projection; the headline contract of this PR is gone, and so is the 4→2 / 6→2 win survived, 88/88 green
Drop sharedProjection from the adopting memo's dependency list in all three surfaces An adopted projection freezes at the identity it had on first render: the graph stops moving while the transcript keeps streaming survived, 88/88 green

Battery: components/workflow + components/workflows + PlanExecutionView* → 10 files / 88 tests, green unmutated. This is the same result as round 1 — expected, because the merge did not touch a test file, and all 11 review threads are still open and not outdated.

5. Not covered

macOS only (no Windows or Linux run). web-shell package suites only — no Playwright e2e or visual suites, no full-repo vitest. This round re-tested the two findings I called blocking, not all eleven; the other nine anchor on test files the merge left byte-identical, so I take round 1's results as standing. One thing a reviewer reproducing this will notice: the inspector reports "0 active Agents" while three agent tasks are genuinely running — identical on both arms, unrelated to this PR.

Verdict

The merge is sound and I would not ask for it to be redone. Of the two things I asked for last time, (a) the rebase onto main with stepNumberByTodo folded in is done, and now verified; (b) real assertions for the App-level pass-down and for the adopting memo's freshness are still open. Close (b) and this is ready to merge from my side; the remaining findings are worth a follow-up sweep but should not hold it.

中文说明

验证报告 · 第二轮 —— 合并后的 head 6c4484138c

在我的第一轮报告之后,@qwen-code /resolve 把 main 合进本分支并推出了 6c44841,并明确写着 "未验证。 未运行构建 / 类型检查 / 测试。" 本轮就是把那棵合并后的树重新构建,再接回真实 qwen serve daemon + 真实浏览器,与它的 main 父提交并排跑,回答被留下的三个问题:这次冲突解决是否等价、性能收益在合并后是否还成立、仓库门禁是否通过。

结论:解决是正确的——我从两个方向以及运行中的应用里都做了核对;收益完整保留在 4→2 / 6→2,渲染次数一致、界面输出逐字节相同;类型检查、lint、格式化、构建与包内测试全部通过。上一轮我判为阻塞的两条评审缺口原样未动,因为这次合并没有改动任何一个测试文件。

装置

  • 在合并后的 head 6c44841 与它的 main 父提交 e5969d6(即 6c44841^2,同一棵树但不含本 PR)各检出 worktree,均用 pnpm 11.24.0 安装并完整构建。macOS 25.6、Node 24.18.1。
  • 两臂共用一个真实 daemon(来自 head 树):node scripts/dev.js serve --port 4180 --workspace <scratch>,隔离 QWEN_HOME,experimental.sessionWorkflow: true、tools.todoWrite.enabled: true、Plan 模式。
  • 模型用仓库自带的脚本化端点(integration-tests/fake-openai-server.ts),会话完全确定:todo_write 写入 8 步带 blockedBy 的计划 → exit_plan_mode → 在浏览器里批准 → 派出 3 个带 todo_id 的真实后台子智能体,每个都被我从外部控制的闸门挂住。是真的 daemon agent task 和真的子进程,不是夹具。
  • 两个 Vite dev server 接同一个 daemon —— head 在 :5191、base 在 :5192,两臂只差被审查的客户端代码,读的是同一个会话。
  • 计数:在 buildSessionWorkflowProjection、createTaskExecutionIndex 以及三个 surface 组件的函数体首行各加一条自增语句,由页面读出。两臂形状完全一致,跑门禁前已全部回滚(git status 干净)。
  • 下面所有对照都是在会话冻结后(三个子智能体均已结束)采集的,两臂看到的数据完全相同。

1. 冲突解决是等价的 —— 从两个方向核对

合并结果 − main 在十个文件中的九个上与 PR 自身的改动逐字节相同,三个测试文件全部在内。唯一冲突的 PlanExecutionView.tsx 上,全部差异就是它的 import 块:解决保留了 isSubAgentToolCall(重构曾删掉它,但 main 新的节点面 agentCount 需要),并保留了 main 扩充过的多行 toolFormatting 导入。反方向看,合并结果 − PR head 等于 main 自己那 342 行改动加上同样这几行 import。main 在 #10938 与 #11434 中新增的标记全部存活,而它删掉的 data-plan-input 依然不存在。

需要「发明」的两处语义接缝都成立:

  • stepNumberByTodo 的推导与 main 的逐字符相同,作用在 inspector 用来编号的同一个 todos prop 上,供给同样的五个消费点,且 todos 在幸存 memo 的依赖列表里。feat(web-shell): make Session Workflow dependencies navigable and quiet its chrome #10938 要求「图、inspector、chips 对同一步给出相同编号」的约束得以保留 —— 而且我是在运行中的应用里核对的,不只是读代码。
  • dependentsByTodo 改为从共享 projection 读取,而不再做一次局部的 blockedBy 遍历。两者都用 Set 去重、丢弃自引用、丢弃指不到任何步骤的 id,且顺序都按 todos。

2. 在运行中的应用里:收益完整,界面无变化

场景 surface 渲染次数
cockpit / inspector / graph
base e5969d6 head 6c44841
进入 cockpit —— cockpit、内嵌图与 inspector 全部挂载(每臂 3 次重复) 2 / 2 / 4 —— 两臂完全相同 4 次 projection / 6 次索引构建 2 / 2
只打开 Workflow inspector 0 / 4 / 0 2 / 2 2 / 2
Hover 风暴,10 次指针事件划过图节点 0 / 0 / 2 0 / 0 0 / 0
会话页加载,未挂载任何 workflow 界面(3 次重复) 0 / 0 / 0 0 / 0 13 / 13

三个界面同时挂载时,合并后的 head 把 projection 推导从 4 次降到 2 次、task 索引构建从 6 次降到 2 次,而渲染次数完全一致 —— 与我第一轮在合并前测到的结果相同。hover 在 base 上本就干净,合并后保持干净。

而且对用户不可见。 cockpit 渲染出的文本两臂完全相同 —— 1080 个字符、校验和 1442004915 —— 包含 main 的 1…8 步骤编号和每一个「Depends on」chip。上面两张截图就是同一个会话的并排对比。

唯一的真实代价未变且仍然很小:App 层的 memo 在 todos/tools/tasks 变化时都会跑,即使所有 workflow 界面都关着 —— 一次会话加载跑 13 次,base 是 0 次。在本会话的形状下(8 todos / 0 工具 / 3 个 agent task)单次耗时 2.8–4.0 µs(页面内 4×4000 次迭代实测),整次加载约 40 µs。属于设计备注,不是回归。

3. 仓库门禁 —— 机器人跳过的那些

类型检查、ESLint、Prettier 与全仓构建在合并后的树上全部通过。web-shell 套件 328 文件 / 8672 用例,3 条失败,没有一条属于本 PR:

  • build-artifact.test.ts › keeps the transcript entry a fraction of the interactive entry —— 两臂都是 3/3 红。它是一个字节上限:1 341 442(head)对 1 339 305(base),上限 1 300 000,也就是说 main 本身就已超标。
  • AddMenu.test.tsx › returns keyboard focus to the trigger after Escape —— 抖动;全量跑时两臂都红,重跑 3 次里绿 2 次。
  • BranchPickerPopover.test.tsx › resets the remotes view after a workspace switch —— 抖动;重跑 3 次一次都没复现。

关于第一条值得记录:本机上有两个字节预算被突破 —— 文档导出渲染器(1 976 702,上限 1 930 000)与上面那个 transcript 入口 —— 而且都是 main 自己就已突破。我的工作区是用 pnpm 安装的;CI 用 npm ci 配 package-lock.json,同一个构建在那边是绿的(main 的 nightly e2e 腿构建正常),而且这两个预算都不在 PR 门禁上。本 PR 自身的份额是 +1 802 B 与 +2 137 B。不构成阻塞、也不是本 PR 造成的 —— 但将来谁要收紧这两个上限,这就是账。

方法上的一个说明:第一次全量跑时本机负载 150–250,head 报了 43 条失败;负载降到 60 重跑只剩 3 条。上表用的是低负载那次,外加每臂三次定向重复。

4. 两条阻塞性评审缺口原样未动

打在 6c44841 上的变异 对用户意味着什么 结果
删掉 App 层两处传递(projection: sessionWorkflowProjection, 与 projection={sessionWorkflowProjection},共 2 行) 每个界面都悄悄退回各自推导 projection;本 PR 的核心契约没了,4→2 / 6→2 的收益也没了 存活,88/88 全绿
从三个界面的采纳 memo 依赖列表里去掉 sharedProjection 被采纳的 projection 冻结在首次渲染时的身份:transcript 还在流,图却不动了 存活,88/88 全绿

变异靶场:components/workflow + components/workflows + PlanExecutionView*,共 10 文件 / 88 用例,未变异时全绿。结果与第一轮一致 —— 这是预期的,因为合并没有动任何测试文件,且 11 条评审 thread 至今全部未解决、也未过期。

5. 未覆盖

仅 macOS(未跑 Windows / Linux)。仅 web-shell 包内套件 —— 未跑 Playwright e2e 与视觉套件,未跑全仓 vitest。本轮只复测了我判为阻塞的那两条,而非全部十一条;其余九条锚定的测试文件在合并前后逐字节相同,因此沿用第一轮的结论。复现时会看到一个现象:三个 agent task 确实在运行时,inspector 却显示「0 active Agents」—— 两臂表现一致,与本 PR 无关。

结论

这次合并是稳的,我不会要求重做。上一轮我提的两点里,(a)「基于 main 合并并把 stepNumberByTodo 收进来」已完成,且本轮已验证;(b)「给 App 层传递与采纳 memo 的新鲜度补上真实断言」仍未完成。把 (b) 补上,从我这边就可以合并了;其余意见值得后续统一清理,但不必卡住本 PR。

@wenshao
wenshao enabled auto-merge September 18, 2026 22:09
@wenshao

wenshao commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qqqys

qqqys commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Independent verification at head 6c4484138c2c — no blocking defect found in the code delta

Ran this PR's own guarantees end to end on a real checkout of the head commit, with mutation witnesses to prove the checks are not vacuous. Reporting the numbers rather than a reading, because the whole claim of this PR is a counting claim.

Scope certified first. merge_base(base, head) == base (e5969d67d4fb), so the branch is not diverged; the three-dot diff reproduces GitHub's own numbers exactly — 10 files, +931 / −470 (the two-dot form agrees here, but only because the branch is currently based). Cover is file-complete: 7/7 production files read at their head blobs, not from the patch — App.tsx, ArtifactPanel.tsx, PlanExecutionView.tsx, taskExecutionIndex.ts, SessionWorkflowCockpit.tsx, SessionWorkflowInspector.tsx, session-workflow-model.ts. The three remaining files are tests.

1. The extraction is behaviour-preserving, function by function

The risk in a "move code into a shared module" PR is a semantic edit hiding inside the move, so I compared every function that now lives in taskExecutionIndex.ts against its original body in PlanExecutionView.tsx at the merge base, whitespace-normalized:

18 of 18 accounted for — 14 byte-identical, 3 differing only by the added export keyword, 1 differing by a rewrite of return cond ? tool : undefined into if (cond) return tool; return undefined. Zero semantic deltas. not_in_base = 0, so nothing in the new module is new behaviour either.

Compile-level surface checked the same way: all 16 symbols that PlanExecutionView.tsx imports or re-exports from the new module exist in it, so the re-export block that keeps existing import paths working does resolve. No import cycle is introduced — session-workflow-model.ts now depends only on taskExecutionIndex, never back on PlanExecutionView.

2. Every derivation the graph handed over is semantically identical

The graph stopped computing eight things privately and now reads them off the shared projection. I checked each against the projection's own implementation rather than assuming the names match:

graph used to compute projection supplies verdict
todosById same new Map(todos.map(...)) identical
toolsByTodo / unassigned same loop, !todoId || !todosById.has(todoId) ≡ !todoId || !knownIds.has(todoId) identical
statesByTodo same getPlanNodeStateFromIndex(todo, todosById, toolsByTodo.get(id) ?? [], taskIndex) identical
completedCount same filter(status === 'completed').length identical
progressPercent same todos.length === 0 ? 0 : Math.floor(...) identical — Math.floor, not Math.round
activeAgentCount activeAgents.length where activeAgents = getActiveAgentsFromIndex(tools, taskIndex) identical
attentionCount attentionTodos.length where attentionTodos = todos.filter(t => states.get(t.id)?.attention) identical
dependentsByTodo same new Set(blockedBy ?? []) walk with the same dependencyId === todo.id || !todosById.has(dependencyId) skip identical

Two of these are worth calling out because they are the ones a refactor like this usually gets wrong. The Math.floor in progressPercent is load-bearing — it feeds aria-valuenow, and rounding would report 100% completion on a long plan while a step is still outstanding; it survives. And dependentsByTodo keeps the self-dependency and unknown-id filter, so the graph's downstream-step set is the same set of edges its topology already serialized.

The memo dependency list changed from [taskIndex, todos, tools] to [projection, todos], which is still correct: the projection is itself memoized on [sharedProjection, tasks, todos, tools] and carries taskIndex, so it changes whenever any of the old inputs did.

I also traced the new optional projection prop through every hop to make sure it is not a declared-but-never-forwarded switch. App puts it in the workflow object it hands ArtifactPanel; ArtifactPanel reaches <SessionWorkflowInspector {...workflow} /> by spread; SessionWorkflowCockpit passes its resolved projection into PlanExecutionView explicitly. All three surfaces really do receive the shared object.

3. Executed evidence

Built the head commit in an isolated worktree (npm run generate && npm run build, both exit 0) and ran the suites:

  • The PR's three suites: 6/6 passed — PlanExecutionView.derivation.test.tsx (2), session-workflow-surfaces.test.tsx (3), session-workflow-model.index.test.ts (1).
  • The whole @qwen-code/web-shell suite: 8671 passed / 1 failed of 8672 across 328 files, 259 s.
  • Real-Chromium Playwright arm on this PR's exact surface: client/e2e/visuals/session-workflow.spec.ts — 2 passed (18.6 s), light and dark. That spec is the relevant one here because it does not just screenshot: it asserts the canvas renders 4 nodes and 3 edges, and then asserts the 1m 14s runtime is visible both on the node face and in the inspector's agent row. That second pair is the tool-call ↔ task linkage, which is precisely what threading one shared taskIndex could have broken silently while still drawing a plausible graph. Playwright started its own dev server (13 [WebServer] lines) on a port no spec uses.

About the one failure, since a green claim that hides it would be worthless. It is BranchPickerPopover.test.tsx > resets the remotes view and restores no focus after a workspace switch — an activeElement assertion. That file is not in this PR's changed set, and it is green three independent ways: 139/139 passed in isolation, twice, and 144/144 when run together with both of this PR's new test files (so the new files do not pollute it). The owning CI lane, Test (ubuntu-latest, Node 22.x), is success at this same head. It is a load-sensitive focus flake under a 259 s / 8672-test run, not a regression from this PR.

4. Mutation witnesses — the counting tests actually count

A derivation-count assertion is exactly the kind of test that can pass while measuring nothing, so I broke the sharing on purpose, once per surface, and restored:

mutation result
none (head as-is) 3/3 passed
SessionWorkflowCockpit ignores sharedProjection and rebuilds 1 failed — expected 1 to be +0 on builds no extra projection or index when the app passes one down; the two standalone cases still pass, correctly
PlanExecutionView (the embedded graph) ignores sharedProjection and rebuilds 2 failed — the shared case expected 1 to be +0, and derives the projection once for a standalone cockpit tree expected 2 to be 1

The second row is the one that matters: 2 → 1 is the graph's own private rebuild being counted and then caught, which is the deduplication this PR claims. Restoration is witnessed, not assumed — git diff --exit-code <head> -- packages/web-shell returns 0 and git status --porcelain is empty afterwards.

5. CI, stated precisely

Product lanes at this head are green: Lint & Static success, Test (ubuntu-latest, Node 22.x) success, Capture web-shell visuals success, web-shell E2E Smoke success, Desktop Shell (ubuntu-22.04) and (windows-2022) success, Integration Tests (no-AK, No Sandbox) success; Test (macos), Test (windows) and build-cli are skipped, not failing. Lane census total_count=42, items_fetched=42, match.

I am deliberately not writing "CI is green". The rollup reads statusCheckRollup.state = FAILURE and mergeStateStatus = BLOCKED, and the single non-green run in the census is the bot lane review-pr (completed / failure), whose latest run under that name is skipped — so a per-lane dedup hides the failure the rollup still counts. That lane is review infrastructure, not a product test.

6. The one thing still blocking this PR is not code

reviewDecision = CHANGES_REQUESTED comes from the triage bot's 2026-09-07 review at the then-head a0e85e8063c3, which is a description-template gate and says so itself — "nothing in this pass is a judgment on the code itself". A maintainer has since approved the current head (wenshao, APPROVED at 6c4484138c2c, 2026-09-18T22:09:29Z, which is after the head commit's 2026-09-18T03:52:19Z).

The description was reworked afterwards (updated_at 2026-09-18T22:52:29Z) and now carries What this PR does, Why it's needed, Reviewer Test Plan and How to verify. Four template sections are still absent as headings: ### Evidence (Before & After), ### Tested on, ## Risk & Scope, ## Linked Issues (Fixes #10865 is at the top of the body rather than under the last one). Whether that gate is now satisfied is a maintainer's call, not mine — I am flagging it because it, not the code, is what keeps mergeStateStatus at BLOCKED with auto-merge armed since 2026-09-18T22:09:39Z.

One observation the triage bot already made at the stale head is still accurate at this one, so I am not re-filing it, only confirming it: the new app-level memo is gated on sessionWorkflowEnabled alone. planAgentTools short-circuits to [] when no workflow surface is open, but environmentAgentTasks is a plain memo over messages, so a chat-only session with the experimental flag on now pays one buildSessionWorkflowProjection plus one createTaskExecutionIndex per message update where before it paid nothing. That is a deliberate-trade-off question for the author, not a correctness defect.

Conclusion

No Critical found. The refactor does what it says: one projection and one task-execution index per render, shared by the cockpit, the inspector and the embedded graph, with the extraction verified behaviour-preserving function by function and every handed-over derivation checked against its original. The perf claim is pinned by tests that fail when the sharing is broken, and the surfaces still render and link correctly in a real browser.

As of the state read immediately before posting, this comment carries no approval and requests no changes. I am not approving here because the outstanding blocker is the documentation gate above, which only a maintainer can waive — and a maintainer already has approved, so an additional approval from me would not change reviewDecision while the bot's review stands undismissed.

中文说明

在 head 6c4484138c2c 的独立检出上完整验证了这个 PR 自己的保证,并用变异测试证明这些检查不是空转。代码改动中没有发现阻塞合并的缺陷。

先确认范围。 merge_base(base, head) == base(e5969d67d4fb),分支未分叉;三点 diff 与 GitHub 自身数字完全一致 —— 10 个文件,+931 / −470。覆盖是文件完备的:7/7 个生产文件都按 head blob 读过,而不是只看补丁。其余 3 个是测试。

1. 抽取是行为等价的,逐个函数比对。 把现在位于 taskExecutionIndex.ts 的每个函数与合并基线上 PlanExecutionView.tsx 里的原实现做空白归一化比对:18 个全部对上 —— 14 个逐字节相同,3 个只差新增的 export,1 个只是把 return cond ? tool : undefined 改写成 if (cond) return tool; return undefined。 语义零差异。编译层面:PlanExecutionView.tsx 导入或再导出的 16 个符号在新模块中全部存在;也没有引入循环依赖。

2. 图交出去的每一项推导都语义相同。 八项逐一核对(见上表)。其中两项最值得点出:progressPercent 保留了 Math.floor(它喂给 aria-valuenow,取整会在长计划仍有步骤未完成时报出 100%);dependentsByTodo 保留了自依赖与未知 id 的过滤条件。memo 依赖从 [taskIndex, todos, tools] 改为 [projection, todos] 仍然正确。我也把新增的可选 projection prop 沿每一跳追到底,确认它不是"声明了却从未转发"的空开关 —— ArtifactPanel 是通过 {...workflow} 展开传给 SessionWorkflowInspector 的。

3. 执行证据。 在隔离 worktree 中构建 head(npm run generate && npm run build 均 exit 0):PR 自带的三个套件 6/6 通过;@qwen-code/web-shell 整套 8672 个测试通过 8671、失败 1(328 个文件,259 秒);真实 Chromium 的 Playwright 臂 session-workflow.spec.ts 2 passed (18.6s)(明暗两套主题)。该 spec 之所以关键,是因为它不只截图:它断言画布渲染出 4 个节点、3 条边,并断言 1m 14s 运行时长同时出现在节点面和 inspector 的 agent 行上 —— 后者正是"共享同一个 taskIndex"最可能悄悄弄坏、却仍能画出看似正常图形的工具调用↔任务关联。

关于那一个失败。 它是 BranchPickerPopover.test.tsx 里的一个 activeElement 断言,该文件不在本 PR 的改动集内,并且三种方式独立复现为绿:单独运行 139/139 通过,两次;与本 PR 两个新测试文件一起运行 144/144 通过(说明新文件没有污染它);同一 head 上归属的 CI lane Test (ubuntu-latest, Node 22.x) 为 success。它是 259 秒 / 8672 测试负载下的焦点时序偶发失败,不是本 PR 引入的回归。

4. 变异见证 —— 计数测试真的在计数。

变异 结果
无(head 原样) 3/3 通过
SessionWorkflowCockpit 忽略 sharedProjection 自行重建 1 失败 —— expected 1 to be +0;两个 standalone 用例仍正确通过
PlanExecutionView(内嵌图)忽略 sharedProjection 自行重建 2 失败 —— 共享用例 expected 1 to be +0,standalone cockpit 用例 expected 2 to be 1

第二行最关键:2 → 1 正是图自己那次私有重建被计入并被抓住,也就是本 PR 声称消除的那一次。恢复是有见证的:git diff --exit-code <head> -- packages/web-shell 返回 0,git status --porcelain 为空。

5. CI 的精确表述。 该 head 上生产 lane 全绿(Lint & Static、Test (ubuntu-latest, Node 22.x)、Capture web-shell visuals、web-shell E2E Smoke、两个 Desktop Shell、Integration Tests (no-AK, No Sandbox) 均为 success;Test (macos)、Test (windows)、build-cli 为 skipped 而非失败)。lane 普查 total_count=42、items_fetched=42,一致。

我刻意不写"CI 全绿":rollup 读作 statusCheckRollup.state = FAILURE、mergeStateStatus = BLOCKED,普查中唯一非绿的运行是机器人 lane review-pr(completed / failure),而该名称下最新一次运行是 skipped —— 按 lane 去重会掩盖 rollup 仍然计入的那次失败。该 lane 属于评审基础设施,不是产品测试。

6. 唯一还阻塞这个 PR 的不是代码。 reviewDecision = CHANGES_REQUESTED 来自 triage 机器人 2026-09-07 在当时 head a0e85e8063c3 上的评审,那是描述模板关卡,它自己也这么写 —— "nothing in this pass is a judgment on the code itself"。维护者随后已批准当前 head(wenshao,APPROVED @ 6c4484138c2c,2026-09-18T22:09:29Z,晚于 head 提交的 2026-09-18T03:52:19Z)。描述在此之后被重写过(updated_at 2026-09-18T22:52:29Z),现已包含 What this PR does、Why it's needed、Reviewer Test Plan、How to verify;仍有四个模板章节作为标题缺失:### Evidence (Before & After)、### Tested on、## Risk & Scope、## Linked Issues。这道关卡是否已满足属于维护者判断,我只是指出:让 mergeStateStatus 停在 BLOCKED(且自 2026-09-18T22:09:39Z 起已开启自动合并)的是它,而不是代码。

triage 机器人在旧 head 上提过的一条观察在当前 head 依然成立,所以我不再重复提交,只作确认:新的 app 级 memo 只以 sessionWorkflowEnabled 为条件。planAgentTools 在没有工作流界面时会短路成 [],但 environmentAgentTasks 是对 messages 的普通 memo,因此开着该实验开关的纯聊天会话,现在每次消息更新都会付出一次 buildSessionWorkflowProjection 加一次 createTaskExecutionIndex,而此前是零。这是留给作者权衡的取舍问题,不是正确性缺陷。

结论:未发现 Critical。 按发帖前即时读到的状态,本条评论不含批准、也不要求修改。我不在此批准,是因为尚未解除的阻塞是上面那道文档关卡,只有维护者能豁免 —— 而维护者已经批准过,在机器人评审未被 dismiss 的情况下,我再加一个批准也不会改变 reviewDecision。

— independent verification by qqqys; built and executed at head 6c4484138c2c3f0669cd727b098304192535f8d7

@wenshao
wenshao dismissed a stale review September 23, 2026 18:56

fixed

@wenshao
wenshao added this pull request to the merge queue Sep 23, 2026
Merged via the queue into QwenLM:main with commit 50d7e07 Sep 23, 2026
61 of 62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(web-shell): session workflow projection is derived three times per render

5 participants