Skip to content

fix(serve): Isolate daemon session maintenance writers - #7975

Merged
doudouOUC merged 9 commits into
QwenLM:mainfrom
doudouOUC:agent/daemon-session-writer-maintenance
Jul 30, 2026
Merged

fix(serve): Isolate daemon session maintenance writers#7975
doudouOUC merged 9 commits into
QwenLM:mainfrom
doudouOUC:agent/daemon-session-writer-maintenance

Conversation

@doudouOUC

@doudouOUC doudouOUC commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

This PR isolates daemon transcript maintenance with the existing writer-lease protocol. Each workspace runtime now pins one absolute session runtime root for its lifetime and passes that root to managed children.

Session deletion, archive, unarchive, disconnect rollback, scheduled-task rollback, keepalive late-spawn cleanup, and ACP orphan cleanup operate inside the selected runtime and acquire one daemon writer lease per session before mutating persisted transcripts. Batch maintenance preserves completed per-session results if another session encounters a conflict after processing begins. Shutdown seals new maintenance admission, waits for admitted maintenance leases, and exposes a typed daemon_draining response without waiting for shared transcript exports.

Why it's needed

Daemon-side archive, delete, and cleanup paths could mutate a transcript while another daemon or managed writer still owned it, and multi-workspace operations could resolve storage through an ambient primary runtime instead of the selected workspace.

The change makes daemon maintenance fail closed when another writer owns the session and keeps every selected workspace bound to its own transcript, lock, organization, and scheduled-task state. It deliberately does not infer safety from hostname, PID visibility, or lock age.

Reviewer Test Plan

How to verify

Hold a writer lease for a persisted session and attempt daemon delete, archive, and unarchive operations from another service instance. Confirm that each operation reports the per-session writer conflict without changing the transcript, then succeeds after the first lease is released.

Configure primary and secondary workspaces with different runtime roots and use workspace-qualified session maintenance. Confirm that transcript classification, writer locks, organization state, scheduled tasks, reads, and exports stay within the selected runtime, including after an environment reload.

Begin maintenance and daemon shutdown concurrently. Confirm that admitted maintenance completes and releases its lease before shutdown finishes, newly admitted maintenance receives daemon_draining, and a shared export does not block maintenance drain. Confirm that managed child termination starts immediately when shutdown begins, so it can unblock an admitted close.

Race one session in a multi-session archive or unarchive request with another local maintenance operation. Confirm that completed sessions remain reported in their normal result arrays while the racing session is reported in errors[].

Automated verification completed with the focused Core, CLI, ACP bridge, and ACP agent suites, including 1,600+ passing assertions, followed by npm run build, npm run lint, and npm run typecheck.

Evidence (Before & After)

N/A — this is daemon lifecycle, storage isolation, and concurrency behavior with no user-interface change.

Tested on

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

Environment (optional)

macOS development checkout, Node.js 22, temporary local runtime roots, managed ACP child-process tests, and loopback daemon HTTP tests.

Risk & Scope

  • Main risk or tradeoff: This changes transcript-maintenance ordering across the daemon, CLI, and Core storage layers; failures intentionally prefer a safe conflict or unavailable response over reporting a mutation as successful.
  • Not validated / out of scope: Automatic recovery after SIGKILL, hostname- or time-based lock stealing, TTL or heartbeat recovery, force unlock, sealed takeover, non-managed standalone writers, and mixed-version writers sharing one workspace remain out of scope.
  • Breaking changes / migration notes: No API success payload shape is intentionally changed. Deployments and rollbacks must drain old daemon and managed ACP writers before starting a mixed-version replacement against the same shared workspace.

Linked Issues

Related to #7752.

中文说明

本 PR 做了什么

本 PR 使用现有 writer lease 协议隔离 daemon transcript 维护。每个 workspace runtime 现在会在整个生命周期内固定一个绝对 session runtime root,并将该根目录传给受管子进程。

会话删除、归档、取消归档、断连回滚、scheduled-task 回滚、keepalive late-spawn 清理和 ACP orphan 清理都会在选中的 runtime 内运行,并在修改持久化 transcript 前为每个 session 单独获取 daemon writer lease。当批量维护开始处理后某个 session 遇到冲突时,已完成的逐 session 结果会被保留。Shutdown 会封闭新的维护准入,等待已进入的 maintenance lease,并返回类型化的 daemon_draining 响应,同时不等待 shared transcript export。

为什么需要

daemon 侧的归档、删除和清理路径可能在另一个 daemon 或受管 writer 仍持有会话时修改 transcript;多 workspace 操作也可能通过环境中的 primary runtime 解析存储,而不是使用实际选中的 workspace。

本变更让 daemon maintenance 在其他 writer 持有会话时安全地 fail closed,并确保选中的每个 workspace 始终绑定到自己的 transcript、lock、organization 和 scheduled-task 状态。它明确不会根据 hostname、PID 可见性或锁时间推断可回收性。

Reviewer 测试计划

如何验证

为一个持久化 session 持有 writer lease,并从另一个 service 实例尝试 daemon delete、archive 和 unarchive。确认每个操作都会返回对应 session 的 writer conflict 且不修改 transcript;第一个 lease 释放后重试能够成功。

为 primary 和 secondary workspace 配置不同 runtime root,并使用 workspace-qualified session maintenance。确认 transcript 分类、writer lock、organization 状态、scheduled task、读取和导出始终位于选中的 runtime,包括环境 reload 之后。

让 maintenance 与 daemon shutdown 并发开始。确认已进入的 maintenance 完成并释放 lease 后 shutdown 才结束,新进入的 maintenance 收到 daemon_draining,并且 shared export 不会阻塞 maintenance drain。确认 shutdown 开始时会立即启动受管子进程终止流程,从而能够解除已进入 close 操作的阻塞。

让一个多 session archive 或 unarchive 请求中的某个 session 与另一项本地 maintenance 操作发生竞争。确认已完成的 session 仍出现在正常结果数组中,而发生竞争的 session 会记录到 errors[]

自动验证已覆盖相关 Core、CLI、ACP bridge 和 ACP agent 测试套件,共通过 1,600 多项断言,随后 npm run buildnpm run lintnpm run typecheck 也全部通过。

证据(修改前与修改后)

N/A——这是 daemon 生命周期、存储隔离和并发行为变更,没有用户界面变化。

已测试平台

OS 状态
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

环境(可选)

macOS 开发检出、Node.js 22、临时本地 runtime root、受管 ACP 子进程测试和 loopback daemon HTTP 测试。

风险与范围

  • 主要风险或取舍:本变更调整了 daemon、CLI 和 Core storage 层之间的 transcript maintenance 顺序;发生失败时会有意优先返回安全的 conflict 或 unavailable,而不会错误地报告 mutation 成功。
  • 未验证或超出范围:SIGKILL 后自动恢复、基于 hostname 或时间的锁抢占、TTL 或 heartbeat 恢复、force unlock、sealed takeover、非受管 standalone writer,以及 mixed-version writer 共享同一 workspace,均不在本 PR 范围内。
  • Breaking change 或迁移说明:没有有意修改 API 成功响应结构。部署和回滚时,必须先 drain 旧 daemon 和受管 ACP writer,再对同一共享 workspace 启动 mixed-version replacement。

关联 Issue

关联 #7752

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /takeover

@qwen-code-dev-bot qwen-code-dev-bot added the autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+) label Jul 29, 2026
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤝 Takeover engaged: the autofix loop now manages this PR — it will address new review feedback and resolve base conflicts until the label is removed or the round cap is reached. Remove the autofix/takeover label (or comment @qwen-code /takeover stop) to release.

中文说明

🤝 已接管:autofix 循环现在管理此 PR —— 将持续处理新的评审反馈与 base 冲突,直到移除标签或达到轮次上限。移除 autofix/takeover 标签(或评论 @qwen-code /takeover stop)即可释放。

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC force-pushed the agent/daemon-session-writer-maintenance branch from d91708c to 3a6659a Compare July 29, 2026 03:52
@github-actions

Copy link
Copy Markdown
Contributor

Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration.

中文

请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。

@qwen-code-dev-bot

qwen-code-dev-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

AutoFix round 3 finishedview run. See this round's report below.

中文说明

AutoFix 第 3 轮已完成 —— 查看运行。本轮报告见下方。

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action taken

No review feedback, inline comments, failed checks, or still-red checks were present in this evaluation round. The PR branch (agent/daemon-session-writer-maintenance) is clean with no pending changes needed.

中文说明

未采取任何操作

本轮评估中没有审查反馈、行内评论、失败的检查或持续红色的检查。PR 分支(agent/daemon-session-writer-maintenance)状态干净,无需进行任何待处理的更改。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 9ea7122, 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

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

Copy link
Copy Markdown
Collaborator Author

CI fix summary

Item Action
Runtime-root fixtures Bound the daemon and ACP test runtimes to a stable per-test storage root before startup, matching the production restart-required contract.
Maintenance fixtures Added persisted transcript fixtures where cleanup must pass preliminary classification before acquiring a writer lease.
Assertions Updated strict-close and local maintenance-conflict expectations to the new close → classify → lease ordering.
Validation npm run build and npm run typecheck passed. ACP transport passed 286/286, session telemetry passed 9/9, and the affected server maintenance cases pass in isolation. The combined server suite still showed two moving, unrelated flakes; both passed immediately in isolation.

Commit: 987bdd4de4

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action required this round

No inline comments or review feedback were raised on this PR.

The failed CI check Test (ubuntu-latest Node 22.x) is a pre-existing failure on the base branch (main), not caused by this PR. Reproduced locally: the same 17 tests in packages/cli/src/serve/acp-http/transport.test.ts and 3 tests in packages/core/src/config/storage.test.ts fail identically on origin/main without any of this PR's changes applied.

All tests touched or added by this PR pass locally (553 tests across 12 test files), and npm run build, npm run typecheck, and npm run lint all pass.

中文说明

本轮无需操作

本 PR 没有收到任何行内评论或审查反馈。

CI 检查 Test (ubuntu-latest Node 22.x) 的失败是基础分支(main)上的已有问题,并非本 PR 引起。本地复现确认:packages/cli/src/serve/acp-http/transport.test.ts 中的 17 个测试和 packages/core/src/config/storage.test.ts 中的 3 个测试在 origin/main 上(未应用本 PR 任何更改时)同样失败。

本 PR 涉及或新增的所有测试均在本地通过(12 个测试文件共 553 个测试),npm run buildnpm run typechecknpm run lint 也全部通过。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

Autofix review round — no action needed

This round triaged the feedback newer than the last evaluation and found nothing actionable:

  • Reviews: none.
  • Inline comments: none.
  • Failed checks: none — all CI checks on the head commit (987bdd4de) are SUCCESS or SKIPPED, including Test (ubuntu-latest, Node 22.x) and Serve A/B (ubuntu-latest, Node 22.x).
  • Still-red checks: none.

