Skip to content

feat(web-shell): Consume live-state session activity timestamps - #9476

Merged
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:feat/web-shell-live-state-activity
Aug 19, 2026
Merged

feat(web-shell): Consume live-state session activity timestamps#9476
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:feat/web-shell-live-state-activity

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

After a normal turn completes, the Web Shell now refreshes that session's recency and re-sorts the loaded active list directly from the activity watermark already carried by the existing two-second live-state poll, instead of flagging a rate-limited full catalog rescan. Each completion is recorded with a per-session sequence, and only a live-state response whose request started after the completion was recorded may settle it, so a stale in-flight response can never mask a newer completion. A watermark is applied only when strictly fresher, only to rows already present on loaded, cursor-less, non-archived pages (unknown sessions are never inserted and archived pages are never touched), and the page is re-sorted with the same ordering the server uses (pinned block first in organized views, then activity, with a stable id tie-break). Whenever the watermark is unusable (old server, or no prompt has reached the running state in the current daemon generation) or the completed session is not on a loaded active page, the previous version-fenced, rate-limited full-catalog fallback still runs with its 10-second coalescing. The two-second polling cadence, the live→catalog→live version handshake, initial load, and the create/archive/delete/rename paths are unchanged, and workspaces without live-state keep the legacy invalidate-and-refresh behavior.

Why it's needed

The server side of the live-state activity protocol shipped in #9396: every live session row can now carry a daemon-observed activity timestamp. The Web Shell, however, still ran the interim behavior from #9366, where every turn completion scheduled a rate-limited full session-catalog rescan just to refresh ordering timestamps — a disk-backed directory scan per cooldown window for data the client already polls every two seconds. This PR implements the consumer side exactly as specified by the follow-up consumption contract in docs/design/2026-08-18-workspace-session-live-state-updated-at.md, retiring steady-state catalog scans from the turn-completion path while keeping every degraded scenario on the previous conservative behavior.

Reviewer Test Plan

How to verify

  • Against a current daemon, open the Web Shell in a workspace with several sessions and complete a turn in a session lower down the active list: the row should move to the top (below any pinned block) within about two seconds, its relative timestamp should refresh, and the network log should show only the ongoing live-state polls — no full session-list request attributable to the completion. Consecutive turns keep settling the same way.
  • Complete a second turn while a live-state request is still in flight: the newer completion must still be reflected once the next poll lands (a stale response must not satisfy it).
  • Degraded paths: when live rows carry no activity stamp (old daemon) or the completed session is not on the loaded active page, expect exactly one rate-limited full catalog refresh (10s coalescing) — the pre-PR behavior.
  • The archived section and group/source-filtered pages never gain rows or reorder from live activity; creating, renaming, archiving, and deleting sessions still refresh the catalog as on main.
  • Unit suites: cd packages/web-shell && npx vitest run client/session-catalog client/components/ChatPane.test.tsx client/App.test.tsx

Evidence (Before & After)

Before: every turn completion scheduled a rate-limited full /sessions rescan to refresh ordering. After: completions settle from the live-state response in place (E2E against a real daemon: completed bottom-most session moved to the top within one poll, daemon catalog version unchanged throughout, confirming recency no longer rides the version-fenced rescan). Screenshots and the full E2E report follow in a separate comment.

Tested on

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

Environment (optional)

Unit tests via vitest (Node 22). E2E via a locally built daemon (npm run build, node packages/cli/dist/index.js serve) driving the built Web Shell with Playwright.

Risk & Scope

  • Main risk or tradeoff: client-side reordering must agree with server ordering. Mitigated by mirroring the server comparator (pinned → activity time → stable id tie-break), only ever advancing a row's stamp (never regressing on stale or invalid data), and falling back to the previous full-refresh behavior whenever anything is uncertain.
  • Not validated / out of scope: a pre-existing per-turn catalog fetch from the display-name fallback (byte-identical on main; this PR strictly reduces per-turn scan volume) — recorded as a follow-up candidate, as are feat(serve): Add live-state session activity watermark #9396's deferred non-blocking hardening items.
  • Breaking changes / migration notes: none. The protocol change is additive; old servers keep the fallback path, old clients ignore the new field.

Linked Issues

Consumer follow-up to #9396 (server-side activity watermark) and #9366 (interim Web Shell live-state consumption).

中文说明

本 PR 做了什么

普通 turn 完成后,Web Shell 现在直接使用既有 2 秒 live-state 轮询响应中携带的活动水位来刷新该会话的时间戳并原地重排已加载的活动列表,而不再触发限频的全量目录重扫。每次完成会以会话级序号记录,只有在记录之后才发起的 live-state 请求的响应才可以确认该次完成,因此飞行中的旧响应永远不会掩盖更新的完成。水位仅在严格更新时应用,且只应用于已加载、无分页游标、非归档页面中已存在的行(绝不插入未知会话、绝不触碰归档页面),随后按服务端一致的排序规则(organized 视图 pinned 块优先,其后按活动时间,最后按稳定 id 决胜)重排该页。当水位不可用(旧服务端,或当前守护进程代际内尚无 prompt 进入运行态)或完成的会话不在已加载活动页时,仍走此前的版本围栏、10 秒限频全量目录回退。2 秒轮询节奏、live→catalog→live 版本握手、首次加载以及创建/归档/删除/重命名路径均保持不变;未启用 live-state 的工作区继续沿用旧的失效加刷新路径。

为什么需要

live-state 活动协议的服务端已随 #9396 合入:每个 live 会话行可携带守护进程观测到的活动时间戳。但 Web Shell 仍运行 #9366 的过渡行为:每次 turn 完成都会安排一次限频的全量会话目录重扫,仅为刷新排序时间戳——即每个冷却窗口一次磁盘目录扫描,而这些数据客户端本来每两秒就在轮询。本 PR 严格按照 docs/design/2026-08-18-workspace-session-live-state-updated-at.md 中的后续消费契约实现消费端,把稳态目录扫描从 turn 完成路径中移除,同时让所有降级场景保持此前的保守行为。

评审验证计划

如何验证

  • 对当前守护进程:在包含多个会话的工作区打开 Web Shell,在活动列表靠下的会话完成一个 turn:该行应在约两秒内移动到列表顶部(pinned 块之下),相对时间刷新,网络日志中只有持续的 live-state 轮询——没有可归因于该次完成的全量会话列表请求。连续多个 turn 保持同样的原地更新。
  • 在上一个 live-state 请求仍在飞行时完成第二个 turn:下一次轮询落地后新的完成仍必须被反映(旧响应不得确认它)。
  • 降级路径:当 live 行不携带活动水位(旧守护进程)或完成的会话不在已加载活动页时,预期恰好一次限频全量目录刷新(10 秒合并)——即本 PR 之前的行为。
  • 归档区与分组/来源过滤页不会因 live 活动新增行或重排;创建、重命名、归档、删除会话仍与 main 一致地刷新目录。
  • 单元测试:cd packages/web-shell && npx vitest run client/session-catalog client/components/ChatPane.test.tsx client/App.test.tsx

证据(前后对比)

之前:每次 turn 完成都会安排一次限频全量 /sessions 重扫来刷新排序。之后:完成从 live-state 响应原地确认(对真实守护进程的 E2E:位于底部的会话完成后在一个轮询周期内升至顶部,全程守护进程目录版本号不变,证明时间戳刷新不再依赖版本围栏重扫)。截图与完整 E2E 报告见后续评论。

已测试平台

macOS ✅;Windows ⚠️;Linux ⚠️

环境(可选)

单元测试使用 vitest(Node 22)。E2E 使用本地构建的守护进程(npm run buildnode packages/cli/dist/index.js serve)配合 Playwright 驱动构建产物中的 Web Shell。

风险与范围

  • 主要风险或权衡:客户端重排必须与服务端排序一致。缓解措施:逐位镜像服务端比较器(pinned → 活动时间 → 稳定 id 决胜)、行时间戳只前进不回退(陈旧或非法数据不生效)、任何不确定情况都回退到此前的全量刷新行为。
  • 未验证/超出范围:turn 完成后既有的显示名兜底目录请求(与 main 逐字相同;本 PR 严格减少每 turn 扫描量),已记录为后续改进候选;feat(serve): Add live-state session activity watermark #9396 遗留的非阻塞加固项同样另行处理。
  • 破坏性变更/迁移说明:无。协议为增量扩展;旧服务端保持回退路径,旧客户端忽略新字段。

关联 Issue

作为 #9396(服务端活动水位)与 #9366(Web Shell 过渡消费)的消费端后续。

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Qwen Triage finishedview run. See the stage comments in this thread for the result.

Qwen Triage 已完成 —— 查看运行。结果见本线程中的各阶段评论。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

E2E Test Report

Executed 2026-08-19 against a real daemon (node packages/cli/dist/index.js serve, dedicated scratch workspace, model moonshot/kimi-k3), driving the built Web Shell with Playwright Chromium. Requests were counted from browser network events; turn completion was detected via the daemon live-state API; document.hidden was pinned to false because the shell intentionally pauses all polling on hidden documents.

Scenario 1 — single turn completion (PASS)

Completed a turn in the bottom-most session of a 5-session active list. The row moved to the top within one poll cycle, its stamp refreshed, live-state polling kept flowing, and the daemon catalogVersion stayed unchanged (revision 16 → 16) — recency no longer rides the version-fenced rescan.

