Skip to content

feat(serve): Propagate session list cancellation - #8954

Merged
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/session-list-cancellation
Aug 12, 2026
Merged

feat(serve): Propagate session list cancellation#8954
doudouOUC merged 2 commits into
QwenLM:mainfrom
doudouOUC:agent/session-list-cancellation

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR propagates request cancellation through daemon Session List reads while preserving the persisted catalog cache introduced by #8892. Shared organized and metadata scans now track independent waiters: cancelling one REST or ACP caller leaves other REST, ACP, and LiveTask waiters running, while cancelling the last waiter aborts and detaches the physical scan so a replacement request can start immediately. Cancellation also reaches numeric pagination, JSONL reads, runtime-status and worktree-sidecar enrichment, persisted-existence checks, and the cooperative directory scan.

Why it's needed

Disconnected Session List clients previously left expensive persisted-session scans running to completion, which could waste daemon resources and delay later catalog work. Directly wiring a caller signal to the shared loader would instead let one client cancel every consumer, so the cache needs waiter-aware cancellation ownership and late-load isolation.

Reviewer Test Plan

How to verify

Start two identical organized Session List requests and disconnect one; the remaining request should return the complete catalog from one physical scan. Disconnect every cancellable waiter; the physical scan should abort, and the next request should start a fresh scan before the following request hits the existing two-second TTL. Verify numeric pagination, trusted-secondary persisted preflight, ACP connection destruction, and cancellation during JSONL, runtime-status, worktree-sidecar, and project-membership reads return no partial data, HTTP 500, ACP error frame, or cancellation error log.

Evidence (Before & After)

N/A — daemon-internal behavior with unchanged REST and ACP response schemas.

Tested on

OS Status
🍏 macOS ✅ tested
🪟 Windows ⚠️ not tested
🐧 Linux ⚠️ not tested

Environment (optional)

macOS local Node.js 22-compatible workspace; isolated qwen serve E2E with 1,200 persisted sessions and package-level Vitest coverage.

Risk & Scope

  • Main risk or tradeoff: Cancellation adds cooperative checkpoints and per-waiter bookkeeping around an existing single-flight cache; tests cover settlement races, last-waiter abort, replacement-load identity, late old-load completion, and no-signal behavior.
  • Not validated / out of scope: Windows and Linux E2E, fixed scan deadlines, asynchronous directory enumeration, concurrent stat calls, worker threads, request-id ACP cancellation, Web Shell GET cancellation, and cancellation for CLI resume or picker callers.
  • Breaking changes / migration notes: None; REST, ACP, TypeScript SDK, cache scope, TTL, capacity, invalidation, runtime routing, and trust semantics remain unchanged.

Linked Issues

References #8892.

中文说明

此 PR 的作用

此 PR 在保留 #8892 引入的持久化目录缓存基础上,将请求取消传播到 daemon Session List 读取。共享的 organized 和 metadata 扫描现在跟踪独立 waiter:取消单个 REST 或 ACP 调用方不会影响其他 REST、ACP 或 LiveTask waiter;最后一个 waiter 取消时会中止并同步脱离物理扫描,使替代请求可以立即启动。取消还会传播到数字分页、JSONL 读取、runtime status 与 worktree sidecar 补充、持久化存在性检查以及协作式目录扫描。

为什么需要

Session List 客户端断开后,昂贵的持久化会话扫描此前仍会运行到结束,浪费 daemon 资源并可能延迟后续目录工作。若将调用方 signal 直接连接到共享 loader,单个客户端又会取消所有消费者,因此缓存需要 waiter 感知的取消所有权和晚到 load 隔离。

Reviewer 测试计划

如何验证

启动两个相同的 organized Session List 请求并断开其中一个;剩余请求应通过一次物理扫描返回完整目录。断开所有可取消 waiter 后,物理扫描应中止,下一请求应启动新的扫描,随后请求命中现有两秒 TTL。验证数字分页、可信 secondary 的 persisted preflight、ACP connection destroy,以及 JSONL、runtime status、worktree sidecar 和项目归属读取期间的取消都不会返回部分数据、HTTP 500、ACP error frame 或取消错误日志。

证据(Before & After)

N/A — daemon 内部行为,REST 和 ACP 响应 schema 未变化。

测试平台

操作系统 状态
🍏 macOS ✅ 已测试
🪟 Windows ⚠️ 未测试
🐧 Linux ⚠️ 未测试

环境(可选)

macOS 本地 Node.js 22 兼容工作区;使用 1,200 个持久化 session 的隔离 qwen serve E2E,并运行了包级 Vitest。

风险与范围

  • 主要风险或权衡:取消功能在现有 single-flight cache 周围增加协作检查点和按 waiter 计数;测试覆盖 settle 竞态、最后 waiter 中止、replacement load identity、旧 load 晚到完成以及无 signal 路径。
  • 未验证 / 范围外:Windows 与 Linux E2E、固定扫描期限、异步目录枚举、并发 stat、worker thread、ACP request-id 取消、Web Shell GET 取消,以及 CLI resume 或 picker 调用方取消。
  • 破坏性变更 / 迁移说明:无;REST、ACP、TypeScript SDK、cache scope、TTL、capacity、invalidation、runtime routing 与 trust 语义均保持不变。

关联 Issue

参考 #8892

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

E2E test report

  • Baseline: global qwen 0.21.8 with an isolated runtime containing 1,200 persisted sessions. A 10 ms deadline disconnected the cold organized request without response bytes; the replacement returned the complete 34,224-byte catalog.
  • Branch: the same disconnect produced no response bytes, HTTP 500, or Session List failure log. The replacement returned the complete 34,224-byte catalog with HTTP 200, and GET /health remained HTTP 200.
  • Cache behavior: two immediately consecutive complete branch requests took 0.912 s and 0.006 s with identical bodies; the daemon recorded the warm request as a 2 ms HTTP 200 response.
  • Deterministic coverage: focused tests verify the 128-entry cancellation checkpoint, independent waiters, last-waiter physical abort and synchronous detachment, replacement-load identity, late old-load isolation, numeric/preflight cancellation, shared REST waiter disconnect, and ACP destruction without an error frame.
  • Repository verification: changed-file Prettier and ESLint, focused Core and CLI Vitest suites, npm run build, npm run typecheck, and npm run lint all passed on the final branch diff.

@doudouOUC
doudouOUC marked this pull request as ready for review August 11, 2026 17:07
@doudouOUC
doudouOUC enabled auto-merge August 11, 2026 17:07
@qwen-code-ci-bot

qwen-code-ci-bot commented Aug 11, 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

@qwen-code /triage