The only new item is an issue-level "CI fix summary" comment. It is an informational recap of the work already committed at head (987bdd4de) — binding test runtimes to a stable storage root, adding persisted transcript fixtures, and updating assertions to the close → classify → lease ordering. It names no defect, requests no change, and asks no question, so there is nothing to implement.

No code changes were made this round; the branch is left as-is.

中文说明

Autofix 审查轮次 —— 无需处理

本轮对上次评估之后新增的反馈进行了分类,未发现任何需要处理的内容:

  • 审查(Reviews): 无。
  • 行内评论(Inline comments): 无。
  • 失败的检查(Failed checks): 无 —— 头提交(987bdd4de)上的所有 CI 检查均为 SUCCESSSKIPPED,包括 Test (ubuntu-latest, Node 22.x)Serve A/B (ubuntu-latest, Node 22.x)
  • 持续失败的检查(Still-red checks): 无。

唯一的新增条目是一条 issue 级别的“CI 修复总结”评论。它是对头提交(987bdd4de)中已完成工作的信息性回顾 —— 将测试运行时绑定到稳定的存储根目录、添加持久化的 transcript fixture,并将断言更新为 close → classify → lease 的新顺序。该评论未指出任何缺陷、未提出任何修改请求、也未提出任何问题,因此没有需要实现的内容。

本轮未做任何代码改动;分支保持原样。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@doudouOUC doudouOUC left a comment

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.

Review: fix(serve): Isolate daemon session maintenance writers

Reviewed the full diff (31 files, +2667/−951) at 987bdd4.

Overview

Three coupled changes:

  1. Runtime pinningWorkspaceRuntime gains sessionRuntimeBaseDir, resolved once at creation and injected as QWEN_RUNTIME_DIR into managed children. Storage.runWithResolvedRuntimeBaseDir() adds a pinned: true context that beats both process.env and nested runWithRuntimeBaseDir() calls.
  2. Per-session writer leasesSessionService.acquireSessionWriterLease(), with daemon delete/archive/unarchive/orphan-cleanup rewritten to: gate → close → classify → lease → re-classify → assert-owned → mutate → reconcile task → release.
  3. Shutdown sealSessionArchiveCoordinator.sealMaintenanceAndWait(), surfaced as 503 daemon_draining (REST) and errorKind: daemon_draining (ACP).

The direction is sound: fail-closed, no hostname/PID/age-based lock stealing, and the storage pinning closes a real cross-workspace mutation hazard. Test coverage is strong — 16 targeted new tests on real temp runtime roots, including state-change-between-classifications, release failure, and shutdown ordering.

Findings below; inline comments on the specific lines.


Blocking

Three /workspace/:id/session-groups mutating routes hang the request. session.ts:3700, :3725, :3761 all added if (!runtime) return; with no response written and no next(). getByWorkspaceCwd returns undefined whenever the entry's state isn't 'active' (workspace-registry.ts:293 — reachable via blockReplacement after a failed runtime replacement), so the connection stays open until a socket timeout. The GET sibling at :3676 already uses the correct helper, resolveRuntimeFromWorkspaceParam (:615), which sends sendWorkspaceMismatch. See inline.


Correctness / behavior risks

Batch archive/unarchive lost all-or-nothing conflict semantics. Previously runExclusiveMany(activeIds) acquired the whole batch before any mutation, so a SessionArchivingError meant nothing had been written. Now each id takes its own gate concurrently and a mid-batch conflict is rethrown out of Promise.all, failing the whole request after other sessions were already archived and their leases released — the client gets a 409 with no record of what changed. Given the design doc says "every session is processed independently", converting a per-session SessionArchivingError into an errors[] entry (as delete already does for non-exclusive errors) would be more consistent.

Archive now enters the gate before classifying. Every id — including not-found and already-archived — now gets closeSession(…, { requireAgentClose: true }) and takes the exclusive gate. The renamed test in transport.test.ts records the visible consequence: a request that previously returned notFound: [id] now returns errorKind: 'session_archiving'. That contradicts the PR body's "local session_archiving conflicts … remain unchanged". Either restore a pre-gate classification for the cheap outcomes, or correct the compatibility section — a client-visible change shouldn't be documented only by a test rename.

Unbounded shutdown drain, now ahead of child teardown. await initialSessionMaintenanceWait; lands before startProcessRegistryShutdown(), and the new test asserts bridge.shutdown waits on it. Shared reads are excluded from the drain, but an admitted archive blocks on closeSession(requireAgentClose: true) — an unresponsive agent then blocks SIGTERM shutdown indefinitely with managed children never terminated. SHUTDOWN_FORCE_CLOSE_MS only covers listener drain. See inline.

workspaceRegistry.primary is a throwing getter, now read outside try. In /sessions/archive and /sessions/unarchive it sits above the try; in /session/:id/export and PATCH /session/:id/organization it's evaluated in the argument list. It throws WorkspaceGenerationClosedError when the primary isn't active, which Express 5 turns into a generic 500 rather than the workspace_runtime_unavailable 503 these surfaces use elsewhere. The delete route already reads it inside the try — match that.

Ambient/pinned split in updateScheduledTaskForMaintenance. The transcript comes from the service's pinned Storage, but the cron path comes from getCronFilePath(service.getProjectRoot()), which resolves the runtime root ambiently. Correct at every call site in this PR, but a future caller that forgets the runWithWorkspaceRuntimeStorage wrapper silently reconciles the wrong workspace's cron file — exactly the class of bug this PR exists to eliminate. Deriving the path from the runtime/service explicitly would make it structurally impossible.


API surface

  • Writer conflicts flatten to strings in batch errors[]. A SessionWriterConflictError lands as { sessionId, error: "<message>" } in a 200 response, so clients can't distinguish "another writer holds it, retry" from a permanent failure. Since making conflicts actionable is the point, consider carrying errorKind/code on the per-session entries.
  • Mutation-applied-but-release-failed reports as an error. Documented and deliberate, and both operations are idempotent on retry — worth confirming the Web Shell treats archive/delete errors as "unknown, re-fetch" rather than "unchanged".

Performance

Per-session afterMutationApplied replaces one batched removeTasksForSessions(root, removed) with N calls. updateCronTasks serializes on a per-path mutex plus a file lock (cronTasksFile.ts:368), so it stays correct — but a 200-session batch delete now performs 200 sequential lock + full-file read + full-file rewrite cycles instead of one. Batching the reconciliation after the leases are released keeps both properties.

Style / smaller items

  • run-qwen-serve.ts:3111 resolveSessionRuntimeBaseDir reimplements Storage.resolvePath + getGlobalQwenDir, including a hardcoded '.qwen' instead of the exported QWEN_DIR. Behaviorally equivalent today, drift-prone; belongs in core.
  • replaceRuntimeEffectiveEnv silently overwrites a reloaded QWEN_RUNTIME_DIR. Correct per the design, but a warning when the reloaded value differs from the pin would save an operator a confusing debug session.
  • Storage.runWithRuntimeBaseDir becoming a silent no-op inside a pinned context is a footgun — at minimum a debugLogger.debug on the ignored redirect.
  • storage.ts — in the pinned branch, contextualDir.dir ?? Storage.getGlobalQwenDir() is unreachable; runWithResolvedRuntimeBaseDir always sets a non-null dir.
  • runWithResolvedRuntimeBaseDir uses path.resolve(dir), so a relative argument resolves against process.cwd() while the contract says "absolute". Assert it, or document the base.
  • session-archive.tslet lease; is an evolving any; classifySessionLocation is a pass-through wrapper around service.getSessionLocation.
  • acp-http/index.tsopts.workspaceRegistry?.primary guards the registry with ?., but primary itself throws; the ?? Storage.getRuntimeBaseDir() fallback doesn't cover that.

Documentation

The first two paragraphs of the PR description — managed ACP children stopping writer admission, draining accepted recording work, releasing ownership before the parent reaps them, and the parent tracking child terminal state across every bridge — have no corresponding code in this diff. No acp-bridge or agent-package files are touched. Either that work belongs here, or the description should be trimmed to what's present (maintenance leases, runtime pinning, shutdown seal). As written it makes the diff much harder to review against its stated intent.

Security

No concerns found. Session IDs are validated against SESSION_FILE_PATTERN before any lock directory is touched (test covers ../invalid), and the pattern is non-global so there's no lastIndex statefulness. Log lines carry workspace/session/action/errorKind only — no owner tokens or lock paths — and go through safeLogValue. Lease acquisition fails fast with no retry loop, so it isn't a DoS amplifier.

Comment thread packages/cli/src/serve/routes/session.ts Outdated
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/server/session-archive.ts
Comment thread packages/cli/src/serve/run-qwen-serve.ts
Comment thread packages/cli/src/serve/server/session-archive.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the latest review against commit a07e3eaaae.

Review item Action
Singular session-group mutations could leave requests open when a registered runtime was unavailable Fixed: all three routes now use the entry-aware runtime resolver and return retryable 503 workspace_runtime_unavailable
A mid-batch archive/unarchive coordinator race could hide completed mutations behind a request-wide 409 Fixed: races after batch admission are reported per session while completed results remain visible
workspaceRegistry.primary allegedly produced generic 500 responses Not taken: Express's final error handler already maps WorkspaceGenerationClosedError to the standard retryable 503; exact-head probes confirmed all cited routes
Maintenance drain allegedly delayed managed-child termination Not taken: process-registry shutdown starts synchronously before the maintenance wait, so child termination proceeds in parallel
Gate-before-classification semantics Retained deliberately so close, classification, and lease acquisition share one local maintenance critical section; the PR description was corrected
Explicit scheduled-task runtime-root plumbing and batched reconciliation Deferred as non-blocking hardening/optimization after the review-round threshold
Remaining API/style/documentation suggestions Deferred as non-critical follow-up scope

Validation:

  • npm run build
  • npm run typecheck
  • cd packages/cli && npx vitest run src/serve/server/session-archive.test.ts (36 passed)
  • cd packages/cli && npx vitest run src/serve/server.test.ts -t 'rejects singular session-group mutations when the selected runtime is unavailable' (1 passed)

@doudouOUC
doudouOUC marked this pull request as ready for review July 29, 2026 14:03
@doudouOUC
doudouOUC enabled auto-merge July 29, 2026 14:04
@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: observed architectural issue, not theoretical. Linked issue #7752 describes daemon-side archive, delete, and cleanup paths racing external writers on the same transcript. The problem is concrete: two daemon processes (or a daemon and a managed ACP child) can mutate the same persisted session because the in-process archive coordinator alone doesn't fence cross-process access. Multi-workspace operations can also resolve storage through the ambient primary runtime instead of the selected workspace.

