Skip to content

fix(web-shell): show session active work - #11267

Merged
yiliang114 merged 11 commits into
mainfrom
codex/issue-11119-live-state
Sep 8, 2026
Merged

fix(web-shell): show session active work#11267
yiliang114 merged 11 commits into
mainfrom
codex/issue-11119-live-state

Conversation

@yiliang114

@yiliang114 yiliang114 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds a wire-additive per-session active-work state derived from the daemon's existing local work counters, negotiated child hold snapshots, category coverage, and freshness window. The workspace live-state response, TypeScript SDK, trusted session catalogs, and ACP list projection carry the state without changing hasActivePrompt. Web Shell session rows keep the existing green foreground-turn indicator, show a blue indicator and spinner for other known active work, and show an explicit unknown marker when negotiated reporting is stale or incomplete. The Todo panel spinner uses the same live state so persisted todos do not look active after session work settles.

Why it's needed

The session list currently becomes indistinguishable from idle as soon as a foreground prompt settles, even when the Session still owns a background Agent, shell, workflow, cron/goal turn, history mutation, queued prompt, or pending terminal continuation. Reusing hasActivePrompt would incorrectly turn those states into foreground turns. The new four-state value preserves positive activity, proves idle only from a fresh fully covered empty snapshot, and exposes degraded knowledge instead of silently treating it as idle.

This is stacked on #11265 so the active-work category set already includes child-owned Session turns.

Reviewer Test Plan

How to verify

  • Observe a Session while a foreground prompt runs and confirm the existing green running indicator remains unchanged.
  • Let that prompt settle while Session-managed background work remains held and confirm live-state reports activeWorkState: "active"; the sidebar keeps a distinct blue activity indicator and spinner.
  • Let the last hold settle and confirm a fresh fully covered empty snapshot reports idle and removes the activity indicator.
  • Make the negotiated hold report stale or omit a required category and confirm the state becomes unknown rather than idle.
  • Use an older or non-negotiating child and confirm the state is unsupported; older daemons that omit the field remain readable.

Evidence (Before & After)

Before: hasActivePrompt: false makes a Session with background work look idle. After: the same row carries activeWorkState: "active" and renders an independent activity indicator. No local screenshot was captured.

Tested on

OS Status
🍏 macOS ⚠️ not run
🪟 Windows ⚠️ not run
🐧 Linux ⚠️ not run

Environment (optional)

No local test, build, typecheck, or CI command was run. Verification was limited to two clean static production-flow reviews and git diff --check.

Risk & Scope

Linked Issues

Part of #11119

Depends on #11265

中文说明

本 PR 做了什么

新增一个向后兼容的 per-session active-work 状态,直接从 daemon 现有的本地 work 计数、协商后的 child hold snapshot、类别覆盖度和 freshness window 派生。workspace live-state 响应、TypeScript SDK、可信 session catalog 和 ACP list 投影都会携带该状态,但不改变 hasActivePrompt。Web Shell session 行继续用绿色表示正在运行的前台 turn;其他已知活动使用独立的蓝色指示点和 spinner;协商式上报过期或类别不完整时显示明确的未知状态。Todo 面板的 spinner 也使用同一份 live state,避免 session work 结束后 persisted todos 仍显示为活动。

为什么需要

当前前台 prompt 一结束,session list 就与真正 idle 无法区分,即使 Session 仍拥有后台 Agent、shell、workflow、cron/goal turn、history mutation、排队 prompt 或待处理终态 continuation。复用 hasActivePrompt 会错误地把这些状态伪装成前台 turn。新的四态值会保留正向活动证据,只在 fresh、全覆盖且为空的 snapshot 下声明 idle,并把退化状态明确暴露出来,而不是静默当作 idle。

本 PR 叠加在 #11265 之上,因此 active-work 类别集合已经包含 child-owned Session turn。

Reviewer Test Plan

如何验证

  • 前台 prompt 运行时观察 Session,确认现有绿色 running 指示不变。
  • prompt 结束但 Session-managed 后台 work 仍有 hold 时,确认 live-state 返回 activeWorkState: "active",侧栏继续显示独立的蓝色活动指示和 spinner。
  • 最后一个 hold 结束后,确认 fresh、全覆盖且为空的 snapshot 返回 idle,活动指示消失。
  • 让协商后的 hold report 过期或缺少必需类别,确认状态变为 unknown,而不是 idle
  • 使用旧版或未协商 active-work 的 child,确认状态为 unsupported;省略该字段的旧 daemon 仍可正常读取。

证据(Before & After)

Before:hasActivePrompt: false 会让仍有后台 work 的 Session 看起来 idle。After:同一行携带 activeWorkState: "active" 并渲染独立活动指示。未采集本地截图。

已测试平台

OS 状态
🍏 macOS ⚠️ 未运行
🪟 Windows ⚠️ 未运行
🐧 Linux ⚠️ 未运行

环境(可选)

未运行本地测试、build、typecheck 或 CI 命令。验证仅包含两轮干净的生产链路静态审核与 git diff --check

风险与范围

关联 Issue

Part of #11119

Depends on #11265

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Qwen Triage ended earlyview run. It stopped before finishing; check the run log.

⚠️ Qwen Triage 提前结束 —— 查看运行。未跑完,请查看运行日志。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — every required heading is present, including the Chinese translation and a genuinely filled-in Risk & Scope rather than a stub.

Problem: observed, not theoretical. #11119 is an open priority/P1 type/bug with a named field session (b3c08d15-15e1-46cd-b6e1-1bd03b8298d3, 2026-09-05), an hour-by-hour timeline and a screenshot, and it calls this exact gap out: live-state reports hasActivePrompt: false and "offers no other liveness signal", so the session list is indistinguishable from idle. One thing worth stating plainly so nobody misreads the scope: this is the observability half of that issue. The root cause #11119 identifies — step 3, the runtime→session route binding going dead across a lifecycle transition while the OS-level task and socket stay alive — stays out of scope, and the design doc says so explicitly. Merging this does not unwedge the session; it makes the wedge visible instead of silent. That is a legitimate split and it is honestly labelled, but it should not be read as a fix for #11119.

Direction: aligned, and the reference signal is unusually direct. The Claude Code CHANGELOG carries "Fixed the terminal progress indicator (iTerm2, Ghostty, ConEmu) showing the session as finished while a background workflow or agent was still running" — the same failure class — plus "[VSCode] Added a hollow ring in the session list for sessions open in a terminal, another VS Code window, or Claude Desktop, so they no longer look closed", which is the same move of giving a session row a distinct visual state for a liveness condition that would otherwise read as closed.

Size: Stage 0 applies via the cross-package clause — the diff spans four packages (acp-bridge, cli, sdk-typescript, web-shell). Breakdown: 101 production lines, 38 docs lines, 0 test lines, 0 generated/schema lines (139 total = 128 additions + 11 deletions). Comfortably under both the 500 and 1000 thresholds, and you hold admin on the repo so the maintainer exemption applies regardless — no size escalation. The zero test lines is what I'd flag, and I come back to it below.

Approach: before reading the diff I'd proposed something smaller — one hasBackgroundWork boolean beside hasActivePrompt, absent-field-means-render-nothing, no new negotiation. Having read it, my boolean would have been wrong on this base. Capability negotiation, hold categories and a freshness window already exist here, and once they do, "no fresh report" and "fresh empty report" are genuinely different facts; collapsing them renders a stale or category-incomplete child as idle, which is precisely the silent failure #11119 is about. So unknown earns its place at the contract level.