Scenario 2 — two back-to-back turns (PASS)

Both completions were reflected and the target session moved to the top; the second completion, recorded while the first response could still be in flight, settled on the following poll (in-flight fencing, also pinned by unit tests).

Fallback scenarios (unit-pinned)

An unusable watermark (old server / no running terminal yet in the daemon generation) and a completed session missing from the loaded active page both fall back to exactly one rate-limited full catalog refresh per 10s window — pinned by the workspace-session-live-state suite rather than E2E, because on a fresh daemon the completed turn itself advances the watermark, making the degraded state unreachable from the primary flow.

Attribution note

The run still observed catalog list requests per turn from a pre-existing display-name fallback fetch (URL signature sessions?size=200 plus its interactive reconcile of the loaded queries). That path is byte-identical on main, and main additionally runs the turn-completion invalidation rescan this PR retires, so per-turn scan volume on this branch is strictly ≤ main. Recorded as a follow-up candidate: feed the display-name fallback from live state or session metadata instead of a fresh catalog scan.

Untouched surfaces (observed)

Session creation, auto-naming, first catalog load, archived section, and the pinned block behaved as on main throughout the run.

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Re-run at 8bf5769 (author pushed one commit addressing the /review feedback threads, then re-triggered triage). Gate re-checked against the new head — findings below, unchanged except size numbers.

Template looks good ✓

Problem: this is not a theoretical concern — it is the planned consumer half of a merged design contract (docs/design/2026-08-18-workspace-session-live-state-updated-at.md, "Follow-up Web Shell Consumption Contract"). The interim behavior it retires (a rate-limited full session-catalog rescan after every turn completion) is documented in that design as the known inefficiency, and both predecessor PRs are already merged: #9366 (interim live-state consumption) and #9396 (server-side activity watermark). The problem demonstrably exists in the current code path.

Direction: aligned. This completes a protocol the repo itself committed to — the server side shipped in #9396 specifically so this consumer could retire steady-state catalog scans from the turn-completion path. No direct CHANGELOG reference, but the area is squarely within the Web Shell work tracked by those PRs.