Direction: aligned. Daemon data integrity is core to the serve functionality. The writer-lease protocol already exists in the codebase — this PR extends it to cover the maintenance paths that were missing it. CHANGELOG has no direct reference, but the daemon serve surface is actively developed and this is a natural hardening of the existing protocol.

Size: this PR touches core paths (packages/core/src/config/storage.ts, packages/core/src/services/sessionService.ts) and spans core + cli packages. Breakdown: 2,367 production logic lines (additions + deletions, excluding tests and docs), 1,593 test lines, 116 design-doc lines. Because production lines exceed 500 on a core-touching fix, this is flagged for maintainer awareness per the two-tier gate. The 1,000+ advisory also applies — consider whether the change could be split, though the cohesion argument is strong here (all maintenance paths need the same treatment; splitting would leave an inconsistent intermediate state).

Approach: the scope feels right for the stated goal. Every maintenance path (delete, archive, unarchive, disconnect rollback, scheduled-task rollback, keepalive cleanup, ACP orphan cleanup) needs the writer-lease wrapper, and the runtime storage pinning is a prerequisite for correct lock-path resolution. The runWithDaemonWriterLease helper keeps the per-session protocol in one place. The scheduled-tasks changes are mechanical (wrapping existing updateCronTasks calls in runWithScheduledTaskTarget), which is the right call — the logic inside is unchanged. No unrelated changes or drive-by refactors spotted.

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

中文说明

感谢贡献!

模板完整 ✓

问题:已观测到的架构问题,非理论性加固。关联 issue #7752 描述了 daemon 侧归档、删除和清理路径与外部 writer 竞争同一 transcript 的问题。问题是具体的:两个 daemon 进程(或 daemon 与受管 ACP 子进程)可能修改同一持久化 session,因为进程内 archive coordinator 无法隔离跨进程访问。多 workspace 操作也可能通过环境中的 primary runtime 解析存储,而非使用实际选中的 workspace。

方向:对齐。Daemon 数据完整性是 serve 功能的核心。Writer-lease 协议已存在于代码库中——本 PR 将其扩展到缺失的维护路径。CHANGELOG 无直接引用,但 daemon serve 表面正在积极开发中,这是对现有协议的自然加固。

规模:本 PR 触及核心路径(packages/core/src/config/storage.tspackages/core/src/services/sessionService.ts)并跨越 core + cli 包。分解:2,367 行生产逻辑(增删合计,不含测试和文档),1,593 行测试,116 行设计文档。因核心路径 fix 的生产行超过 500,按两级门控标记为维护者关注。1,000+ 大 PR 建议也适用——考虑是否可以拆分,但此处的内聚性论点很强(所有维护路径需要相同处理;拆分会留下不一致的中间状态)。

方案:范围与目标匹配。每条维护路径都需要 writer-lease 包装,runtime storage 固定是正确锁路径解析的前提。runWithDaemonWriterLease 辅助函数将逐 session 协议集中在一处。Scheduled-tasks 变更是机械性的(将现有 updateCronTasks 调用包装在 runWithScheduledTaskTarget 中),内部逻辑未变。未发现无关改动或顺手重构。

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

进入代码审查 🔍

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: given the problem (daemon maintenance racing external writers), I would (1) pin each workspace runtime's storage root at creation, (2) add a Storage.runWithResolvedRuntimeBaseDir that ignores later env reloads, (3) expose SessionService.acquireSessionWriterLease delegating to the existing lock protocol, (4) wrap each maintenance path in acquire → mutate → release with per-session error isolation, and (5) add a shutdown drain that seals admission and waits for active leases.

Comparison: the PR matches this proposal almost exactly. The implementation is well-structured:

  • runWithDaemonWriterLease in session-archive.ts is the right abstraction — it handles the full lifecycle (acquire, mutate with ownership assertion, release with owner-token verification) and correctly prioritizes the release error over the mutation error when both fail. The mutationAppliedAfterError callback is a good crash-safety pattern.
  • Per-session processing in batch operations (delete, archive, unarchive) preserves completed results when one session hits a conflict — the old batch-all-or-nothing approach would have lost successful mutations.
  • The Storage.runWithResolvedRuntimeBaseDir pinned context is clean — it threads through AsyncLocalStorage and correctly short-circuits nested configurable contexts.
  • The shutdown drain (sealMaintenanceAndWait) correctly excludes shared transcript exports from the drain budget, so a long export doesn't consume the termination window.
  • workspace-runtime-storage.ts is a small, focused helper that avoids repeating the Storage.runWithResolvedRuntimeBaseDir(runtime.sessionRuntimeBaseDir, ...) pattern everywhere.

No correctness bugs, security holes, or regressions found. The code follows project conventions (ESM, strict TypeScript, collocated tests, kebab-case files).

One observation (non-blocking): the resolveSessionRuntimeBaseDir function in run-qwen-serve.ts partially duplicates the resolution priority from Storage.resolveRuntimeBaseDir. This is justified because it needs to resolve at boot time and pin the value before any Storage context exists, but a future refactor could unify them.

Sandboxed verification would settle the remaining behavioural gap: @qwen-code /verify — that the writer-lease contention actually fails closed under real multi-process conditions (two daemon instances racing on the same session) is not fully observable from unit tests alone, even though the test suite covers the protocol with real temporary runtime roots. This is a sponsored run (fork author); a maintainer triggers it with @qwen-code /verify, and the resulting report should be read with the same skepticism as the fork's own CI logs.

sequenceDiagram
    participant P1 as REST or ACP request
    participant P2 as ArchiveCoordinator
    participant P3 as SessionService
    participant P4 as WriterLease
    participant P5 as Transcript FS
    P1->>P2: runExclusiveMany(sessionId)
    P2->>P2: check maintenanceSealed
    P2->>P3: closeSession (local owner)
    P2->>P3: classifySessionLocation
    P3-->>P2: active or archived or notFound
    P2->>P4: acquireSessionWriterLease
    P4-->>P2: lease (or conflict error)
    P2->>P3: reclassify + assertOwnedAndUnchanged
    P2->>P5: mutate transcript
    P5-->>P2: result
    P2->>P4: release (owner-token verified)
    P2->>P3: updateScheduledTask (best-effort)
    P2-->>P1: per-session result
Loading
Files changed (31 of 31 shown)
File What changed
docs/design/daemon-session-maintenance-writer-lease.md New design doc covering the protocol, scope, and compatibility notes
packages/core/src/config/storage.ts Adds pinned runtime context to AsyncLocalStorage; new runWithResolvedRuntimeBaseDir
packages/core/src/config/storage.test.ts Tests for pinned context override and nested-context immunity
packages/core/src/services/sessionService.ts New acquireSessionWriterLease method with session-ID validation
packages/core/src/services/session-service-writer-lease.test.ts Tests for lease acquisition, contention, and invalid-ID rejection
packages/cli/src/serve/server/session-archive.ts Core change: runWithDaemonWriterLease helper, per-session lease protocol for delete/archive/unarchive, shutdown drain, orphan cleanup
packages/cli/src/serve/server/session-archive.test.ts Extensive tests for lease contention, drain, partial batch results, orphan reattachment
packages/cli/src/serve/routes/session.ts Threads runtime storage context through all session routes; replaces raw SessionService construction
packages/cli/src/serve/routes/scheduled-tasks.ts Wraps all cron-task mutations in runWithScheduledTaskTarget for storage isolation
packages/cli/src/serve/run-qwen-serve.ts Resolves and pins sessionRuntimeBaseDir at boot; injects into managed child env; shutdown drain integration
packages/cli/src/serve/server.ts Wires archiveCoordinator to app.locals for shutdown; adds cleanupSession helper; passes runtimeBaseDir to keepalive
packages/cli/src/serve/workspace-runtime-storage.ts New helper: runWithWorkspaceRuntimeStorage and createWorkspaceRuntimeSessionService
packages/cli/src/serve/workspace-registry.ts Adds sessionRuntimeBaseDir to WorkspaceRuntime interface
packages/cli/src/serve/acp-http/dispatch.ts Wraps ACP message handling in pinned storage context; uses deleteDaemonSessionIfOrphan for cleanup
packages/cli/src/serve/acp-http/index.ts Passes sessionRuntimeBaseDir to AcpDispatcher constructor
packages/cli/src/serve/server/error-response.ts Maps DaemonDrainingError to 503 with daemon_draining code
packages/cli/src/serve/scheduled-task-keepalive.ts Threads runtimeBaseDir and cleanupSession through keepalive; wraps tick in pinned context
packages/cli/src/serve/virtual-subagent-sessions.ts Replaces env-based runtime dir with pinned sessionRuntimeBaseDir
packages/cli/src/serve/acp-http/dispatch-error.test.ts Tests DaemonDrainingError RPC mapping
packages/cli/src/serve/acp-http/transport.test.ts Updates test harness for new dispatcher constructor arg
packages/cli/src/serve/acp-http/workspace-qualified-acp.test.ts Updates test harness for sessionRuntimeBaseDir
packages/cli/src/serve/multi-workspace-sessions.test.ts Tests workspace-qualified storage isolation
packages/cli/src/serve/routes/scheduled-tasks.test.ts Tests storage-scoped cron-task operations
packages/cli/src/serve/routes/session-telemetry.test.ts Updates for runtime-based session service
packages/cli/src/serve/run-qwen-serve.test.ts Tests resolveSessionRuntimeBaseDir priority and env pinning
packages/cli/src/serve/scheduled-task-keepalive.test.ts Tests late-spawn cleanup ordering
packages/cli/src/serve/server.test.ts Tests session-group rejection when runtime unavailable
packages/cli/src/serve/server/error-response.test.ts Tests DaemonDrainingError HTTP mapping
packages/cli/src/serve/virtual-subagent-sessions.test.ts Updates for pinned runtime dir
packages/cli/src/serve/workspace-qualified-rest.test.ts Adds sessionRuntimeBaseDir to test harnesses
packages/cli/src/serve/routes/workspace-qualified-extensions.test.ts Minor test harness update

Testing

CI is fully green on 9ea71220d432237dcbeda9851aca907c3dc8c012 — no failures, no pending runs.

Check Conclusion
Test (ubuntu-latest, Node 22.x) success
Serve A/B (ubuntu-latest, Node 22.x) success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) success
precheck-pr / precheck success
Classify PR success
Test (macos-latest, Node 22.x) skipped
Test (windows-latest, Node 22.x) skipped
Integration Tests (CLI, No Sandbox) skipped

Not verified: macOS and Windows test runs were skipped in CI. The author reports testing on macOS only. The writer-lease contention behavior under real multi-process conditions is covered by unit tests with real temporary runtime roots but not by a live multi-daemon E2E scenario.