Two things I'd still push on:

  • The UI surface for unknown is a literal ? glyph. "Degraded knowledge" is the right idea in a wire contract; in a sidebar it is a punctuation mark a user has to decode, and it lands on every row whose child happens to be mid-heartbeat or negotiating an older category set. Have you considered rendering it as nothing — the same as unsupported — and keeping unknown as a wire and tooltip fact only? That preserves the honest state machine without adding a new glyph vocabulary to the session list.
  • Nothing has compiled this change, and no CI lane will cover the PR as targeted. ci.yml and web-shell-visuals.yml both trigger pull_request only on base main/release/**; this PR's base is codex/issue-11118-session-hold, so the lint/static/unit lane and the web-shell visual diff are filtered out rather than failing or pending. Combined with the description's "No local test, build, typecheck, or CI command was run", a cross-package wire-contract change currently has zero compile or test signal behind it. Detail and the real check list are in the next comment.

Risk: the Stage 1e high-risk path scan matched nothing, so no elevated-risk signal from revert history. There is a different dependency worth naming though: this PR's unknown/idle semantics only hold on top of #11265, which adds the session hold category the goal and cron path relies on. Merge order is therefore load-bearing, not incidental — retargeting onto main before #11265 lands would change which states get emitted.

Flagging these for discussion before diving deeper.

中文说明

感谢贡献!

模板完整 ✓ —— 所有必需小标题都在,包含中文说明,Risk & Scope 也是认真填写的,不是占位。

问题: 是已观测到的问题,不是理论性加固。#11119 是一个仍处于 open 的 priority/P1 type/bug,带有具体现场 session(b3c08d15-15e1-46cd-b6e1-1bd03b8298d3,2026-09-05)、逐小时时间线和截图,并且直接点出了这个缺口:live-state 只报 hasActivePrompt: false,"没有提供任何其他 liveness 信号",因此 session 列表与真正 idle 无法区分。有一点需要说清楚,以免范围被误读:本 PR 是该 issue 的可观测性那一半#11119 定位的根因——第 3 步,即 runtime→session 的路由绑定在生命周期切换中失效,而 OS 层任务与 socket 仍然存活——仍在范围之外,设计文档也明确写了。合并本 PR 不会解开卡死的 session,只是让卡死从"静默"变成"可见"。这个拆分是合理的,标注也诚实,但不应被理解为修复了 #11119

方向: 对齐,而且参考信号非常直接。Claude Code 的 CHANGELOG 里有 "Fixed the terminal progress indicator (iTerm2, Ghostty, ConEmu) showing the session as finished while a background workflow or agent was still running"——同一类故障;还有 "[VSCode] Added a hollow ring in the session list for sessions open in a terminal, another VS Code window, or Claude Desktop, so they no longer look closed"——同样是给 session 行加一个独立视觉状态,用来表达否则会被读成"已关闭"的 liveness 情况。

规模: Stage 0 因跨包改动而适用——diff 横跨四个包(acp-bridgeclisdk-typescriptweb-shell)。明细:生产代码 101 行、文档 38 行、测试 0 行、生成/schema 0 行(合计 139 = 128 增 + 11 删)。远低于 500 与 1000 两个阈值,且你在本仓库持有 admin 权限,维护者豁免同样适用——不因规模升级。我要点出的是测试 0 行,下面会再展开。

方案: 在读 diff 之前我提出的是更小的方案——在 hasActivePrompt 旁边加一个 hasBackgroundWork 布尔值,字段缺失就不渲染,不引入新的协商。读完之后,我认为在这个 base 上我那个布尔方案是错的。能力协商、hold 类别和 freshness window 在这里已经存在;一旦存在,"没有新鲜上报"与"新鲜且为空的上报"就是两个不同的事实,把它们压成一个布尔值会让过期或类别不完整的 child 被渲染成 idle——而这正是 #11119 里那种静默故障。所以在契约层面 unknown 是有必要的。

仍有两点我想提出来:

  • unknown 在 UI 上是一个字面的 ? 字符。 在 wire 契约里"知识退化"这个想法是对的;但在侧栏里它是一个需要用户去解读的标点符号,而且任何 child 正处在心跳间隙、或协商到的类别集合较旧时,行上都会出现它。有没有考虑过把它渲染成"什么都不显示"(与 unsupported 一致),只把 unknown 保留在 wire 和 tooltip 层面?这样既保留了诚实的状态机,又不必给 session 列表增加一套新的图形词汇。
  • 这份改动还没有被任何编译器检查过,而且按当前 target 也不会有任何 CI lane 覆盖它。 ci.ymlweb-shell-visuals.ymlpull_request 触发都只针对 base 为 main/release/**;本 PR 的 base 是 codex/issue-11118-session-hold,所以 lint/静态检查/单测 lane 以及 web-shell 视觉 diff 是被过滤掉了,而不是失败或等待中。再加上描述里写的"未运行本地测试、build、typecheck 或 CI 命令",一个跨包的 wire 契约改动目前背后没有任何编译或测试信号。明细和真实的 check 列表见下一条评论。

风险: Stage 1e 高风险路径扫描没有命中,因此从 revert 历史角度看没有升级风险信号。但有另一个依赖值得点名:本 PR 的 unknown/idle 语义只有建立在 #11265 之上才成立,因为 goal 与 cron 路径依赖 #11265 新增的 session hold 类别。所以合并顺序是关键性的,不是附带细节——在 #11265 落地之前把本 PR retarget 到 main,会改变实际发出的状态。

先提出来讨论,再深入看代码。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 3dd860aafba52463dba5c52fe34fea8a6347067d · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

I traced the new field hop by hop before judging the logic, because an optional field added to a wire contract fails silently — a missed projection just means the UI never sees it, with no error anywhere. The plumbing is complete:

  • One construction site. toSessionSummary (bridge.ts:4182) is the only place a BridgeSessionSummary is built, and both listWorkspaceSessions and getSessionSummary route through it, so patching it covers every summary consumer.
  • Three wire projections, all updated. The ACP list in dispatch.ts, the live-state route in session.ts, and the standalone merge in standalone-session-service.ts. Each uses the same conditional-spread shape as its neighbours.
  • The SDK is a typed passthrough. getSessionLiveState issues a typed workspaceJsonRequest with no field-by-field mapping, so nothing drops the field in transit.
  • The store both merges and change-detects. applyLiveState writes it onto the catalog row and liveSessionSnapshotsEqual compares it, which matters — adding it to the merge alone would have left useSyncExternalStore bailing out of re-rendering on exactly the transition the indicator exists to show. That is the same class of bug Web shell: conversation and session loading indicators drop mid-turn on long tasks while the To Do spinner keeps spinning #9487 documents in the neighbouring comment, and it is handled.
  • I also checked the seven other hasActivePrompt sites in bridge.ts and the virtual-subagent-sessions.ts load payload. Those are attach/restore results and a diagnostics snapshot, not summary projections, so correctly not carrying the field.

BridgeSessionSummary is documented as the wire mirror of the SDK's DaemonSessionSummary with a "keep fields synchronized" note; both sides were updated in the same diff. The green/blue split also checks out against the CSS — .sessionStatusDot is --agent-blue-500 and .sessionStatusDotRunning overrides it to green, so reusing the base class for active work does render blue as described.

No correctness blocker, no security issue, and existing tests are unaffected (with the field absent every new branch is inert). Everything below is a Suggestion.

1. The merge order is load-bearing, not incidental. entryActiveWorkState decides unknown from ACTIVE_WORK_HOLD_CATEGORIES.some(c => !capability.categories.includes(c)). On main today that constant is ['agent','notification','shell','workflow'] and no child pushes a session-level hold; #11265 adds 'session' and the child-side { category: 'session', id: 'session:active-turn' }. So the goal/cron path only resolves to active because of the base branch. If this retargets onto main before #11265 lands, a Goal turn gives hasActivePrompt: true (via goalTurnActive) while entryHasLocalWork stays false — bridge.ts:7725's own comment says "Goal turns never set promptActive at all" — and a fresh, fully covered, empty snapshot then emits activeWorkState: 'idle' alongside hasActivePrompt: true. The sidebar still renders green because hasActivePrompt wins the ternary, so it is not user-visible, but the wire would contradict the doc's "idle proves idle". At the reviewed commit this is correct; the ask is to make the dependency machine-visible rather than only prose in the description.

2. No tests for the derivation. Zero test lines for a four-state function whose whole value is the idle/unknown boundary. bridge.test.ts already imports ACTIVE_WORK_HOLD_CATEGORIES and builds categories: [...ACTIVE_WORK_HOLD_CATEGORIES] fixtures (lines 354 and 428), so the matrix is cheap to pin: active from local work, active from a fresh hold, unsupported with no capability, unknown on a stale snapshot, unknown on a missing category, idle on fresh + covered + empty. Per this project's review rule a missing test is a Suggestion rather than a Critical — but here the untested path is close to being the substance of the change.

3. hasRunningSession quietly changes poll cadence. It gates sessionPollInterval, flipping the catalog poll from IDLE_SESSION_POLL_INTERVAL_MS to ACTIVE_SESSION_POLL_INTERVAL_MS. Widening it to background work means one session holding a long-running agent, shell or cron turn keeps the sidebar polling at the fast cadence for that whole duration. Probably intended — a stale indicator defeats the point — but it is a cost change and it is not in Risk & Scope.

4. Third copy of the coverage predicate. ACTIVE_WORK_HOLD_CATEGORIES.some(c => !capability.categories.includes(c)) now appears three times in bridge.ts: entryIsAutoCloseCandidate, activeWorkCoverage, and the new entryActiveWorkState. All three mean "this child cannot vouch for the full retention predicate". A named helper would stop the next category addition from having to hunt down all three.

5. Small UI and a11y nits. The archive-disabled tooltip still reads sidebar.archiveRunningDisabled when the row is only doing background work — you added sidebar.activeWork but no archive variant, so the reason shown is slightly wrong. The ? span carries aria-label but no role and no aria-hidden, while its sibling indicators use aria-hidden="true"; a bare <span>'s aria-label is not reliably exposed, so assistive tech announces a literal "?" rather than "Background activity unknown". The active-work dot shares .sessionStatusDot with completed-unread — they are mutually exclusive in the chain and the spinner differentiates them, so it reads fine, just note the colour alone is not the distinguisher. And on a background-work row the spinner now displaces the git-branch icon in the meta slot, same as a running prompt already does.

sequenceDiagram
    participant P1 as Child runtime Session
    participant P2 as ACP bridge
    participant P3 as serve routes
    participant P4 as Web Shell catalog store
    participant P5 as Sidebar row
    P1->>P2: hold snapshot (categories, timestamp)
    P2->>P2: entryActiveWorkState - local work, freshness, category coverage
    P3->>P2: listWorkspaceSessions and live-state
    P2-->>P3: BridgeSessionSummary with activeWorkState
    P3-->>P4: wire row, field optional
    P4->>P4: applyLiveState merge plus snapshot equality check
    P4-->>P5: catalog row
    P5->>P5: green when hasActivePrompt, blue dot plus spinner when active, marker when unknown
Loading
Files changed (13)
File What changed
docs/design/web-shell/session-active-work-live-state.md New design doc: the four-state contract, and an explicit scope line ruling out persistence and route rebinding
docs/developers/qwen-serve-protocol.md Documents the field on both the trusted list and live-state responses, including what each state means
packages/acp-bridge/src/bridge.ts The derivation itself, plus wiring it into the single summary constructor
packages/acp-bridge/src/bridgeTypes.ts Declares the optional field on the summary interface
packages/cli/src/serve/acp-http/dispatch.ts Forwards it on the ACP session list projection
packages/cli/src/serve/conversations/standalone-session-service.ts Carries it through the live/persisted summary merge
packages/cli/src/serve/routes/session.ts Forwards it on the workspace live-state route
packages/sdk-typescript/src/daemon/types.ts Mirrors the field on both SDK summary and live-state types
packages/web-shell/client/components/sidebar/SessionDetailsTooltip.tsx Tooltip status text gains active-work and unknown branches
packages/web-shell/client/components/sidebar/WebShellSidebar.module.css One new muted style for the unknown marker
packages/web-shell/client/components/sidebar/WebShellSidebar.tsx Indicator, spinner, row class, archive gating and poll-cadence widening
packages/web-shell/client/i18n.tsx Two new keys in both EN and ZH
packages/web-shell/client/session-catalog/session-catalog-store.ts Merges the field onto catalog rows and adds it to change detection

Test evidence

This is an unattended CI run, so per the gate's rules I did not build, run or execute anything from this PR. The evidence below is the PR's own CI, read through the API for commit 3dd860aafba52463dba5c52fe34fea8a6347067d.

The important thing is not what is red — nothing is — it is what is absent. Qwen Code CI (the lint_and_static and test lane) does not appear as pending, skipped or failed; it was never triggered. Both ci.yml and web-shell-visuals.yml scope their pull_request trigger to base main and release/**, and this PR's base is codex/issue-11118-session-hold. So the lanes that would typecheck a cross-package contract change, run the acp-bridge suite, and render a before/after diff of the sidebar are all filtered out by the stacked base. What did run is Qwen Live Host CI — triggered because live-host.yml has a path filter on packages/sdk-typescript/src/daemon/types.ts and no branch filter — and tui-parity. Neither compiles the web-shell client nor exercises entryActiveWorkState.

The author's own statement that no local test, build or typecheck was run is quoted from the description; it is the author's claim, not evidence I reproduced.

Check Conclusion
Live Host (macos-latest) success
OpenTUI no-flicker gate success
TUI parity snapshots (ink vs opentui) success
delay-automatic-review success
assign success
label success
authorize (x2) success
verify skipped
tmux-testing skipped
precheck-pr (x2) skipped
resolve-pr skipped
review-config skipped
publish-resolution / publish-tmux / publish-verify skipped
ack-review-request skipped
review-pr in_progress (bot orchestration, not PR CI)
triage in_progress (this run)
Qwen Code CI (lint_and_static, test) never triggered — base branch filtered
Web-shell Visuals never triggered — base branch filtered

Not verified, and why: typecheck, lint and unit tests — no lane covers this base and none was run locally, so a compile error in the four-package contract change would currently be invisible. The four-state derivation — no test asserts any branch. The rendered sidebar indicators — the visual diff lane is filtered out, and no before/after capture was supplied ("No local screenshot was captured").

Sandboxed verification would settle this: @qwen-code /verify — that entryActiveWorkState actually emits unknown rather than idle for a negotiated-but-stale child, and unsupported for a non-negotiating one, is not observable from the diff, and because no lane on this base compiles packages/acp-bridge or runs its suite, the whole four-state matrix is currently unpinned by anything. For the sidebar surface itself, @qwen-code /tmux would show the blue active-work dot, the spinner and the unknown marker rendering alongside the existing green running dot — the Web-shell Visuals diff that would normally carry that is branch-filtered to base main and did not run.

中文说明

代码审查

在评判逻辑之前,我先逐跳追踪了这个新字段,因为加在 wire 契约上的可选字段是静默失败的——漏掉一处投影,只会让 UI 永远看不到它,任何地方都不会报错。整条链路是完整的:

  • 只有一个构造点。 toSessionSummary(bridge.ts:4182)是唯一构造 BridgeSessionSummary 的地方,listWorkspaceSessionsgetSessionSummary 都经过它,所以改这一处就覆盖了所有 summary 消费方。
  • 三处 wire 投影都改到了。 dispatch.ts 的 ACP 列表、session.ts 的 live-state 路由、standalone-session-service.ts 的 standalone 合并,都用了与相邻代码一致的条件展开写法。
  • SDK 是类型化直传。 getSessionLiveState 发的是带类型的 workspaceJsonRequest,没有逐字段映射,因此传输过程中不会丢字段。
  • store 既做了合并,也做了变更检测。 applyLiveState 把它写到 catalog 行上,同时 liveSessionSnapshotsEqual 会比较它——这一点很关键:只加合并而不加比较,useSyncExternalStore 会恰好在指示器本该显示的那次变更上跳过重渲染。这与相邻注释里记录的 Web shell: conversation and session loading indicators drop mid-turn on long tasks while the To Do spinner keeps spinning #9487 是同一类 bug,这里处理对了。
  • 我还核对了 bridge.ts 里另外七处 hasActivePrompt 以及 virtual-subagent-sessions.ts 的 load 返回体。那些是 attach/restore 结果和诊断快照,不是 summary 投影,因此不带这个字段是正确的。

BridgeSessionSummary 被注明是 SDK DaemonSessionSummary 的 wire 镜像,并要求"字段保持同步";两侧在同一个 diff 里都更新了。绿/蓝的区分也与 CSS 对得上——.sessionStatusDot--agent-blue-500.sessionStatusDotRunning 覆盖为绿色,所以复用基础类渲染 active work 确实是描述里说的蓝色。

没有正确性阻塞项,没有安全问题,现有测试也不受影响(字段缺失时所有新分支都是惰性的)。以下都是 Suggestion。

1. 合并顺序是关键性的,不是附带细节。 entryActiveWorkState 通过 ACTIVE_WORK_HOLD_CATEGORIES.some(c => !capability.categories.includes(c)) 判定 unknown。而 main 上这个常量目前是 ['agent','notification','shell','workflow'],也没有 child 会推送 session 级别的 hold;是 #11265 加入了 'session' 以及 child 侧的 { category: 'session', id: 'session:active-turn' }。所以 goal/cron 路径之所以能解析成 active,完全依赖 base 分支。如果本 PR 在 #11265 落地前 retarget 到 main,Goal turn 会让 hasActivePrompt: true(经由 goalTurnActive),而 entryHasLocalWork 仍为 false——bridge.ts:7725 自己的注释就写着"Goal turns never set promptActive at all"——于是一份新鲜、全覆盖且为空的 snapshot 会发出 activeWorkState: 'idle'并且同时 hasActivePrompt: true。侧栏仍然渲染绿色(三元判断里 hasActivePrompt 优先),所以用户看不到问题,但 wire 会与文档里"idle 即证明空闲"相矛盾。在被审查的这个 commit 上它是正确的;我的建议是让这个依赖变成机器可见的,而不只是写在描述里。

2. 派生逻辑没有测试。 一个四态函数,其全部价值就在 idle/unknown 的边界上,却是 0 行测试。bridge.test.ts 已经导入了 ACTIVE_WORK_HOLD_CATEGORIES,并且已有 categories: [...ACTIVE_WORK_HOLD_CATEGORIES] 的 fixture(354 与 428 行),所以这个矩阵成本很低:本地 work 导致 active、新鲜 hold 导致 active、无能力导致 unsupported、snapshot 过期导致 unknown、缺类别导致 unknown、新鲜+全覆盖+为空导致 idle。按本项目的 review 规则,缺测试是 Suggestion 而非 Critical——但这里未测的路径几乎就是改动的实质。

3. hasRunningSession 悄悄改变了轮询频率。 它控制 sessionPollInterval,会把 catalog 轮询从 IDLE_SESSION_POLL_INTERVAL_MS 切到 ACTIVE_SESSION_POLL_INTERVAL_MS。把它扩展到后台 work,意味着只要有一个 session 持有长时间运行的 agent、shell 或 cron turn,侧栏就会在整段时间里以快速频率轮询。这大概是有意的——指示器过期就失去意义了——但它是一项成本变化,而 Risk & Scope 里没有提到。

4. 覆盖度判断出现了第三份拷贝。 ACTIVE_WORK_HOLD_CATEGORIES.some(c => !capability.categories.includes(c)) 现在在 bridge.ts 里出现三次:entryIsAutoCloseCandidateactiveWorkCoverage,以及新增的 entryActiveWorkState。三处含义都是"这个 child 无法为完整的保留判据作证"。抽一个具名 helper,能让下次新增类别时不必去把三处都找出来。

5. UI 与无障碍小问题。 行内只有后台 work 时,archive 禁用的 tooltip 仍然显示 sidebar.archiveRunningDisabled——你加了 sidebar.activeWork,但没有对应的 archive 文案,所以给出的原因略有偏差。? 这个 span 带了 aria-label,但既没有 role 也没有 aria-hidden,而同级指示器用的是 aria-hidden="true";裸 <span>aria-label 不会被可靠地暴露,因此辅助技术会念出字面的"?",而不是"Background activity unknown"。active-work 圆点与 completed-unread 共用 .sessionStatusDot——两者在条件链里互斥,且 spinner 能区分它们,所以观感没问题,只是要说明单靠颜色并不能区分。另外在后台 work 的行上,spinner 现在会挤掉 meta 槽里的 git 分支图标,这与正在运行的 prompt 已有的行为一致。

测试证据

这是一次无人值守的 CI 运行,因此按 gate 规则我没有对本 PR 做任何 build、运行或执行。以下证据来自 PR 自己的 CI,通过 API 读取 commit 3dd860aafba52463dba5c52fe34fea8a6347067d

关键不在于哪些是红的——没有红的——而在于哪些根本不存在Qwen Code CIlint_and_statictest lane)既不是 pending、也不是 skipped 或 failed,而是从未被触发。ci.ymlweb-shell-visuals.yml 都把 pull_request 触发限定在 base 为 mainrelease/**,而本 PR 的 base 是 codex/issue-11118-session-hold。因此那些本应对跨包契约改动做 typecheck、跑 acp-bridge 测试套件、并渲染侧栏前后对比的 lane,全都被这个 stacked base 过滤掉了。真正跑起来的是 Qwen Live Host CI——它被触发是因为 live-host.ymlpackages/sdk-typescript/src/daemon/types.ts 有 path 过滤且没有分支过滤——以及 tui-parity。两者都不会编译 web-shell client,也不会执行 entryActiveWorkState

作者关于未运行本地测试、build 或 typecheck 的说明,是引自 PR 描述;那是作者的陈述,不是我复现出的证据。

未验证的部分及原因:typecheck、lint 与单元测试——没有 lane 覆盖这个 base,本地也没跑,所以四包契约改动里若存在编译错误,目前是不可见的。四态派生——没有任何测试断言其中任一分支。侧栏指示器的实际渲染——视觉 diff lane 被过滤掉,也没有提供前后对比截图("未采集本地截图")。

沙箱化验证可以定论:@qwen-code /verify —— entryActiveWorkState 对"已协商但上报过期"的 child 是否真的发出 unknown 而非 idle、对未协商的 child 是否发出 unsupported,从 diff 上无法观测;而由于这个 base 上没有任何 lane 会编译 packages/acp-bridge 或运行它的测试套件,整个四态矩阵目前没有任何东西钉住。至于侧栏界面本身,@qwen-code /tmux 可以展示蓝色 active-work 圆点、spinner 和 unknown 标记与现有绿色 running 圆点并排渲染的效果——平时承担这一点的 Web-shell Visuals diff 因分支过滤限定在 base main,本次没有运行。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 3dd860aafba52463dba5c52fe34fea8a6347067d · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — the code is coherent and I could trace every hop, but nothing has compiled or tested it, and I am not willing to attest to a green result that does not exist.

Stepping back: my independent proposal was a single boolean, and the PR's four-state contract is better than mine on this base. Once capability negotiation, hold categories and a freshness window already exist, "no fresh report" and "fresh empty report" really are different facts, and flattening them would reproduce the silent-idle failure that #11119 is about. The idle-only-from-a-fresh-fully-covered-empty-snapshot rule is the right inversion of the usual default, and the store change gets the part that is easiest to miss — adding the field to liveSessionSnapshotsEqual as well as the merge, without which useSyncExternalStore would have skipped re-rendering on exactly the transition being surfaced. If I inherited this in six months I would thank the author rather than curse them: the design doc states the contract, the protocol doc states it on both responses, and the scope line rules out the adjacent temptations explicitly.

What keeps this at 3 rather than 4 is verification, not design. Two facts sit next to each other and neither is the PR's fault individually:

  • No CI lane covers it. ci.yml and web-shell-visuals.yml scope pull_request to base main/release/**, and this PR's base is a stacked branch, so the lint, typecheck, unit and visual-diff lanes were never triggered — not skipped, not pending, never fired.
  • The description states no local test, build or typecheck was run either.

So a change that touches a synchronized wire contract across four packages, adds a state machine to a 14,867-line bridge and adds render branches to a 6,384-line sidebar component, currently has zero compile signal and zero test signal behind it. PENDING computes to 0 here, which normally means "approve now" — but on this base 0 means no lane will ever run, not every lane went green. Approving on that reading is precisely the false attestation the deferred path exists to avoid, so I am not approving and not emitting an approve-on-green marker.

Second reservation, narrower: the derivation is entirely untested, and its only interesting behaviour is the idle/unknown boundary. The scaffolding is already there in bridge.test.ts, so this is minutes of work, not a rewrite.

Third, the merge order is load-bearing. The goal/cron path resolves to active only because #11265 adds the session hold category; without it, a Goal turn emits hasActivePrompt: true together with activeWorkState: 'idle'. That is correct at this commit and wrong on main as it stands today, and nothing in the branch metadata encodes the ordering — only the description prose does.

None of this is a reason to rework the change, which is why this is a defer and not a request for changes. Two ways to unblock, either sufficient:

  1. Land fix(serve): report child-owned session turns as active work #11265 and retarget this onto main, so Qwen Code CI and Web-shell Visuals actually run and the visual diff shows the indicators. Then this is a straightforward approve.
  2. Keep the stacked base, but run npm run build && npm run typecheck plus cd packages/acp-bridge && npx vitest run src/bridge.test.ts locally, paste the real output, and add the four-state matrix test. A maintainer can also trigger @qwen-code /verify for the derivation and @qwen-code /tmux for the sidebar surface.

I tried to resolve an accountable owner to hand this to and could not: QWEN_MAINTAINER_HANDLE is unset, the PR carries no labels so the area-owner map matched nothing, and there are no human reviews yet to fall back on. Per the gate's rule I am posting this without an @mention rather than guessing a login. The author holds admin on this repo, so the call is theirs — flagging that no automated gate will fire on this PR until it is retargeted, which is the part that is easy to miss on a stacked branch.

中文说明

Confidence: 3/5 —— 代码是自洽的,每一跳我都能追通,但没有任何东西编译过或测试过它,而我不愿意为一个并不存在的绿色结果背书。

退一步看:我独立提出的方案是单个布尔值,而本 PR 的四态契约在这个 base 上优于我的方案。一旦能力协商、hold 类别和 freshness window 已经存在,"没有新鲜上报"与"新鲜且为空的上报"就确实是两个不同的事实,把它们压平会重现 #11119 所说的那种静默 idle 故障。"只有在新鲜、全覆盖且为空的 snapshot 下才发 idle"这个规则,是对常见默认值的正确反转;store 那一处改动也抓住了最容易漏掉的部分——除了合并之外还把字段加进了 liveSessionSnapshotsEqual,否则 useSyncExternalStore 会恰好在需要呈现的那次变更上跳过重渲染。如果六个月后由我来维护这份代码,我会感谢作者而不是骂他:设计文档写清了契约,协议文档在两个响应上都写清了,scope 一节还明确排除了相邻的诱惑。

让它停在 3 分而不是 4 分的是验证,不是设计。两个事实并排摆着,单独看都不是本 PR 的错:

  • 没有任何 CI lane 覆盖它。ci.ymlweb-shell-visuals.ymlpull_request 限定在 base 为 main/release/**,而本 PR 的 base 是 stacked 分支,所以 lint、typecheck、单测与视觉 diff lane 从未被触发——不是 skipped,不是 pending,是根本没触发。
  • 描述里也写明本地未运行任何测试、build 或 typecheck。

于是一个横跨四个包、修改了需保持同步的 wire 契约、给 14867 行的 bridge 加了状态机、又给 6384 行的 sidebar 组件加了渲染分支的改动,背后目前既没有编译信号也没有测试信号。这里 PENDING 算出来是 0,通常意味着"现在就批准"——但在这个 base 上,0 的含义是永远不会有任何 lane 运行,而不是所有 lane 都变绿了。按这种读法去批准,正是 deferred 路径要避免的虚假背书,所以我不批准,也不发出 approve-on-green 标记。

第二点保留意见更窄:派生逻辑完全没有测试,而它唯一有意思的行为就是 idle/unknown 的边界。bridge.test.ts 里的脚手架已经齐备,所以这是几分钟的工作量,不是重写。

第三点,合并顺序是关键性的。goal/cron 路径之所以能解析成 active,只是因为 #11265 加了 session hold 类别;没有它,Goal turn 会同时发出 hasActivePrompt: trueactiveWorkState: 'idle'。这在当前 commit 上是正确的,而在今天的 main 上是错误的,并且分支元数据里没有任何东西编码这个顺序——只有描述文字里有。

这些都不构成重做这份改动的理由,所以这是 defer,而不是 request changes。两种解法,任一即可:

  1. 先合并 fix(serve): report child-owned session turns as active work #11265,把本 PR retarget 到 main,让 Qwen Code CI 与 Web-shell Visuals 真正跑起来,视觉 diff 也能呈现这些指示器。那样这就是一个直接的 approve。
  2. 保留 stacked base,但本地运行 npm run build && npm run typecheck,加上 cd packages/acp-bridge && npx vitest run src/bridge.test.ts,把真实输出贴出来,并补上四态矩阵测试。维护者也可以触发 @qwen-code /verify 验证派生逻辑、@qwen-code /tmux 验证侧栏界面。

我尝试解析出一个可交接的责任人但没能成功:QWEN_MAINTAINER_HANDLE 未设置,PR 没有任何 label 因此 area-owner 映射没有命中,也还没有人类 review 可供回退。按 gate 规则,我选择不猜测登录名,直接发出这条不带 @mention 的评论。作者在本仓库持有 admin 权限,所以决定权在他手上——需要点明的是:在这个 PR 被 retarget 之前,不会有任何自动化 gate 在它上面触发,而这恰恰是 stacked 分支上最容易被忽略的一点。

Qwen Code · qwen3.8-max-2026-09-02

Reviewed at 3dd860aafba52463dba5c52fe34fea8a6347067d · re-run with @qwen-code /triage

…e-state

# Conflicts:
#	packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
@yiliang114
yiliang114 changed the base branch from codex/issue-11118-session-hold to main September 7, 2026 07:25
@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

🖼️ web-shell visual preview

Rendered against a mock daemon (no real backend): the PR base vs this PR head de6b2ab. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

terminal-turn-error-copy-narrow-dark before/after

Full-resolution recordings (.webm) are attached to the workflow run.

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head de6b2ab, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

health-deep-with-session

field PR base (before) this PR (after)
activeWork true false
activeWorkReporting "partial" "full"
activeWorkStaleMs 0 9

Qwen Code · serve A/B

@wenshao

wenshao commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Local verification against a real daemon — PR #11267 @ 6ff45b40

I built a real end-to-end environment for this rather than reading the diff, because an optional wire field fails silently: a missed projection just means the UI never sees it. Everything below was produced by a real qwen serve daemon built from this branch, serving the real Web Shell, driven by a scripted OpenAI-compatible model so the turns are deterministic. "Background work" here is a genuine OS process (sleep 1800) launched by a real run_shell_command tool call with is_background: true, whose PID I checked in GET /session/:id/tasks and in ps.

Verdict: the feature does exactly what the description says — but the branch cannot merge as-is. Two CI gates are red, and both have one-line fixes.


🔴 Blocking — 2 CI failures, both reproduced locally

B1. Prettier — Lint & Static (ubuntu-latest, Node 22.x) is already red on this head.

Running Prettier...
Checking formatting...
[warn] packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
[warn] Code style issues found in 1 file. Run Prettier with --write to fix.
##[error]Process completed with exit code 1.

It is the PR's own new hunk. main is clean on the same file; the two new ternary branches need to be collapsed onto one line each:

-            ) : sessionWorkActive &&
-              !scheduledTaskIcon &&
-              !completedUnread ? (
+            ) : sessionWorkActive && !scheduledTaskIcon && !completedUnread ? (
...
-            ) : activityUnknown &&
-              !scheduledTaskIcon &&
-              !completedUnread ? (
+            ) : activityUnknown && !scheduledTaskIcon && !completedUnread ? (

npm run format fixes it.

B2. SDK public-surface typecheck — Test (ubuntu-latest, Node 22.x) fails in @qwen-code/sdk.

> tsc --noEmit -p tsconfig.public-surface.json
test/unit/daemon-public-surface.test.ts(580,58): error TS2344:
  Property 'activeWorkState' is missing in type '{ sessionId: string; … }'
  but required in type '{ …; activeWorkState: …; }'

daemon-public-surface.test.ts pins DaemonSessionLiveState with an exhaustive expectTypeOf(...).toEqualTypeOf(...), so adding a field to the interface without adding it here is a hard failure. Fix at packages/sdk-typescript/test/unit/daemon-public-surface.test.ts:580:

     expectTypeOf<DaemonSessionLiveState>().toEqualTypeOf<{
       sessionId: string;
       clientCount: number;
       hasActivePrompt: boolean;
+      activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported';
       isWaitingForPermission: boolean;
       isWaitingForUserQuestion: boolean;
       updatedAt?: string;
     }>();

I applied both fixes locally: tsc --noEmit -p tsconfig.public-surface.json → exit 0, daemon-public-surface.test.ts → 21/21 green, Prettier clean.


✅ What the feature actually does — verified

before/after

Three live sessions, same scenario on both arms: a foreground turn still running, a settled turn that left a background shell running, and a genuinely idle session. On main the middle row is indistinguishable from the idle one — which is precisely the gap #11119 reports. On this branch it carries activeWorkState: "active", a blue dot and a spinner. The green running dot is unchanged on both arms, so hasActivePrompt really did keep its meaning.

All four values reproduced, none of them synthetic:

four states

The strongest result is the third panel. With a child that negotiates active-work but advertises only ['agent','notification'], a session with a real running sleep 1800 reports unknown — never idle. That is the PR's central safety claim, and it holds under a case that would have produced a false idle under any flattened boolean.

For unsupported and the degraded-category unknown I used a simulated older ACP child: the daemon under test is the unmodified PR bundle, and only the spawned child (selected with QWEN_CLI_ENTRY) answers the initialize handshake the way a pre-#8588 / pre-#9042 child would. The unknown-from-staleness path I could not reach by freezing a live child — the channel-liveness watchdog kills the channel at ~20 s, well before the 3 × 15 s active-work staleness window, so a wedged-but-alive child is a narrower window than the docs imply.

Negative control: kill <pid of sleep 1800> flips the row activeidle within 3 s and the blue dot disappears. The field tracks the real process, not a sticky flag.

All four wire projections carry the fieldGET /workspaces/:id/sessions/live-state, GET /workspace/:id/sessions, GET /session/:id/status, and ACP session/list over the WebSocket transport. Compatibility both ways: PR Web Shell against a daemon that omits the field renders exactly like main with no new console errors; client polling rate over 60 s is byte-identical between arms.

A side effect the description doesn't mention, and it's a good one:

archive

On main, Archive is enabled on a session that is still running a background shell, and I measured what that costs: archiving pid 3132303's session killed the sleep 1800 process within 6 s, with no warning. On this branch the menu item goes inert. Worth calling out in the PR body — it is a real data-loss footgun being closed.

One precision, though: the guard is client-side only. Calling POST /sessions/archive directly still archives an activeWorkState: "active" session and kills its background shell — I measured that on the PR arm too (pid 3131195, alive before, gone 6 s after). So this protects the Web Shell user, not an SDK or API caller.


🟡 Non-blocking observations

N1 — every freshly created session shows the ? marker for ~10–13 s.

tooltip and N1

Measured over three consecutive runs: 9669 ms, 12886 ms, 13081 ms, each settling to idle. The cause is upstream of this PR — applyActiveWorkSnapshot iterates info.sessionIds, so the snapshot the child publishes while the Session is being created finds no daemon entry yet, and the last snapshot is never re-applied, leaving the row to wait out a full 15 s report interval. But this PR is what makes it visible: clicking "New task" now paints a ? on the new row for over ten seconds on the most common path in the product. Worth either seeding childHoldsAt at registration, re-applying the cached snapshot when an entry appears, or suppressing the marker for sessions younger than one report interval.

N2 — unknown does not gate the destructive action. With activeWorkState: "unknown" and a background shell genuinely running, Archive stays enabled (disabled: false, no title). Not a regression — main behaves the same — but the PR's own rule is that unknown is not idle, and this is the one place the new knowledge isn't used. Gating archive on unknown as well would be the fail-closed reading; and given the measurement above, the durable place for that guard is the archive route rather than the menu item.

N3 — unsupported renders identically to idle. A row on a non-negotiating child shows no marker at all, so background work is invisible again. I think that's the right call (a ? on every row of an old child would be pure noise), but it is worth one sentence in the design doc, because unsupported is strictly more ignorant than unknown and yet renders as the confident state.

N4 — no tests. The diff adds zero test coverage for entryActiveWorkState, the three projections, liveSessionSnapshotsEqual's new field, or the sidebar rendering. bridge.test.ts already carries 119 activeWork assertions from #11265, so there is an obvious home for a four-case table test. The existing suites do stay green: bridge.test.ts 913 ✅, web-shell sidebar + session-catalog 528 ✅ (25 files), App.test.tsx 759 ✅, serve/routes/session* 135 ✅, workspace-qualified-rest + conversations + acp-http 830 ✅.

N5 — copy nit. The disabled-archive tooltip reads "A running session cannot be archived; archiving would end its turn". For active work there is no turn; a variant for the active-work case would read better.

N6 — heads-up on scope. Since #11269 was merged into this head branch, the diff against main now also carries the Todo-spinner gating (App.tsx, TodoPanel.tsx, session-catalog-hooks.ts). The PR body still describes only the live-state change.


Environment / what I did not check
  • Arms: PR 6ff45b40, base main 9a954b41 (the real merge base), plus two simulated-legacy-child arms and one mixed arm (PR UI + old daemon). Each arm is its own bundle, its own qwen serve, its own QWEN_HOME and workspace. A/B swap verified by grepping activeWorkState out of dist/chunks/*.js and dist/web-shell/assets/*.js — present on the PR arm, absent on base.
  • Linux only. eslint --max-warnings 0 on the changed files: clean. npm run check-i18n: clean. packages/acp-bridge and packages/web-shell tsc --noEmit: clean. The SDK browser-bundle size warning (237882 bytes vs the 221184 threshold) is byte-identical on both arms — pre-existing, not this PR.
  • Not checked: macOS/Windows, the agent / workflow / session hold categories (I exercised shell and daemon-local work), long-horizon behaviour over hours, and multi-workspace / untrusted-secondary projections.
中文版

本地真实环境验证 — PR #11267 @ 6ff45b40

我没有只读 diff,而是搭了一套真实的端到端环境:可选的 wire 字段一旦漏了某条投影,是静默失败的——UI 只是永远看不到它,哪里都不报错。下面所有结论都来自用本分支构建的真实 qwen serve daemon,由它自己托管真实 Web Shell,模型侧用脚本化的 OpenAI 兼容服务保证 turn 可复现。这里说的"后台任务"是真实的操作系统进程(sleep 1800),由真实的 run_shell_command + is_background: true 工具调用拉起,PID 在 GET /session/:id/tasksps 里都核对过。

结论:功能本身与描述完全一致,但这个分支现在不能合——两个 CI 门是红的,且各只需一行修复。

🔴 阻塞项 —— 2 个 CI 失败,本地均已复现

B1. Prettier —— Lint & Static (ubuntu-latest, Node 22.x) 在当前 head 上已经红了。

[warn] packages/web-shell/client/components/sidebar/WebShellSidebar.tsx
[warn] Code style issues found in 1 file. Run Prettier with --write to fix.
##[error]Process completed with exit code 1.

问题就出在本 PR 新增的那段代码上;同一文件在 main 上是干净的。两个新增三元分支各需要合并成一行:

-            ) : sessionWorkActive &&
-              !scheduledTaskIcon &&
-              !completedUnread ? (
+            ) : sessionWorkActive && !scheduledTaskIcon && !completedUnread ? (

npm run format 即可修复。

B2. SDK public-surface 类型检查 —— Test (ubuntu-latest, Node 22.x)@qwen-code/sdk 里失败。

test/unit/daemon-public-surface.test.ts(580,58): error TS2344:
  Property 'activeWorkState' is missing in type '{ sessionId: string; … }'

daemon-public-surface.test.ts 用穷举式的 expectTypeOf(...).toEqualTypeOf(...) 钉住了 DaemonSessionLiveState,接口加字段而这里不加就是硬失败。修复位置 packages/sdk-typescript/test/unit/daemon-public-surface.test.ts:580

       hasActivePrompt: boolean;
+      activeWorkState?: 'active' | 'idle' | 'unknown' | 'unsupported';
       isWaitingForPermission: boolean;

两处修复我本地都验过:tsc --noEmit -p tsconfig.public-surface.json 退出 0,daemon-public-surface.test.ts 21/21 通过,Prettier 干净。

✅ 功能验证结果

三个 live session,两臂跑同一套场景:一个前台 turn 仍在运行、一个 turn 已结束但留下后台 shell、一个真正空闲。在 main 上中间那行和空闲行完全无法区分——这正是 #11119 描述的问题;在本分支上它带着 activeWorkState: "active"、蓝点和 spinner。两臂的绿色 running 点完全一致,说明 hasActivePrompt 的语义确实没被改动。

四个取值全部复现,没有一个是造出来的。最有说服力的是第三个面板:当 child 协商了 active-work 但只上报 ['agent','notification'] 时,一个真的在跑 sleep 1800 的 session 报的是 unknown,而不是 idle——这正是本 PR 最核心的安全性主张,而且是在一个任何"压扁成布尔值"的方案都会误报 idle 的场景下成立的。

unsupported 和"类别覆盖不全导致的 unknown"这两臂用的是模拟的旧版 ACP child:被测 daemon 是未经修改的 PR 产物,只有通过 QWEN_CLI_ENTRY 指定的 child 在 initialize 握手时按 pre-#8588 / pre-#9042 的方式应答。"因过期而 unknown"这条路径我没能用冻结活 child 的方式走通——channel liveness 看门狗在约 20 秒就会杀掉整条 channel,远早于 3 × 15 秒的 active-work 过期窗口,所以"卡住但还活着的 child"这个窗口比文档暗示的要窄。

反向对照kill <sleep 1800 的 pid> 后 3 秒内该行从 active 翻回 idle,蓝点消失——这个字段跟的是真实进程,不是一个粘住的标志位。

四条 wire 投影都带上了该字段GET /workspaces/:id/sessions/live-stateGET /workspace/:id/sessionsGET /session/:id/status,以及 WebSocket 上的 ACP session/list。双向兼容性也验了:PR 的 Web Shell 连到不带该字段的旧 daemon,渲染结果与 main 一致且没有新增 console 错误;60 秒内的客户端轮询次数两臂完全相同。

一个描述里没提、但值得单独说的副作用:在 main 上,一个仍在跑后台 shell 的 session,Archive 是可点的;我实测了它的代价——archive 之后 6 秒内 pid 3132303 那个 sleep 1800 进程就没了,全程无任何提示。本分支上这个菜单项变成了不可用。建议补进 PR 描述——这是实打实堵住了一个数据丢失的坑。

但有一点需要说准:这层保护只在客户端。 直接调 POST /sessions/archiveactiveWorkState: "active" 的 session 照样会被归档、后台 shell 照样被杀——这一条我在 PR 臂上也实测了(pid 3131195,调用前存活,6 秒后消失)。所以它保护的是 Web Shell 用户,不是 SDK / API 调用方。

🟡 非阻塞观察

N1 —— 每个新建 session 会显示 ? 标记约 10–13 秒。 连续三次测量:9669 ms、12886 ms、13081 ms,之后都落到 idle。根因不在本 PR:applyActiveWorkSnapshot 遍历的是 info.sessionIds,child 在 Session 建立过程中发出的那份快照在 daemon 侧还找不到对应 entry,而最后一份快照又不会被重新应用,于是该行只能干等满一个 15 秒上报周期。但让它变得可见的是本 PR:点一次 "New task",最常用的路径上就会出现十几秒的 ?。建议在注册时给 childHoldsAt 播种、entry 出现时重放缓存快照,或者对创建时间不足一个上报周期的 session 不画这个标记。

N2 —— unknown 没有拦住破坏性操作。activeWorkState: "unknown" 且后台 shell 真的在跑时,Archive 仍然可点(disabled: false,无 title)。这不是回归(main 也一样),但本 PR 自己的原则是"unknown 不等于 idle",这里恰恰是唯一没有用上新知识的地方。把 archive 也按 unknown 收紧,才是 fail-closed 的读法;并且结合上面的实测,这道闸更该放在 archive 路由上,而不是菜单项上。

N3 —— unsupported 的渲染和 idle 完全一样。 旧 child 上的行不画任何标记,后台任务又变回不可见。我认为这个取舍是对的(旧 child 上每行都挂 ? 纯属噪声),但值得在设计文档里补一句:unsupportedunknown 更"无知",却渲染成了那个自信的状态。

N4 —— 没有测试。 这个 diff 对 entryActiveWorkState、三条投影、liveSessionSnapshotsEqual 的新字段、侧栏渲染,一个测试都没加。bridge.test.ts 里已经有 119 处来自 #11265activeWork 断言,加一个四态表驱动测试是很自然的落点。现有套件确实都是绿的:bridge.test.ts 913 ✅、web-shell sidebar + session-catalog 528 ✅(25 个文件)、App.test.tsx 759 ✅、serve/routes/session* 135 ✅、workspace-qualified-rest + conversations + acp-http 830 ✅。

N5 —— 文案小问题。 Archive 禁用时的 title 是 "A running session cannot be archived; archiving would end its turn",但 active work 场景下并没有"turn",建议为该场景单独一句文案。

N6 —— 范围提醒。 由于 #11269 已经合进了本分支的 head,现在相对 main 的 diff 还带上了 Todo spinner gating(App.tsxTodoPanel.tsxsession-catalog-hooks.ts),而 PR 描述里只写了 live-state 这部分。

环境与未覆盖项

  • 臂:PR 6ff45b40、base main 9a954b41(真实 merge base),外加两个模拟旧 child 的臂和一个混合臂(PR 前端 + 旧 daemon)。每臂各自独立构建、独立 qwen serve、独立 QWEN_HOME 与 workspace。A/B 是否真的换过,用 dist/chunks/*.jsdist/web-shell/assets/*.jsactiveWorkState 的存在性核对过:PR 臂有、base 臂没有。
  • 仅 Linux。改动文件的 eslint --max-warnings 0:干净;npm run check-i18n:干净;packages/acp-bridgepackages/web-shelltsc --noEmit:干净。SDK 浏览器包体积告警(237882 字节 vs 221184 阈值)两臂逐字节相同,属既有问题,与本 PR 无关。
  • 未覆盖:macOS / Windows、agent / workflow / session 三类 hold(我跑的是 shell 与 daemon 本地 work)、数小时量级的长时行为,以及多 workspace / 非受信 secondary 的投影。

🤖 Generated with Claude Code — Claude Opus 5

Resolve packages/acp-bridge/src/bridge.ts: main extracted
hasInFlightPromptActivity() (promptActive || goalTurnActive || a parked
deferred restore prompt) where this branch still had the inline two-term
expression, so take main's helper and keep this branch's adjacent
activeWorkState field. The helper is a strict superset of the expression
it replaces.

Also fix the Test job's typecheck failure: DaemonSessionLiveState gained
`activeWorkState`, so the public-surface pin in
packages/sdk-typescript/test/unit/daemon-public-surface.test.ts failed
with TS2344. Add the optional union to the expected literal so it matches
the interface field for field.

Verified locally: no conflict markers, git diff --check clean, prettier
--check clean on both edited files, and the PR's per-file footprint is
byte-for-byte the same before and after the merge (bridge.ts +20/-0, and
identical numstat for the seven other files both sides touched). Tests
not run: no dependency tree on this machine matches this lockfile.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Patrol-Run: qwen-pr-conflict/jmtrcr08gpe
@yiliang114
yiliang114 enabled auto-merge September 8, 2026 06:33
@yiliang114

yiliang114 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the real-daemon verification. I rechecked each item against the current head.

  • B1/B2 were already fixed by 23e7c4a, and the required checks were green before this follow-up.
  • N1 was still reproducible. Fixed in 7e9567c: the bridge retains validated active-work rows received while newSession is registering, then seeds only the explicitly matching session row. Missing rows remain unknown.
  • N4: added assertions for active, idle, unknown, and unsupported, plus a regression test for the registration race. The full bridge test file passes (914/914); build and typecheck also pass after merging current main.
  • N2 is intentionally unchanged here: guarding explicit daemon archive/close routes would change their forceful API semantics and needs a separate product/contract decision.
  • N3 remains intentional compatibility behavior for legacy children; N5 is copy-only and not needed for this fix; N6 is already reflected in the current PR body.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

Resolved the review against the current head de6b2ab89a5a.

  • B1/B2 were stale: formatting and the SDK public-surface type pin are already fixed on the current branch.
  • N1 is fixed at the source by 7e9567c0f62d: the bridge retains the latest child snapshot and applies it when the Session entry is registered; the current tests cover all four states plus the registration race.
  • N3 remains intentional compatibility behavior: an older child reports unsupported without adding a noisy unknown marker.
  • N4 no longer applies: the branch now has four-state bridge coverage, the registration-race regression case, and the SDK public-surface assertion.
  • N5 is fixed by using work-accurate archive copy instead of saying every active Session has a running turn.
  • N6 is fixed in the PR description: Todo spinner gating is now stated as included and removed from out-of-scope.
  • N2 is deliberately not folded into this PR: making the archive API fail closed for unknown changes the public route contract and product semantics. This PR keeps the bounded Web Shell guard for known active work.

Verification for this follow-up: git diff --check is clean. Per project workflow, no local CI was run and the newly triggered automated checks were not awaited.

@qwen-code-dev-bot qwen-code-dev-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

门禁 Review — head de6b2ab89a

决定:REQUEST_CHANGES,一条。 daemon 侧我全看了、也认可;卡住的是客户端这一半整块没有任何断言覆盖,而它改的是用户看得见的行为。

已经核实没问题的部分(写清楚,免得下一轮重看)

  • 状态推导是 fail-closed 的:entryActiveWorkStatebridge.ts:3167)顺序是 本地 work 或新鲜 hold → active;没协商能力 → unsupportedchildWorkIsUnknown 或类别不全 → unknown;只剩全类别 + 新鲜快照才落到 idlechildHoldsAreFresh:3114)把 childHoldsAt === null 直接判为不新鲜,注释也写清了"旧快照不是'空闲'的证据,它是'没有报告'"——这正是这类 live-state 最容易做错的地方。entryHasActiveWork:3162)把 unknown 读成 busy,健康面不会因为沉默而放行回收,理由注释也在。
  • 注册竞态是对的::7053 用 channel 上保留的 snapshot 给"报告先于 newSession 返回"的会话补 childHolds/childHoldsAt,且 reportedChildHolds === undefined 时留 null/null(→ unknown,而不是 idle)。applies a snapshot received before session registration 把这条路跑通了 ✔。
  • 四个状态在 bridge.test.ts 都有断言(unknown/idle/active/unsupported)✔;wire 两处投影都是 !== undefined 才带字段 ✔ 旧 daemon 的响应形状不变;liveSessionSnapshotsEqual 把新字段纳进比较(否则 UI 根本不会重渲染,这一步很容易漏)✔;useDaemonActivePromptBridge 保留成 wrapper、新 useDaemonSessionActivityBridge 另出口,hasActivePrompt 语义完全没动 ✔。
  • 归档提示的措辞(en/zh 一起改)跟上了 running 的新含义,hasLiveActivity = true 的默认也保证了未接线调用方行为不变 ✔。

要补的:客户端新行为零覆盖

git grepsessionWorkActiveactivityUnknownhasLiveActivitydata-web-shell-session-active-work 这四个符号,在 packages/web-shell只命中生产代码,没有任何测试引用。同时 App.test.tsx 这次只是把 mock 从 useDaemonActivePromptBridge 换成 useDaemonSessionActivityBridge,并且固定返回 activeWorkState: undefined——也就是说现有用例一条都不会经过新分支。于是这几件事全部处于"改了也没人会红"的状态:

  1. 三态指示器:activeWorkState === 'active' 时那个蓝点、'unknown' 时带 aria-label?'unsupported'/缺字段时什么都不显示,以及绿点仍只代表前台 prompt。
  2. running = hasActivePrompt || sessionWorkActiveWebShellSidebar.tsx:4377,并被 :4709disabled={busy || isCurrent || running} 消费):这一行让有后台工作的会话把那个动作变成"可见但不可点",也就是改变了归档/删除等操作的可用性。这是本 PR 里唯一一处行为收紧,最需要一条用例钉住它的边界(active 让动作 inert;unknown 让它 inert)。
  3. todo.status === 'in_progress' && hasLiveActivityTodoPanel 的转圈从此依赖 live state,需要一条"会话工作结算后不再转圈"的用例,否则这个修复本身可以随时被改回去。

三条都是现有渲染套件里的 render 断言,加起来不到一两个小时;activeWorkState 只要让现有 mock 返回不同值就能进分支。补上我就签——daemon 侧不需要再动。

CI 事实。 head 上 Integration Tests (no-AK) 11m17s 绿,Test (ubuntu-latest)Lint & StaticServe A/BCapture web-shell visualsreview-pr 提交时仍在跑,没有任何红;页面 REVIEW_REQUIRED,还没有任何 review。顺带一句:0 条 thread 说明这是第一次正式被 review,不是我推翻谁的结论。

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — fix(web-shell): show session active work

No blocking findings. Approved.


Scope

Reviewed source only. 21 changed files, +252/-41 lines.

NOT reviewed: docs/design markdown (design doc, informational only) · local build/typecheck/runtime (working tree unavailable).


What was checked

Contract completeness (Class 1 — writer/reader symmetry)

New activeWorkState field traced end-to-end:

  • Declared in BridgeSessionSummary (bridgeTypes.ts:817), DaemonSessionSummary (sdk-types:1374), DaemonSessionLiveState (sdk-types:1601).
  • Written unconditionally by entryActiveWorkState() in bridge.ts; the return type is NonNullable<…> so every live session summary carries the field.
  • Forwarded in all three server projection paths: routes/session.ts, standalone-session-service.ts mergeLiveStandaloneSummary, dispatch.ts ACP list.
  • Read by session-catalog-store.ts equality check and merge, surfaced through useSessionActivePromptState / useDaemonSessionActivityBridge in hooks.
  • Consumed in WebShellSidebar (archive guard, running indicator, spinner, poll interval), TodoPanel (hasLiveActivity), App.tsx (hasLiveActivity prop computation).
  • No missing end found.

Race condition fix (Class 7/8 — snapshot arriving before session registration)

The hasSessionSpawnInFlight() guard in BridgeClient is correctly scoped:

() =>
  channelInfo?.sessionIds === sessionIds &&
  channelInfo.sessionSpawnsInFlight > 0
  • Channel-scoped: checks the closure-captured sessionIds set against the live channelInfo.sessionIds reference and the per-channel sessionSpawnsInFlight counter.
  • Counter lifecycle: incremented at bridge.ts:5449 (before newSession), decremented with Math.max(0, …-1) at bridge.ts:5826 (unconditionally after spawn attempt). No leak path visible.
  • Snapshot storage: applyActiveWorkSnapshot stores the full reported map in info.activeWork.snapshot. createSessionEntry reads childSnapshot?.sessions.get(sessionId) — a keyed lookup — so concurrent spawns pick up only their own holds by ID.
  • Stale-snapshot safety: childHoldsAt is set to childSnapshot.receivedAt, so the existing freshness gate (childHoldsAreFresh) applies. A snapshot older than intervalMs * ACTIVE_WORK_STALE_INTERVALS at registration time still produces 'unknown', not 'active'.
  • New test at bridge.test.ts:+486 specifically exercises the race and verifies the correct 'active''idle' transition.

State machine correctness (Class 10 — stated intent vs. code)

entryActiveWorkState() four-state mapping verified against PR description:

  • active: local work (pendingPromptCount > 0 or pendingAgentNotificationCount > 0) OR child-reported held work in a fresh snapshot.
  • unsupported: channel has no activeWork capability (non-negotiating child/old daemon).
  • unknown: capability present but snapshot is stale, OR negotiated categories don't cover ACTIVE_WORK_HOLD_CATEGORIES.
  • idle: all guards passed; only emitted from a fresh, fully-covered, empty snapshot.

Consistent with the PR's stated invariant: "prove idle only from a fresh fully covered empty snapshot."

Backward compatibility (Class 2 — API contract)

  • All three type additions use ? (optional). Older daemons that omit the field remain readable.
  • useDaemonActivePromptBridge preserved as a thin wrapper over useDaemonSessionActivityBridge. Callers that weren't updated continue to work.
  • TodoPanel hasLiveActivity defaults to true, preserving spinner behavior for all existing callers.
  • SDK public-surface test (daemon-public-surface.test.ts) updated to include the new field.

Archive guard behavior (Class 10)

session.activeWorkState !== 'active' — the guard intentionally does NOT block archival for 'unknown' or 'unsupported'. This matches the design: active is positive knowledge; unknown is degraded coverage, not confirmed activity. Correct by design.

Polling interval

hasRunningSession now includes activeWorkState === 'active' alongside hasActivePrompt. This keeps the 2-second poll cadence while background work is known active. Correct.


Unreviewed dimensions

  • Working tree unavailable → no local build, typecheck, or runtime execution. Execution-tier findings (test validity by mutation, rung 3 runtime behavior) not performed. No open finding requires runtime evidence to settle.
  • docs/design/web-shell/session-active-work-live-state.md not reviewed (design doc; no code).

Reviewed with AI assistance.

@yiliang114

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — findings reported (agent verdict) - workflow run

Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check.

Scripted assertions: 118 passed · 0 failed · 118 total

Flakiness gate: ✅ 4 changed test file(s) x 5 identical rounds, no divergence

中文 — 判定:❌ 不通过 · 报告了发现(agent 判定)

沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查

脚本断言:118 通过 · 0 失败 · 118 总计

抖动门:✅ 4 changed test file(s) x 5 identical rounds, no divergence

Verification report

PR #11267 deep verification — fix(web-shell): show session active work

Verdict: findings — 118 scripted assertions executed, 118 pass / 0 fail. The central claim is proven load-bearing against the base build, and the bundled race fix is proven to require both of its hunks. No Critical and no regression was found. Four findings are worth a reviewer's attention: one factually wrong step in the PR's own Reviewer Test Plan, one undocumented 15× change to a polling cadence, one undocumented suppression of a hover affordance, and one place where the new retention is broader than the race it fixes. A coverage census is reported separately and is not a merge condition.

Verified head: de6b2ab89a5aadc8956f44e54b766e90f0609fb0 (git rev-parse HEAD^2)
Base tip: cffc40495a34eb7d636c14e040172776aa82c9c2 (HEAD^1)
Effective diff: git diff HEAD^1..HEAD — 21 files, +252/−41.

中文摘要

结论:findings(有值得 reviewer 关注的问题,但无 Critical、无回归)。118 条脚本化断言全部通过(118 pass / 0 fail)。

A/B 结论:中心主张成立。用真实子进程 + 真实 stdio 管道驱动生产 spawn 路径,构造了 4 个构建分支(V0=base、V1=只回退 bridgeClient 过滤放宽、V2=只回退 createSessionEntry 播种、V3=PR head)。在同一条 channel 上放两个 session(一个有 hold、一个没有):base 两行的 hasActivePrompt 都是 false无法区分;head 分别报 active / idle。四态契约(active/idle/unknown/unsupported)逐格实测符合设计文档,且 hasActivePrompt 语义未被改变。注册竞态修复需要两个 hunk 同时存在:单独回退任一个,新测试都以 expected 'unknown' to be 'active' 变红(913/914 仍通过),证明新测试非空转。详见 Table 101-ab-matrix-four-arms.png02-mutation-matrix-vacuity.png

findings:① PR 自述的 Reviewer Test Plan 第 5 步不准确——"较旧的 child" 若协商了心跳但类别不全,实测是 unknown unsupported,版本偏斜时侧栏每行会长期显示 ?;② hasRunningSession 被放宽后,会话目录轮询从 30s 变为 2s(15×),Risk & Scope 未提及;③ running 被放宽后,后台工作会话的行内操作按钮在 hover/focus-within 时不再显现(键盘 focus-visible 仍可,已界定);④ 保留的快照按 sessionId 索引而非按触发它的 spawn 索引,实测可被之后以同一 id 注册的 session 继承(已界定:不跨 channel、失败方向偏 active 而非 idle)。

未覆盖范围:真实 daemon 的 HTTP 线路(3 处 CLI 透传投影仅做普查,未起真实服务);Web Shell 的真实浏览器渲染与截图;跨 channel 污染只给出静态链路论证(单 bridge 只有一条 live channel,无法构造);逐 commit 归因(浅克隆 depth 2,快照 11 个 commit 本地仅 1 个可达)。

Central claim and A/B proof

Central claim. A per-session four-state activeWorkState is derived in the bridge from daemon-owned work counters, negotiated child hold snapshots, category coverage and a freshness window; it attributes activity per session where base could only expose a bridge-wide boolean, and it does so without changing hasActivePrompt.

Secondary claim (bundled fix). An active-work snapshot that arrives before the session registers is no longer dropped, so the newly registered session reports active immediately instead of unknown.

Method: four build arms, not two

The secondary claim is two hunks that each independently suffice to break the fix, so a two-cell A/B cannot attribute it. I compiled four arms and asserted each arm's identity by marker census and by byte comparison:

arm bridge.js bridgeClient.js represents
V0 base f68d65e0… base 692bf5fb… HEAD^1 — neither hunk
V1 head 485e73b9… base 692bf5fb… filter relaxation reverted
V2 seed-reverted a679208e… head 2307b630… registration seeding reverted
V3 head 485e73b9… head 2307b630… PR head

V3's bridge.js and bridgeClient.js were asserted byte-identical to the CI's own head build, so V3 is the shipped artifact and not a re-build that could have drifted. Exactly three .js files differ base↔head (bridge.js, bridge.test.js, bridgeClient.js) and the .js file set is identical; bridgeTypes.js is byte-identical, which confirms the two type-only changes (BridgeSessionSummary, DaemonSessionSummary/DaemonSessionLiveState) emit nothing at runtime.

Every cell was produced by a real child process speaking NDJSON JSON-RPC over real stdio pipes, spawned by the production createSpawnChannelFactory via QWEN_CLI_ENTRY. The peer is a real ACP agent built on the shipped @agentclientprotocol/sdk AgentSideConnection; nothing in the bridge or BridgeClient is stubbed. Snapshots after registration are pushed over a real loopback TCP control socket.

Table 1 — activeWorkState by arm

Witness: 01-ab-matrix-four-arms.png. ABSENT = the optional wire field is not present.

scenario / observation V0 base V1 client-reverted V2 seed-reverted V3 head
S0 held row (child hold, no prompt) ABSENT active active active
S0 free row (no hold) ABSENT idle idle idle
S0 held hasActivePrompt false false false false
S0 rows distinguishable per session false true true true
S0 bridge-wide activeWork (unattributed) true true true true
S2 fresh empty snapshot, full coverage ABSENT idle idle idle
S3 negotiated but never reported ABSENT unknown unknown unknown
S4 legacy categories only ABSENT unknown unknown unknown
S5 categories absent ABSENT unknown unknown unknown
S6 no negotiation at all ABSENT unsupported unsupported unsupported
S7 daemon-owned work, during prompt ABSENT active active active
S7 daemon-owned work, after settle ABSENT unsupported unsupported unsupported
S8 RACE at registration (0.7 s spawn) ABSENT unknown unknown active
S9 RACE with an 8 s spawn ABSENT unknown unknown active
S9 RACE with a 13 s spawn TIMEOUT-10s TIMEOUT-10s
S10 1st session (no row named it) ABSENT unknown unknown unknown
S10 2nd session reusing that id ABSENT unknown unknown active
S11 proven idle → prompt → settle ABSENT idleactiveidle same idleactiveidle

Three readings of this table carry the verdict:

  1. The headline cell is S0. On base, two sessions on one channel — one held by a background agent, one genuinely free — both report hasActivePrompt: false. Base does know something is busy (bridge.activeWork: true), but that flag is bridge-wide and cannot say which session; distinguishable is false. Head reports active and idle on the two rows. That is exactly the gap the design doc names, and it is the only cell in the table where base and head differ on a fact base already possessed.
  2. hasActivePrompt is unchanged, as claimed: false in S0/S1/S2/S8/S10 on every arm, and true only in S7/S11 where a real foreground prompt is in flight. No cell turned background work into a foreground turn.
  3. The race fix needs both hunks. Reverting the BridgeClient filter alone (V1) or the createSessionEntry seeding alone (V2) each leaves S8/S9 at unknown; only V3 reaches active. This holds at a 0.7 s spawn and at an 8 s spawn.

The freshness threshold, bisected through the real code path

The seeding writes childHoldsAt = childSnapshot.receivedAt, so a pre-registration report could in principle go stale before the session registers. I bisected that threshold rather than sampling it:

  • The freshness window is intervalMs × ACTIVE_WORK_STALE_INTERVALS = intervalMs × 3. The daemon clamps a child's advertised cadence into [5 s, 60 s] (clampActiveWorkIntervalMs), so the window's floor is 15 s and its production default is 45 s (15 s × 3).
  • Driving a 13 s / 15 s / 17 s session/new produced BridgeTimeoutError: newSession timed out after 10000msidentically on V0 and V3. That is an A/A control: the failure is a pre-existing property of the bridge (DEFAULT_INIT_TIMEOUT_MS, present at base), not a regression.
  • Because 10 s < 15 s ≤ window, a slow spawn cannot stale the retained snapshot under default configuration. The 8 s cell (the slowest spawn the default timeout permits, with margin) still reports active on V3.

This consequence does not hold if an operator raises initializeTimeoutMs above 15 s; that configuration was not exercised.

Corrections to the PR description

These are corrections to the description, not requests to change the code.

  1. Reviewer Test Plan step 5 is inaccurate. It asks the reviewer to "use an older or non-negotiating child and confirm the state is unsupported". Those are two different outcomes. A child that never negotiates reports unsupported (S6, measured). But a child that negotiates active-work v1 while covering only the legacy categories reports unknown (S4, measured), and one that negotiates v1 with categories absent also reports unknown (S5, measured). The real acpAgent produces exactly that second shape: it answers a daemon that sends no categories array with ACTIVE_WORK_LEGACY_HOLD_CATEGORIES (['agent','notification']), and the daemon intersects what comes back with its own five-category set. Only a non-negotiating child yields unsupported.
  2. "a blue indicator and spinner" — the spinner is not blue. The new active-work dot is blue as described (--agent-blue-500, .sessionStatusDot), and the foreground-turn dot stays green (.sessionStatusDotRunning--success-color). But the spinner it also renders is .sessionLoading, whose border is --muted-foreground (grey). That is pre-existing styling reused correctly; only the description overstates the colour.

Findings

Ordered by severity. None is a Critical; none blocks the central claim.

F1 — Widening hasRunningSession silently raises catalog polling 15× for long-running background work (Suggestion)

hasRunningSession now counts session.activeWorkState === 'active'. Its consumer is not cosmetic — it selects the session-catalog poll cadence:

projectExpanded || hasRunningSession || selectedSessionSource === 'channel'
  ? (hasRunningSession || selectedSessionSource === 'channel') && !error
    ? ACTIVE_SESSION_POLL_INTERVAL_MS   // 2000
    : IDLE_SESSION_POLL_INTERVAL_MS     // 30_000

A workspace that previously polled every 30 s now polls every 2 s — 15× — for as long as any session reports background work. That is precisely the case this feature exists to surface (a background Agent, shell, workflow, or cron turn can run for many minutes), whereas hasActivePrompt was true only for the short duration of a foreground turn. Each browser tab with the sidebar open multiplies it.

Reproduce: grep -n "hasRunningSession" packages/web-shell/client/components/sidebar/WebShellSidebar.tsx (definition ~2325, consumer ~2366) and read the two constants at lines 159–160.

Bound — where it does not apply. The poll path is gated on sessionCatalogRequestsEnabled && !primaryWorkspaceSessionLiveStateEnabled, so a workspace using the live-state subscription is unaffected. It is reachable on the capability-absent / older-daemon path, where the catalog list itself now carries activeWorkState through the ACP projection. Suggested handling: name the cadence change in Risk & Scope, or gate the poll interval on hasActivePrompt alone while letting the indicator use the wider predicate.

F2 — Widening running suppresses the row's hover action reveal (Suggestion)

running is now Boolean(session.hasActivePrompt || sessionWorkActive), and running && styles.runningSession is applied to the row. .runningSession has no standalone rule; it appears only inside :not() selectors:

.sessionRow:hover:not(.runningSession) .sessionActions,
.sessionRow:focus-within:not(.runningSession) .sessionActions { opacity: 1 }

with .sessionActions { opacity: 0 } by default. So a session with background work no longer reveals its row actions on mouse hover or on focus-within, where before this PR it did.

Bound — the a11y reading is worse than the reality. .sessionActions:has(.sessionActionButton:focus-visible) still sets opacity: 1, so a keyboard user tabbing onto a button does reveal the actions; and opacity: 0 keeps the buttons focusable and clickable rather than removing them. The regression is a mouse-hover affordance, not a lost capability. It also sits oddly beside canArchiveNonCurrent, which deliberately keeps the archive action visible-but-inert for active work — the row now hides the button that the PR elsewhere took care to keep visible.

Reproduce: mutate running back to Boolean(session.hasActivePrompt) and compare the row's class list for a session with activeWorkState: 'active', hasActivePrompt: false.

F3 — The retained snapshot is keyed by session id, not by the spawn that caused it (Suggestion)

The BridgeClient filter now retains every row of a snapshot while any spawn is in flight (ownsSession(id) || hasSessionSpawnInFlight()), and applyActiveWorkSnapshot stores the whole map in info.activeWork.snapshot, which createSessionEntry later reads by id. The retention therefore outlives the spawn that justified it.

Measured (S10): a child emits exactly one snapshot — corroborated at the destination by its own stderr, emit seq=1 [{"sessionId":"FOREIGN",…}] — naming an id it does not own, while spawn #1 is in flight; spawn #1 yields id first, and spawn #2 then yields FOREIGN and sends nothing. On V3 the second session reports active at registration and activeWorkCoverage.covered goes 0→1, inheriting a hold reported before that session existed. V0/V1/V2 all report unknown.

This is not academic: doSpawn documents that "a caller-supplied id can legitimately reuse an id after an abandoned restore settles", so id reuse is a supported scenario.

Bound — what this is not.

  • Cross-channel contamination is disproved by the static chain, not merely unevidenced. info.activeWork.snapshot lives on ChannelInfo; applyActiveWorkSnapshot skips entries where entry.channel !== info.channel; createSessionEntry reads only its own ci.activeWork.snapshot; and a bridge holds a single live channel (ensureChannel returns the existing channelInfo unless it is dying). The invariant the deleted comment named — "a channel must never influence another channel's state" — still holds. It was not exercised at runtime because two simultaneously live channels are not constructible through the public API; see Not covered.
  • No new authority is granted. The child is already the authority on its own holds and can make any session it owns read active simply by reporting a hold after registration.
  • The failure direction is safe. A stale retained row yields active or unknown, never a false idle.

Suggested handling if the author wants it narrower: drop the retained row once the spawn that caused it settles, or stamp it with the in-flight spawn and ignore it otherwise.

F4 — Coverage census (completeness reporting, not a merge condition)

Witness: 02-mutation-matrix-vacuity.png. 12 mutations, each an exact string replacement that hard-fails if its target text is absent, so a mutation that silently did not apply can never be misreported as a survivor. The tree was asserted git status --porcelain-clean after every restore (12/12).

mutation what it removes suite result classification
M3 unsupportedidle (fails open) bridge.test red, 1 failed / 913 passed positive control
M1 the PR's BridgeClient hunk bridge.test red, 1 failed / 913 passed pinned
M2 the PR's createSessionEntry hunk bridge.test red, 1 failed / 913 passed pinned
M5 the entryHasLocalWork term bridge.test red pinned
M6 the info.activeWork.snapshot store bridge.test red pinned
WC revert the new i18n archive string sidebar-removal red positive control, same suite as W1/W2/W5
M4 the category-coverage unknown clause bridge.test green, 914 passed coverage gap
W1 the blue active-work dot branch sidebar-removal green, 132 passed coverage gap
W2 the activeWorkState !== 'active' archive guard sidebar-removal green coverage gap
W5 both tooltip branches sidebar-removal green coverage gap
W3 the TodoPanel hasLiveActivity gate TodoPanel green, 3 passed coverage gap
W4 live-state → catalog-row propagation hooks + sidebar green coverage gap

The new test is not vacuous, and the reds fail the intended assertion. M1 and M2 each turn exactly one test red — createAcpSessionBridge > active work > applies a snapshot received before session registration — with AssertionError: expected 'unknown' to be 'active' // Object.is equality. That names expected versus actual, so the revert reached the assertion rather than breaking an import or a fixture. The positive control M3 goes red on a different test (grades a child that never acknowledges the capability as none, expected 'idle' to be 'unsupported'), which proves the harness can make this suite fail and that each mutant is caught by its own test rather than by collateral damage. Every green survivor still collected tests (914 / 132 / 3), so none is an empty run.

Adjudication of the survivors. All six are ordinary coverage gaps — the behaviour is right, nothing asserts it. None is dead code and none is redundant defence:

  • M4 is the sharpest. Deleting the category-coverage clause leaves all 914 bridge tests green, yet that clause is the entire mechanism behind S4/S5 — it is what makes a legacy or pre-category child report unknown instead of a confident idle. The fix's own new tests pin the four reported shapes; the version-skew sibling is unpinned. Given F1's correction shows this shape is reachable in production, a fixture advertising categories: ['agent','notification'] with a fresh empty snapshot and asserting unknown would close it.
  • W1/W2/W5/W4: the whole Web Shell rendering half of this PR is unpinned. A census makes this concrete rather than inferred — repo-wide, only three test files mention activeWorkState at all (daemon-public-surface.test.ts, a type-level pin; bridge.test.ts, the real behavioural tests; and App.test.tsx), and App.test.tsx mentions it exactly once, as activeWorkState: undefined inside the mockUseDaemonSessionActivityBridge mock. Its 790 tests therefore cannot observe any active-work behaviour by construction. The positive control WC goes red in the same suite the sidebar mutants survive in, so the suite is live and collecting — it simply asserts nothing here.
  • The three CLI wire projections (acp-http/dispatch.ts, routes/session.ts, conversations/standalone-session-service.ts) are unpinned by census: zero test files under packages/cli reference activeWorkState; the only three files in that package which mention it are the three production projections the PR added.

A missing test for changed behaviour is a Suggestion, not a Critical, so none of the above blocks. But the unpinned axis is exactly where the next regression will land: mutating any of the six guards today is indistinguishable from not mutating it.

Not covered

  • The real HTTP wire. The three CLI projections are pass-through spreads verified by census and by reading, not by a live daemon request. I did not boot qwen serve and GET /workspaces/:ws/sessions/live-state; that needs a running runtime and auth this job does not have. The claim "the field reaches the wire" is therefore argued from the projection sites plus the SDK type pin, not observed on a socket. This reproduces the derivation end to end, not the transport.
  • Cross-channel contamination was not exercised at runtime. Disproved by static chain only (per-ChannelInfo map, the entry.channel !== info.channel guard, one live channel per bridge). A bridge holds a single live channelInfo, so two simultaneously live channels are not constructible through the public API; forcing one would have meant driving a dying channel, which is a different question.
  • No browser rendering. The blue dot, the ? marker, the spinner and the hover-affordance change (F2) were verified from the CSS module and the class logic, not from a painted frame. verify-capture.mjs gives the captured command no TTY, so it cannot rasterise a React UI; the terminal-capture pipeline needs a browser this container does not have.
  • Per-commit attribution. The checkout is depth 2, so git rev-list HEAD^1..HEAD^2 returns 1 commit while the metadata snapshot lists 11. git rev-parse --is-shallow-repository is true, so per-commit claims were treated as unreachable and the aggregate HEAD^1..HEAD diff was verified instead. The 11 commits include two merge-from-main resolutions; I verified the merge added nothing to packages/acp-bridge over the PR head (git diff --name-only HEAD^2..HEAD -- packages/acp-bridge is empty) but did not separately exercise the conflict-resolution commit's claims about bridge.ts footprint.
  • initializeTimeoutMs above 15 s. The staleness bound in the threshold section depends on the 10 s default being below the 15 s window floor. An operator raising that timeout reopens the question; not exercised.
  • unstable_resumeSession and the restore path. I confirmed the spawn-in-flight counter is incremented in doSpawn, the single spawn path shared by new sessions and caller-supplied-id restores, so the sibling is covered structurally. I did not drive an actual session/load race.
  • Repo-wide gates. npm run typecheck was run in full and passes (exit 0). Lint was not run. Only the seven affected test files were run, not the full per-package suites; the PR's own CI covers the rest.

Methodology

Environment: the CI verify container (node:22-bookworm), Node v22.23.2, working tree at the merge commit 8936e40 with npm ci and npm run build already completed at head. The base control is a scratch worktree at HEAD^1 under tmp/base-tree; running npm run build inside its packages/acp-bridge exits 1 on packages/core type errors (unresolved @opentelemetry/*, fdir, mime/lite, ignore and ajv typings as seen from the nested worktree) but still emits acp-bridge's own JS. Every one of those errors is in packages/core, a package this PR does not touch, and the head build of the same tree had already succeeded in CI — so I verified the emit rather than assuming it: the emitted .js file set is identical to head's, exactly three files differ, and bridge.js contains zero occurrences of entryActiveWorkState. The two revert arms were compiled in a second scratch worktree at HEAD. Internal workspace links were checked with readlink -f rather than require.resolve (these packages are ESM-only with import-only exports, so require.resolve throws ERR_PACKAGE_PATH_NOT_EXPORTED): node_modules/@qwen-code/qwen-code-core resolves into the head tree, which is a clean control because packages/core is untouched by this PR and sdk-typescript's change is type-only — confirmed by bridgeTypes.js being byte-identical base↔head.

Each cell was driven by a real child process over real stdio pipes through the production spawn path, with post-registration snapshots pushed over a real loopback TCP socket; the child's own stderr was captured as a destination-side corroborator of how many snapshots it actually emitted (used to attribute S10). Harnesses are .mjs files in this directory so a maintainer can rerun them: fake-acp-child.mjs (the peer), harness.mjs (one arm, ARM_DIR=…), evaluate.mjs (the matrix and its 75 assertions), mutate.mjs (the 12 mutations), matrix.mjs, aggregate.mjs (writes assertions.json). Raw per-arm output is raw-V{0,1,2,3}.jsonl, raw2-*.jsonl, s9-*.jsonl; per-cell stderr is raw-*.stderr.log; suite logs are gates.log and mutout-*.log. Targeted gates: bridge.test.ts 914 passed, bridgeClient.test.ts 130, daemon-public-surface.test.ts 21, TodoPanel.test.tsx 3, session-catalog-hooks.test.tsx 15, WebShellSidebar.workspace-removal.test.tsx 132, App.test.tsx 790 — 2005 tests, all exit 0 — plus npm run typecheck exit 0.

Flakiness gate log

rounds=5 files=4 skipped=0
file packages/acp-bridge/src/bridge.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/bridge.test.ts
file packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/daemon-public-surface.test.ts
file packages/web-shell/client/App.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/App.test.tsx
file packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/acp-bridge/src/bridge.test.ts: PPPPP
  packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: PPPPP
  packages/web-shell/client/App.test.tsx: PPPPP
  packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: PPPPP

verdict: pass
summary: 4 changed test file(s) x 5 identical rounds, no divergence

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: P (exit 0)
round 1 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: P (exit 0)
round 2 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: P (exit 0)
round 2 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: P (exit 0)
round 3 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: P (exit 0)
round 3 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: P (exit 0)
round 4 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: P (exit 0)
round 4 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: P (exit 0)
round 5 · packages/acp-bridge/src/bridge.test.ts: P (exit 0)
round 5 · packages/sdk-typescript/test/unit/daemon-public-surface.test.ts: P (exit 0)
round 5 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/sidebar/WebShellSidebar.workspace-removal.test.tsx: P (exit 0)

Evidence images

01-ab-matrix-four-arms

02-mutation-matrix-vacuity

Harness scripts and raw logs are in the workflow run artifacts (7-day retention).

Qwen Code · sandboxed verification

@yiliang114
yiliang114 added this pull request to the merge queue Sep 8, 2026
Merged via the queue into main with commit 6b7e561 Sep 8, 2026
90 of 93 checks passed
@yiliang114

Copy link
Copy Markdown
Collaborator Author

About the error in this PR's visual preview

The red error row in the preview above is scripted mock data, not a failure on this branch. createTerminalTurnErrorScenario in packages/web-shell/client/e2e/visuals/screenshots.spec.ts hand-builds a turn_error event so the error row's Copy affordance (shipped in #10001) can be photographed. That string appears nowhere in product code — only in that one spec file — so nothing at runtime can produce it.

Two things worth knowing, because they meant this preview told you nothing about your own change:

1. The view was flagged purely by clock noise. The compose step for run 34207473931 reported exactly one CHANGED view:

skip    terminal-turn-error-copy-dark.png (0.01% diff)
CHANGED terminal-turn-error-copy-narrow-dark.png (0.02% diff)   <- threshold is 0.02
skip    terminal-turn-error-copy-narrow-light.png (0.01% diff)
skip    terminal-turn-error-copy-touch-dark.png (0% diff)

It landed exactly on the threshold, and the whole diff was a 09:09:28 timestamp against a 09:18:16 one — the head pass ran 09:08:42–09:11:37 and the base pass 09:17:49–09:20:27, since the job reinstalls dependencies between them. The narrow viewport is half the pixels of the wide one, which is why the same ~100 timestamp pixels cleared the threshold there and were skipped everywhere else.

2. The garbled word is a real bug, and it is not in your diff. ...before the answer finisheu. — the fixture says finished.. The hover timestamp chip is positioned at the row's top-right and its lower edge cuts horizontally through the first line, removing the d's ascender while the bowl stays visible below.

Both are fixed in #11380: the chip moves to the bottom-right, the visuals harness renders at a fixed clock, and the turn-error captures are dropped (their assertions stay). Once that lands, previews here should stop being dominated by that scenario, and a flagged view should mean a real change.

One thing that may matter for this PR: no visual scenario currently exercises activeWorkState, so the sidebar's new activity indicator has no screenshot covering it. If you want the preview to demonstrate it, a scenario carrying activeWorkState: "active" would need to be added.

中文

上面 preview 里的红色 error 行是写死的 mock 数据,不是这个分支出了问题screenshots.spec.ts 里的 createTerminalTurnErrorScenario 手工构造了一个 turn_error 事件,用来给 error 行的 Copy 按钮(#10001 上线的功能)拍照。那句话在产品代码里一次都没出现,只存在于那一个 spec 文件,运行时不可能产生。

有两点值得说明,因为它们意味着这次 preview 没能反映你的实际改动:

1. 这张图被标记纯粹是时间戳噪声。 run 34207473931 的 compose 步骤里只有一个 CHANGED,且正好卡在 0.02% 阈值上,全部差异就是 09:09:2809:18:16 —— head 那趟跑在 09:08:42–09:11:37,base 那趟跑在 09:17:49–09:20:27,中间要重装依赖。窄视口像素数只有宽视口一半,所以同样约 100 个时间戳像素在这里越线、在别处都被 skip。

2. 那个被糊掉的词是真 bug,而且不在你的改动里。 fixture 里写的是 finished.,显示成 finisheu. 是因为悬浮时间戳定位在行的右上角,下边缘横切过第一行文字,砍掉了 d 的上升笔画,只剩字碗。

两者都在 #11380 修了:chip 移到右下角,visuals harness 用固定时钟渲染,turn-error 的截图删掉(断言全部保留)。合并后这类 preview 就不会再被这个场景占满,被标记的视图也才真正代表有改动。

对这个 PR 可能有影响的一点:目前没有任何 visual scenario 覆盖 activeWorkState,所以侧栏新增的活动指示器没有截图能证明它。如果希望 preview 展示它,需要补一个带 activeWorkState: "active" 的 scenario。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.23.1.

pull Bot pushed a commit to AmirulAndalib/qwen-code that referenced this pull request Sep 9, 2026
…#11380)

* fix(web-shell): stop the hover timestamp from garbling message text

`MessageTimestamp`'s hover chip is absolutely positioned at the row's
top-right with a 92%-opaque background, so it paints over the first line of
the message underneath and lets the glyphs bleed through it. The first line of
a wrapped message runs the full column width, so it is almost always the one
that gets hit: in the web-shell visual preview for QwenLM#11267 the turn-error copy
"...before the answer finished." renders as "finisheu." beneath an 09:18:16
tip, which reads as a corrupted error message rather than as an overlay.

Anchor the chip at the bottom-right instead and make its background opaque.
The last line of a wrapped block is a ragged remainder, and rows that carry an
action bar - the turn-error Copy button is one - leave that corner empty
outright, so the common case no longer overlaps text at all; where it still
does, an opaque chip with a border reads as an overlay instead of as broken
characters. `.chatTip` and `.copyButton` share the same translucent
background over content that `.chatActions` pulls up by -24px, so they get the
same treatment.

This makes overlap rare, not impossible - a message ending in a full-width
code block or table still gets the chip in its corner. Reserving space in flow
was rejected on purpose: adding padding on hover reflows the transcript, and a
permanent float notch shrinks every code block that establishes its own
formatting context.

* test(web-shell): freeze the wall clock for visual captures

The visuals job renders the PR head, then reinstalls dependencies and renders
the merge-base, so the two passes photograph the same view minutes apart. Any
view showing a wall-clock time therefore differed on every run for no reason
other than when it was taken. On QwenLM#11267 that was the entire preview: the only
view the compose step reported as CHANGED was
`terminal-turn-error-copy-narrow-dark` at exactly the 0.02% threshold, and the
whole diff was a 09:09:28 timestamp against a 09:18:16 one. The narrow
viewport is half the pixels of the wide one, which is why the same ~100
timestamp pixels cleared the threshold there and were skipped at 0.01%
everywhere else - so the noise also crowded out any real change the PR made.

Pin `Date.now()` / `new Date()` with `page.clock.setFixedTime` from
`gotoSession` and `gotoNewSession`, which every visuals spec navigates
through, including the Pixel 7 touch page. `setFixedTime` fakes only clock
readings and keeps timers running, so replay, streaming and
`freezeLoopingAnimations` are unaffected; elapsed-duration UI settles on a
constant instead of drifting, which is the point. This is the same class of
fix as `freezeLoopingAnimations`, and `harness.spec.ts` pins its contract the
same way.

`formatTimestamp` renders local time, so pin `timezoneId: 'UTC'` alongside
it. CI runners are already UTC, so this changes no baseline there; it is what
makes a local capture match them. Contexts created directly from the browser
do not inherit `use` from the config, so the flow-recording context and the
touch context repeat it.

* fix(web-shell): future-date the frozen capture clock

The first pass froze captures at 2026-01-01, which is BEFORE dates the
fixtures hardcode. `formatRelativeTime` measures `Date.now() - value`, so the
channel editor's pairing requests, dated 2026-07-28, came out negative and
collapsed to "just now" where the real clock rendered "7/28/2026" -- visible
as the only unexplained CHANGED pair in this PR's own preview
(`channel-editor-existing-{dark,light}`).

Move the constant to 2027-01-01. Future-dating is the safe direction: every
fixture then reads as older than now, which is what all of them mean, and the
relative formatter lands in the same branch the live clock did.

`git-branch-picker` seeded `computedAt` from `Date.now()`, which in a spec is
Node's real clock rather than the page's frozen one. The status therefore
looked newer than the branch listing and armed a reconcile re-fetch that no
real session performs; it now derives from the same constant. Both rules are
written into the constant's docstring for the next fixture.

* test(web-shell): make the turn-error fixture identify itself

The visual harness injects a fake `turn_error` so the error row's Copy button
can be photographed, but its copy was written to read like a real incident --
"The model provider closed the response stream before the answer finished.
Retry the request or copy these details when reporting the failure." One test
produces four screenshots (two themes x two viewports), so every web-shell
preview shows that sentence four times, and readers keep taking the preview
for a live failure rather than for mock data. It is the top question this
preview generates.

Nothing about the scenario needs realistic copy; it needs an error row. Say
what the row is instead, and follow the string in the two locators that filter
on it.

* revert(web-shell): drop the fixture recopy, the timezone pin and the tip move

Three of the changes on this branch were not root-caused before being made,
and this reverts them to what the reported symptom actually justifies.

The tip no longer moves to the bottom-right. The symptom -- "finished."
reading as "finisheu." -- comes entirely from the chip's 92%-opaque
background letting the glyph beneath bleed through it, so the fix is the one
word that makes it opaque. Repositioning was a second, bundled change: more
correct in that it stops covering a word at all, but not what was reported,
and it is what churned six screenshots. `.chatTip` and `.copyButton` carry the
identical translucent background over content, so they change with it.

The turn-error fixture keeps its original copy. Rewording it fixed no bug and
measurably backfired: the touch pair started differing BECAUSE of the edit, so
the preview went from four red screenshots to six, and the replacement text
was a line longer. The red rows are the subject of the only visual test that
hovers a message row -- the Copy affordance shipped in QwenLM#10001 -- not evidence
of a failure, and no wording makes them look otherwise.

The `timezoneId: 'UTC'` pins go too. CI runners are already UTC, so they
changed no baseline and only served a local reproduction nobody had asked for.
The `harness.spec.ts` clock case goes with them: `freezeWallClock` is a
one-line wrapper, so the case asserted that Playwright's own `setFixedTime`
works. `freezeLoopingAnimations` keeps its case because that function has real
logic to pin.

What survives is what measurement supports: the opaque background, the frozen
clock with its future-dated constant, and the `git-branch-picker` fixture that
the freeze would otherwise desynchronise.

* test(web-shell): drop the touch turn-error captures, keep their assertions

The touch variant renders under `hover: none`, so `.row:hover > .tip` never
fires and the hover timestamp is absent from those two screenshots entirely.
A run that moved the chip and repainted its background scored both of them at
0% diff, which is direct evidence they carry no coverage of it -- they only
contributed two full-height red error images to every web-shell preview.

The behaviour they were meant to protect is the one QwenLM#10001 shipped: on a touch
device the row actions stay visible with no hover to reveal them. That is
asserted directly (`toHaveCSS('opacity', '1')` plus a visible Copy button) and
those assertions stay. Only the captures go.

The four remaining turn-error captures stay for now: they are the only views
in the suite where the hover timestamp lands on real text, so they are the
sole visual evidence for the opacity fix on this branch.

* fix(web-shell): move the hover timestamp off the first line of the message

Reverting the chip to the top-right was wrong, and zooming into the captured
pixels is what showed it. The chip's lower edge cuts horizontally through the
first line of the message; it does not cover the last word so much as
decapitate it, removing a letter's ascender while the bowl stays visible
underneath. That is the whole of the reported symptom: "finished." reads as
"finisheu." because only the bowl of the d survives.

An opaque background therefore cannot fix it. The faint remnant of the
ascender currently showing through the 92% chip is the only hint the letter is
a d at all -- painting the chip solid erases that hint and leaves a cleaner
"u". The previous commit would have made the reported symptom slightly worse.

Anchoring at the bottom-right is what removes the overlap: the last line of a
wrapped block is a ragged remainder, and rows carrying an action bar leave
that corner empty outright. Opacity is kept for its own reason -- a glyph
bleeding through any overlay reads as broken text -- and the border makes the
chip legible as an overlay where it does still land on content.

* test(web-shell): stop capturing the turn-error scenario

This scenario is the only visual test that hovers a message row, which it must
do to reveal the Copy affordance shipped in QwenLM#10001. The hover timestamp is
`opacity: 0` until `.row:hover`, so those captures were the only place in the
whole suite where a hover-timestamp change could appear -- and the six
screenshots they produced meant every web-shell preview led with full-height
red error images, whatever the PR had actually touched. Readers repeatedly
took the preview for a live failure.

They have now done the one job that justified keeping them: run 34231051803
proved the overlap fix at pixel level, with the d in "finished." regaining its
ascender at full h-height while the base panel still shows "finisheu.". That
evidence is recorded in the PR description, where it does not have to be
re-rendered on every unrelated push.

The behaviour stays pinned by assertion, not by image: seven opacity checks
across the default viewport, the 720px viewport and the Pixel 7 touch context
cover reveal on hover, reveal on focus, hide on blur, and always-visible
actions under `hover: none`. A capture never verified any of that; Playwright
does not diff these images, the preview bot does, and the preview is a review
aid rather than a test.

The scenario, the fixture and every assertion remain. Only the captures go.

* test(web-shell): fail when a fixture date outlives the frozen capture clock

Captures render at a fixed instant, so a fixture dated after it silently reads
as "just now": `formatRelativeTime` measures `Date.now() - value`, a future
value yields a negative age, and that lands in the `mins < 1` branch. This
already happened on this branch. The first frozen constant sat at 2026-01-01,
before the channel editor's 2026-07-28 pairing requests, and turned "7/28/2026"
into "just now" across four views. Nothing failed -- no assertion covers that
label -- and it was caught only by reading a preview diff by eye.

Scan the visuals specs for ISO date literals and require each to precede
FIXED_CAPTURE_TIME. The docstring already asked for this; a comment does not
fail a build. `harness.ts` is skipped because it is where the constant itself
is written, so its literal is the boundary rather than a fixture.

The scan reaches further than the fixture that caused the incident: it covers
eleven literals across the suite, eight of them a 2026-07-03 pair I had not
been aware of. Verified in both directions -- silent against the tree as it
stands, and flagging a planted 2027-06-01 literal.

Not a general date linter. It catches ISO strings in fixtures, which is the
shape the regression actually took; an epoch number is already opaque enough
that nobody reads it as a date.

* test(web-shell): fail when a fixture date outlives the frozen capture clock

Captures render at a fixed instant, so a fixture dated after it silently reads
as "just now": `formatRelativeTime` measures `Date.now() - value`, a future
value yields a negative age, and that lands in the `mins < 1` branch. This
already happened here. The first frozen constant sat at 2026-01-01, before the
channel editor's 2026-07-28 pairing requests, and turned "7/28/2026" into
"just now" across four views. Nothing failed -- no assertion covers that label
-- and it surfaced only by reading a preview diff by eye.

Scan the visuals specs for ISO date literals and require each to precede
FIXED_CAPTURE_TIME. The constant's docstring already asked for this, and a
docstring does not fail a build. `harness.ts` is skipped because it is where
the constant itself is written, so its literal is the boundary, not a fixture.

The scan reaches further than the fixture that caused the incident: eleven
literals across the suite, eight of them a 2026-07-03 group that was not on my
radar. Checked in both directions before committing -- silent against the tree
as it stands, and flagging a planted 2027-06-01 literal.

Not a general date linter. It catches ISO strings in fixtures, which is the
shape this regression actually took; an epoch number is already opaque enough
that nobody reads it as a date.

b162c19 was meant to carry this and landed empty: the scratch copy of the file
was overwritten by a later extraction step before it was hashed, so the commit
recorded no change while its message described one. History is append-only on
an open PR here, so it stays in the log; this commit is the content.

* fix(web-shell): correct three wrong comments and give the contracts a gating home

Review found eight issues on this branch. Three were comments asserting things
the code does not do, and comments that confidently state the opposite of the
behaviour are worse than no comment.

The CSS block kept its original opening sentence, "Anchored inside the
message's top-right corner", above a paragraph declaring the BOTTOM-right
anchor and a `bottom: 2px` rule. The retained sentence also carries the
overflow rationale that is still true, so it reads as the block's thesis --
someone "restoring" `top: 2px` to match it puts the chip back over the first
line and reintroduces the exact decapitation this branch exists to remove.
Only the corner is dropped; the overflow reason stays.

The `git-branch-picker` fixture comment claimed a Node-stamped `computedAt`
would look NEWER than the branch listing and arm a reconcile re-fetch. The
constant is future-dated, so a real-clock value sits months BELOW
`listingFetchedAt`, and the gate is `at <= listingFetchedAt` -- the branch was
already unreachable and the edit is inert today. The comment now says what the
edit is for (the harness rule about "now" values) and what would actually
exercise the reconcile path.

The `freezeWallClock` docstring said `setFixedTime` fakes clock readings and
not timers. It installs Playwright's full fake clock: timers, rAF, rIC,
`performance` and `Intl` are all replaced. Outcomes are unaffected today, but
`performance.mark`/`measure` are stubbed and every `Date.now()`-delta window
in the app is pinned permanently shut. Nothing seeds those states, so nothing
fails -- until a scenario needs one to elapse and hangs as a bare expect
timeout while the docstring insists timers behave normally.

`.toolTimeTip` in SubAgentPanel mirrors this chip over a sub-tool line, was
missed by a paragraph claiming to sweep the overlays, and was strictly worse:
same `top: 2px` geometry, no `background` at all, so two texts composited
directly. It gets the same treatment, and the paragraph now names it.

The remaining three findings share a root: nothing observes any of this.
`.tip` is `opacity: 0` until hover and no capture paints it any more, so
reverting the anchor or the background keeps every suite green.
`freezeWallClock` runs only implicitly and no assertion reads the page clock,
so dropping the call resumes the drift silently. And the date guard sat in
`harness.spec.ts`, which no gating config collects -- `vitest.config.ts`
excludes `e2e/**`, `playwright.config.ts` ignores `visuals/**`, and the one
workflow step that does run it is deliberately `continue-on-error: true` so a
failing scenario cannot discard the preview.

`visual-capture-contracts.test.ts` is a plain vitest file outside `e2e/`,
collected by `test:ci:workspaces`, pinning all three: fixture dates precede
the frozen clock, both navigation helpers freeze before they navigate, and
every hover chip stays bottom-anchored and opaque. The date scan also now
reads `e2e/utils`, where `mockDaemon.ts` holds the date the default
scenario's session rows actually render -- scanning only `visuals/` missed it,
which is what the review caught. Its reach is still ISO string literals; the
comment says so rather than implying the name's full promise.

`FIXED_CAPTURE_TIME` moves to `constants.ts` so the guard can import it
without pulling in `@playwright/test`.

Each guard was checked in both directions before committing: silent against
the tree as it stands, and firing on a planted 2027-06-01 date in
`mockDaemon.ts`, a `freezeWallClock` moved after `page.goto`, and a `.tip`
reverted to `top`.

* fix(web-shell): stop claiming the sub-tool chip moved off its row

Moving `.toolTimeTip` to the bottom of its row was inert, and the comment
saying it 'inherits its fix for the same reason' overstated it the same way
three comments this branch already had to correct.

The row has no vertical slack to move into. `.line` is `padding: 2px 0` around
13px text, so roughly 17-24px tall, and `.lineArg` is `white-space: nowrap` so
it never wraps to a second line. The chip is about 19px once padding and a
border are added -- as tall as the row it sits in. `bottom: 2px` therefore
lands where `top: 2px` already was, and the padding and border I added only
made the chip taller than the row on the narrow end of that range.

What was actually wrong there is that the chip declared no `background` at
all, so its digits overprinted the row's text rather than covering it. That is
the whole fix: one declaration, no geometry change. The text under the chip is
still covered -- cleanly now instead of overprinted -- and the comment says so
rather than implying the overlap is gone.

`.tip` keeps its bottom anchor, which does work: a message wraps, so its last
line is a ragged remainder and rows carrying an action bar leave that corner
empty outright.

The contract test splits along the same line: every chip must be opaque, only
`.tip` must be bottom-anchored. Asserting a bottom anchor for a row that has
nowhere to put one would have pinned a cosmetic no-op as if it were the fix.

* docs(web-shell): correct the claim that only turn-error paints the chip

The comment left where the captures were removed said this scenario's
screenshots were "the only place a hover-timestamp change could show up".
That is false, and this PR's own preview disproves it: the parallel agents
test leaves the cursor on the group header after `summary.click()`, so
`parallel-agents-expanded` paints the chip through residual hover -- and it is
the only view still reported as CHANGED here, at 0.08%, precisely because the
chip moved.

The distinction that survives is deliberate versus incidental. This test is
the only one that hovers on purpose; the parallel agents coverage is a side
effect of a click and would vanish the moment that test stopped clicking.
Neither is what pins the chip now -- visual-capture-contracts.test.ts is.
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.

6 participants