Size: core module gate not applicable — all 10 changed files are under packages/web-shell/client/** (single package, not a core path). 159 production lines vs. 653 test lines (+785/−27 total).

Approach: the scope matches the contract one-for-one (per-session completion sequences, request-start snapshotting, strictly-fresher watermark application, server-mirroring comparator, preserved version-fenced fallback for every degraded case). The new commit stays strictly within the same scope — it only reworks the settle derivation and adds the missing test pins requested in review, net −22 production lines. The PR also correctly records the pre-existing display-name fallback fetch as out of scope rather than sneaking it in.

Risk: no elevated risk signals — none of the changed files match the high-risk path patterns from the revert-history analysis.

Moving on to code review. 🔍

中文说明

感谢贡献!

复跑于 8bf5769(作者推送了一个处理 /review 反馈的提交并重新触发 triage)。门禁已对新 head 重新检查——除规模数字外结论不变。

模板完整 ✓

问题:这不是理论性问题——它是已合入设计契约(docs/design/2026-08-18-workspace-session-live-state-updated-at.md 中"Follow-up Web Shell Consumption Contract")的计划内消费端实现。它要退役的过渡行为(每次 turn 完成后触发限频的全量会话目录重扫)在该设计文档中已被明确记录为已知低效点,且两个前置 PR 均已合入:#9366(过渡的 live-state 消费)与 #9396(服务端活动水位)。问题真实存在于当前代码路径中。

方向:对齐。这是在完成仓库自身已承诺的协议——服务端已随 #9396 合入,正是为了让本消费端把稳态目录扫描从 turn 完成路径中移除。CHANGELOG 无直接条目,但该领域正是上述 PR 所追踪的 Web Shell 工作。

规模:核心模块门禁不适用——全部 10 个改动文件均位于 packages/web-shell/client/**(单一包,非核心路径)。生产代码 159 行,测试 653 行(总计 +785/−27)。

方案:范围与契约逐条对应(会话级完成序号、请求发起时快照、仅在严格更新时应用水位、镜像服务端比较器、所有降级场景保留版本围栏回退)。新提交严格保持在同一范围内——只重构了 settle 判定推导并补齐评审要求的测试钉住,生产代码净减 22 行。PR 也正确地将既有的显示名兜底请求记录为超出范围,而没有顺手夹带进来。

风险:无升级风险信号——改动文件均未命中 revert 历史分析中的高风险路径模式。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 8bf57693a1f41a9ddbfedfbbee54661fc2f93545 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Code review

I reviewed this against the consumption contract in the design doc and the server code it mirrors. No blockers found — the implementation is faithful to the contract and the diff stays strictly inside it.

What I verified in detail:

  • Comparator parity (the PR's own flagged risk): compareSessionsByActivity is an exact mirror of the server. The organized variant matches compareOrganizedCursorKeys in packages/cli/src/serve/server/session-list.ts bit for bit (pinned desc → activity desc → sessionId.localeCompare), and getSessionActivityTime matches getSummaryActivityTime including the unparsable-stamp → 0 rule. Source/group-filtered pages are covered too — the server's metadata path sorts with the same activity → id comparator. One benign nuance: the plain unfiltered legacy list endpoint sorts by time only, with ties kept in merge insertion order, while the client applies an id tie-break. That can only reorder rows whose stamps are equal to the millisecond, deterministically — not worth changing.
  • Contract rule 2 (running-state only): both turnCompleted call sites (App.tsx, ChatPane.tsx) fire only on a non-idle → idle transition of the session that was actually streaming, so a queued-only terminal that never streamed records nothing.
  • Sequence fencing: the pending map is snapshotted before the request goes out and resolveSessionActivity is sequence-guarded, so a stale in-flight response cannot settle a newer completion. The reconcile path deliberately cannot settle either (its staged page may commit staler data than the liveB watermark) — only direct polls settle. Both races are pinned by tests.
  • Fallback preservation: unusable watermark or unloaded row sets invalidationRequested, feeding the pre-existing version-fenced reconcile with its 10s coalescing — same path as before, same cooldown. Failure windows behave as on main: completions stay pending through live-state errors and settle after recovery; nothing reconciles during the window either way.
  • Guard rails: watermarks apply only when strictly fresher than the row's effective recency (updatedAt ?? createdAt), only to existing rows on cursor-less non-archived pages, never to archived rows or pages; unknown sessions are never inserted; re-sort happens only when a stamp actually advanced. Pending state is cleaned up on live-state release and store dispose.

No unrelated changes in the diff; the display-name fallback fetch is untouched as promised. The added comments explain the non-obvious why (sequence fencing, cursor/archived exclusion) in house style.

Re-run at 8bf5769 — review feedback verified

The new commit addresses all eight /review threads. I checked each claim against the diff and the resulting code, not just the replies:

  • R1-4 settle/applyLiveState drift (the material one): fixed by construction. applyLiveState now returns the set of session ids that absorbed a usable watermark on a loaded, reorder-owning page, and the settle loop consumes that set instead of re-deriving the by-id index, the Date.parse acceptance rule, and the loaded-page probe. I read the final code: absorbed.add fires under exactly the same gates as stamping — workspace-matched entry, ownsActivityReorder, row-level foreign-workspace exclusion, isArchived !== true, parseable stamp — and a session settles in place when its row is stamped or already at least as fresh, which is the correct semantics (nothing left to fetch). The standalone hasLoadedActiveSession probe is deleted with zero leftover references, and the net effect is −22 production lines. The two sites can no longer drift.
  • R1-1 gate coverage: the rewritten absorbs watermarks only for rows on reorder-owning pages test pins every gate against the returned set — cursored page, cross-workspace row, archived row and archived page, unknown session, missing watermark — asserting only the active-page row is absorbed.
  • R1-2 / createdAt lower bound: orders unstamped rows by createdAt and bounds stamps by createdAt pins the previously untested ?? createdAt fallback with the discriminating probe shape (unstamped rows order by createdAt-recency, not the id tie-break) plus both sides of the lower bound for incoming stamps.
  • R1-3 fallback resolution leak: both fallback tests now assert the pending entry is resolved (snapshotSessionActivity undefined), and the not-loaded case additionally asserts an idle cooldown window stays quiet — the leaked-entry perpetual-rescan mutant now fails.
  • Ghost-session branch: falls back when the completed session is absent from the live response covers the previously untested row?.updatedAt path; the refactor additionally removes the fragile optional chain itself, since settle now only checks set membership.
  • Deferred (agreed): lifting the comparator into a shared sdk-typescript module is deferred as a cross-package public-API decision — a reasonable scope call, recorded as a follow-up together with the server-side inline drift.

The settle flow after the refactor:

sequenceDiagram
    participant P1 as ChatPane - App
    participant P2 as SessionCatalogStore
    participant P3 as live-state poll loop
    participant P4 as daemon live-state route
    P1->>P2: turn completed, record per-session sequence
    P2->>P3: wake immediately
    P3->>P3: snapshot pending sequences
    P3->>P4: GET sessions live-state
    P4-->>P3: rows with updatedAt watermark
    P3->>P2: applyLiveState stamps strictly-fresher rows, returns absorbed set
    P3->>P3: settle absorbed sessions, else flag rate-limited reconcile
Loading
Files changed (10 of 10)
File What changed
packages/web-shell/client/App.tsx forwards the completed session id to turnCompleted
packages/web-shell/client/App.test.tsx asserts the session id is forwarded
packages/web-shell/client/components/ChatPane.tsx same forwarding for pane-owned streaming completions
packages/web-shell/client/components/ChatPane.test.tsx asserts pane completion and outer-session dedupe carry the id
packages/web-shell/client/session-catalog/session-catalog-hooks.ts turn completions now record session activity when live-state owns the workspace, keeping the legacy rescan otherwise
packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx pins legacy vs live-state routing of completions
packages/web-shell/client/session-catalog/session-catalog-store.ts watermark application, server-mirroring comparator, pending-sequence lifecycle; applyLiveState now returns the absorbed-watermark set and the separate loaded-session probe is gone
packages/web-shell/client/session-catalog/session-catalog-store.test.ts stamp/reject cases, pinned-block ordering, sequence lifecycle, per-gate absorption coverage, createdAt lower-bound ordering
packages/web-shell/client/session-catalog/workspace-session-live-state.ts snapshots pending completions per request, settles against the absorbed set or flags the fallback
packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx settle-in-place, in-flight fencing, unloaded-row and ghost-session fallbacks with resolution pins, reconcile and failure recovery paths

Testing evidence

Unattended CI re-run — nothing was built or executed here; the evidence is the PR's own CI signal for the reviewed head, fetched through the API. CI is fully green on 8bf5769: the main unit suite (Test (ubuntu-latest, Node 22.x)), the web-shell E2E smoke, the visual capture, and both Desktop Shell jobs all pass; macOS/Windows tests and CLI integration tests are skipped by design (merge queue / on demand only).

Check Conclusion
Capture web-shell visuals (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Remind on force-push ✅ success
Secret scan (TruffleHog) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Not independently verified: the central behavioural claim — that a completion causes no additional full-catalog request when the watermark is usable and the row re-sorts within one poll — is pinned by mock-based unit tests here; against a real daemon it rests on the author's Playwright E2E report in this thread (their claim, not re-run evidence, and run against the pre-feedback head — the incremental commit is a semantics-preserving refactor plus tests, so the report still applies, but it was not re-run either). Sandboxed verification would settle this: @qwen-code /verify — an A/B run could prove per-turn /sessions request volume drops to zero on the watermark-usable path versus the base build, which neither static review nor the unit suite can show. (Sponsored run: the author is a fork contributor, so a maintainer's @qwen-code /verify comment approves the head it was written against; that run carries a pre-execution risk screen and a full workspace wipe — read its report with the same skepticism as the fork's own CI logs.)

中文说明

代码审查

我对照设计文档中的消费契约及其镜像的服务端代码做了审查,未发现阻塞问题——实现忠实于契约,diff 严格限定在契约范围内。

重点核验:

  • 比较器一致性(PR 自己标注的主要风险):compareSessionsByActivity 与服务端逐位一致——organized 变体与 session-list.ts 中的 compareOrganizedCursorKeys 完全相同(pinned 降序 → 活动时间降序 → sessionId.localeCompare),getSessionActivityTimegetSummaryActivityTime 一致(含不可解析时间戳记 0 的规则)。来源/分组过滤页同样覆盖。一个良性差异:无过滤的 legacy 列表端点仅按时间排序、相等时保持归并插入顺序,而客户端使用 id 决胜——只影响毫秒级完全相等的时间戳,且是确定性的,不值得改。
  • 契约规则 2(仅运行态):两处 turnCompleted 调用点都只在实际正在 streaming 的会话由非 idle 转为 idle 时触发。
  • 序号围栏:待处理集合在请求发出前快照,resolveSessionActivity 按序号守卫,飞行中的旧响应无法确认更新的完成。reconcile 路径刻意不允许确认。两个竞态均有测试钉住。
  • 回退保留:水位不可用或行不在已加载页面时置 invalidationRequested,进入既有的版本围栏、10 秒合并的 reconcile——与之前相同的路径与冷却。
  • 防护边界:水位仅在严格比行的有效时间(updatedAt ?? createdAt)更新时应用,且只作用于已加载、无游标、非归档页面上已存在的行;绝不触碰归档行/页,绝不插入未知会话;只有时间戳实际前进才重排。

复跑于 8bf5769 —— 评审反馈核验

新提交处理了全部八条 /review 线程。我逐条对照 diff 与最终代码核验(而非只看回复):

  • R1-4 settle/applyLiveState 漂移(实质性的一条):从构造上消除。applyLiveState 现在返回在已加载、拥有重排权的页面上吸收了可用水位的会话 id 集合,settle 循环直接消费该集合,不再重新推导索引、解析规则与加载页探测。我读了最终代码:absorbed.add 与盖章处于完全相同的门槛之下,且会话在其行被盖章或已至少同样新时原地 settle——语义正确(没有需要补扫的内容)。独立探测函数 hasLoadedActiveSession 已删除且无任何残留引用,生产代码净减 22 行。两处不再可能漂移。
  • R1-1 门槛覆盖:重写后的测试对返回集合逐门钉住——带游标页、跨工作区行、归档行与归档页、未知会话、缺失水位——断言只有活动页的行被吸收。
  • R1-2 / createdAt 下限:新测试以判别性探针形态钉住此前无测试的 ?? createdAt 回退,并覆盖水位下限的两侧。
  • R1-3 回退后 resolve 泄漏:两个回退测试均断言待处理条目已被 resolve,未加载用例额外断言一个空闲冷却窗口保持安静——泄漏条目导致的永久重扫变异体现在会失败。
  • 幽灵会话分支:新增测试覆盖此前无测试的"完成会话不在 live 响应中"路径;重构还移除了脆弱的可选链本身。
  • 延期项(认同):把比较器上提到 sdk-typescript 共享模块作为跨包公共 API 决策延期——合理的范围判断,已与服务端内联漂移一并记为后续候选。

测试证据

无人值守 CI 复跑——未构建或执行任何代码,以上证据为通过 API 获取的该 head 的 PR 自身 CI 信号。8bf5769 的 CI 全绿:主单元测试、web-shell E2E 冒烟、视觉捕获与两个 Desktop Shell 任务均通过;macOS/Windows 测试与 CLI 集成测试按设计跳过。

未独立验证:核心行为性结论——水位可用时完成不产生额外全量目录请求、行在一个轮询周期内重排——目前由基于 mock 的单元测试钉住;对真实守护进程的结论来自作者在本帖中的 Playwright E2E 报告(作者声明,非复跑证据,且针对反馈前的 head——增量提交是语义保持的重构加测试,报告仍然适用,但也未被复跑)。沙箱验证可以定论:@qwen-code /verify —— A/B 运行可以证明水位可用路径上每 turn 的 /sessions 请求量相对基线构建降为零。(赞助运行:作者为 fork 贡献者,需维护者发出 @qwen-code /verify;该运行带执行前风险筛查与完整工作区清空——请以审视 fork CI 日志的同等怀疑态度阅读其报告。)

Qwen Code · qwen3.8-max

Reviewed at 8bf57693a1f41a9ddbfedfbbee54661fc2f93545 · re-run with @qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 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 8bf5769. Only screenshots that changed are shown (flows below, if any, are head-only) — refreshes on every push.

Screenshots · before / after

⚠️ No preview: one or more scenarios failed to render on this head — see the workflow run. This is not "no visual change" — a scenario that times out or throws produces no image. Fix the failing scenario (or a genuine regression it caught) and the preview returns on the next push.

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

Qwen Code · web-shell visuals

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — contract-faithful and race-tested at the first head, and the follow-up commit did exactly what it should: made the one structural concern (settle/stamp drift) impossible by construction and pinned every previously untested gate. Remaining nits are non-blocking and named below.

Stepping back: my first-pass independent proposal for this consumer landed on the same mechanism the PR implements, and my independent proposal for the R1-4 feedback — have the store own the settle answer instead of two co-varying copies — is exactly what 8bf5769 does, with the probe function deleted outright rather than kept as a second source of truth. I looked for a simpler path and didn't find one; the follow-up commit even lands net negative on production lines.

What changed my reading since the first pass, concretely: I re-verified the absorbed-set semantics line by line — it fires under exactly the stamp gates, and settling a session whose row is already at least as fresh (no stamp applied) is correct because there is nothing left to fetch. The deferred comparator-sharing item is the right scope call: lifting it into sdk-typescript would turn a web-shell PR into a cross-package public-API change touching maintainer-gated paths, and the mirror stays comment-pinned to its server sources in the meantime.

Reservations, named so they don't get lost: (1) the "zero extra catalog requests per turn" claim is proven by mocks in CI and by the author's Playwright E2E (their claim, run against the pre-feedback head); @qwen-code /verify is named in the review comment as the lane that would settle it against the base build — a maintainer can sponsor it if they want that proof before merge; (2) the client-side comparator mirror is maintained by hand until the shared-module follow-up lands; (3) the millisecond-tie nuance on the plain legacy list (first-pass note) stands as benign.

CI is fully green on the reviewed head and a maintainer has already approved it. Verdict: approve, pinned to the reviewed commit.

中文说明

置信度:4/5 —— 首版 head 即忠实于契约、竞态均有测试钉住;后续提交做了正该做的事:把唯一的结构性隐患(settle/盖章漂移)从构造上变为不可能,并把此前每个无测试的门槛都钉住。剩余的保留意见均为非阻塞,列在下方。

退一步看:我第一次独立构想的消费端方案与 PR 实现的机制相同;我对 R1-4 反馈的独立方案——让 store 成为 settle 判定的唯一权威,而不是两份必须手工同步的拷贝——正是 8bf5769 所做的,且探测函数被直接删除而非留作第二事实来源。我找过更简路径,没有找到;后续提交的生产代码甚至是净减少。

相较首轮我判断的变化依据:逐行复核了 absorbed 集合的语义——它与盖章处于完全相同的门槛之下;对行已至少同样新(未盖章)的会话原地 settle 是正确的,因为没有需要补扫的内容。比较器共享的延期项是正确的范围判断:上提到 sdk-typescript 会把一个 web-shell PR 变成触及维护者门禁路径的跨包公共 API 变更;在此之前镜像以注释锚定其服务端来源。

明确列出不被忽略的保留意见:(1) "每 turn 零额外目录请求"由 CI 中的 mock 与作者的 Playwright E2E 证明(作者声明,针对反馈前的 head);评审评论已点名 @qwen-code /verify 作为合并前对照基线构建定论的途径——维护者可以赞助运行;(2) 客户端比较器镜像在共享模块后续项落地前靠手工维护;(3) 无过滤 legacy 列表上毫秒级并列的良性差异维持原判断。

该 head 的 CI 全绿,且已有维护者批准。结论:批准,并与所评审的提交绑定。

Qwen Code · qwen3.8-max

Reviewed at 8bf57693a1f41a9ddbfedfbbee54661fc2f93545 · re-run with @qwen-code /triage

@qwen-code-ci-bot qwen-code-ci-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.

LGTM, looks ready to ship — CI landed green after the review. ✅

@qwen-code-ci-bot qwen-code-ci-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.

Reviewed. Suggestions are inline.

中文说明

已审查。 建议见行内评论。

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment thread packages/web-shell/client/session-catalog/session-catalog-store.test.ts Outdated
Comment thread packages/web-shell/client/session-catalog/session-catalog-store.ts
Comment thread packages/web-shell/client/session-catalog/workspace-session-live-state.ts Outdated

@qwen-code-ci-bot qwen-code-ci-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.

Reviewed — no blockers. Suggestions are inline.

中文说明

已审查——无阻断问题。 建议见行内评论。

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment thread packages/web-shell/client/session-catalog/workspace-session-live-state.ts Outdated
Comment thread packages/web-shell/client/session-catalog/session-catalog-store.ts
Comment thread packages/web-shell/client/session-catalog/workspace-session-live-state.ts Outdated
Comment thread packages/web-shell/client/session-catalog/session-catalog-store.ts

@yiliang114 yiliang114 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.

Reviewed the core (workspace-session-live-state.ts, session-catalog hooks/store, App.tsx).

Approve. Correct WebShell consumption of the #9396 live-state activity watermark: pending activity is snapshotted before the live-state read, and any session whose activity was not absorbed by the applied live state triggers invalidation + resolve. No obvious blockers.

0 unresolved threads.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 19, 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: 647 passed · 0 failed · 647 total

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

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

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

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

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

Verification report

PR 9476 verification — feat(web-shell): Consume live-state session activity timestamps

Verdict: findings — 647/647 scripted assertions passed, 0 unexpected failures. The central claim is proven load-bearing by A/B against the base build; two low-severity, non-blocking findings and one description correction follow (tie-order divergence vs the default list route; lenient watermark acceptance; the "filtered pages never reorder" bullet is inaccurate). Verified head: 8bf57693a1f41a9ddbfedfbbee54661fc2f93545 (merge-ref checkout 57eeb332b8, base tip d96f264de7).

中文摘要
  • 结论:findings(647 条脚本断言全部通过,0 意外失败;两条低严重度、非阻塞发现 + 一条描述更正)。
  • A/B 结论:核心主张成立且承重。同一场景下,head 在 turn 完成后由 live-state 轮询的 updatedAt 水位原地确认排序,目录请求数保持 1(wire 级实测 catalog=1, live=5);base 则每个冷却窗口发出一次全量 /sessions 重扫(catalog=2, live=6)。飞行中的旧响应不会确认更新的完成(序号守卫,M1 突变可杀);水位不可用(旧服务端、会话不在已加载页)时回退路径与 base 完全同量(各 +1,10s 合并,无泄漏)。
  • 发现:(F1, 低) 客户端重排比较器与 organized/metadata/live 三条服务端路径逐位一致(400 页语料 0 失配),但与默认列表路由的裸 bTime-aTime 排序在活动时间并列时不一致(47/400 纯并列页)——已测修复:id 决胜仅用于 organized 视图后并列失配降为 0 且套件全绿。(F2, 低) 水位接受门是 Number.isFinite(Date.parse(v)),V8 宽松解析会让 JSON 数字 12345(按年份 +012345 解析)盖戳并把原始数字写入字符串字段;仅当服务端不守约时可达——已测修复:ISO 形状守卫,合法 ISO 路径不受影响。(C1) 描述称 group/source 过滤页"永不重排",实测会按服务端一致顺序原地重排(但永不插入行,符合设计契约)。
  • 未覆盖:逐 commit 归因(浅克隆 depth 2,验证聚合 diff);真实守护进程的 Playwright E2E 与视觉渲染(wire 级 harness 用真实 DaemonClient + 回环 HTTP 替代);create/rename/archive/delete 刷新路径(diff 未改动);浏览器与 Node 之间 localeCompare 排序差异(同进程一致性已证)。

Central claim + A/B

Central claim: after a turn completes in a live-state-enabled workspace, the completion settles from the next live-state poll's updatedAt watermark with zero full catalog rescans, while every degraded scenario keeps the previous rate-limited rescan behavior.

Both arms run the identical harness (harness/ab-settle.test.tsx, real React hooks + real store, fake daemon peer encoding server semantics; and harness/wire-ab.test.tsx, real DaemonClient over loopback HTTP with counts taken at the server). Base arm = scratch worktree at d96f264de7 reusing the root node_modules (PR leaves package.json/lockfile untouched; @qwen-code/sdk resolves to the head dist whose source the PR does not modify — realpath asserted).

Cell (loop harness) head base
1 initial version-fenced load catalog=1, order [top,mid,bot] same
2 completion on bottom row order flips, stamp applied, +0 catalog requests order flips via +1 rescan
3 second completion mid-flight stale response leaves newer completion pending; next poll settles; +0 coalesced +1, next window +2
4 old daemon (no watermark) +1 rescan, pending resolved, idle window quiet +1 (parity)
5 ghost session (not loaded) +1 rescan, pending resolved +1 (parity)

Witnesses: 01-ab-loop-head-arm.png, 02-ab-loop-base-arm.png.

Wire harness (real HTTP) catalog hits live-state hits unexpected routes
head 1 5 0
base 2 6 0

Witnesses: 03-wire-ab-head-arm.png, 04-wire-ab-base-arm.png. The flip (0 vs +1 rescan for the identical scenario, differing only in the code under test) is the load-bearing proof; the idle window stayed scan-free on head (steady state retired).

Reviewer Test Plan walk: step 1 ✔ (cells 2 + wire); step 2 ✔ (cell 3 + PR test + M1); step 3 ✔ (cells 4/5 + M2); step 4 ⚠ see Correction C1 (archived pages never stamped ✔, filtered pages do reorder, never gain rows); step 5 ✔ 621/621.

Corrections

C1 — "group/source-filtered pages never reorder" is inaccurate as written. The Reviewer Test Plan bullet says archived sections and group/source-filtered pages "never gain rows or reorder from live activity". Measured (06-boundary-probes.png, probe A): a view:'organized',group:'all' page and a sourceType-filtered page both absorb the watermark and re-sort in place (organized=["b","a","c"], sourceFiltered=["a","c","b"]). They can never gain rows — applyLiveState maps existing rows only, which is exactly what the design doc's consumption contract forbids ("must not insert an unknown live session into … filtered pages"). The reorder itself uses the server comparator (parity proven below), so it is server-consistent. This is a description overstatement, not a code defect; no change requested.

Findings

F1 (low) — default-view in-place re-sort diverges from the default list route on activity ties. The client comparator (session-catalog-store.ts compareSessionsByActivity) mirrors the server's cursor-key comparators (session-list.ts:596-634: pinned → activity → localeCompare id tie-break). A 400-page randomized corpus driving the real applyLiveState (05-parity-corpus.png) shows 0/400 mismatches against those comparators (118 tie pages, 247 unparsable-stamp pages included; archived-row guard 0 failures). But the default (non-organized, unfiltered) list route sorts with a bare Date.parse(b…) - Date.parse(a…) — no NaN floor, no id tie-break (session-list.ts:1227-1231) — and the client diverges from it on 239/400 pages, of which 47/400 have all-parseable stamps (pure tie-break divergence; the rest are NaN-handling). Consequence: rows with identical ms stamps order by id after an in-place settle but by disk-insertion order after any later rescan — tied groups can flip-flop between the two paths. Organized view (the parity-critical one) is exact. Measured fix option (scratch, not applied): apply the id tie-break only in the organized branch — tie-only default mismatches drop 47→0, organized parity stays 0, and PR store + live-state suites + A/B harness stay green (79/79, logs/scratch-fix-run.log). Alternatively accept and document the divergence; either is defensible, but the PR body's "stable id tie-break = the order the server uses" overstates parity for the default view.

F2 (low) — watermark acceptance is Number.isFinite(Date.parse(v)), and V8's lenient parser admits digit strings and JSON numbers. Boundary probes (06-boundary-probes.png): null/boolean/object/''/garbage are rejected (not absorbed → correct fallback); but updatedAt: 12345 (JSON number) coerces to "12345", parses as year +012345, stamps the row and stores the raw number in the string-typed field (updatedAt=12345); "9999" likewise. Reachability requires a protocol-nonconforming daemon — the shipped daemon emits ISO strings or omits the field (session-list.ts:418, watermark recorder), so this is defense-in-depth, not an exploitable bug. Parseable-but-older values ("2026") are absorbed without stamping — the intended "already at least as fresh" settle. Measured fix option: an ISO shape guard (/^\d{4}-\d{2}-\d{2}T/) rejects all non-ISO values while a valid ISO stamp still settles (scratch run green, same log).

Vacuity / mutation matrix

Six one-point mutants, each killed by the PR suites and/or the A/B harness — 6/6 KILLED (07-mutation-matrix.png): M1 sequence-guard removal (live-state suite 1 failed, failure is the intended behavioral assertion expected undefined to be defined), M2 fallback-flag removal (3 failed), M3 key-hunk revert — the positive control — (store suite 3 failed), M4 absorbed over-accept (1+2 failed), M5 hook routing (1 failed), M6 dropped sessionId (App session-callbacks 2 failed; ChatPane green as expected — its own call site is untouched). No mutant survived, so no coverage-gap/dead-code adjudication was needed.

Not covered

  • Per-commit attribution: checkout is depth-2 (merge, base tip, PR head only); the aggregate HEAD^1..HEAD diff was verified, per-commit tables were out of reach.
  • Real-daemon Playwright E2E and visual Web Shell rendering; the wire harness substitutes a real DaemonClient against a loopback server implementing the two endpoints.
  • Create/rename/archive/delete catalog-refresh paths — unchanged by this diff (only the two turnCompleted call sites changed outside session-catalog).
  • Cross-runtime localeCompare collation drift (browser ICU vs Node ICU): parity was proven within one process; a browser-vs-daemon tie-order edge remains theoretical.
  • Windows/macOS behavior; repo-wide gates beyond the targeted web-shell suites (CI covers the rest).

Methodology

Environment: node:22-bookworm container, merge-ref checkout (HEAD=merge, HEAD^1=base d96f264de7, HEAD^2=head 8bf57693a1). Loop and wire harnesses drive the real store/hooks/React via vitest (jsdom) with fake timers (loop) and real timers + loopback HTTP (wire); the base arm ran in a scratch worktree wired to the root node_modules, with the internal-dependency realpath asserted (@qwen-code/sdk → head dist, source untouched by the PR). The parity corpus transcribes the server comparators verbatim from session-list.ts (lines cited) and drives the real applyLiveState over 400 seeded pages. Mutation matrix applies six one-point mutants, runs the PR suites + A/B harness as detectors, restores, and verifies git status clean. Gates: PR's unit command (621/621), npm run typecheck (clean), eslint on all ten changed files (clean) with a planted-violation liveness control. Raw logs in logs/, rerunnable harnesses in harness/; evidence images 0107 captured with scripts/verify-capture.mjs.

Flakiness gate log

rounds=5 files=5 skipped=0
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/ChatPane.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/components/ChatPane.test.tsx
file packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/session-catalog/session-catalog-hooks.test.tsx
file packages/web-shell/client/session-catalog/session-catalog-store.test.ts: (cd packages/web-shell) npx --no-install vitest run ./client/session-catalog/session-catalog-store.test.ts
file packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: (cd packages/web-shell) npx --no-install vitest run ./client/session-catalog/workspace-session-live-state.test.tsx


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/web-shell/client/App.test.tsx: PPPPP
  packages/web-shell/client/components/ChatPane.test.tsx: PPPPP
  packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: PPPPP
  packages/web-shell/client/session-catalog/session-catalog-store.test.ts: PPPPP
  packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: PPPPP

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

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/components/ChatPane.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: P (exit 0)
round 1 · packages/web-shell/client/session-catalog/session-catalog-store.test.ts: P (exit 0)
round 1 · packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/components/ChatPane.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: P (exit 0)
round 2 · packages/web-shell/client/session-catalog/session-catalog-store.test.ts: P (exit 0)
round 2 · packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/components/ChatPane.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: P (exit 0)
round 3 · packages/web-shell/client/session-catalog/session-catalog-store.test.ts: P (exit 0)
round 3 · packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/components/ChatPane.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: P (exit 0)
round 4 · packages/web-shell/client/session-catalog/session-catalog-store.test.ts: P (exit 0)
round 4 · packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/App.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/components/ChatPane.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/session-catalog/session-catalog-hooks.test.tsx: P (exit 0)
round 5 · packages/web-shell/client/session-catalog/session-catalog-store.test.ts: P (exit 0)
round 5 · packages/web-shell/client/session-catalog/workspace-session-live-state.test.tsx: P (exit 0)

Evidence images

01-ab-loop-head-arm

02-ab-loop-base-arm

03-wire-ab-head-arm

04-wire-ab-base-arm

05-parity-corpus

06-boundary-probes

07-mutation-matrix

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

Qwen Code · sandboxed verification

@qwen-code-ci-bot qwen-code-ci-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.

Reviewed — no blockers. Suggestions are inline.

Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:

  • packages/web-shell/client/session-catalog/session-catalog-store.ts:150 — [probe] sessionId tie-break direction of the activity comparator is unpinned by tests
中文说明

已审查——无阻断问题。 建议见行内评论。

收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。

— qwen3.8-max via Qwen Code /review (v0.21.14)

Comment thread packages/web-shell/client/session-catalog/session-catalog-store.ts
Comment thread packages/web-shell/client/session-catalog/session-catalog-store.ts

@qwen-code-ci-bot qwen-code-ci-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.

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 19, 2026
Merged via the queue into QwenLM:main with commit d3202c6 Aug 19, 2026
273 checks passed
@doudouOUC
doudouOUC deleted the feat/web-shell-live-state-activity branch August 19, 2026 15:23
@wenshao

wenshao commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Local end-to-end verification — real daemon, real browser, two-arm A/B

I built a real verification environment for this PR and ran it against a live qwen serve daemon. Summary first: the mechanism works exactly as described and I found no correctness defect, but the end-to-end scan reduction only materialises in one of the flows I could reach — details in Caveat 1 below.

How it was verified

  • Daemon: bundled from the merge base dfbd12e (node esbuild.config.jsdist/cli.js), run as serve --port 4171 --token … --workspace … --no-web with an isolated QWEN_HOME. git diff dfbd12e..PR -- ':!packages/web-shell' is empty, so both arms drive the same daemon binary — only the Web Shell source differs.
  • Two arms: packages/web-shell vite dev servers on the PR tree (:5291) and on a merge-base worktree (:5292), both with QWEN_DAEMON_URL pointed at the same daemon. In command === 'serve' vite aliases @qwen-code/webui and @qwen-code/sdk to sibling sources, so each arm runs its own client code with no dist build.
  • Model: a scripted OpenAI-compatible server, so every turn settles deterministically in ~200 ms.
  • Browser: Chromium via Playwright, ?token=…, sidebar order read from the live DOM ([data-web-shell-session-title]), every daemon request classified on the wire into live-state poll vs full /sessions catalog scan.
  • Fixture: 6 seeded sessions, each with one completed turn; the measured session is pushed to the bottom of the active list before each measurement, and every run starts from a wiped session store + freshly booted daemon.

Daemon ground truth first, by direct REST probe across one turn:

   27ms ver=32024221#9 active=false updatedAt=2026-08-19T14:56:21.693Z
  631ms ver=32024221#9 active=true  updatedAt=2026-08-19T14:56:21.693Z
  832ms ver=32024221#9 active=false updatedAt=2026-08-19T15:04:55.037Z

The catalog revision does not advance across a turn while the watermark does — the premise this PR is built on holds against the real daemon.

Results

# Scenario PR #9476 main (dfbd12e)
1 One turn, session bottom-most, connection.displayName populated 0 catalog scans, row at top in 570 ms 2 catalog scans, row at top in 2000 ms
2 10 consecutive turns, ~40 s, same conditions 0 scans / 28 live-state polls 8 scans / 24 live-state polls
3 Pinned block: 2 pinned sessions, turn in an unpinned one lands as the first unpinned row, DOM == server, 0 scans
4 A pinned session completes a turn head of the pinned block, DOM == server same end state
5 Degraded — live rows carry no updatedAt (pre-#9396 daemon, emulated by stripping the field on the wire) exactly one rate-limited refresh (2 requests), row still correct in 588 ms (this is main's normal path)
6 Another client runs turns, no catalog-version bump converges to the server order with 0 scans list stays stale for the whole window
7 Adversarial: inject a now + 1 h watermark for two archived sessions list byte-identical, no row admitted to the active list

Ordering agreement was asserted on every scenario by refetching GET /workspaces/:id/sessions?view=organized&group=all and comparing to the rendered DOM: client order == server order in 100% of checks, including the pinned-block cases. The client comparator mirrors the server's pinned → activity → sessionId ordering.

sidebar A/B

request timeline

Across all six configurations I measured, the PR arm never issued more catalog scans than main. It does issue a few more live-state polls (28 vs 24 in scenario 2) — memory-only, no disk touch.

Unit tests, RED/GREEN, static checks

  • npx vitest run client/session-catalog client/components/ChatPane.test.tsx client/App.test.tsx614 passed (5 files).
  • Non-vacuity: reverting only the five production files to dfbd12e and keeping the PR's tests flips exactly 18 tests RED (596 still pass), covering the watermark stamping, the pinned reorder, the pending-activity sequence, the reorder-ownership rule, the in-flight-response fence, both fallback paths, and the pending-across-reconcile / pending-across-failure cases. Restoring → green again.
  • tsc --noEmit -p packages/web-shell → exit 0; eslint --max-warnings 0 on the five changed files → exit 0.

Caveat 1 — the win is currently masked in the flows a user actually hits

Scenarios 1 and 2 above only isolate this PR's effect because I first populated connection.displayName (via a real rename while attached). With it unset, App.tsx runs the pre-existing display-name fallback on every turn completion — an immediate refreshActiveSessionDisplayName() plus a trailing one — and each of those is a fresh catalog load. With live-state on, requestFresh raises an interactive live-state refresh, which bypasses the reconcile cooldown and re-stages every subscribed entry for the workspace — so one fallback invocation costs size=200 + group=all + group=pinned, i.e. 3 catalog requests, twice per turn. Measured, same fixture, same 10 turns:

Flow PR #9476 main
Existing session opened from the sidebar (10 turns) 60 scans 60 scans
Brand-new session created in-tab, turns 2–8 42 scans 42 scans

That is 6 catalog requests per turn on both arms, and main's own invalidation reconcile gets fully absorbed into the same 10 s cooldown window — so end to end, the two arms are indistinguishable in these flows today. Note the session was auto-named by the daemon ("Brand new session first turn" appears in the catalog), but connection.displayName is only populated by a session_metadata_updated event carrying displayName, which never arrived, so the fallback keeps firing.

This is pre-existing behaviour, byte-identical on both arms, and the PR description already records it as out of scope — I am not asking for it here. I am flagging that the follow-up is what actually unlocks the benefit this PR builds, so it is worth landing soon after, and that the "retires steady-state catalog scans from the turn-completion path" framing is true of the mechanism but not yet of the observed request volume.

One genuine user-visible improvement is not masked: scenario 6. Another client's activity now reorders the list within one poll with no catalog fetch, where main's list stayed stale (main would still catch up on the observer's own next turn, via the fallback refresh).

Caveat 2 — description nit

The archived section and group/source-filtered pages never gain rows or reorder from live activity

ownsActivityReorder only excludes archived and cursored pages. Group- and source-filtered pages (the sidebar's own group=all&sourceType=default and group=pinned queries) do reorder from live activity — scenario 4 is exactly that, the group=pinned page reordering in place. The behaviour is correct (it matches the server's ordering of the filtered collection, which I verified), so this is a wording fix in the PR body, not a code change.

Cost note

On this box (20 sessions, fast local disk) a cold catalog scan measured ~1.1 ms p50 against ~0.84 ms for a live-state read — the per-request latency saving is small here. The saving that matters is scan volume, which scales with session count and disk latency, not the numbers from this machine.

Verdict

Correct, well-guarded, and strictly non-worse on every axis I measured — no blocking findings. The two caveats above are context for the merge decision, not objections.

中文说明

本地端到端验证 —— 真实 daemon + 真实浏览器 + 双臂 A/B

我为这个 PR 搭建了真实验证环境,对着运行中的 qwen serve 跑了完整验证。先给结论:机制与描述完全一致,未发现正确性缺陷;但端到端的扫描削减目前只在其中一条路径上真正体现,详见下方「注意事项 1」。

验证方式

  • Daemon:从 merge base dfbd12e 打包(node esbuild.config.jsdist/cli.js),以 serve --port 4171 --token … --workspace … --no-web 运行,使用隔离的 QWEN_HOMEgit diff dfbd12e..PR -- ':!packages/web-shell',因此两臂驱动的是同一个 daemon 产物,只有 Web Shell 源码不同。
  • 双臂:分别在 PR 树(:5291)与 merge-base worktree(:5292)启动 packages/web-shell 的 vite dev server,QWEN_DAEMON_URL 同时指向同一个 daemon。command === 'serve' 时 vite 会把 @qwen-code/webui@qwen-code/sdk 别名到同级源码,因此每一臂跑的都是自己的客户端代码,无需构建 dist。
  • 模型:脚本化的 OpenAI 兼容服务,每个 turn 稳定在约 200 ms 内结束。
  • 浏览器:Playwright 驱动 Chromium,?token=… 注入令牌,侧边栏顺序直接读实时 DOM([data-web-shell-session-title]),并在网络层把每个 daemon 请求归类为 live-state 轮询全量 /sessions 目录扫描
  • 数据夹具:6 个已完成一轮对话的会话;每次测量前把被测会话压到活动列表底部,且每轮运行都先清空会话存储并重启 daemon。

先给 daemon 侧的基准事实(直接 REST 探针,跨越一次完整 turn):

   27ms ver=32024221#9 active=false updatedAt=2026-08-19T14:56:21.693Z
  631ms ver=32024221#9 active=true  updatedAt=2026-08-19T14:56:21.693Z
  832ms ver=32024221#9 active=false updatedAt=2026-08-19T15:04:55.037Z

一次 turn 前后目录 revision 没有推进,而水位推进了 —— 本 PR 依赖的前提在真实 daemon 上成立。

测量结果

# 场景 PR #9476 main (dfbd12e)
1 单次 turn,会话位于列表最底部,connection.displayName 已填充 0 次目录扫描570 ms 升顶 2 次目录扫描,2000 ms 升顶
2 连续 10 次 turn,约 40 s,同条件 0 次扫描 / 28 次 live-state 轮询 8 次扫描 / 24 次 live-state 轮询
3 置顶块:2 个置顶会话,在非置顶会话完成 turn 落到首个非置顶行,DOM == 服务端,0 次扫描
4 置顶会话自身完成 turn 升到置顶块首位,DOM == 服务端 终态相同
5 降级 —— live 行不带 updatedAt(在网络层剥离该字段,模拟 #9396 之前的 daemon) 恰好一次限频刷新(2 个请求),588 ms 内顺序仍然正确 (这就是 main 的常规路径)
6 其他客户端跑 turn,且不触发目录版本变更 0 次扫描收敛到服务端顺序 整个观察窗口内列表保持陈旧
7 对抗测试:为两个归档会话注入 now + 1 小时 的水位 列表逐行不变,且没有任何行被放进活动列表

每个场景都会重新拉取 GET /workspaces/:id/sessions?view=organized&group=all 并与渲染后的 DOM 比对:所有检查中客户端顺序与服务端顺序 100% 一致,包含置顶块的两种情形。客户端比较器确实逐位镜像了服务端的 pinned → 活动时间 → sessionId 排序。

(图见英文部分。)

在我测量的全部 6 种配置中,PR 臂的目录扫描次数从未多于 main;代价是多了几次 live-state 轮询(场景 2 中 28 对 24),这些都是纯内存读取,不触碰磁盘。

单测、RED/GREEN 与静态检查

  • npx vitest run client/session-catalog client/components/ChatPane.test.tsx client/App.test.tsx614 通过(5 个文件)。
  • 非空转验证:把 5 个生产文件回退到 dfbd12e、保留 PR 的测试,恰好 18 个测试转红(596 个仍通过),覆盖水位打戳、置顶重排、pending 活动序号、重排归属规则、飞行中响应围栏、两条回退路径,以及 reconcile 期间/失败期间 pending 保持的用例;恢复后重新全绿。
  • tsc --noEmit -p packages/web-shell → 退出码 0;对 5 个改动文件执行 eslint --max-warnings 0 → 退出码 0。

注意事项 1 —— 在用户实际会走的路径上,收益目前被掩盖

上面场景 1、2 之所以能隔离出本 PR 的效果,是因为我先通过(附着状态下的)真实重命名把 connection.displayName 填上了。若它为空,App.tsx 会在每次 turn 完成时走既有的 display-name 兜底 —— 一次立即的 refreshActiveSessionDisplayName() 加一次延迟的 —— 而每一次都是 fresh 目录加载。在 live-state 开启时,requestFresh 会发起 interactive 级别的 live-state 刷新,绕过 reconcile 冷却并重新暂存该工作区所有已订阅条目 —— 因此一次兜底调用的代价是 size=200 + group=all + group=pinned 共 3 个目录请求,每 turn 两次。同一夹具、同样 10 次 turn 的实测:

路径 PR #9476 main
从侧边栏打开已有会话(10 次 turn) 60 次扫描 60 次扫描
本 tab 内新建会话,第 2–8 次 turn 42 次扫描 42 次扫描

即两臂都是每 turn 6 个目录请求,而 main 自身的失效 reconcile 被完全吸收进同一个 10 s 冷却窗口 —— 因此在这些路径上,两臂端到端不可区分。注意会话其实已被 daemon 自动命名(目录里能看到 "Brand new session first turn"),但 connection.displayName 只由携带 displayNamesession_metadata_updated 事件填充,而该事件始终没有到达,兜底于是一直触发。

这属于既有行为,两臂逐字节相同,PR 描述也已把它列为范围外 —— 我并不要求在本 PR 处理。我要指出的是:真正解锁本 PR 收益的是那个后续项,值得紧接着落地;同时「把稳态目录扫描从 turn 完成路径中移除」这一表述对机制成立,但对当前可观测的请求量还不成立。

有一处用户可见的改进没有被掩盖:场景 6。其他客户端的活动现在能在一个轮询周期内重排列表且不发起任何目录请求,而 main 的列表保持陈旧(main 仍会在观察者自己下一次 turn 时通过兜底刷新追上)。

注意事项 2 —— 描述小瑕疵

归档区与分组/来源过滤页不会因 live 活动新增行或重排

ownsActivityReorder 实际只排除了归档页带游标页。分组/来源过滤页(侧边栏自身的 group=all&sourceType=defaultgroup=pinned 查询)确实会因 live 活动重排 —— 场景 4 正是 group=pinned 页发生了原地重排。该行为是正确的(与服务端对过滤后集合的排序一致,我已验证),所以这只需要改 PR 描述的措辞,不需要改代码。

成本补充

在这台机器上(20 个会话、本地快速磁盘),冷目录扫描实测 p50 约 1.1 ms,live-state 读取约 0.84 ms —— 单请求延迟收益在此并不显著。真正有意义的是扫描次数的削减,它随会话数量与磁盘延迟增长,而不是这台机器上的绝对数字。

结论

在我测量的所有维度上都正确、防护到位、且严格不劣于 main —— 无阻塞性问题。上面两条注意事项是给合并决策的上下文,不构成反对意见。


🤖 Generated with Claude Code — Claude Opus 5 (1M context)

: undefined;
const liveActivityTime =
liveUpdatedAt !== undefined ? Date.parse(liveUpdatedAt) : Number.NaN;
if (Number.isFinite(liveActivityTime)) absorbed.add(session.sessionId);

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.

absorbed.add() fires when the live watermark merely parses, not when it actually advances the row, so a completion whose watermark did not move settles with no stamp change and no fallback rescan.

The daemon advances the watermark only for a terminal of a prompt that reached running (packages/acp-bridge/src/bridge.ts:2038 const mutateTurnState = pendingEntry.state === 'running';). Queue a prompt behind a running turn and cancel it, or drop the stream mid-turn: App.tsx:7247 fires turnCompleted on any non-idle to idle transition, the live row still carries the PREVIOUS turn's stamp, Number.isFinite(liveActivityTime) is true so the id enters absorbed, line 542 then rejects it as not fresher, and the settle loop therefore never sets invalidationRequested and deletes the pending record — while the transcript mtime the server would have returned did move. Measured A/B with a fake daemon whose live updatedAt is older than the row's: base issues 1 extra catalog read for that completion, PR head issues 0. Note the fix is not simply moving the add inside the > branch — a mutation test shows that passes all 77 tests but would fire a spurious reconcile whenever the daemon reports an equal-or-older watermark for a row the page already holds fresher data for; the two cases ("already at least as fresh" vs "no running terminal happened") need to be distinguished.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

// The catalog revision doesn't advance on turn completion; record
// the completion so the live-state loop can settle it from the
// response's updatedAt watermark instead of a full catalog scan.
store.recordSessionActivity(workspaceCwd, sessionId);

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.

Turn completions were the only steady-state catalog reader once live-state is on, so retiring the rescan freezes every row whose recency the live overlay does not carry.

With live-state enabled the store refuses all background/poll catalog work (session-catalog-store.ts:822 if (this.isWorkspaceLiveStateEnabled(entry.query.workspaceCwd)) return;) and the sidebar disables its own autoLoad/pollIntervalMs (WebShellSidebar.tsx:995, :1304); every remaining invalidateWorkspace producer is a user action (create/rename/archive/delete), and no persistent subscriber sets maxAgeMs. A persisted-only row, or a row advanced by a second qwen process on the same workspace, has no live entry so applyLiveState never touches its updatedAt, and the bridge revision does not move (bridge.ts:2512 is reached only from membership/metadata changes). Measured A/B: 30 completions over a minute produce 6 catalog rescans on base (the other row's timestamp converges to its new value) and 0 on head (it stays frozen at the pre-advance value) for as long as the sidebar is open.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

// reorder-owning page (stamped or already at least as fresh). The settle
// loop consumes this instead of re-deriving the index and acceptance
// rules, so the two sites cannot drift apart.
const absorbed = new Set<string>();

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.

absorbed is a workspace-wide union across every catalog entry, so one page absorbing a watermark suppresses the fallback rescan a different page still needed.

The loop at 509-512 filters only on workspaceCwd and snapshot.page — it includes zero-subscriber entries still inside the 30s SESSION_CATALOG_RETENTION_MS window, and refreshActiveSessionDisplayName mints exactly such an entry on the same turn completion (App.tsx:5251-5259, options: { pageSize: 200 }, no cursor, no archiveState, so ownsActivityReorder is true). Probe: two loaded reorder-owning entries, session s3 present on entry B (wider filter) but not on entry A (the server clamps a page to MAX_SESSION_PAGE_SIZE = 100, session-list.ts:29); applyLiveState returns absorbed = ['s3'] from B while A's page is untouched, so workspace-session-live-state.ts:303 never sets invalidationRequested and A keeps a page 1 that omits the workspace's most recently active session. The verdict should be per-entry (or an intersection over entries that actually hold the row), not a union.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

return Number.isFinite(time) ? time : 0;
}

function compareSessionsByActivity(

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.

The mirrored comparator adds a sessionId tie-break and a 0 floor that the server's default (non-organized, non-metadata) list path does not have, so rows reorder client-side and snap back on the next reconcile.

The server's default branch is session-list.ts:1227-1231return bTime - aTime; with no id tie-break (relying on sort stability) and no Number.isFinite guard (a NaN comparator result is treated as 0, leaving the row in place). That branch is reached whenever view and sourceType are both absent: WorkspaceSection.tsx:215-229 and WebShellSidebar.tsx:994-1004 build exactly that query when organizationEnabled is false and no source filter is selected, and App.tsx:5256 always does. Probe on the head store: a page the server returned as [zzz, aaa, trigger] (zzz and aaa share an activity millisecond) becomes [aaa, zzz, trigger] after any activity advance, and WorkspaceSection.tsx:628 renders sessionsResult.sessions directly, so the two rows visibly swap and swap back on the next reconcile. Same for an unparsable stamp, which the client sinks to the bottom and the server leaves in place.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

}
const byTime = getSessionActivityTime(b) - getSessionActivityTime(a);
if (byTime !== 0) return byTime;
return a.sessionId.localeCompare(b.sessionId);

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.

localeCompare() with no explicit locale makes the tie-break depend on the viewer's browser collation, so the client can order equal-activity rows opposite to the daemon.

Measured: 'aa1'.localeCompare('ab1','en') === -1 but 'aa1'.localeCompare('ab1','da') === 1 (Danish/Norwegian collate aa after z); case order flips too ('0a'.localeCompare('0A') is -1 in en, +1 in da). Session ids are lowercase hex UUIDs, so aa…/ab… prefixes occur. On a Danish- or Norwegian-locale browser two equal-activity rows render in the opposite order from the page the daemon returned (the daemon runs the same call under Node's own default locale) and they swap on every activity advance / reconcile cycle. Passing an explicit locale — or comparing with </> — on both sides removes the dependence.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

const updatedAt =
liveUpdatedAt !== undefined &&
Number.isFinite(liveActivityTime) &&
liveActivityTime > getSessionActivityTime(session)

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.

The client's live-vs-persisted freshness rule is hand-written and differs from laterActivityTimestamp, the module whose docstring claims every recency surface shares it.

packages/cli/src/serve/server/activity-timestamp.ts:21 compares the live watermark against the persisted updatedAt only and prefers live on a tie (liveTime >= persistedTime ? live : persisted); the client compares against updatedAt ?? createdAt with a strict >. For a row whose persisted updatedAt is absent and whose createdAt parses later than the bridge watermark (daemon/recorder clock skew, or a summary written by an older daemon), mergeLiveSessionSummary (session-list.ts:453) gives that row the live watermark while applyLiveState rejects it and keeps createdAt — and line 538 still counts it as absorbed, so no fallback rescan runs either. The same session then reports two different recencies depending on whether you read the REST list or the Web Shell sidebar, which is exactly what activity-timestamp.ts:17-19 says the shared helper exists to prevent.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

this.liveStatePendingActivity.set(workspaceCwd, pending);
}
pending.set(sessionId, ++this.liveStateActivitySequence);
for (const handler of this.liveStateWakeHandlers) handler(workspaceCwd);

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.

recordSessionActivity wakes every poll loop on every turn completion with no dedupe and no minimum interval; the invalidation path it replaced deliberately did neither.

requestLiveStateRefresh both self-dedupes (if (this.liveStateWorkspaceRefreshRequests.get(workspaceCwd) === 'interactive') return;) and wakes only for 'interactive', so the old 'invalidated' turn-completion path added zero off-cadence requests. Measured A/B: one turnCompleted produces 0 immediate getWorkspaceSessionLiveState calls on base and 1 on head. The only brakes in poll() are state.inFlight, liveRetryAt (set on error only) and document.hidden, so in a workspace where several sessions settle turns in a burst the 2s cadence degrades to roughly one request per completion. The wake buys at most 2s of reorder latency; a lastPollStartedAt + SESSION_LIVE_STATE_POLL_MS floor would coalesce the burst.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

live.sessions,
);
state.liveRetryAt = 0;
if (pendingActivity) {

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.

reconcile() discards applyLiveState's new return at both of its call sites and never settles pending activity, so a completion recorded during a reconcile can trigger a duplicate full reconcile on the very next tick.

A completion recorded while a reconcile is running has its wake dropped by the state.inFlight guard (lines 248-255), so it survives the reconcile — even though readLiveState at line 216 was issued after the record and line 233 already stamped the row post-commit. Probe: the two absorbed sets returned inside the reconcile both contain the completed session, both are thrown away, and snapshotSessionActivity still holds the record afterwards; when that session is off-page the next tick runs a second full stage+commit (the first reconcile was interactive so invalidationReconcileAt is still -Infinity and the cooldown at 326-336 does not stop it). The correct fix mirrors poll(): snapshot the pending set BEFORE line 216 and settle only that snapshot, since a completion recorded after that request went out is not in liveB.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

reportCatalogTurnCompletion
) {
sessionCatalogController.turnCompleted(catalogOwnerCwd);
sessionCatalogController.turnCompleted(

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.

In split view ChatPane and App can both report the same completion, and the second record bumps the sequence so the in-flight settle no-ops, costing a full extra poll cycle.

SplitView.tsx:488-491 sets reportCatalogTurnCompletion={sessionId !== currentSessionId || paneWorkspaceCwd !== connection.workspaceCwd}, and paneWorkspaceCwd = workspaceCwdById.get(sessionId) (SplitView.tsx:420) is undefined until the session list resolves, so the second disjunct is true even for a pane showing the current session. React runs the child effect first: ChatPane records seq 1, the wake starts a poll that snapshots {S:1}, then App's effect records seq 2, so resolveSessionActivity(cwd, S, 1) hits if (pending?.get(sessionId) !== sequence) return; and no-ops. Probe: a single record leaves snapshotSessionActivity('/work') === undefined after the wake poll, a double record leaves Map(1) { 'session-a' => 2 } — the completion waits another SESSION_LIVE_STATE_POLL_MS, and if the row is off-page it raises invalidationRequested twice, scheduling a duplicate full reconcile.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

).toBeUndefined();
});

it('settles a turn completion from the live watermark without a catalog scan', async () => {

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.

No end-to-end test exercises the new watermark path at all, and the request-count E2E the design doc assigns to this PR was not written.

packages/web-shell/client/e2e/utils/mockDaemon.ts:941-947 returns live rows with only sessionId/clientCount/hasActivePrompt/isWaitingForPermission/isWaitingForUserQuestion and never updatedAt, and this PR does not touch it — so in e2e/web-shell.session-live-state.spec.ts absorbed is always empty and every completion takes the full-rescan fallback; that spec also never drives a turn, so reverting this entire PR leaves it green. (The handler's upstream filter (session.clientCount ?? 0) > 0 || session.hasActivePrompt === true || … also excludes idle rows, so adding updatedAt alone is not enough.) docs/design/2026-08-18-workspace-session-live-state-updated-at.md:604 assigns the coverage explicitly: "The later Web Shell PR adds request-count E2E coverage proving repeated turns on an already-loaded row issue no additional full catalog requests."


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

return session;
}
changed = true;
if (session.updatedAt !== updatedAt) activityAdvanced = true;

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.

The "only reorder when the effective value changes" gate and the absorbed-set rule are both unpinned — mutating either keeps the whole suite green.

Mutation testing against the 77 passing tests: replacing if (session.updatedAt !== updatedAt) activityAdvanced = true; with an unconditional activityAdvanced = true; — 77/77 still pass; replacing ...(updatedAt !== undefined ? { updatedAt } : {}) (line 562) with a bare updatedAt, — 77/77 still pass; and narrowing absorbed.add (line 538) to the accepted-stamp branch — 77/77 still pass. Control mutations do fail (deleting sessions.sort fails 3, dropping session.updatedAt === updatedAt from the equality check fails 3), so the suite is otherwise live. Line 555 encodes design-doc rule "should only reorder when the effective value changes" (2026-08-18-workspace-session-live-state-updated-at.md:474): if a refactor breaks it, every 2s poll where clientCount/hasActivePrompt jitters re-sorts the whole page with the client comparator, the sidebar list churns continuously, and nothing goes red.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

const schedule = vi.spyOn(store, 'scheduleWorkspaceRefresh');

// Without live-state ownership the legacy rescan path stays.
act(() => controller!.turnCompleted('/w', 'sess-1'));

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.

The new hooks test leaks a real 2-second timer and three unrestored spies, breaking the teardown convention its sibling suites follow.

This file has no vi.useFakeTimers(), no vi.restoreAllMocks() and no store.dispose(); afterEach (lines 80-83) only unmounts the root and removes the container. The new case's first half takes the non-live-state branch and really calls store.scheduleWorkspaceRefresh('/w'), whose internal setTimeout(…, SESSION_CATALOG_TRAILING_REFRESH_MS = 2000) is never cleared, so it lands during later cases and calls invalidateWorkspace('/w') on a store nobody disposed; the three vi.spyOn(store, …) handles are never restored. The sibling workspace-session-live-state.test.tsx afterEach does both vi.useRealTimers() and vi.restoreAllMocks(). Isolation currently survives only because each test builds a fresh client (the store is cached per client) — coincidence, not containment.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

// stamp (e.g. a staged page committed fresher data mid-flight) must
// not regress the row.
const liveUpdatedAt =
acceptsActivity && session.isArchived !== true

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.

Two guards in the new code are unreachable, and the tests that "cover" one of them fabricate a response the daemon cannot produce.

On an entry where acceptsActivity is true (archiveState !== 'archived') session.isArchived can never be true: the server filters the active list by archiveState and mergeLiveSessionSummary forces isArchived: false, and nothing in packages/web-shell/client ever writes the field. session-catalog-store.test.ts:601-606 and :788 cover it by placing { isArchived: true } rows inside an archiveState: 'active' page, so the guard's entire safety signal rests on invented server behavior. Line 540's liveUpdatedAt !== undefined && is dead the same way — when liveUpdatedAt is undefined liveActivityTime is Number.NaN (536-537) so Number.isFinite is already false; deleting it leaves tsc --noEmit clean and 77/77 tests green, and Number.isFinite(liveActivityTime) is then evaluated twice (538 and 541).


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

// the live-state overlay carries no updatedAt — flag a
// rate-limited reconcile so activity stamps keep refreshing.
store.requestWorkspaceLiveStateInvalidation(workspaceCwd);
// The catalog revision doesn't advance on turn completion; record

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.

This PR is the "follow-up consumer" the design doc describes, but the doc still states the consumer is unimplemented.

docs/design/2026-08-18-workspace-session-live-state-updated-at.md still reads "This document defines the server and TypeScript SDK contract only. Web Shell consumption is a separate implementation change." (lines 5-7), heads section 447 as "Follow-up Web Shell Consumption Contract" in the future tense, says at 519 "It intentionally excludes all Web Shell changes.", and at 640 "That metric changes only after the follow-up consumer ships." A reader cannot tell which of the seven numbered rules landed and which were relaxed — rule 2 ("records … only for a turn known to have reached the running state", which this client does not distinguish) and rule 5 ("using the server comparator", which diverges on the default path) both shipped with caveats.


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

);
}

// Mirrors the server's getSummaryActivityTime: activity is updatedAt with a

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.

The server's activity-ordering rule is hand-copied into the client with only a comment binding the two, and no test compares the two implementations.

getSessionActivityTime duplicates getSummaryActivityTime (session-list.ts:582) and compareSessionsByActivity duplicates compareOrganizedCursorKeys (:627) and compareLiveSessionCursorKeys (:596) — the same functions that decide the server's pagination cursor boundaries — with no shared module and no mirror test, so any future server ordering change silently desyncs the client and surfaces as rows that reorder on poll and reorder back on reconcile, months later, with no link to the server commit that caused it. Both packages already depend on @qwen-code/sdk, where DaemonSessionLiveState.updatedAt is already declared, so one exported comparator plus one shared activity-stamp accessor would remove the drift surface (the repo already has both a types-sync.ts and a *-sdk-mirror.test.ts precedent for this).


Reviewed with Claude Code (claude-opus-5, 1M context) — /code-review 9476 --comment.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.15.

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.

4 participants