Real-scenario testing: N/A — this is daemon lifecycle, storage isolation, and concurrency behavior with no user-interface change.

中文说明

代码审查

独立方案: 针对此问题(daemon 维护与外部 writer 竞争),我会 (1) 在创建时固定每个 workspace runtime 的存储根目录,(2) 添加忽略后续 env 重载的 Storage.runWithResolvedRuntimeBaseDir,(3) 暴露 SessionService.acquireSessionWriterLease 委托给现有锁协议,(4) 将每条维护路径包装在 acquire → mutate → release 中并逐 session 隔离错误,(5) 添加封闭准入并等待活跃 lease 的 shutdown drain。

对比: PR 与此方案几乎完全一致。实现结构良好:

  • session-archive.ts 中的 runWithDaemonWriterLease 是正确的抽象——处理完整生命周期(获取、带所有权断言的变更、带 owner-token 验证的释放),并在两者都失败时正确优先报告 release 错误。
  • 批量操作中的逐 session 处理保留了已完成的结果——旧的批量全有或全无方式会丢失成功的变更。
  • Storage.runWithResolvedRuntimeBaseDir 固定上下文干净——通过 AsyncLocalStorage 传递并正确短路嵌套的可配置上下文。
  • Shutdown drain 正确排除了共享 transcript 导出,不占用终止预算。
  • workspace-runtime-storage.ts 是小型聚焦的辅助模块。

未发现正确性 bug、安全漏洞或回归。代码遵循项目约定。

一个观察(非阻塞):run-qwen-serve.ts 中的 resolveSessionRuntimeBaseDir 部分重复了 Storage.resolveRuntimeBaseDir 的解析优先级。这是合理的(需要在 boot 时解析并固定),但未来重构可以统一。

沙箱验证可以解决剩余行为差距:@qwen-code /verify——writer-lease 竞争在真实多进程条件下是否确实 fail closed,无法仅从单元测试完全观察。这是赞助运行(fork 作者);维护者用 @qwen-code /verify 触发。

测试

CI 在 9ea71220d432237dcbeda9851aca907c3dc8c012 上全部通过——无失败,无待处理运行。

未验证:macOS 和 Windows 测试在 CI 中被跳过。作者仅在 macOS 上测试。Writer-lease 竞争行为在真实多进程条件下由使用真实临时 runtime root 的单元测试覆盖,但未有实时多 daemon E2E 场景。

真实场景测试:N/A——这是 daemon 生命周期、存储隔离和并发行为变更,没有用户界面变化。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review, but the Stage 0 maintainer-awareness escalation (2,367 production lines touching core across two packages) needs a maintainer's sign-off.

Stepping back: this is a well-designed change that solves a real problem. The writer-lease protocol already existed — the PR extends it to the maintenance paths that were missing it, and pins workspace storage roots so multi-workspace operations can't accidentally resolve through the primary runtime. The implementation matches my independent proposal almost exactly, the code is straightforward, and the test coverage is thorough (692 lines of session-archive tests alone, plus coverage for the storage pinning, keepalive cleanup, and error mapping).

The reason this caps at 3/5 is purely the Stage 0 policy: a fork PR with 2,367 production logic lines touching packages/core/src/config/storage.ts and packages/core/src/services/sessionService.ts requires maintainer awareness regardless of how clean the review is. The change is cohesive — splitting it would leave maintenance paths with inconsistent writer-lease coverage — but the scale and core-path scope mean a human should make the final call.

No blocking issues found. The non-blocking observation about resolveSessionRuntimeBaseDir partially duplicating Storage.resolveRuntimeBaseDir is a future-cleanup note, not a concern about this PR.

@doudouOUC — nice work. The design doc, the per-session error isolation, and the shutdown drain protocol are all well thought out.

中文说明

置信度:3/5 —— 审查干净,但 Stage 0 维护者关注升级(2,367 行生产逻辑触及核心,跨两个包)需要维护者签字。

退后一步看:这是一个设计良好的变更,解决了真实问题。Writer-lease 协议已经存在——PR 将其扩展到缺失的维护路径,并固定 workspace 存储根目录,使多 workspace 操作不会意外通过 primary runtime 解析。实现与我的独立方案几乎完全一致,代码直接,测试覆盖全面(仅 session-archive 测试就有 692 行,加上存储固定、keepalive 清理和错误映射的覆盖)。

封顶 3/5 的原因纯粹是 Stage 0 策略:一个 fork PR 有 2,367 行生产逻辑触及 packages/core/src/config/storage.tspackages/core/src/services/sessionService.ts,无论审查多干净都需要维护者关注。变更是内聚的——拆分会导致维护路径的 writer-lease 覆盖不一致——但规模和核心路径范围意味着应由人类做最终决定。

未发现阻塞问题。关于 resolveSessionRuntimeBaseDir 部分重复 Storage.resolveRuntimeBaseDir 的非阻塞观察是未来清理备注,不是对本 PR 的担忧。

@doudouOUC —— 做得好。设计文档、逐 session 错误隔离和 shutdown drain 协议都考虑周全。

Qwen Code · qwen3.8-max-preview

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

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao @tanzhenxin @yiliang114 @LaZzyMan — Stage 0 maintainer-awareness escalation: a fix touching core (packages/core/src/config/storage.ts, packages/core/src/services/sessionService.ts) at ~2,367 production lines across two packages. Policy withholds bot auto-approval on a core change of this size regardless of review outcome.

Update on re-run (564bd95): the review is clean with no blocking findings, CI is fully green (69 checks, 0 failures, including the main unit suite, Serve A/B, and Real daemon E2E), and @wenshao has already approved. The bot still withholds its own approval per the core-size policy — the merge decision is the maintainers' to make. A @qwen-code /verify run would additionally pin the multi-process fail-closed guarantee before merge. See the Stage 3 comment for the full reflection.

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 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: 40 passed · 0 failed · 40 total

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

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

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

Verification report (report.md)

# PR #7975 Deep Verification Report

**Verdict: `merge-ready`** — 40/40 scripted assertions passed, 0 failed. Verified head: `9ea71220d432237dcbeda9851aca907c3dc8c012`.

<details>
<summary>中文摘要</summary>

**结论**: `merge-ready`。全部 40 项脚本化断言通过,0 项失败。

**A/B 结论**: 在外部 writer 持有 lease 的情况下,HEAD 的 `deleteDaemonSessions`、`archiveDaemonSessions`、`unarchiveDaemonSessions` 均返回 writer conflict 错误并保留 transcript 不变;BASE 对照组(无 lease 检查)则直接删除/移动 transcript。Writer lease 是防止 daemon 与外部 writer 竞争的关键机制,A/B 证明其 load-bearing。

**Findings**: 无阻塞性问题。`server.test.ts` 中 2 个失败为预先存在的 IPv6 环境问题(`EADDRNOTAVAIL ::1`),与 PR 无关。

**未覆盖范围**: 逐 commit 归因(浅克隆仅 1/9 commit 可达);`scheduled-tasks.ts` 路由重构的行为验证;`dispatch.ts` ACP 路径变更;多 workspace 端到端集成;Windows/Linux 平台差异。

</details>

## Scope

**Central claim**: Daemon maintenance operations (delete, archive, unarchive) acquire a per-session writer lease before mutating transcripts, and fail closed with a `SessionWriterConflictError` when another writer holds the session.

**Secondary claims**:
1. **Draining guard**: `SessionArchiveCoordinator.sealMaintenanceAndWait()` seals new maintenance admission during shutdown, waits for active maintenance to complete, and new requests receive `DaemonDrainingError` → HTTP 503 `daemon_draining`.
2. **Workspace runtime pinning**: `Storage.runWithResolvedRuntimeBaseDir()` pins the runtime dir so env reloads and nested configurable contexts cannot redirect storage operations.

**Out of scope** (listed in _Not covered_): per-commit attribution, scheduled-tasks route refactor, ACP dispatch changes, multi-workspace E2E, platform-specific behavior.

## A/B Load-Bearing Proof

### Method

The HEAD build (already compiled at `HEAD`) was tested against a BASE control that simulates the pre-PR behavior. The PR does not change `package.json`/`package-lock.json`, so the dependency tree is identical. The base worktree build failed due to `node_modules` symlink resolution issues in the shallow checkout, so the BASE control was constructed by calling `service.removeSession()` / `service.archiveSessions()` directly — exactly what the base code did before the PR added `runWithDaemonWriterLease`.

Each arm:
1. Creates a temp runtime dir and workspace with a valid session transcript (JSONL with `cwd` field matching the workspace)
2. Acquires an external writer lease via `SessionWriterLease.acquire()` (simulating another process holding the session)
3. Attempts the maintenance operation
4. Asserts on the result and transcript state

### Delete A/B (`ab-writer-lease-delete.mjs`)

| Cell | Observable | Result |
|------|-----------|--------|
| HEAD: delete with held lease | `errors.length > 0`, `removed = []`, transcript preserved, error = "already open in another Qwen process" | ✅ 4/4 |
| HEAD: delete after lease release | `removed = [sessionId]`, transcript deleted | ✅ 2/2 |
| BASE: `removeSession()` with held lease | `removed = true`, transcript deleted (no lease protection) | ✅ 2/2 |

**8/8 assertions passed.** The writer lease is load-bearing: without it, the daemon deletes a transcript that another process still owns.

### Archive/Unarchive A/B (`ab-writer-lease-archive.mjs`)

| Cell | Observable | Result |
|------|-----------|--------|
| HEAD: archive with held lease | `errors.length > 0`, `archived = []`, active transcript preserved, no archived copy | ✅ 4/4 |
| HEAD: archive after release | `archived = [sessionId]`, archived copy exists | ✅ 2/2 |
| HEAD: unarchive with held lease | `errors.length > 0`, `unarchived = []`, archived transcript preserved, no active copy | ✅ 4/4 |
| HEAD: unarchive after release | `unarchived = [sessionId]`, active copy exists | ✅ 2/2 |
| BASE: `archiveSessions()` with held lease | `archived = [sessionId]`, archived copy created (no lease protection) | ✅ 2/2 |

**14/14 assertions passed.** Archive and unarchive are equally protected by the writer lease.

## Draining Guard (`harness-draining-guard.mjs`)

| Test | Observable | Result |
|------|-----------|--------|
| Seal blocks new `runExclusiveMany` | Throws `DaemonDrainingError` with `code = 'daemon_draining'` | ✅ 2/2 |
| Seal resolves immediately when idle | `< 100ms` | ✅ 1/1 |
| Seal waits for active maintenance | Does not resolve while maintenance active; resolves after completion | ✅ 3/3 |
| Shared reads continue after seal | `runSharedMany` returns normally | ✅ 1/1 |
| `DaemonDrainingError` shape | `name`, `code`, `message`, `instanceof Error` | ✅ 4/4 |
| Seal waits for ALL concurrent maintenance | Blocks until both m1 and m2 complete | ✅ 2/2 |