@doudouOUC doudouOUC self-assigned this Aug 11, 2026
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (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: 76 passed · 0 failed · 76 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

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

Verification report

PR #8954 — feat(serve): Propagate session list cancellation

Verdict: merge-ready — 76/76 scripted assertions passed (0 unexpected failures), verified head 14257b7293caf3fa88c1abb3540928177d8ff717 (merge ecd1016e57 over base 28ab8bae56).

中文摘要
  • 结论: merge-ready。76/76 脚本断言通过,0 个意外失败。
  • A/B 结论: 中心声明(load-bearing)已证明。缓存机制层 41/41:head 端"取消一个 waiter 不影响其他 waiter / 最后一个 waiter 取消即中止并脱离物理扫描、替代请求立即开新扫描 / 先结算优先"全部成立;base 端按预期表现出旧语义(替代请求并入仍在跑的 in-flight 扫描、loader 无 signal)。核心扫描层 13/13:head 对 400 个真实持久化 session 的扫描在中途 abort 时以调用方 reason(AbortError)拒绝(3.2 ms,而完整扫描 ~50 ms),base 忽略 signal 跑完;head 无 signal 路径与 base 结果逐条一致。
  • 接线 A/B: 把 PR 自己的新测试复制到 base worktree 运行,23 个行为断言按预期变红(cache 9、REST 6、ACP 1、core 7),既有测试保持绿;head 端全绿(cache+transport 321、server 928、core 201)。变异矩阵:PC 与 3 个守卫变异被杀,2 个幸存守卫经静态链+实证分类为不可达的防御性双保险(非缺陷)。
  • Findings: 无阻塞项;仅 1 条信息级观察(两个不可达防御守卫,见下)。
  • 未覆盖: Windows/Linux 1,200-session E2E(作者声明)、Web Shell GET 取消、request-id ACP 取消、CLI resume/picker 取消(均 PR 自述范围外)、全仓测试套件、yield 间隔的性能刻画。

Central claim + A/B

Central claim: the persisted catalog cache owns cancellation per-waiter — cancelling one waiter rejects only that caller; cancelling the last waiter aborts and synchronously detaches the physical scan so a replacement starts immediately; settlement is first-wins.

A/B harness harness/cache-ab.mjs drives the compiled PersistedSessionListCache of both arms (head dist vs base dist rebuilt in tmp/base-tree; the base module imports only node:path, and the base arm additionally ran under a resolve-hook guard, harness/no-workspace-imports.mjs, that fails on any @qwen-code/* runtime import — the realpath check showed tmp/base-tree/node_modules/@qwen-code/qwen-code-core resolves into the HEAD tree, so the guard proves no head code leaked into the control). Witness: evidence/01-cache-ab-head-vs-base.png.

Cell Oracle HEAD BASE (predicted)
C1 one of two waiters cancels leader rejects with own reason; load signal not aborted; follower resolves; 1 scan; snapshot installed ✔ all n/a (no signal API); both waiters resolve; 1 scan; installed
C2 last waiter cancels load signal aborted (reason = cache DOMException 'AbortError'); replacement status scan, loader called 2×; late non-cooperative settle of detached load installs nothing ✔ all replacement joins in-flight (single_flight), loader 1×, no signal arg
C3 load settles, late abort waiter resolves with snapshot; snapshot retained resolves (no abort concept)
C4 no-signal waiter resolves, scan
C5 cancel before loader microtask loader never called; retry = fresh scan n/a
C6 already-aborted caller synchronous throw of caller reason; no slot/load n/a
C7 cache-hit caller cancels rejects with own reason; no extra scan n/a

Result: 41/41 arm-predicted checks, 0 unhandled rejections (an abort-during-settlement run would otherwise surface as one).

Secondary claim 1 — cancellation reaches the physical scan. harness/scan-ab.mjs builds a real 400-session fixture per arm and drives compiled SessionService.listSessions (evidence/02-scan-ab-head-vs-base.png): head rejects a mid-scan abort with the caller's AbortError at 3.2 ms (full scan ≈ 46–55 ms), rejects a pre-aborted signal immediately, and its no-signal result is id-for-id identical to base's (parity guard for the new yield path). Base ignores the same signal option and completes all 400. 13/13.

Secondary claim 2 — REST/ACP wiring cancels only the disconnecting caller. Proven by running the PR's own new tests against both source trees (copied verbatim into the base worktree; vitest aliases are worktree-relative, so the base arm resolves base source):

Suite (new tests) BASE HEAD
cache unit (19) 9 F / 10 P 19 P
REST server.test.ts (6 new) 6 F / 16 P 928 P (full file)
ACP transport.test.ts (1 new) 1 F 302 P (full file)
core ×4 files (8 new) 7 F / 1 P 201 P (full files)

Every base failure is a behavioral assertion (expected undefined to be defined, promise resolved instead of rejecting, expected function to throw); the one base pass is the no-signal preservation test, which is correct on both arms. The base-side positive control is the 10/16/1 pre-existing tests passing — the arm executes the right code.

Reviewer Test Plan walkthrough

  1. "Start two identical organized requests and disconnect one; the remaining request should return the complete catalog from one physical scan." — Executed twice: mechanism cell C1 and the REST-level test keeps a shared REST catalog scan alive when only one request disconnects (green head / red base). ✔
  2. "Disconnect every cancellable waiter; the physical scan should abort, and the next request should start a fresh scan before the TTL." — Cell C2 (abort observed on the loader's signal; replacement = scan within the same tick batch, far below the 2 s TTL) and REST test aborts and detaches a catalog scan after its last waiter cancels. ✔
  3. "Numeric pagination, trusted-secondary persisted preflight, ACP connection destruction … return no partial data, HTTP 500, ACP error frame, or cancellation error log."propagates cancellation through numeric pagination, cancels the trusted-secondary persisted preflight when the request disconnects, and the ACP test asserting bufferedConnectionFrames unchanged and no /acp dispatch error log — all green head, red base. Core-level cancel-during-JSONL/runtime-status/sidecar/membership reads: 7 core tests green head / red base. ✔

Findings

  1. (Informational, non-blocking) Two new cache guards are unreachable double-safety. The mutation matrix (evidence/03-mutation-matrix-cache-guards.png) shows !controller.signal.aborted (install path) and !slot.inFlight.controller.signal.aborted (single-flight reuse) each survive deletion with 19/19 green, while the positive control (cache-hit abort check → honors cancellation… red) and M1/M4 kill exactly their intended tests. Static chain: the only controller.abort( call site (line 240) runs inside settle(), which synchronously detaches the load when it fires; onAbort returns early once load.settled, so at install time an aborted controller implies the load is already detached, and at lookup time an aborted in-flight load implies the same. Classification: dead-but-harmless defensive guards, not a coverage gap (the behavior they would gate cannot occur) and not a defect. The author may keep them as belt-and-braces or drop them; no action required.
  2. (Informational) req.once('aborted') is deprecated but live on this runtime. node-aborted-probe.mjs on Node v22.23.2 (the lane's own container): a client socket destroy fires both req 'aborted' and res 'close' with writableEnded=false, so the route's two guards are both live and either alone would catch the disconnect. No action required.

Not covered

  • Windows/macOS E2E and the author's 1,200-session Linux E2E (no such environment here); the scan harness used 400 real sessions — enough to cross the 128-entry yield threshold three times.
  • Web Shell GET cancellation, request-id ACP cancellation, CLI resume/picker cancellation — explicitly out of scope per the PR body.
  • Repo-wide test suite; only the affected files were run (1,450 tests green at head across the seven changed test files).
  • Mutation matrix covers the cache guards (all five) plus one core spot-check (yield block killed its test while the no-signal test stayed green); the remaining per-checkpoint throwIfAborted guards in core are pinned only by their own new tests (green head / red base).
  • Performance characterization of the 128-yield interval (correctness only; the no-signal path provably never yields — does not yield during directory enumeration without a signal green on both arms).
  • Base-side packages/cli was compiled with a copy of head's install-generated src/generated/git-commit.ts (a version constant, absent from the worktree; unrelated to behavior). Base build initially failed typecheck because git worktrees lack the npm-hoisted packages/*/node_modules (mime/ajv/fdir/ignore); resolved by linking those dirs — an environment quirk, recorded in logs/base-core-build.log.
  • Per-commit attribution: single commit, reachable and matching the metadata snapshot; aggregate diff = commit diff.

Methodology

Environment: node:22-bookworm CI container, merge-ref checkout (HEAD merge, HEAD^1 base, HEAD^2 PR head). Base control = git worktree add tmp/base-tree 28ab8bae56 with packages/core + packages/cli rebuilt there (lockfile untouched by the PR, so shared root node_modules is a clean control; the one realpath confound is named above and guarded). Harnesses import compiled dist/ output of each arm directly and ran under a resolve hook that rejects any @qwen-code/* import in the base arm. Wiring A/B re-runs the PR's own vitest tests against both source trees. Mutation matrix applied single-hunk deletions in a scratch HEAD worktree, ran the cache suite per mutant, and reverted (each run logged revert_clean=yes). Gates: tsc --noEmit on both packages (clean; tsc proven live by the base build's genuine errors), eslint on all 15 changed files (clean; proven live by planting two violations → 2 errors, exit 1). Raw logs and harnesses in logs/ and harness/; counts in assertions.json map 1:1 to the checks above (54 harness checks + 2 probe + 8 wiring-suite predictions + 8 mutation predictions + 4 gates = 76).

Evidence images

01-cache-ab-head-vs-base

02-scan-ab-head-vs-base

03-mutation-matrix-cache-guards

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 commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Re-run after the round-1 fixes landed — the head moved from 14257b7 to 9e835e2e11, and the delta is test files only. Gate re-checked from scratch on the new head.

Template ✓ — all sections present, bilingual body, reviewer test plan filled in (Before/After correctly marked N/A for daemon-internal behavior).

Problem: real. #8892 merged the persisted catalog cache with single-flight shared scans; by construction, a disconnected Session List caller leaves the physical scan running to completion while its in-flight slot blocks replacement requests. That is a property of code now on main, not a hypothesis — and the design doc is updated alongside the fix.

Direction: aligned. This is the cancellation half of the #8892 design, in the daemon session-management area that recent CHANGELOG entries (#8891, #8892) show is under active development. REST and ACP response schemas stay unchanged; LiveTask callers deliberately remain non-cancellable.

Size: core paths are touched (packages/core/src/services/**, packages/core/src/utils/**) and the change spans packages/cli + packages/core, so the two-tier gate applies. 471 production logic lines (REST routes 125, session-list plumbing 103, cache waiter logic 86, core services/utils 116, ACP dispatch 41) vs 1,026 test lines vs 10 doc lines — under the 500-line feat maintainer-awareness threshold and under the 1,000-line large-PR advisory. Title is feat, not refactor: no Tier-1 hard block, no fork-refactor approval guardrail.

Approach: scope fits the goal. A per-load controller with a per-waiter registry, first-wins settlement, last-waiter abort with synchronous detach, and cooperative checkpoints at every site where a swallowed abort would surface as a wrong result. That is the same shape I'd propose from the problem statement alone; the obvious simpler alternative — wiring the first caller's signal straight to the shared loader — would let one disconnect cancel every other waiter, which is exactly what the description rejects. No drive-by edits; the doc change is this feature's design doc.

Risk: no Stage 1e high-risk-path matches. Moving on to code review. 🔍

中文说明

Round-1 修复后的 re-run —— head 从 14257b7 移到 9e835e2e11,差异仅为测试文件。门禁已在新 head 上从头重新检查。

模板 ✓ —— 各节齐全、中英双语,reviewer 测试计划已填写(daemon 内部行为,Before/After 正确标注 N/A)。

问题:真实存在。 #8892 合入了带 single-flight 共享扫描的持久化目录缓存;按其构造,断开的 Session List 调用方会让物理扫描一直跑完,且其 in-flight 槽位会挡住替换请求。这是已在 main 上的代码的固有属性,不是假设 —— 设计文档也与修复同步更新。

方向:对齐。 这是 #8892 设计的取消那一半,处于近期 CHANGELOG 条目(#8891#8892)表明正在活跃开发的 daemon 会话管理领域。REST 与 ACP 响应 schema 保持不变;LiveTask 调用方有意保持不可取消。

规模: 触及核心路径(packages/core/src/services/**packages/core/src/utils/**)且跨 packages/cli + packages/core,适用两级门禁。生产逻辑 471 行(REST 路由 125、session-list 管线 103、缓存 waiter 逻辑 86、core services/utils 116、ACP dispatch 41),测试 1,026 行,文档 10 行 —— 低于 feat 类 500 行维护者关注阈值,也低于 1,000 行大 PR 建议线。标题为 feat 而非 refactor:无 Tier-1 硬阻断,也不触发 fork-refactor 批准护栏。

方案:范围与目标相称。 按 load 持有 controller、按 waiter 注册、first-wins 结算、最后一个 waiter 中止并同步脱离,外加在每个"吞掉中止就会变成错误结果"的位置设置协作检查点。这正是我只看问题陈述就会提出的形态;显然更简单的替代 —— 把第一个调用方的 signal 直接接到共享 loader —— 会让一次断开取消所有其他等待者,也正是 PR 描述所否决的方案。无顺手改动;文档改动即本特性的设计文档。

风险: 无 Stage 1e 高风险路径命中。进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 9e835e2e11bfc85490f6f1265e4ae7faf75a91a2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

I wrote down my own design before reading the diff — waiter registry on the shared load, load-owned AbortController, first-wins settlement, last-waiter abort with synchronous detach, cooperative checkpoints down the scan — and this PR is exactly that shape. I then went looking specifically for the races that make this design hard, and each one is handled and pinned by a dedicated test:

  • First-wins settlement: once the physical load's outcome is accepted, a late caller abort cannot replace it — there is even a test that triggers the abort from inside the snapshot getter mid-delivery. The !controller.signal.aborted guard also keeps an aborted load from installing its snapshot.
  • Cancel-before-start: an only waiter cancelling before the loader microtask never starts the loader and leaves no slot behind; already-aborted callers are rejected synchronously without creating a slot.
  • Detach + replacement identity: after the last waiter cancels, the load controller is aborted and the slot is detached synchronously, so a replacement scan starts immediately; a late resolve or reject from the detached load cannot install, clear, or overwrite the replacement (both directions tested). Waiter .then handlers stay attached to the load promise after settlement, so a detached load rejecting can never become an unhandled rejection.
  • An abort never converts to a wrong result: an abort reason carrying code: 'ENOENT' is not swallowed into an empty directory listing, and cancellation cannot surface as "session does not exist", a missing worktree sidecar, a liveMergeFailed persisted-only fallback, or a false trusted-secondary preflight — every swallow site re-throws via throwIfAborted() first, and every site has a test.
  • No-signal preservation: without a signal, directory enumeration never yields to the event loop (pinned by a setImmediate spy), and every existing consumer — CLI resume/branch commands, ACP agent, live-task service, worktree startup/exit, session-id admission, and the other readLines/readRuntimeStatus callers — keeps the unchanged code path. The only consumer of persistedSessionListCache.lookup is the catalog loader itself.

The round-1 delta (14257b79e835e2e11) is tests only: last-waiter physical-abort coverage at REST and cache level, two-connection over-the-wire proof that destroying one ACP connection does not cancel the survivor, discriminating core coverage for JSONL / runtime-status / sidecar reads, and scan_duration_ms pinned on both the scan leader and the single-flight waiter. No production line changed, which I confirmed against the compare API.

One open item carried over from the external review rounds, R2-7: cancellation stops at the SessionOrganizationService.readSnapshot boundary, with signal checks immediately before and after instead of inside it. That matches the documented design (a bounded small-file snapshot API), and the code is consistent with it — but whether to ever extend cancellation into the organization service is explicitly a maintainer call, so it is flagged in the verdict comment rather than silently absorbed.

No blocking findings, no AGENTS.md violations.

sequenceDiagram
    participant P1 as REST or ACP caller
    participant P2 as Session list route
    participant P3 as Cache waiter
    participant P4 as Cache load
    participant P5 as Persisted scan
    P2->>P3: attach waiter with caller signal
    P3->>P4: join the in-flight load, waiter count plus 1
    P4->>P5: loader runs with the load-owned signal
    Note over P1,P3: caller disconnects - only that waiter rejects
    P3->>P4: last waiter cancels, count reaches 0
    P4->>P5: abort the load controller, detach the slot
    Note over P4: the next request starts a fresh scan immediately
Loading
Files changed (16 of 16 shown)
File What changed
packages/cli/src/serve/server/persisted-session-list-cache.ts Per-load AbortController, waiter counting, first-wins settlement, last-waiter abort with synchronous detach
packages/cli/src/serve/server/session-list.ts Caller signal plumbed through organized, metadata, and numeric paths with checkpoints around merge, sort, pagination, and scan duration reporting
packages/cli/src/serve/routes/session.ts Request-level controller wired to req aborted and res close; preflight and catalog reads take the signal; abort errors swallowed silently with listener cleanup in finally
packages/cli/src/serve/acp-http/dispatch.ts session/list passes the connection abort signal; a destroyed connection returns without reply or error log
packages/core/src/services/sessionService.ts listSessions checks cancellation and yields every 128 directory entries when a signal is present; sessionExists takes a signal
packages/core/src/services/worktreeSessionService.ts readWorktreeSession accepts a signal and re-throws cancellation before the ENOENT-null fallback
packages/core/src/utils/jsonl-utils.ts readLines creates its stream with the signal and re-throws cancellation, including during stream close
packages/core/src/utils/runtimeStatus.ts readRuntimeStatus accepts a signal and re-throws cancellation before the null fallback
packages/cli/src/serve/server/persisted-session-list-cache.test.ts Race coverage: one-waiter cancel, settle-before-abort, cancel-before-start, detach and replacement identity in both directions, null reason, already-aborted caller, cache-hit cancel
packages/cli/src/serve/server.test.ts Route-level tests: shared scan survives one disconnect, last-waiter abort and replacement, numeric and preflight cancellation, sidecar and organization checkpoints, scan duration on leader and waiter
packages/cli/src/serve/acp-http/transport.test.ts Over-the-wire tests: connection destruction cancels session/list without buffered frames or error logs, and does not cancel a surviving second connection
packages/core/src/services/sessionService.test.ts 128-entry yield checkpoint, no-yield without signal, ENOENT-coded abort reason not swallowed, cancellation not converted to missing session or membership
packages/core/src/services/worktreeSessionService.test.ts Abort reason propagation and signal pass-through for sidecar reads
packages/core/src/utils/jsonl-utils.test.ts Abort reason propagation, including cancellation during stream cleanup
packages/core/src/utils/runtimeStatus.test.ts Abort reason propagation and signal pass-through for runtime-status reads
docs/design/session-list-persisted-catalog-cache.md Design doc updated with waiter, detach, checkpoint, and out-of-scope semantics

Test evidence (the PR's own CI, fetched via API — PR code was not executed in this run)

CI on the reviewed commit is fully settled: every pull_request workflow run completed, and every check that ran is green — Linux unit suite, Serve A/B (no response-shape drift vs base), SDK Java with the real daemon E2E, Desktop Shell builds on both OSes, web-shell smoke, and precheck. Three checks are skipped by design, not by this PR: Test (macos-latest, Node 22.x), Test (windows-latest, Node 22.x), and Integration Tests (CLI, No Sandbox) are merge-queue-only jobs in ci.yml and will run if the PR enters the queue. The cancelled route checks are bot command-routing jobs from the re-trigger, not PR CI.

Check Conclusion
Classify PR ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
macos-latest / Java 21 ✅ success
precheck-pr / precheck ✅ success
Real daemon E2E / Java 11 ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
Test (ubuntu-latest, Node 22.x) ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

One row per check name (latest run); merge-queue-only skipped checks listed in prose above. / 每个检查名一行(取最新一次运行),merge-queue-only 的 skipped 检查见上文说明。

The unit coverage is substantive: the cache tests drive the real PersistedSessionListCache (only SessionService.listSessions stubbed), two route tests issue real HTTP requests through createServeApp and abort them mid-flight, and the ACP tests run over the wire. Sandboxed verification is the lane that settles the one claim CI cannot — that on a live daemon, disconnecting the last waiter actually aborts the physical scan and the replacement cold scan serves the full catalog with no 500, partial body, or ACP error frame. The /verify run on this thread passed 76/76 assertions against 14257b7 with full A/B proof (cache semantics 41/41, core scan 13/13, the PR's own tests transplanted to base go red as predicted), and since the only commit after that is tests-only, that evidence applies byte-for-byte to this head's production code; the verify stage dispatched by this run is in flight and will post its own report. Not verified: the author's 1,200-session E2E is self-reported and macOS-only, and Windows/Linux E2E is author-marked out of scope (the merge queue covers platform suites).

中文说明

代码审查:读 diff 前我先独立写下自己的设计(共享 load 上的 waiter 注册表、load 自持的 AbortController、first-wins 结算、最后一个 waiter 中止并同步脱离、扫描链路上的协作检查点),本 PR 正是这一形态。随后专门去找这类设计最难处理的竞态,每一种都被处理且有专门测试钉住:first-wins 结算(甚至有在投递中途从 snapshot getter 内部触发中止的测试);loader 微任务前取消不会启动 loader、已中止的调用方同步拒绝且不建槽位;脱离与替换 load 的身份隔离(旧 load 迟到的 resolve 与 reject 两个方向都无法安装、清除或覆盖替换 load,且 waiter 的 then 处理器在结算后仍挂在 load promise 上,脱离的 load 拒绝不会变成 unhandled rejection);中止永远不会变成错误结果 —— 带 ENOENT code 的中止理由不会被吞成空目录,取消也不会变成"会话不存在"、丢失 sidecar、liveMergeFailed 回退或假的 preflight,每个吞错点都先 throwIfAborted 且都有测试。无 signal 路径完全保留(有 setImmediate spy 测试钉住),全部既有消费方 —— CLI resume/branch、ACP agent、live-task service、worktree 启动/退出、session-id admission 及其他 readLines/readRuntimeStatus 调用 —— 都走未改动的路径;persistedSessionListCache.lookup 的唯一消费方就是目录 loader 本身。Round-1 差异(14257b7 → 9e835e2)仅为测试:REST 与缓存层的最后 waiter 物理中止覆盖、双 ACP 连接销毁隔离、core 读取的判别性覆盖、scan leader 与 single-flight waiter 两侧的 scan_duration_ms 钉住 —— 已通过 compare API 确认无生产代码改动。外部评审遗留项 R2-7:取消止步于 SessionOrganizationService.readSnapshot 边界,改为前后设置 signal 检查 —— 与文档化设计一致,但是否把取消扩展进 organization service 明确是维护者决定,已在结论评论中标出而非默认吸收。无阻塞性问题,无 AGENTS.md 违规。时序图与改动文件概览见上。

测试证据(通过 API 读取 PR 自己的 CI —— 本次运行未执行 PR 代码):审查提交上的 CI 已完全落定:所有 pull_request 工作流运行完成,执行的检查全部为绿 —— Linux 单元测试、Serve A/B(响应相对 base 无漂移)、含真实 daemon E2E 的 SDK Java、双平台 Desktop Shell 构建、web-shell smoke、precheck。三个检查按设计跳过而非本 PR 所致:macOS/Windows 单元测试与 CLI 集成测试在 ci.yml 中是 merge-queue-only 任务,PR 进入队列时才会运行。被取消的 route 检查是 re-trigger 产生的机器人命令路由任务,不属于 PR CI。单元测试覆盖是实质性的:缓存测试驱动真实的 PersistedSessionListCache(仅 stub listSessions),两个路由测试通过 createServeApp 发起真实 HTTP 请求并中途 abort,ACP 测试走真实传输层。沙箱验证是补足 CI 无法覆盖部分的通道 —— 在真实 daemon 上断开最后一个 waiter 是否确实中止物理扫描、替换冷扫描是否完整返回目录而无 500/部分响应/ACP 错误帧。本线程的 /verify 运行已在 14257b7 上以 76/76 断言通过并带完整 A/B 证据(缓存语义 41/41、核心扫描 13/13、PR 自己的测试移植到 base 后按预期变红),而其后的唯一提交仅为测试文件,因此该证据逐字节适用于当前 head 的生产代码;本次运行派发的 verify 阶段正在执行,完成后会单独发布报告。未验证:作者的 1,200 会话 E2E 为自报且仅在 macOS 执行;Windows/Linux E2E 作者标记为范围外(merge queue 覆盖平台套件)。

Qwen Code · qwen3.8-max

Reviewed at 9e835e2e11bfc85490f6f1265e4ae7faf75a91a2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — clean, careful concurrency work, now on fully settled evidence: every CI check that runs is green on this head, the sandboxed verification applies byte-for-byte (the round-1 commit is tests-only), and every race in the waiter design is pinned by a test. Withholding the fifth point for the two things a human owns: the contested R2-7 boundary, and the author's behavioral E2E being macOS-only.

Stepping back: this is the second half of #8892 — a gap the single-flight cache creates by construction, closed by the minimal honest shape. I tried to find a materially simpler design (per-call aborts, one shared caller signal, detach without abort) and each one either starves sibling waiters or leaves the physical scan running — the very thing being fixed. Every hunk earns its place; deleting any of them reopens one of the swallow-an-abort holes the tests pin. The round-1 push added exactly the missing pins — last-waiter physical abort, two-connection ACP isolation, scan_duration_ms on both sides of the single-flight — and nothing else; six months from now the attachWaiter settlement logic reads like it was written by someone who knew precisely which races they were signing up for. The author carries a high volume of PRs right now, but this one stands on its own evidence.

Verdict: approve. No pull_request runs are pending on this head and all checks that ran are green, so approval is pinned to 9e835e2e11 in this run — replacing the approval the force-push dismissed. Two notes for the maintainer, neither blocking:

  • R2-7 is still an open design call. Cancellation intentionally stops at the SessionOrganizationService.readSnapshot boundary with checkpoints before and after; the code matches the documented design, but the external review round left this thread contested and flagged as needing an explicit maintainer decision. @yiliang114 as the most recent human reviewer — worth closing that thread one way or the other at merge time.
  • The live-disconnect behavior rests on the sandboxed /verify A/B evidence (identical production code) plus route-level tests; the author's 1,200-session daemon E2E is self-reported and macOS-only, and the verify stage dispatched by this run will post a fresh report when it completes.
中文说明

置信度:4/5 —— 干净、细致的并发工作,且证据已完全落定:该 head 上所有执行的 CI 检查为绿,沙箱验证逐字节适用(round-1 提交仅为测试),waiter 设计中的每一种竞态都有测试钉住。保留最后 1 分给两件由人决定的事:有争议的 R2-7 边界,以及作者的行为性 E2E 仅在 macOS 执行。

退一步看:这是 #8892 的下半场 —— single-flight 缓存与生俱来的缺口,用最小且诚实的形态补上。我尝试找更简单的设计(按调用方中止、单一共享调用方 signal、只脱离不中止),每一种要么饿死其他等待者,要么放任物理扫描继续跑 —— 而那正是要修的问题。每个改动块都有其价值:删掉任何一处都会重新打开某个被测试钉住的"吞掉中止"的洞。Round-1 推送恰好补上了缺失的钉子 —— 最后 waiter 物理中止、双 ACP 连接隔离、single-flight 两侧的 scan_duration_ms —— 此外别无其他;六个月后读 attachWaiter 的结算逻辑,能看出作者清楚自己应对的是哪些竞态。作者目前有不少 open PR,但这一个靠自身证据站得住。

结论:批准。该 head 上没有在途的 pull_request 运行,执行的检查全部为绿,因此本次运行将批准钉在 9e835e2e11 上 —— 取代被 force-push 驳回(dismiss)的那次批准。给维护者的两条非阻塞备注:

  • R2-7 仍是一个待定的设计决定。 取消有意止步于 SessionOrganizationService.readSnapshot 边界,前后设置检查点;代码与文档化设计一致,但外部评审轮次将该线程标记为有争议、需要维护者明确决定。请最近一位人类评审 @yiliang114 在合入时关闭该线程(无论采用哪种决定)。
  • 真实断开行为依赖沙箱 /verify 的 A/B 证据(生产代码完全相同)加路由级测试;作者的 1,200 会话 daemon E2E 为自报且仅在 macOS 执行,本次运行派发的 verify 阶段完成后会单独发布新报告。

Qwen Code · qwen3.8-max

Reviewed at 9e835e2e11bfc85490f6f1265e4ae7faf75a91a2 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has neither a verdict nor a deferral on 14257b7293caf3fa88c1abb3540928177d8ff717 — no APPROVED, CHANGES_REQUESTED, or COMMENTED review of its own. A DISMISSED one does not count: dismiss_stale_reviews voids the bot's approval on every push, which is exactly when a fresh one is needed. If this re-run was meant to review or approve, it did not, and an approval left by another account is a separate vote that does not count as the bot's own.

⚠️ 机器人在 14257b7293caf3fa88c1abb3540928177d8ff717既没有裁决也没有 defer —— 没有属于它自己的 APPROVEDCHANGES_REQUESTEDCOMMENTED 评审。DISMISSED 不算:dismiss_stale_reviews 会在每次推送时作废机器人的批准,而那恰恰是需要一次新批准的时刻。如果这次重跑本应评审或批准,那么它没有做到;而其他账号留下的批准是另一张票,不能算作机器人自己的。

The stage comments above were updated with the latest result. View workflow run.

上方各阶段评论已更新为最新结果。查看工作流运行

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not explored to full depth (tool budget reached): You are review agent reverse-audit — Reverse audit agen...: none — all checks above completed within budget.; chunk 4: none — all checks I started completed within budget..

中文说明

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

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未探索到全部深度(达到工具调用预算):You are review agent reverse-audit — Reverse audit agen...:none — all checks above completed within budget.;chunk 4:none — all checks I started completed within budget.

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

Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/core/src/services/sessionService.ts
Comment thread packages/core/src/services/sessionService.ts
Comment thread packages/core/src/utils/jsonl-utils.ts
Comment thread packages/core/src/utils/runtimeStatus.ts
Comment thread packages/core/src/services/worktreeSessionService.ts

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

Not reviewed: cross-file tracing — the agent failed to return twice.

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and local verification used Node 24.18.1.

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and Windows was not tested locally.

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

未审查:cross-file tracing — the agent failed to return twice。

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and local verification used Node 24.18.1。

未审查:build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and Windows was not tested locally。

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

— gpt-5.6-sol via Qwen Code /review (v0.21.10)

Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/core/src/services/sessionService.ts
Comment thread packages/core/src/services/sessionService.ts
Comment thread packages/cli/src/serve/acp-http/dispatch.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

[codex] Review round 1 handled in 9e835e2e11.

Area Outcome
REST cancellation Added last-waiter physical-abort coverage, strengthened shared-waiter isolation, and verified cancelled trusted-secondary preflight never falls through to live listing.
Shared scan observability Pinned scan_duration_ms on both the scan leader and the single-flight waiter.
Core signal propagation Added discriminating coverage for JSONL, migrated-session runtime status, worktree sidecars, and catalog enrichment.
ACP isolation Added two-connection over-the-wire coverage proving one destroyed connection does not cancel the survivor.
Organization store Not changed: the approved design intentionally keeps this bounded snapshot API out of scope and checks cancellation immediately before and after it.
Transport abort reasons Not pinned separately: request-aborted and response-close are redundant transport signals with platform-dependent ordering; the end-to-end resource-reclamation contract is covered instead.

Verification: focused Core and CLI Vitest suites passed; repository build, typecheck, and lint passed. Two consecutive broad diff audits found no further actionable issue.

Resolved 19/19 review threads after replying to each.

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

Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally.

Not reviewed: build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and not covered locally.

Not reviewed: build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and not covered locally.

Not explored to full depth (tool budget reached): You are review agent reverse-audit — Reverse audit agen...: none — all checks I started were completed; I did not run live deletion probes, having verified the candidate paths statically against the exact post-change sou…; You are review agent reverse-audit — Reverse audit agen...: none — all checks above completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all planned checks completed within budget.; You are review agent reverse-audit — Reverse audit agen...: none — all checks I started were completed within budget..

Not reviewed: reverse audit — did not converge within the reverse-audit round cap of 5.

中文说明

未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally。

未审查:build-and-test — Test (macos-latest, Node 22.x) was skipped in CI and not covered locally。

未审查:build-and-test — Test (windows-latest, Node 22.x) was skipped in CI and not covered locally。

未探索到全部深度(达到工具调用预算):You are review agent reverse-audit — Reverse audit agen...:none — all checks I started were completed; I did not run live deletion probes, having verified the candidate paths statically against the exact post-change sou…;You are review agent reverse-audit — Reverse audit agen...:none — all checks above completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all planned checks completed within budget.;You are review agent reverse-audit — Reverse audit agen...:none — all checks I started were completed within budget.

未审查:反向审计——在 5 轮的反审轮数上限内未收敛。

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

Comment on lines +622 to +624
readOptions.signal?.throwIfAborted();
const snapshot = await organizationService.readSnapshot();
readOptions.signal?.throwIfAborted();

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.

[Critical] R2-7: Blocker re-check — the open Critical from @wenshao (R1-7) still stands at this commit. The organized path checks readOptions.signal?.throwIfAborted() immediately before and after organizationService.readSnapshot(), but passes no signal into the read itself; SessionOrganizationService.readSnapshot()/readStore() accept no AbortSignal, so the store file read + JSON parse + normalization still run to completion after a disconnect. The round-1 reply declined the change citing the approved design (single bounded store read); a reply alone does not retire a blocker, and the mechanism is present in the code as it stands. — Failure scenario: a client disconnects while an organized request is reading the organization store → readSnapshot() continues the un-signaled file read, parsing, and normalization until it returns, so the aborted request still consumes I/O and CPU for the whole organization read.

Suggested fix: propagate an optional AbortSignal through readSnapshot()/readStore() (pass it to the store file read), or reach an explicit maintainer decision on the declined scope in the existing thread.

中文说明

R2-7: 阻塞项复查 —— @wenshao 的未解决 Critical(R1-7)在当前提交仍然存在。organized 路径在 organizationService.readSnapshot() 前后都检查了 readOptions.signal?.throwIfAborted(),但没有把 signal 传入读取本身;SessionOrganizationService.readSnapshot()/readStore() 不接受 AbortSignal,因此断开连接后 store 文件的读取 + JSON 解析 + 规范化仍会执行到结束。第一轮的回复以已批准的设计(单次有界的 store 读取)为由拒绝了该修改;仅有回复并不能解除阻塞项,该机制在当前代码中依然存在。

失败场景:客户端在 organized 请求读取 organization store 时断开 → readSnapshot() 继续执行未绑定 signal 的文件读取、解析和规范化直到返回,已取消的请求在整个 organization 读取期间继续消耗 I/O 与 CPU。

建议修复:为 readSnapshot()/readStore() 增加可选 AbortSignal(传给 store 文件读取),或在现有讨论串中就拒绝的范围与维护者达成明确决定。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks — this is a scope disagreement rather than a branch defect. The approved plan explicitly keeps the single small-file organization snapshot read outside the cancellable API and requires signal checks immediately before and after it; the current code follows that contract. Please have a maintainer confirm whether to override that approved boundary before expanding SessionOrganizationService.

Comment thread packages/cli/src/serve/server.test.ts
Comment thread packages/cli/src/serve/server/session-list.ts
Comment thread packages/cli/src/serve/server/session-list.ts
Comment on lines +4389 to +4391
req.once('aborted', onRequestAborted);
res.once('close', onResponseClosed);
if (req.aborted || res.destroyed) onRequestAborted();

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.

[Suggestion] R2-2: The upfront req.aborted || res.destroyed pre-check — the only thing that cancels the controller when the client was already gone before the listeners attached — is not exercised by any test. Probe-verified: deleting the line left all 1252 serve tests green; no test references req.aborted/res.destroyed, and the trusted-secondary preflight test aborts mid-scan, after the handler already passed this check. — Failure scenario: a client disconnects while earlier middleware (auth, workspace-runtime resolution) is still being awaited; req's one-shot 'aborted' event has already fired and, if res 'close' fired too, neither re-armed listener ever triggers → deleting the pre-check lets a full persisted catalog scan run to completion for a dead connection, with the suite staying green.

Suggested fix: add a REST test that destroys/aborts the request before the handler's listeners attach (e.g. abort the supertest request immediately after issuing it, with a delayed first listSessions call) and asserts the scan signal aborts / no physical scan completes.

中文说明

R2-2: 前置的 req.aborted || res.destroyed 预检查 —— 客户端在监听器挂载前就已离开时唯一能取消 controller 的路径 —— 没有任何测试覆盖。探针验证:删除该行后全部 1252 个 serve 测试仍全绿;没有测试引用 req.aborted/res.destroyed,且 trusted-secondary preflight 测试是在扫描中途(handler 已通过该检查之后)才 abort。

失败场景:客户端在前面的中间件(鉴权、workspace-runtime 解析)仍在 await 时断开;req 的一次性 'aborted' 事件已经触发,若 res'close' 也已触发,重新挂载的两个监听器都不会再触发 → 删除该预检查会让完整的持久化目录扫描为一个已死连接执行到底,而测试套件依旧全绿。

建议修复:新增 REST 测试,在 handler 监听器挂载前销毁/abort 请求(例如发出 supertest 请求后立即 abort,并延迟第一次 listSessions 调用),断言扫描 signal 被中止/物理扫描未完成。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks — not taking this one. An immediate Supertest abort would not prove the pre-check because the route may never run; a valid test needs contrived middleware, and this handler has no await before listener installation. That complexity is not justified as a Critical fix after the current review depth.

Comment thread packages/cli/src/serve/acp-http/dispatch.ts
Comment thread packages/core/src/services/worktreeSessionService.ts
Comment thread packages/core/src/services/worktreeSessionService.ts
Comment thread packages/cli/src/serve/routes/session.ts
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review round 2 triage on 9e835e2e11:

Outcome Items Rationale
Disputed / maintainer decision needed R2-7 The implementation follows the approved boundary: the single small-file organization snapshot remains outside the cancellable API, with signal checks immediately before and after. Expanding SessionOrganizationService requires an explicit maintainer override.
Not taking R2-2 The proposed immediate Supertest abort can pass without entering the route; a valid test needs contrived middleware, while this route has no await before listener installation.
Deferred to follow-up R1-6, R2-1 (both anchors), R2-3, R2-4, R2-5, R2-6 These are non-Critical test/documentation hardening items. Repository policy limits further churn to Critical fixes after roughly five review rounds.

No code change was made. Seven deferred threads were resolved; the two disputed threads remain open. The unrelated Linux ENOTEMPTY teardown flake was retried once after the watcher authorized it.

@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 at 9e835e2 — cancellation propagation through the daemon session-list path (REST disconnect + ACP connection teardown → catalog waiter cancellation, per-load AbortController in the persisted catalog cache).

Correctness — no P0/P1 found:

  • Clean waiter/load signal separation: caller signals attach only to their own waiter promise (attachWaiter); the physical load owns its controller and is never directly combined with caller signals. Abort listeners are removed on settlement ({once:true} + explicit removeEventListener) — no leaked listeners, no timers added.
  • First-wins settlement (load.settled + per-waiter flag) prevents a late caller abort from replacing a completed scan; detached/stale loads cannot install snapshots (identity + generation + aborted guards) nor clear a replacement load — both race directions are test-covered.
  • Last-waiter cancellation aborts and synchronously detaches the load so a replacement scan starts immediately; the detached managed rejection is still handled by the already-attached waiter handlers, so no unhandled-rejection path even if a loader ignores cancellation.
  • Every catch-swallow site re-checks signal.throwIfAborted() before falling back (ENOENT→empty incl. an ENOENT-coded abort reason, sidecar→null, sessionExists→false, live-merge→liveMergeFailed, readLines/readRuntimeStatus/readWorktreeSession), so cancellation cannot be misreported as missing data.
  • The 128-entry setImmediate yield in listSessions only triggers when a signal is passed; the signal-less CLI/LiveTask path is verified unchanged.
  • Route wiring removes both req 'aborted' / res 'close' listeners in finally; the ACP dispatch silently returns when the connection signal is aborted, avoiding buffered error frames on destroyed connections (asserted via bufferedConnectionFrames).

Tests: ~950 new lines covering single/last-waiter cancel, detach+replace, pre-aborted lookup, null abort reason, cache-hit cancellation, REST/ACP disconnect integration, worktree enrichment, numeric pagination, and core signal plumbing.

CI at head sha: Test (ubuntu-latest, Node 22.x) ✅, web-shell E2E smoke ✅, Desktop Shell (ubuntu/windows) ✅; cancelled route runs are fork-routing no-ops; mac/win/integration skipped as expected for fork PRs.

Non-blocking nits (P3):

  • The signal ? sessionExists(id, {signal}) : sessionExists(id) ternary is repeated in three places; always passing the options object would be equivalent since it defaults to {}.
  • req.aborted / 'aborted' is legacy-leaning on newer Node, but fine for the current HTTP/1 serve stack.

LGTM.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ✅ passed — merge-ready (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: 122 passed · 0 failed · 122 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

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

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

Verification report

<!-- qwen-triage:verify -->
<!-- qwen-triage:verify-substantive -->

Sandboxed verification: ✅ passed — merge-ready (agent verdict) - follow-up round at new head

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: 122 passed · 0 failed · 122 total

中文 — 判定:✅ 通过 · 可合入(agent 判定)

沙箱验证在隔离、无凭证的容器中对该 PR 的新 head(9e835e2e,含第二轮 review 反馈提交,base 已前进到 a64d1291d2)重新执行了全部测量(未复用上一轮数字,未使用输入闭包捷径)。仅作为评审证据,不构成评审、批准或 CI 检查

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

Verification report

PR #8954 — feat(serve): Propagate session list cancellation (follow-up round)

Verdict: merge-ready — 122/122 scripted assertions passed (0 unexpected failures), verified head 9e835e2e11bfc85490f6f1265e4ae7faf75a91a2 (merge 2c2ba5f5e5 over base a64d1291d2). Previous round verified head 14257b7293 over base 28ab8bae56; both the PR and the base advanced, so every measurement below was re-run at the new head (no input-closure shortcut taken).

中文摘要
  • 结论merge-ready。122/122 脚本断言通过,0 个意外失败。
  • A/B 结论(各表见下,数字以表为准):缓存层 A/B 表 head 40/40、base 24/24——"取消一个 waiter 只拒绝该调用方 / 最后一个 waiter 取消即 abort 并同步脱离物理扫描、替代请求立即开新扫描 / first-wins 结算 / null reason 保留 / 脱离后旧 load 的晚到 settle 或 reject 不影响替代 load"全部成立,base 端按旧语义表现(共享 promise、loader 无 signal、caller abort 无效)。核心扫描层 A/B 表 head 15/15、base 14/14,且无 signal 路径对 400 个真实 session 的扫描结果 head/base 逐字节一致。接线 A/B 表 head 1458/1458 全绿,base 端 32/34 个新行为测试按预期变红(另 2 个为构造上两臂皆绿的保持/first-wins 测试),无预期外失败。
  • 变异矩阵:M0/M1/M2/M5/M6 各恰好杀死其目标测试(正对照活),M3/M4 如预期幸存(不可达防御守卫,静态链在新代码下重新推导一致)。
  • Findings:无新增;上一轮两条信息级观察均复测后维持(见"Previous findings"表)。
  • 未覆盖:见 Not covered——含逐 commit 归因(shallow checkout 第一个提交不可达)、作者 1,200-session E2E、Web Shell/request-id ACP/CLI resume 取消(PR 自述范围外)、全仓套件、yield 间隔性能刻画、REST 入口 req.aborted 竞态守卫的确定性测试。

Previous-finding status table (follow-up round)

# Finding (round 1) Severity Status at 9e835e2e
1 Two cache guards unreachable double-safety: !controller.signal.aborted (install path) and !slot.inFlight.controller.signal.aborted (single-flight reuse) Informational Stands (re-measured). Mutation M3/M4 still survive 19/19 at the new head (evidence/03-mutation-matrix-cache-guards.png); the static chain re-derived on the new attachWaiter/detach flow still shows unreachability — the single controller.abort( site runs inside attachWaiter.settle, which synchronously detaches the load (current.inFlight = undefined) whenever it fires, so an aborted load can never be the slot's inFlight at reuse time, and an install requires current.inFlight === load, which a detach has already made false. Classification unchanged: dead-but-harmless defensive guards, not a defect. Agree with keeping or dropping them; no action required.
2 req.once('aborted') deprecated but live on the lane's runtime Informational Stands (re-measured). node-aborted-probe.mjs on Node v22.23.2: client destroy fires both req 'aborted' and res 'close' with writableEnded=false (evidence/05-node-aborted-probe.png). Both route guards live; either alone catches the disconnect. No action required.

No new findings this round. (The two PR tests that are green on base — keeps the load result when it settles before caller cancellation and does not yield during directory enumeration without a signal — are not findings: the former asserts first-wins, which is the default behavior when no cancellation exists (its discriminating power is mutation M5, which kills it), and the latter is a preservation test (base never yields, so it passes trivially). Both are green at head.)

Central claim + A/B

Central claim: the persisted catalog cache owns cancellation per-waiter — cancelling one waiter rejects only that caller with its original reason; cancelling the last waiter aborts the physical load and synchronously detaches it so a replacement starts immediately; settlement is first-wins; a detached load's late settle or rejection cannot install or clear anything; no-signal callers remain non-cancellable waiters.

A/B harness harness/cache-ab.mjs drives the compiled PersistedSessionListCache of both arms (head dist vs base dist rebuilt in tmp/base-tree at a64d1291d2; base module imports only node:path, and the base arm additionally ran under harness/resolve-guard.mjs, which throws on any @qwen-code/* runtime import — the realpath check showed tmp/base-tree/node_modules/@qwen-code/qwen-code-core resolves into the HEAD tree, and the guard log is empty, proving no head code leaked into the control). Witness: evidence/01-cache-ab-head-vs-base.png (live re-run of both arms).

Cell Oracle HEAD (40/40) BASE (24/24, predicted)
C1 one of two waiters cancels leader rejects with own reason; load signal present and NOT aborted; follower resolves; 1 scan; snapshot installed ✔ 6 loader gets no AbortSignal; both resolve; 1 scan; installed (4)
C2 last waiter cancels rejects with own reason; load signal aborted with cache DOMException 'AbortError'; replacement scan in same tick; loader 2×; late detached settle installs nothing; replacement resolves + installs ✔ 8 caller abort ignored; replacement joins finished load; loader 1× (3)
C3 settle before abort (first-wins) waiter resolves despite abort during delivery; trap armed ✔ 2 resolves (2, by construction)
C4 no-signal waiter scan, resolves ✔ 2 ✔ 2
C5 cancel before loader microtask rejects; loader never called; retry = fresh scan ✔ 3 loader runs; resolves (2)
C6 already-aborted caller synchronous throw of caller reason; no slot/loader; retry = scan ✔ 3 ignores; resolves (1)
C7 cache-hit caller cancels rejects with own reason; no extra scan; value retained for others ✔ 4 status is cache hit; resolves regardless (2)
C8 null caller reason rejects with null (reason preserved) ✔ 1 resolves (1)
C9 detached rejection vs replacement old waiter rejects; replacement survives old rejection, resolves, installs ✔ 4 n/a (needs waiter-owned cancellation)
C10 waiter promise identity independent promises, same snapshot, 1 scan ✔ 3 shared promise (pre-PR shape), all resolve, 1 scan (3)
C11 invalidate does not abort load signal not aborted; joined waiter resolves; generation cannot repopulate ✔ 3 ✔ 3
GLOBAL zero unhandled rejections per arm ✔ 1 ✔ 1

Secondary claim 1 — cancellation reaches the physical scan. harness/scan-ab.mjs builds a real 400-session fixture per arm (hermetic via QWEN_RUNTIME_DIR) and drives compiled SessionService.listSessions, sessionExists, readLines, readRuntimeStatus, readWorktreeSession (evidence/02-scan-ab-head-vs-base.png): head rejects a mid-scan abort (scheduled setImmediate, landing in the 128-entry yield window) with the caller's reason; pre-aborted signals reject at every layer; accept paths return real data with a live signal; base ignores every signal and completes. Head 15/15, base 14/14. The no-signal scan of all 400 sessions is byte-identical between arms (parity JSON diff: identical: true), i.e. the yield/cancellation machinery provably does not perturb the default path. Full scan ≈ 66–68 ms/arm.

Secondary claim 2 — REST/ACP wiring cancels only the disconnecting caller. Proven by running the PR's own new tests against both source trees (copied verbatim into the base worktree; vitest aliases are worktree-relative, so the base arm resolves base source):

Suite BASE HEAD
server.test.ts (full file) 921 P / 9 F 930 P
transport.test.ts (full file) 301 P / 2 F 303 P
persisted-session-list-cache.test.ts 10 P / 9 F 19 P
sessionService.test.ts 127 P / 6 F 133 P
worktreeSessionService.test.ts 19 P / 2 F 21 P
jsonl-utils.test.ts 29 P / 2 F 31 P
runtimeStatus.test.ts 19 P / 2 F 21 P

Witness: evidence/04-wiring-base-red-head-green.png. Base reds = exactly the 32 new/changed behavior tests (every base failure is a behavioral assertion: expected undefined to be defined, promise resolved instead of rejecting, expected ... to throw); the 2 base greens among PR tests are the by-construction pair named above; the 1,424 non-PR base tests stay green (positive control that the base arm executed the right code), and head is 1458/1458.

Reviewer Test Plan walkthrough

  1. "Start two identical organized requests and disconnect one; the remaining request should return the complete catalog from one physical scan." — Executed at three levels: cache cell C1, REST test keeps a shared REST catalog scan alive until every request disconnects (red base / green head), ACP test keeps a shared session/list scan alive when one connection is destroyed (red base / green head). ✔
  2. "Disconnect every cancellable waiter; the physical scan should abort, and the next request should start a fresh scan before the TTL." — Cell C2 (abort observed on the loader's signal with cache AbortError; replacement status scan in the same tick batch, far below the 2 s TTL; late detached settle installs nothing) and REST test aborts a REST catalog scan when its last request disconnects (red base / green head). ✔
  3. "Numeric pagination, trusted-secondary persisted preflight, ACP connection destruction … return no partial data, HTTP 500, ACP error frame, or cancellation error log."propagates cancellation through numeric pagination, cancels the trusted-secondary persisted preflight when the request disconnects, and the ACP test asserting bufferedConnectionFrames unchanged and no /acp dispatch error log — all red base / green head; core-level cancel-during-JSONL / runtime-status / sidecar / membership reads likewise (6 base reds in sessionService.test.ts, 2 each in worktree/jsonl/runtimeStatus). ✔

Findings

None new. The two carried informational observations stand (see status table). No blocking issue.

Not covered

  • Per-commit attribution: the checkout is depth-2; git rev-list HEAD^1..HEAD^2 returns only 9e835e2e while the metadata lists two commits (14257b72 unreachable). The aggregate HEAD^1..HEAD diff was verified; per-commit attribution is out of reach by construction.
  • Author's 1,200-session Linux E2E and macOS/Windows E2E (no such environments here); the scan harness used 400 real sessions — enough to cross the 128-entry yield threshold three times.
  • Web Shell GET cancellation, request-id ACP cancellation, CLI resume/picker cancellation — explicitly out of scope per the PR body.
  • Repo-wide test suite; only the seven changed test files (1,458 tests green at head) plus targeted gates.
  • Performance characterization of the 128-yield interval (correctness only; the no-signal path provably never yields — does not yield during directory enumeration without a signal green on both arms, and M6-companion shows deleting the yield block leaves it green).
  • The route's entry-time guard if (req.aborted || res.destroyed) onRequestAborted(); has no deterministic REST-level test (supertest cannot produce an already-aborted request before handler entry); the equivalent mechanism is proven at the cache level (C6, synchronous throw of an already-aborted caller).
  • Base-arm environment notes: the base worktree needed the repo root node_modules symlinked in (a fresh worktree lacks it, and packages/core/tsconfig.json's paths mapping for @lydell/node-pty resolves relative to the worktree — first attempt failed with TS7016, preserved in logs/base-core-build-first-attempt.log; also proof that tsc is live), per-package node_modules symlinks, and a copy of the install-generated, gitignored packages/cli/src/generated/git-commit.ts. The PR leaves the lockfile untouched, so the shared node_modules is a clean dependency control; the one realpath confound (@qwen-code/qwen-code-core symlink into the head tree) is guarded by resolve-guard.mjs (empty guard logs) for the dist harnesses and by worktree-relative vitest aliases for the wiring arm.
  • Mutation matrix covers the six cache-guard mutations plus the core yield spot-check and its no-signal companion; the remaining per-checkpoint throwIfAborted guards in session-list.ts/session.ts are pinned by the wiring A/B (red base / green head) but not individually mutated.

Methodology

Environment: node:22-bookworm CI container (Node v22.23.2), merge-ref checkout (HEAD merge 2c2ba5f5e5, HEAD^1 base a64d1291d2, HEAD^2 PR head 9e835e2e). Base control = git worktree add tmp/base-tree a64d1291d2 with packages/core + packages/cli rebuilt there (tsc --build exit 0; log in logs/base-*-build.log); mutation scratch = git worktree add tmp/mut-tree HEAD. Harnesses import compiled dist/ output of each arm directly and ran under a resolve hook that rejects/redirects @qwen-code/* imports in the base arm (logs empty). Wiring A/B re-runs the PR's own vitest tests against both source trees with JSON reporters parsed by harness/wiring-classify.mjs. Mutation matrix applied single-hunk deletions in the scratch tree, ran the pinned suite per mutant, and reverted (revert_clean=true every time; raw output in logs/mutation-matrix.log and evidence/03-mutation-matrix-cache-guards.png). Gates: incremental tsc --build at head exit 0 for both packages (liveness: planted TS2322 reported, plus the genuine base-build TS7016); eslint clean on all 15 changed TS files (liveness: planted no-unused-vars via stdin reported). Evidence PNGs rendered by scripts/verify-capture.mjs from live re-runs of each harness. Raw logs and harnesses in logs/ and harness/; counts in assertions.json map 1:1 to the checks above (40 + 24 + 15 + 14 + 7 + 10 + 3 + 9 gates = 122).

Evidence images

01-cache-ab-head-vs-base

02-scan-ab-head-vs-base

03-mutation-matrix-cache-guards

04-wiring-base-red-head-green

05-node-aborted-probe

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.

LGTM, looks ready to ship. ✅

@doudouOUC
doudouOUC dismissed wenshao’s stale review August 12, 2026 05:03

already have 2 approved, 3ks.

@doudouOUC
doudouOUC added this pull request to the merge queue Aug 12, 2026
Merged via the queue into QwenLM:main with commit 9259c35 Aug 12, 2026
914 of 951 checks passed
@doudouOUC
doudouOUC deleted the agent/session-list-cancellation branch August 12, 2026 05:03
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.11.

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