**13/13 assertions passed.** The draining guard correctly seals new maintenance, waits for active work, and allows reads to continue.

## Storage Pinning (`harness-storage-pinning.mjs`)

| Test | Observable | Result |
|------|-----------|--------|
| Pinned context returns pinned dir | `getRuntimeBaseDir()` = pinned path | ✅ 1/1 |
| Pinned context ignores `QWEN_RUNTIME_DIR` | Env var change does not affect pinned context | ✅ 1/1 |
| Pinned context ignores nested configurable context | `runWithRuntimeBaseDir` inside pinned context is a no-op | ✅ 1/1 |
| Non-pinned context respects `QWEN_RUNTIME_DIR` | Configurable context still defers to env var | ✅ 1/1 |
| Relative path resolution | `runWithResolvedRuntimeBaseDir('relative/dir')` → absolute path | ✅ 1/1 |

**5/5 assertions passed.** Pinned runtime contexts are immune to env reloads and nested configurable contexts, while non-pinned contexts retain the existing priority chain.

## Vacuity Check

**Central test**: `"does not archive while another writer holds the lease"` in `session-archive.test.ts:348`.

**Mutation**: Replaced `runWithDaemonWriterLease`'s lease acquisition with a no-op stub (`const lease = { assertOwnedAndUnchanged: async () => {}, release: async () => {} }`).

**Result**: Test **fails** with the intended behavioral mismatch:
```
AssertionError: expected [ Array(1) ] to deeply equal []
  expect(blocked.archived).toEqual([]);
  // received: ["550e8400-e29b-41d4-a716-446655440005"]
```

The archive succeeds when it should be blocked — exactly the bug the PR fixes. Restoring the original source makes the test pass. The test is **load-bearing**, not vacuous.

## Targeted Gates

| Test file | Pass | Fail | Notes |
|-----------|------|------|-------|
| `session-archive.test.ts` | 43 | 0 | |
| `error-response.test.ts` | 6 | 0 | |
| `storage.test.ts` (core) | 66 | 0 | |
| `session-service-writer-lease.test.ts` (core) | 2 | 0 | |
| `run-qwen-serve.test.ts` | 206 | 0 | |
| `scheduled-task-keepalive.test.ts` | 25 | 0 | |
| `server.test.ts` | 835 | 2 | Pre-existing: `EADDRNOTAVAIL: address not available ::1` — CI container lacks IPv6 loopback; test names byte-identical on base, not in PR diff |

**Total: 1183 pass, 2 pre-existing fail.**

## Findings

No blocking findings.

## Not Covered

- **Per-commit attribution**: Shallow checkout (depth 2) provides only 1 of 9 commits locally (`git rev-list HEAD^1..HEAD^2` = 1 vs 9 in metadata). Verified the aggregate `HEAD^1..HEAD` diff only.
- **`scheduled-tasks.ts` route refactor** (426 lines changed): The PR restructures scheduled-task session management to use workspace-runtime-scoped services. Not independently exercised beyond the `scheduled-task-keepalive.test.ts` gate (25/25 pass).
- **`dispatch.ts` ACP path changes** (54 lines): ACP HTTP dispatch now passes `cleanupSession` and uses workspace-runtime storage. Not independently exercised beyond the existing ACP test gates.
- **Multi-workspace E2E**: The PR's reviewer test plan describes configuring primary and secondary workspaces with different runtime roots. Not exercised end-to-end; the storage pinning harness covers the mechanism in isolation.
- **`deleteDaemonSessionIfOrphan`**: New function for orphan cleanup. Covered by unit tests in `session-archive.test.ts` (tests: "skips orphan deletion when a new owner attached", "deletes the transcript when killSession resolves true", "throws when the lease is held by another writer") but not independently A/B tested.
- **Windows/Linux platform differences**: PR author marks these as ⚠️ (not validated).
- **`session.ts` route changes** (711 lines): Session routes now use `createWorkspaceRuntimeSessionService` and `runWithWorkspaceRuntimeStorage`. Not independently exercised beyond the server test gate.
- **Base worktree build**: Failed due to `node_modules` symlink resolution in the shallow checkout. BASE control was constructed by calling the pre-PR API surface directly (`service.removeSession()`, `service.archiveSessions()`), which is what the base code did. The `node_modules/@qwen-code/qwen-code-core` symlink resolves to the HEAD tree (`readlink -f` = `/__w/qwen-code/qwen-code/packages/core`); this does not contaminate the control because the base code paths do not call any new core API.

## Methodology

Environment: CI merge-ref checkout at depth 2, `HEAD` = `f1fe73d92` (merge commit), `HEAD^1` = `7db57552e` (base tip), `HEAD^2` = `9ea71220d` (PR head). `npm ci` and `npm run build` completed before verification. No `$GITHUB_TOKEN` available; no network calls made.

Four harnesses drove the compiled `dist/` output directly via ESM imports: `ab-writer-lease-delete.mjs` (8 assertions), `ab-writer-lease-archive.mjs` (14 assertions), `harness-draining-guard.mjs` (13 assertions), `harness-storage-pinning.mjs` (5 assertions). Each harness creates isolated temp directories, exercises the real `SessionService`, `SessionWriterLease`, `SessionArchiveCoordinator`, and `Storage` classes, and cleans up after itself. The vacuity check mutated the TypeScript source (lease acquisition → no-op stub), ran the central test via vitest, confirmed the intended assertion failure, and restored the original. Targeted gates ran the 7 affected test files via `npx vitest run` from within each package directory. Raw logs are in the harness stdout above; harness scripts are preserved in this artifact directory for rerun.

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

Qwen Code · sandboxed verification

@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No actionable feedback this round. The only new comment is an automated CI status update (sandboxed verification running) — no reviews, inline comments, failed checks, or maintainer requests to address.

中文说明

本轮没有可操作的反馈。唯一的新评论是自动 CI 状态更新(沙箱验证正在运行)——没有审查意见、行内评论、失败的检查或维护者请求需要处理。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /verify

@qwen-code-ci-bot

qwen-code-ci-bot commented Jul 30, 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: 64 passed · 0 failed · 64 total

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

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

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

Verification report (report.md)

# PR #7975 Deep Verification Report (follow-up round)

**Verdict: `merge-ready`** — 64/64 scripted harness assertions passed, 0 failed. Verified head: `9ea71220d432237dcbeda9851aca907c3dc8c012` (`git rev-parse HEAD^2`).

<details>
<summary>中文摘要</summary>

**结论**: `merge-ready`。本轮为复验(follow-up)。当前检出与上一轮**逐字节相同**(HEAD `f1fe73d92`、base `HEAD^1`=`7db57552e`、PR head `HEAD^2`=`9ea71220d` 均一致,metadata `headRefOid`=`9ea71220d`),因此上一轮所有测量的输入闭包可证明未变;但本轮仍按规则**重新测量**了全部核心结论,而非引用旧报告。

**A/B 结论**: 在外部 writer 持有 lease 时,HEAD 的 `deleteDaemonSessions` / `archiveDaemonSessions` / `unarchiveDaemonSessions` / `deleteDaemonSessionIfOrphan` 均 fail closed(返回 writer conflict、transcript 保持不变),释放 lease 后成功;对照组(base 直接调用 `removeSession`/`archiveSessions`/`unarchiveSessions`,已用 `git show HEAD^1` 核实即 base 原始路径)在持有 lease 时仍会**覆盖** transcript。另增“同入口对照”:将 HEAD 编排中的 lease 强制改为 no-op 后,同一入口函数也会覆盖外部 writer——证明 lease 检查本身就是 load-bearing 的那一行。见下表「Writer-lease A/B」。

**Findings**: 无阻塞性问题。`server.test.ts` 2 个失败为预先存在的环境问题(容器无 IPv6 loopback,`EADDRNOTAVAIL ::1`):已证明这两个测试名不在 PR diff 中、在 base 上逐字节相同、且容器直接 `listen('::1')` 即报 `EADDRNOTAVAIL`。

**未覆盖范围**: 逐 commit 归因(浅克隆仅 1/9 commit 可达);`scheduled-tasks.ts` / `session.ts` 路由重构与 `dispatch.ts` ACP 路径仅经门禁测试覆盖、未独立 A/B;多 workspace 端到端;Windows/Linux 平台差异。

</details>

## Previous-finding status (follow-up round)

The checkout is byte-identical to the previous round (same HEAD/base/head OIDs), so the prior input closure is provably unchanged; every carried-forward measurement below was nonetheless **re-run at this head**, not diffed from the old report.

| # | Previous finding / note | Severity | Status at this head |
|---|--------------------------|----------|---------------------|
| 1 | `server.test.ts`: 2 fails `EADDRNOTAVAIL ::1` (called pre-existing) | non-blocking | **Stands, now proven environmental.** Re-ran: 835 pass / 2 fail, same two test names. The names are NOT in the PR diff, exist byte-identical on base (lines 21456/21491), and a direct `net.createServer().listen(0,'::1')` in this container returns `EADDRNOTAVAIL` — the cause is the missing IPv6 loopback, independent of PR code. |
| 2 | `deleteDaemonSessionIfOrphan` not independently A/B tested | coverage gap | **Now covered.** New orphan-delete A/B (6 assertions): throws `SessionWriterConflictError` + preserves transcript while held; deletes after release; skips + preserves when a new owner attached (`killSession→false`). |
| 3 | Multi-session batch race not independently exercised | coverage gap | **Now covered.** New BATCH RACE harness (5 assertions): independent session archived, racing session reported in `errors[]` with writer-conflict, racer transcript preserved. |
| 4 | Base worktree build failed (node_modules symlink); base control = raw service calls | methodology | **Superseded by a tighter control.** Verified against `git show HEAD^1:…/session-archive.ts` that base called `service.removeSession`/`archiveSessions`/`unarchiveSessions` directly with no lease wrapper (the exact control used), and added a same-entry-point control (HEAD orchestration with lease enforcement disabled) so both arms run the identical entry point. Internal-link realpath check documented in Methodology. |
| 5 | Per-commit attribution out of reach (shallow) | non-blocking | **Stands.** `git rev-list HEAD^1..HEAD^2` = 1 commit reachable vs 9 in metadata; aggregate `HEAD^1..HEAD` diff verified only. |
| 6 | `scheduled-tasks.ts` / `session.ts` route refactor, `dispatch.ts` ACP, multi-workspace E2E, Windows/Linux not independently exercised | non-blocking | **Stands** (gates only). Their test files pass (see Targeted gates), but no independent A/B this round. |

I agree with the previous `merge-ready`; this round adds the orphan-delete and batch-race evidence it lacked and re-confirms the rest.

## Scope

**Central claim**: daemon maintenance operations (delete, archive, unarchive, orphan-delete) acquire a per-session writer lease before mutating persisted transcripts and fail closed with a `SessionWriterConflictError` when another writer holds the session.

**Secondary claims**:
1. **Draining guard**: `SessionArchiveCoordinator.sealMaintenanceAndWait()` seals new maintenance admission, waits for admitted maintenance, keeps shared reads running; new requests get `DaemonDrainingError` → HTTP 503 `daemon_draining`.
2. **Workspace runtime pinning**: `Storage.runWithResolvedRuntimeBaseDir()` pins an absolute runtime root immune to `QWEN_RUNTIME_DIR` reloads and nested configurable contexts, while non-pinned contexts keep the existing priority chain.

## A/B Load-Bearing Proof — central claim

Mock-free harness `ab-writer-lease.mjs` drives the compiled `dist/` output: real `SessionService`, real `SessionWriterLease` lock files, real filesystem. The external writer is modeled by acquiring a genuine lease (`acquireSessionWriterLease(…, reclaimPolicy:'never')`) before invoking the daemon entry point. **37/37 assertions passed.**

| Cell | Observable oracle | Result |
|------|-------------------|--------|
| HEAD delete, lease held | `removed=[]`, 1 error, error is writer-conflict, transcript PRESERVED | ✅ 4/4 |
| HEAD delete, after release | `removed=[sid]`, transcript gone | ✅ 2/2 |
| CONTROL delete (base `removeSession`, lease held) | returns `true`, transcript CLOBBERED despite held lease | ✅ 2/2 |
| HEAD archive, lease held | `archived=[]`, 1 error, active preserved, no archived copy | ✅ 4/4 |
| HEAD archive, after release | `archived=[sid]`, archived copy exists | ✅ 2/2 |
| CONTROL archive (base `archiveSessions`, lease held) | `archived=[sid]`, transcript CLOBBERED (moved) | ✅ 2/2 |
| HEAD unarchive, lease held | `unarchived=[]`, 1 error, archived preserved, no active copy | ✅ 4/4 |
| HEAD unarchive, after release | `unarchived=[sid]`, active copy exists | ✅ 2/2 |
| CONTROL unarchive (base `unarchiveSessions`, lease held) | `unarchived=[sid]`, transcript CLOBBERED (moved) | ✅ 2/2 |
| HEAD orphan-delete, lease held | throws `SessionWriterConflictError`, transcript preserved | ✅ 2/2 |
| HEAD orphan-delete, after release | returns `true`, transcript gone | ✅ 2/2 |
| HEAD orphan-delete, new owner attached | returns `false`, transcript preserved | ✅ 2/2 |
| BATCH archive (1 held + 1 free) | free archived; racer in `errors[]` w/ conflict; racer preserved | ✅ 5/5 |
| SAME-ENTRY-POINT control (HEAD orchestration, lease→no-op) | `removed=[sid]`, CLOBBERS external writer | ✅ 2/2 |

The writer lease is load-bearing: HEAD fails closed and preserves the transcript under contention; the base path (and the same HEAD orchestration with the lease check disabled) clobbers a transcript another writer still owns. The same-entry-point control isolates the lease check as the decisive line — everything else in the orchestration is identical between that arm and the HEAD arm.

## Secondary claim 1 — draining guard

Harness `harness-draining-guard.mjs` drives the real `SessionArchiveCoordinator` and `sendBridgeError`. **20/20 assertions passed.**

| Test | Observable | Result |
|------|-----------|--------|
| `DaemonDrainingError` shape | `instanceof Error`, `name`, `code='daemon_draining'`, message | ✅ 4/4 |
| Seal blocks new exclusive maintenance | `runExclusiveMany` throws `DaemonDrainingError` (`code='daemon_draining'`) | ✅ 2/2 |
| Seal resolves immediately when idle | < 100 ms | ✅ 1/1 |
| Seal waits for active maintenance | not resolved while active; resolves after completion; maintenance ran | ✅ 4/4 |
| Seal waits for ALL concurrent maintenance | pending while m2 active; resolves after both; order `[m1,m2]` | ✅ 3/3 |
| Shared reads continue after seal | `runSharedMany` returns normally | ✅ 1/1 |
| End-to-end `archiveDaemonSessions` after seal | rejects with `DaemonDrainingError` | ✅ 1/1 |
| `sendBridgeError` mapping | HTTP 503, `body.code`/`errorKind` = `daemon_draining`, message present | ✅ 4/4 |

## Secondary claim 2 — workspace runtime pinning

Harness `harness-storage-pinning.mjs` drives the real `Storage`. **7/7 assertions passed.**

| Test | Observable | Result |
|------|-----------|--------|
| Pinned context returns pinned dir | `getRuntimeBaseDir()` = pinned path | ✅ 1/1 |
| Pinned ignores `QWEN_RUNTIME_DIR` | env reload does not redirect | ✅ 1/1 |
| Pinned ignores nested configurable context | inner `runWithRuntimeBaseDir` is a no-op | ✅ 1/1 |
| Non-pinned respects `QWEN_RUNTIME_DIR` | env wins over configurable context | ✅ 1/1 |
| Non-pinned configurable context, no env | configurable dir used | ✅ 1/1 |
| Relative path resolution | absolute, resolves against cwd | ✅ 2/2 |

## Vacuity check

Mutated the source (interface-preserving: real lease acquisition in `runWithDaemonWriterLease` → no-op lease `{assertOwnedAndUnchanged: noop, release: noop}`, modeling base which acquired none), ran the three central tests, confirmed each failed the **intended behavioral assertion**, then restored (sha256 verified identical, `git status` clean):

| Test | Failure under mutation (intended mismatch) |
|------|---------------------------------------------|
| `archiveDaemonSessions > does not archive while another writer holds the lease` | `expected [ Array(1) ] to deeply equal []` at `expect(blocked.archived).toEqual([])` (line 363) |
| `unarchiveDaemonSessions > does not unarchive while another writer holds the lease` | `expected [ Array(1) ] to deeply equal []` at `expect(result.unarchived).toEqual([])` (line 733) |
| `deleteDaemonSessions > does not delete while another writer holds the lease` | `expected [ Array(1) ] to deeply equal []` at `expect(result.removed).toEqual([])` (line 984) |

Each failure is the behavioral mismatch the test exists to catch (the mutation proceeds when it should block), not an import/compile/fixture break. All three central tests are load-bearing. (The previous round vacuity-checked archive only; this round covers archive + unarchive + delete.)

## Targeted gates

| Test file | Pass | Fail | Notes |
|-----------|------|------|-------|
| `core/config/storage.test.ts` | 66 | 0 | |
| `core/services/session-service-writer-lease.test.ts` | 2 | 0 | |
| `cli/serve/server/session-archive.test.ts` | 43 | 0 | |
| `cli/serve/server/error-response.test.ts` | 6 | 0 | |
| `cli/serve/scheduled-task-keepalive.test.ts` | 25 | 0 | |
| `cli/serve/run-qwen-serve.test.ts` | 206 | 0 | |
| `cli/serve/multi-workspace-sessions.test.ts` | 92 | 0 | |
| `cli/serve/acp-http/dispatch-error.test.ts` | 1 | 0 | |
| `cli/serve/routes/scheduled-tasks.test.ts` | 81 | 0 | |
| `cli/serve/server.test.ts` | 835 | 2 | Pre-existing + environmental: `EADDRNOTAVAIL ::1`; names not in PR diff, byte-identical on base, container has no IPv6 loopback |

**Total: 1357 pass, 2 pre-existing environmental fail.**

- **Typecheck**: `npm run typecheck` exit 0 across all workspaces (core, cli, sdk, web-shell, webui, external-context).
- **Lint**: `npx eslint` clean (exit 0) on all 13 changed production files. Gate proven live: a planted probe (`any` + unused var) produced 4 errors / exit 1, then was removed.

## Corrections

None to the code. One methodology note relative to the previous report: the prior round's base control (raw `service.removeSession`/`archiveSessions`) was sound but unverified against base source; this round confirmed via `git show HEAD^1:…/session-archive.ts` that those were exactly the calls base made (lines 159/405/475, no lease wrapper), and supplemented it with a same-entry-point control so the A/B is not open to the "you called a different function" objection.

## Findings

No blocking findings.

## Not covered

- **Per-commit attribution**: shallow checkout (depth 2) — `git rev-list HEAD^1..HEAD^2` = 1 commit reachable vs 9 in metadata. Verified the aggregate `HEAD^1..HEAD` diff only.
- **`scheduled-tasks.ts` route refactor (426 lines) and `session.ts` route changes (711 lines)**: covered by their passing test files (`routes/scheduled-tasks.test.ts` 81/81; `server.test.ts` 835 pass) but not independently A/B exercised.
- **`dispatch.ts` ACP path changes (54 lines)**: `dispatch-error.test.ts` 1/1 and `multi-workspace-sessions.test.ts` 92/92 pass; not independently A/B exercised.
- **Multi-workspace end-to-end** (reviewer test plan: primary + secondary runtime roots across an env reload): the storage-pinning harness proves the mechanism in isolation; the full route-level flow was not driven end-to-end.
- **Windows/Linux platform differences**: author marks these ⚠️ (not validated); this container is Linux and exercised the Linux path only.
- **Base worktree full build**: not attempted (the previous round found `node_modules` symlink resolution fails in the shallow checkout); the base control was instead constructed from the verified base source path and a same-entry-point mutation, as described above.

## Methodology

Environment: CI merge-ref checkout at depth 2 — `HEAD`=`f1fe73d92` (merge), `HEAD^1`=`7db57552e` (base tip), `HEAD^2`=`9ea71220d` (PR head, the verified head). `npm ci` and `npm run build` completed before verification; the prebuilt `dist/` was used as-is and never rebuilt (the vacuity mutation touched TypeScript source only, exercised through vitest's on-the-fly transform, and was restored — sha256-verified — before any other step). No `$GITHUB_TOKEN`; no network calls.

Three harnesses drove the compiled `dist/` directly via ESM imports: `ab-writer-lease.mjs` (37 assertions), `harness-draining-guard.mjs` (20), `harness-storage-pinning.mjs` (7). Each creates isolated temp runtime/workspace dirs, seeds transcripts with the same JSONL shape as the PR's own test helper, exercises the real `SessionService` / `SessionWriterLease` / `SessionArchiveCoordinator` / `Storage` / `sendBridgeError`, and cleans up. The external-writer condition is a genuine on-disk lease, not a stub. Internal-link check: `readlink -f node_modules/@qwen-code/qwen-code-core` → `/__w/qwen-code/qwen-code/packages/core` (the head tree); this does not contaminate the control because the core writer-lease module is unchanged by the PR (not in the diff), so both arms share the same real core. The vacuity check mutated source, ran the three central tests via vitest, confirmed the intended assertion failures, and restored. Targeted gates ran 10 affected test files via `npx vitest run` from within each package directory; typecheck and lint ran at repo scope / on changed files. Raw logs: `ab-writer-lease.log`, `harness-draining-guard.log`, `harness-storage-pinning.log` in this artifact directory; harness `.mjs` scripts preserved for rerun.

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

Qwen Code · sandboxed verification

Aleks-0 pushed a commit to Aleks-0/qwen-code that referenced this pull request Jul 30, 2026
Post-merge measurement of QwenLM#8016, on the first two live runs after it
landed (QwenLM#7975 and QwenLM#8066): the browser installed successfully — "Install
evidence browser: success" in both — and both reports carried **zero
images**. QwenLM#7975 has 31 tables, QwenLM#8066 has 407 assertions. Neither has a
witness.

The cause is the third instance of one structural bug, and it is the
same one twice already fixed:

  QwenLM#7917  the /verify recommendation sat inside a section headed
         "local invocation ONLY" -> 1/16 reach
  QwenLM#8016  captures were "Optionally ... when text cannot carry the
         oracle" -> 0/14
  now    captures are a required deliverable in the ARTIFACT CONTRACT,
         but the plan the agent executes is the Scope-selection budget
         list, and that list had zero mentions of evidence, png,
         capture or screenshot

An agent that budgets by that list never allocates time for a capture,
however firmly a later section says to produce one. So captures are now
budget item 4 alongside the A/B, the harnesses and the gates, with the
time reserved (~5 min), the gate named (QWEN_VERIFY_CHROMIUM=1), the
count bounded (normally two, at most a handful), and the measured
failure stated so the rule carries its own reason.

The report structure gets the matching half: the A/B table item now says
to reference the capture of those cells by filename. A produced image
with nowhere to go is as dead as an unproduced one.

Mutation-verified 4/4: dropping the budget item, the time allowance, the
count bound, or the report-side reference each turns the test red.

Not changed, and worth stating rather than implying: the seven techniques
from QwenLM#8010 do not appear in either report. I do not read that as failure
— every one of them is conditional ("when one fix bundles two changes",
"when the oracle is an instrument", "when a PR adds a writer to a shared
store") and neither PR met those conditions. QwenLM#8010 is not yet falsifiable
from these runs. What both reports DO show is older skill rules working:
a naive-matcher differential in QwenLM#8066, and QwenLM#7975's realpath check finding
node_modules/@qwen-code/qwen-code-core resolving into the HEAD tree and
then reasoning about whether that contaminates the control.

107/107 tests; prettier and eslint clean.

Co-authored-by: wenshao <wenshao@example.com>
@qwen-code-dev-bot

Copy link
Copy Markdown
Collaborator

🤖 Reviewed the latest feedback — no changes needed. Why, point by point: · 已审阅最新反馈——无需改动。逐点说明原因如下:

No action this round

There is no actionable feedback to address on PR #7975.

  • Reviews: none.
  • Inline comments: none.
  • Failed checks: none — all reported checks are SUCCESS or SKIPPED (platform-specific jobs skipped by design); none are FAILURE/ERROR.
  • Issue-level comments: one, from @qwen-code-ci-bot. It is an automated sandboxed-verification report (64/64 scripted assertions passed, verdict "merge-ready"). It is advisory evidence for human reviewers — explicitly not a review, an approval, or a CI check — and it names no defect and requests no change.
  • Base conflict: --conflict false, so no merge of origin/main is needed.

No code change was made and no verification commands were run, since there is nothing to fix or re-verify. The branch is left as-is at 9ea71220d.

中文说明

本轮无需处理

PR #7975 没有需要处理的可执行反馈。

  • 评审(Reviews): 无。
  • 行内评论(Inline comments): 无。
  • 失败的检查(Failed checks): 无 —— 所有上报的检查均为 SUCCESSSKIPPED(平台相关任务按设计被跳过),没有任何 FAILURE/ERROR
  • Issue 级评论: 仅一条,来自 @qwen-code-ci-bot。它是一份自动化的沙箱验证报告(64/64 个脚本断言通过,结论为“可合入”)。该评论仅作为供人工评审者参考的证据 —— 明确不构成评审、批准或 CI 检查 —— 且未指出任何缺陷,也未要求任何改动。
  • 与 base 的冲突: --conflict false,因此无需合并 origin/main

本轮未做任何代码改动,也未运行任何验证命令,因为没有需要修复或重新验证的内容。分支保持在 9ea71220d 不变。

Base-conflict check · 基分支冲突检查: no conflict with main. · 与 main 无冲突。


🧠 Handled by Qwen Code · model/模型 qwen3.8-max-preview

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has no review of its own on 9ea71220d432237dcbeda9851aca907c3dc8c012. If this re-run was meant to approve, it did not — an approval left by another account is a separate vote and does not count as the bot's own.

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

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — this PR touches core infrastructure (packages/core/src/config/storage.ts, packages/core/src/services/sessionService.ts) with 2,367 production logic lines across 31 files. The review is clean (no correctness bugs, security holes, or regressions found; CI fully green), but the Stage 0 two-tier gate requires maintainer awareness for core-touching PRs at this scale. Needs a human call on whether the scope and core-path changes are appropriate for a fork contribution.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

⚠️ The bot has no review of its own on 9ea71220d432237dcbeda9851aca907c3dc8c012. If this re-run was meant to approve, it did not — an approval left by another account is a separate vote and does not count as the bot's own.

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

@doudouOUC doudouOUC self-assigned this Jul 30, 2026

@ytahdn ytahdn 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 👍

Solid approach to isolating daemon session maintenance with the existing writer-lease protocol. The fail-closed semantics for conflicts and the daemon_draining response during shutdown are well-designed. The multi-workspace runtime root pinning and the partial-result preservation in batch maintenance are both important correctness guarantees that are well-implemented here.

1,600+ passing assertions covering Core, CLI, ACP bridge, and ACP agent suites gives good confidence. The risk/scope section is thorough and honest about what remains out of scope.

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 30, 2026
Merged via the queue into QwenLM:main with commit 3bdaeac Jul 30, 2026
67 of 68 checks passed

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

LGTM — writer-lease lifecycle is correctly implemented with release in finally-equivalent paths. Shutdown drain seals synchronously and waits for admitted work. Batch isolation preserves per-session results on conflict. Workspace runtime isolation prevents cross-contamination. Test coverage is thorough (lease lifecycle, drain, batch conflicts, workspace isolation all covered).

Non-blocking suggestions:

  1. Separate alreadyActive + errors dual-listing into a warnings[] array for non-fatal reconciliation failures
  2. Make keepalive cleanupSession required (or have the fallback acquire the lease) to close the latent bypass
  3. Extract the repeated batch orchestration skeleton into a generic runPerSessionMaintenance<T> helper
  4. Add a comment documenting why the pre-check + synchronous set-addition is race-free in single-threaded JS
  5. Add edge-case tests: deleteDaemonSessionIfOrphan non-SessionNotFoundError propagation, unarchive dual-listing behavior

@wenshao

wenshao commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Local verification round — real daemon, A/B against base

Verdict: verified — 76 executed scripted assertions passed, 0 failed. No blocking finding. That is 38 distinct checks run twice: once at the PR head 9ea71220d432237dcbeda9851aca907c3dc8c012 against base 97aaa3808d1e198069e5f4005821f2581afd5464, then the same 38 against current main a1c33de18 (merged + #8144) — every property still holds on the tree that actually shipped.

This ran on a maintainer machine (macOS, Darwin 25.6.0, Node v24.18.1), not in the sandboxed /verify lane. Every arm is a real qwen serve daemon started from a separately installed and built worktree, driving the real HTTP routes against real transcripts and real lock files on disk. Nothing about the daemon or the writer-lease protocol is mocked. Raw logs and rerunnable harnesses: pr-assets/7975-verify.

The PR merged as 3bdaeac04 at 14:02Z while this round was running, so this is post-merge evidence rather than a merge gate. One merge-time issue did surface and is already fixed — see Finding 3.

中文版报告

结论:已验证 —— 实际执行的 76 条脚本化断言全部通过,0 条失败,无阻断性问题。 即 38 条不同的检查各跑两遍:一遍在 PR head 9ea71220d(对照 base 97aaa3808),一遍在当前 main a1c33de18(合并后并含 #8144)—— 真正发布出去的这棵树同样满足全部性质。

本轮在维护者本机(macOS,Darwin 25.6.0,Node v24.18.1)执行,不是沙箱 /verify 通道。每一侧都是从独立安装并构建的 worktree 启动的真实 qwen serve daemon,通过真实 HTTP 路由操作磁盘上真实的 transcript 与真实的 lock 文件,daemon 与 writer-lease 协议本身没有任何 mock。原始日志与可复跑的 harness 见 pr-assets/7975-verify

本 PR 已于 14:02Z 以 3bdaeac04 合入,因此本报告是合并后的验证证据,而非合并前的门禁。合并过程中确实暴露了一个问题,且已被修复 —— 见「问题 3」。

三个 A/B 场景的结论

  1. Writer lease 生效(详见 A/B 表 1)。另一个活着的进程持有 writer lease 时,base 的 daemon 会照常删除、归档、取消归档,并报告成功;head 则对该 session 返回 writer conflict,transcript 的 inode 完全不变,同一批次中未被占用的 session 仍然正常完成,释放 lease 后重试成功。三种操作 3/3 全部翻转。
  2. Workspace runtime 隔离生效(详见 A/B 表 2)。为 ws2 配置独立的 advanced.runtimeOutputDir,并在 primary runtime 下放置同名诱饵 transcript:base 的列表读到的是诱饵,删除删掉的也是诱饵,同时真实 transcript 存活;head 的列表、删除、writer lock 全部落在选中的 runtime 内。
  3. Shutdown drain 生效(详见 A/B 表 3)。在已进入的维护批次执行中途发送 SIGTERM:head 让该批次跑完(100 个 transcript 确实落到 archive 目录)后才退出,之后到达的维护请求返回 503 daemon_draining 且不修改任何文件;base 则会接受这个请求并真的删掉了 transcript。

发现的问题

  • 问题 1(建议级,非阻断)runWithWorkspaceRuntimeStorage 没有单元测试覆盖。把它改成直接 return fn() 后,multi-workspace-sessionsworkspace-qualified-rest 两个套件 118/118 仍然全绿,但真实环境下 GET /workspaces/<ws>/sessions 会退回读取 primary runtime 根目录。删除路径不受影响(session service 显式携带 runtime 根),所以这是覆盖缺口而非死代码。建议补一条测试。
  • 问题 2(信息级):每个 session 增加一次 lease 带来的开销。100 个 session 的归档批次从 149ms 变为 537ms(+3.9ms/session)。单独测量一次 acquire+release 为 9.4ms,其中 2.8ms(30%)是 darwin 上为写入 process_start_identity 而 fork 的 /bin/ps;daemon 的批内并发吸收了其余部分。由于批量接口上限为 100 个 id,单请求最坏约 +0.4s。属于该保护应付的代价,此处只作记录。
  • 问题 3(合并期,已修复):PR head 自身 npm run buildnpm run lintnpm run typecheck 全部通过,但合并提交 3bdaeac04 无法编译,本轮已复现为两处 TS2304: Cannot find name 'SessionService'。原因是 main 上后加的 side-task 路由仍在直接构造 session service,与本 PR 改用 workspace runtime helper 的改动形成语义冲突。已由 fix(serve): restore build by routing side-task rollback through the workspace session service #8144 修复,当前 main 构建通过且 38 条行为断言全部重跑通过。这不是本 PR 代码的缺陷,而是「PR 单独验证通过 ≠ 合并结果验证通过」的实例——base 落后较多的 serve 类 PR 建议合并前先对 main 重新构建一次。

未覆盖范围

ACP / JSON-RPC 侧的 data.errorKind = daemon_draining 只有单元测试,未实机驱动;scheduled-task 回滚、keepalive late-spawn 清理、ACP orphan 清理同样只有单元测试;「shared export 不阻塞 maintenance drain」未实机验证;仅在 macOS 上运行,Windows/Linux 未测;SIGKILL 恢复、锁抢占、TTL 等本就在 PR 范围之外。每个 harness 使用一次性 HOME 且没有模型凭据,ACP 子进程 preheat 在 6 个 arm 上全部以相同方式超时(A/A 对照,说明是环境而非回归),因此「先关闭活跃 owner 再维护」这条路径没有被真实的活跃 session 驱动过;transcript 是符合真实磁盘格式的合成 fixture。


Central claim: daemon maintenance now fails closed when another writer owns the session

Each arm: one daemon, one workspace, six persisted transcripts. A separate Node process acquires a real SessionWriterLease (processKind: 'acp') on three of them via the same core build the daemon uses, and holds it. Each request mixes one held session with one free session, so the batch behaviour is observed at the same time.

writer lease A/B

A/B table 1 — maintenance against a live foreign lease

Request Oracle base 97aaa3808 head 9ea71220d
POST /sessions/delete held transcript on disk deleted (active → gone), errors: [] unchanged, same inode
POST /sessions/archive held transcript on disk moved to archive/ unchanged, same inode
POST /sessions/unarchive held transcript on disk moved to active unchanged, same inode
all three per-session error none — success reported session_writer_conflict: "This session is already open in another Qwen process."
all three uncontended session in the same batch completed completed
all three retry after lease.release() completed, transcript actually moved/removed

3/3 operations flip from "silently mutates another writer's transcript" to "refuses per session, mutates nothing". Request status stays 200 with the conflict in errors[], so the batch contract is unchanged. 22/22 assertions.


Secondary claim: workspace-qualified maintenance stays inside the selected runtime

ws2 carries advanced.runtimeOutputDir = RT2. For every probe id, one transcript is planted in RT2 (the selected runtime) and a decoy with the same id in RT1 (the primary runtime) — so a daemon resolving through the ambient primary runtime hits the decoy and can be caught doing it.

runtime isolation A/B

A/B table 2 — two workspaces, two runtime roots

Probe base head
GET /workspaces/<ws2>/sessions reads RT1 — every entry is the decoy reads RT2 — every entry is the real transcript
POST /workspaces/<ws2>/sessions/delete reports success but deletes the RT1 decoy; the real RT2 transcript survives deletes the RT2 transcript; RT1 decoy untouched
lease held in RT2 (selected root) no protection — deletes anyway 409 writer conflict, nothing removed
lease held in RT1 (primary root) correctly does not block ws2 work

The base row is the sharper half of this: base does not merely read the wrong root, it deletes a file the caller never asked about and reports success. 9/9 assertions.


Secondary claim: shutdown seals new maintenance and waits for admitted maintenance

The natural duration of a 100-session archive batch was measured first (head 537 ms, base 149 ms) so SIGTERM could be placed inside the batch rather than hoped into it — sent at +188 ms on head.

shutdown drain A/B

A/B table 3 — SIGTERM during an admitted batch

Oracle base head
admitted batch after SIGTERM completes completes — archived: 100, and 100 files verified in archive/, 0 left active
maintenance arriving after SIGTERM 200accepted, transcript deleted 503 daemon_draining (code and errorKind both), transcript untouched
process exit ordering exits only after the admitted batch responded

7/7 assertions.


Vacuity check on the new tests

Five single-point mutations of the PR's own guards, each run against the suite that should catch it, plus an unmutated control.

mutation matrix

# Mutation Suite Result
M1 daemon maintenance acquires no writer lease session-archive.test.ts killed — 7 failed
M2 maintenance not run inside the selected-runtime storage context multi-workspace-sessions + workspace-qualified-rest survived — 118/118 green → Finding 1
M3 pinned runtime root downgraded to reload-overridable core/storage.test.ts killed — 2 failed
M4 shutdown seals but does not wait for admitted maintenance session-archive.test.ts killed — 1 failed
M5 draining daemon still admits new maintenance session-archive.test.ts + server.test.ts killed — 4 failed
control, unmutated session-archive.test.ts green, 43/43

Each killed mutant fails the intended behavioural assertion by name (e.g. "does not archive while another writer holds the lease", "seals new maintenance and waits only for admitted exclusive work"), not a compile or import error.

Targeted gates at head: cli 1694 passed across the 14 changed test files; core 92 passed / 1 skipped; npm run typecheck and npm run lint both exit 0. The typecheck gate is demonstrably live — an unrelated planted edit during mutation work was caught as TS6133 before I corrected it.

Re-run on the merged tree. Because the PR landed mid-round, all three harnesses were re-run against current main a1c33de18 (a fresh npm install + npm run build of that commit): 22/22, 9/9, 7/7 — 38/38 again, with the base arms as controls. The batch-archive latency reproduced too (549 ms vs base 149 ms, ≈4.0 ms/session, against 537 ms at the PR head). So the merged-and-fixed tree carries every property proved on the PR head.


Findings

1 — runWithWorkspaceRuntimeStorage has no test holding it down (Suggestion, not blocking). Replacing its body with return fn() leaves both workspace suites at 118/118 green, but on a real daemon GET /workspaces/<ws>/sessions immediately regresses to reading the primary runtime root — my harness kills the mutant on exactly that assertion (see the bottom line of the runtime-isolation screenshot). The delete path is unaffected because createWorkspaceRuntimeSessionService carries the runtime root explicitly; it is the ambient-resolution consumers reached through runWorkspaceInspectionWithLogPolicy that depend on the pinned context. So this is a genuine coverage gap, not dead code — a future refactor could drop the wrapper and ship a silent regression with CI green. Reproduce:

# In a built tree, replace the body of runWithWorkspaceRuntimeStorage with
#   void Storage;            // keep the import used under noUnusedLocals
#   return fn();
npm run build -w packages/cli
(cd packages/cli && npx vitest run src/serve/multi-workspace-sessions.test.ts \
                                   src/serve/workspace-qualified-rest.test.ts)   # 118/118 green
node harness/ab-runtime-isolation.mjs head <tree> /tmp/iso-m2.json                # listing shows the decoy

2 — Per-session lease cost, measured and accounted (informational). A 100-session archive batch goes 149 ms → 537 ms (+388 ms, ≈3.9 ms/session). Measured in isolation, one acquire + release costs 9.4 ms, of which 2.8 ms (30%) is the /bin/ps fork darwin needs to stamp process_start_identity into the lock record; the daemon's in-batch concurrency absorbs the rest, which is why the end-to-end figure is 3.9 ms rather than 9.4 ms. The batch routes cap sessionIds at 100, so the worst case is ≈+0.4 s per request. That is the price of the guarantee, recorded rather than raised as a problem.

3 — The PR head built clean; the merged result did not (already fixed). npm run build, lint and typecheck all passed at 9ea71220d. The merge commit 3bdaeac04 did not compile — reproduced here by restoring that commit's session.ts into a built main tree:

src/serve/routes/session.ts(2327,25): error TS2304: Cannot find name 'SessionService'.
src/serve/routes/session.ts(2344,28): error TS2304: Cannot find name 'SessionService'.

A side-task route added to main after this PR's base still constructed a session service directly, which this PR's move to the workspace-runtime helper left unbound — a semantic conflict no per-side check can see. Fixed 52 minutes later by #8144, and current main builds clean with all 38 behavioural assertions still passing. Not a defect in this PR's code, but a concrete case for rebuilding stale-base serve PRs against main before merge rather than trusting the PR-level gate.


Not covered

  • ACP / JSON-RPC surface. Only the REST routes were driven live. The data.errorKind = daemon_draining JSON-RPC path and the ACP orphan-cleanup path rest on unit tests alone here.
  • Scheduled-task rollback, keepalive late-spawn cleanup. Unit tests only; no live harness.
  • "A shared export does not block maintenance drain." Asserted in the PR description, not exercised live in this round.
  • Live session owners. Each harness runs with a throwaway HOME and no model credentials, so the ACP child preheat timed out on all six arms identically (A/A control — environmental, not a regression). Paths that close a real live owner before maintenance were therefore driven with no live owner present. Transcripts were synthetic fixtures written in the real on-disk format.
  • Platforms. macOS only. Windows and Linux untested, matching the PR's own table.
  • Out of scope by the PR's own statement, and untested here: SIGKILL recovery, hostname/time-based lock stealing, TTL or heartbeat recovery, force unlock, mixed-version writers.

Methodology

Two worktrees at 9ea71220d and 97aaa3808, each with its own npm install and npm run build; readlink -f node_modules/@qwen-code/qwen-code-core was checked in both to confirm the internal workspace symlinks point into their own tree, so the base arm cannot silently load head code. Each harness creates a temp HOME and workspace (realpath-resolved, because the daemon canonicalises the workspace root and a /var vs /private/var mismatch silently reads back as notFound), plants transcripts through Storage's own project-directory resolution, starts node packages/cli/dist/index.js serve --port … --workspace … --no-web, waits on /health, then drives the routes with fetch and re-stats every transcript — the oracles are HTTP status, the response body, and file existence plus inode, never a log line. Foreign writers are separate Node processes holding real leases through the same core build. Every number in this report maps to a check in harness/assert-*.mjs that can fail; harnesses, raw per-arm JSON, and the mutation log are on pr-assets/7975-verify.

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.2.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

autofix/takeover Summon the autofix loop to manage this PR (remove to release; needs triage+)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants