Skip to content

fix(core): Fence concurrent ACP session writers - #7237

Merged
doudouOUC merged 7 commits into
QwenLM:mainfrom
doudouOUC:fix/session-writer-p0a
Jul 21, 2026
Merged

fix(core): Fence concurrent ACP session writers#7237
doudouOUC merged 7 commits into
QwenLM:mainfrom
doudouOUC:fix/session-writer-p0a

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR extracts the independently landable P0a from #7166 and protects the ACP/daemon path that produced the incident. Each (runtime base, session ID) gets one cross-process writer through an atomic hard-link lease; the owner reloads the authoritative transcript after acquisition, and every append verifies the owner token, transcript file identity, metadata, and UTF-8 byte length before advancing the in-memory tail.

The daemon reuses an already-live owner, gates user, cron, notification, and teammate turns on verified ownership, serves live transcript replay through the owner's write barrier and pinned session storage, and removes a live entry only after an acknowledged close has drained and released the lease. Runtime and persistence roots remain pinned across logical working-directory changes. Desktop metadata is appended through the owning ACP session instead of rewriting JSONL, and a writer conflict is surfaced to the user instead of silently replacing the requested persisted session with a fresh session. ACP and HTTP expose stable, sanitized conflict and availability errors.

Why it's needed

In the incident, process A had already recorded a tool result when a daemon fresh-loaded the same persisted session from that tail. Process A then recorded the remaining tool work and final answer, while the daemon later appended another user message using its stale parent. Both writes looked valid in isolation, but they created unmarked sibling branches. Restart followed the physical tail, so the complete answer that the user had already seen was absent from the active restored chain.

P0a prevents that ordering without taking on the broader interactive/headless transition and maintenance protocol from #7166. A second cooperating ACP writer is rejected before model work, and a replacement can proceed only after it owns the lease and reloads the final physical tail.

Reviewer Test Plan

How to verify

  1. Hold process A after a persisted tool-result tail and start process B with the same workspace, runtime base, and session ID. Process B should receive session_writer_conflict before model invocation and should not change the transcript.
  2. Let process A append its final answer and close, then retry process B. Process B should acquire the lease, reload the final answer, append the next user record with that final answer as its parent, and preserve one active chain after another restart.
  3. Repeat load or resume inside one daemon. The existing owner should be reused, live history refresh should pass through the owner barrier, and a request with another workspace or runtime root should fail closed.
  4. Terminate an owner without releasing its lock, replace a live lock, and modify a live transcript externally. A dead local owner should be reclaimable; lost ownership and unexpected transcript changes should block later top-level turns.
  5. Exercise successful close, definitive close refusal, close timeout, and transport loss. A definitive refusal should remain retryable; an unknown close outcome should terminate the shared ACP channel so no uncertain writer remains live.
  6. Verify the stable JSON-RPC kinds session_writer_conflict, session_writer_lost, session_transcript_changed, and session_writer_unavailable map to sanitized HTTP 409/503 responses.

Evidence (Before & After)

N/A (non-UI persistence and lifecycle change).

Tested on

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

Environment (optional)

macOS 26.4.1; Node.js v26.0.0; npm 11.12.1; Bun 1.3.13; local filesystem. npm run build, root npm run lint, root npm run typecheck, and Desktop typecheck passed. Focused results include 843 Core tests passed with 1 skipped, 413 ACP bridge tests passed, 290 ACP agent tests passed, 371 ACP Session tests passed, 275 HTTP transport tests passed, 39 Desktop integration tests passed, 18 cross-process lease tests passed with 1 platform-conditional skip, and 5 error-response tests passed. Desktop lint:shared is still blocked by existing repository ESLint configuration errors for unavailable import/no-internal-modules rules and an unrelated source-auth test violation.

Risk & Scope

  • Main risk or tradeoff: This is a cross-package Core/daemon consistency change across 34 files and more than 1,000 production-logic lines, so it needs maintainer awareness under the repository's core-infrastructure gate. It intentionally fails closed when ownership cannot be proven. An unknown acknowledged-close outcome terminates the shared ACP channel, which also reaps sibling sessions on that channel, in exchange for ensuring that no uncertain writer survives.
  • Not validated / out of scope: Windows and Linux runtime execution, network or shared filesystems, and authenticated manual end-to-end model traffic were not validated locally. Interactive/headless writers, fully transactional session switching, branch/fork/rewind and archive/delete maintenance, same-daemon initializing-entry/load-close coalescing, and repair of already-branched transcripts remain P0b or follow-up scope.
  • Breaking changes / migration notes: Mixed old/new binaries are unsafe because an old writer ignores the lease. Deployment and rollback must drain old daemon/ACP processes before starting the new version. The runtime filesystem must support atomic same-directory hard links; otherwise writer acquisition fails closed with session_writer_unavailable. No transcript schema migration is required.

Linked Issues

Related to #7164. Extracted from #7166.

中文说明

本 PR 做了什么

本 PR 从 #7166 中拆出可独立合入的 P0a,只保护本次事故涉及的 ACP/daemon 路径。每个 (runtime base, session ID) 通过原子 hard-link 租约只允许一个跨进程 writer;owner 在取得租约后权威重载 transcript,并在每次追加前校验 owner token、transcript 文件身份、元数据和 UTF-8 字节长度,然后才推进内存中的尾节点。

daemon 会复用已经 live 的 owner,在 user、cron、notification 和 teammate 轮次开始前验证所有权,通过 owner 的写屏障和固定的 session storage 提供 live transcript 回放,并且只在已确认的 close 完成 drain 和 lease release 后移除 live entry。逻辑工作目录变化不会改变固定的 runtime 与 persistence root。Desktop 元数据改为通过持有 owner 的 ACP session 追加,不再重写 JSONL;writer 冲突会直接返回给用户,而不是静默把请求恢复的持久化会话替换为 fresh session。ACP 与 HTTP 对外提供稳定且脱敏的冲突/不可用错误。

为什么需要

事故时序中,进程 A 已经记录了一个 tool result,此时 daemon 从该尾节点 fresh-load 了同一个持久化会话。之后进程 A 继续记录剩余工具工作和 final answer,而 daemon 又使用过期 parent 追加了下一条 user message。两次写入各自看起来都合法,但形成了未标记的 sibling branches。重启按物理文件尾恢复,因此用户已经看到的完整回答不在恢复后的 active chain 中。

P0a 在不引入 #7166 中更广泛的 interactive/headless 切换和维护协议的前提下阻止该时序。第二个遵守协议的 ACP writer 会在模型工作开始前被拒绝;替代进程只有在取得租约并重新加载最终物理尾后才能继续。

Reviewer 测试计划

如何验证

  1. 让进程 A 在已持久化 tool-result 尾之后保持 writer,再以相同 workspace、runtime base 和 session ID 启动进程 B。进程 B 应在模型调用前收到 session_writer_conflict,且不得修改 transcript。
  2. 让进程 A 追加 final answer 并关闭,再重试进程 B。进程 B 应取得租约、重载 final answer、以该 final answer 为 parent 追加下一条 user record,并在再次重启后保持一条 active chain。
  3. 在同一个 daemon 中重复 load 或 resume。系统应复用现有 owner,live history refresh 应经过 owner barrier,而来自另一个 workspace 或 runtime root 的请求应失败关闭。
  4. 终止 owner 且不释放锁、替换 live lock,并从外部修改 live transcript。本机已死亡 owner 应可回收;所有权丢失和非预期 transcript 变化应阻止后续顶层轮次。
  5. 覆盖成功 close、明确 close 拒绝、close timeout 和 transport 丢失。明确拒绝应保持可重试;未知 close 结果应终止共享 ACP channel,确保没有结果不确定的 writer 继续存活。
  6. 验证稳定 JSON-RPC kind session_writer_conflictsession_writer_lostsession_transcript_changedsession_writer_unavailable 分别映射为脱敏的 HTTP 409/503 响应。

证据(Before & After)

N/A(非 UI 的持久化与生命周期变更)。

测试平台

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

环境(可选)

macOS 26.4.1;Node.js v26.0.0;npm 11.12.1;Bun 1.3.13;本地文件系统。npm run build、根目录 npm run lint、根目录 npm run typecheck 和 Desktop typecheck 均通过。聚焦测试结果包括:Core 843 项通过、1 项跳过,ACP bridge 413 项通过,ACP agent 290 项通过,ACP Session 371 项通过,HTTP transport 275 项通过,Desktop 集成测试 39 项通过,跨进程 lease 测试 18 项通过、1 项平台条件跳过,以及错误响应测试 5 项通过。Desktop lint:shared 仍受仓库现有 ESLint 配置问题阻塞,包括缺少 import/no-internal-modules 规则,以及一个与本改动无关的 source-auth 测试违规。

风险与范围

  • 主要风险或权衡:这是一个跨 34 个文件、超过 1,000 行生产逻辑的 Core/daemon 一致性改动,根据仓库的 core-infrastructure gate 需要 maintainer awareness。任何无法证明所有权的状态都会有意失败关闭。已确认 close 的结果若仍未知,系统会终止共享 ACP channel,这也会回收同一 channel 上的 sibling sessions,以确保没有结果不确定的 writer 存活。
  • 未验证或不在范围内:本地未验证 Windows 与 Linux 运行时、网络或共享文件系统,以及需要认证的手工端到端模型流量。Interactive/headless writer、完全事务化的 session switching、branch/fork/rewind 与 archive/delete 维护、同 daemon 的 initializing-entry/load-close coalescing,以及已分支 transcript 的修复仍属于 P0b 或后续范围。
  • Breaking change / 迁移说明:新旧二进制混用不安全,因为旧 writer 不会遵守租约。发布和回滚前必须先 drain 旧 daemon/ACP 进程,再启动新版本。runtime 文件系统必须支持同目录原子 hard link;否则 writer acquisition 会以 session_writer_unavailable 失败关闭。不需要进行 transcript schema migration。

关联 Issue

关联 #7164;从 #7166 中拆出。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

P0a verification report

The cross-process regression recreates the incident boundary without production identifiers: process A owns a transcript whose physical tail is a tool result, process B is rejected before it can load as a writer, process A appends the final answer and releases, and process B then acquires, authoritatively reloads that final answer, and appends the next user record with the final-answer UUID as its parent. Reloading the transcript preserves one active chain.

Local verification on macOS 26.4.1 (Node.js v26.0.0, npm 11.12.1, Bun 1.3.13):

  • Cross-process lease regression: 18 passed, 1 platform-conditional test skipped.
  • Focused Core suites: 843 passed, 1 skipped.
  • ACP bridge: 413 passed.
  • ACP agent: 290 passed.
  • ACP Session: 371 passed.
  • HTTP transport: 275 passed.
  • Desktop integration: 39 passed.
  • Error-response mapping: 5 passed.
  • npm run build, root npm run lint, root npm run typecheck, Desktop typecheck, and git diff --check passed.

Authenticated manual model traffic and Windows/Linux runtime behavior were not exercised locally. Desktop lint:shared remains blocked by existing repository ESLint configuration errors for unavailable import/no-internal-modules rules and an unrelated source-auth test violation.

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

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

Comment thread packages/core/src/services/session-writer-lease.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/core/src/services/session-writer-lease.ts
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Self-review notes (full pass: design + code)

Posted five inline notes; consolidated summary here so reviewers can triage quickly.

Verified during review (beyond the PR description's claims)

  • Reclaim protocol: real-process tests cover a SIGKILL'd owner, concurrent reclaimer election, and crashed-reclaimer recovery. The fully-written-then-hard-link install also avoids the empty/partial-lock read window of the existing cronTasksLock wx-create pattern.
  • Close-outcome classification: BridgeTimeoutError and BridgeChannelClosedError carry no integer code, so isDefinitiveAcpRequestError cannot misclassify a bridge timeout or transport loss as a definitive child refusal — the channel-kill path fires exactly when intended.
  • RPC codes -32020..-32023 collide with nothing else in the tree.
  • Live-session rename / title / rewind all route through the leased recorder (rewind is append-only re-rooting, no file rewrite); no tmp-cleanup job touches session-writer-locks/; after a logical /cd, getSessionService() stays pinned to storage.getProjectRoot().
  • Throwaway Configs (workspace-mcp-discovery, mcp reconnect, telemetry, ACP bootstrap) all pass chatRecording: false, so none of them contends for a lease (the discovery Config uses a fixed session ID and would otherwise conflict across daemons on every startup).

Known tradeoffs / follow-ups (details in the inline notes)

  1. Torn transcript tail (no trailing newline) permanently fails ACP acquisition with session_transcript_changed; interactive mode still opens the same file. Follow-up: truncate-to-last-complete-line under the held lease, or at least a distinct error + internal log.
  2. A refused or timed-out close has already cancelled pending permissions and the active prompt — the session stays live but not undisturbed. Needs a doc note on CloseSessionOpts.
  3. Session.beginClose() doubles as the live-replay exclusion window, so a prompt racing a live session/load fails with a misleading "Session is closing"; two concurrent live loads surface "Session close is already in progress". A distinct state/message would help.
  4. Converting chatRecordingService.test.ts to leased mode dropped three legacy-path regression guards (mkdir retry, sync-failure dedup rollback) that still apply to the unleased interactive/headless recorder.
  5. Cosmetic: crashed reclaimers leak .reclaim.* guard files (never GC'd); a failed Config.initialize cleanup shadows the original init error with SessionWriterUnavailableError.

CI status

The ubuntu Test job failed at the NOTICES.txt freshness gate — runner-side dependency drift, not this PR (no dependency changes here; #7233 passed the same job 24 minutes earlier). That gate sits before the unit-test step, so the unit tests and the macOS/Windows/integration matrix did not run in CI. Ran the relevant suites locally on macOS: session-writer-lease 18 passed / 1 platform-conditional skip; chatRecordingService ×4 + storage 158 passed. Serve A/B and web-shell E2E did run in CI and passed. Will rerun the full matrix once main regenerates NOTICES.

@doudouOUC
doudouOUC requested a review from wenshao July 19, 2026 16:04
@doudouOUC
doudouOUC marked this pull request as ready for review July 19, 2026 16:07
@doudouOUC
doudouOUC enabled auto-merge July 19, 2026 16:07
@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓

Problem: Observed production incident with clear evidence — linked issue #7164 describes the exact timing where process A recorded a tool result, the daemon fresh-loaded the same session, and both writers produced unmarked sibling branches. The design doc and PR body reconstruct the incident sequence convincingly. This is not theoretical hardening.

Direction: Directly aligned — session data integrity is core to the product. The P0a scoping (extracted from #7166) is well-bounded: it protects the ACP/daemon append path that produced the incident without taking on the broader interactive/headless transition. CHANGELOG has no direct reference to writer fencing, but the area (session persistence integrity) is unambiguously relevant.

Size: This PR touches core paths across 4 packages (core, cli, acp-bridge, desktop). Breakdown:

  • Production logic: 3,179 lines (additions + deletions, excluding test/generated/schema)
  • Test code: 3,914 lines
  • Docs: 80 lines (design doc)

⚠️ 3,179 production lines exceeds both the 500-line maintainer-awareness threshold and the 1,000-line large-PR advisory. This is flagged for maintainer attention. The PR author acknowledges this in the Risk & Scope section.

Approach: The lease protocol is well-designed — atomic hard-link acquisition, dead-process recovery via process-start identity, transcript fingerprinting, and fail-closed semantics throughout. The error taxonomy (4 stable RPC kinds mapped to sanitized HTTP 409/503) is clean and the design doc's invariants are clear.

One observation on scope: the Desktop text-element persistence change (from direct JSONL rewriting to an ACP ext-method) adds ~475 production lines. I initially considered this scope creep, but it's actually a necessary consequence of the lease — once the transcript is fenced, direct rewriting by Desktop would trip session_transcript_changed. So the coupling is justified, though it does widen the review surface.

The createAndStoreSession behavioral change (throwing on duplicate session ID instead of disposing the existing one) is a meaningful semantic shift. It's correct for lease protection, but worth calling out for the maintainer's awareness.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓

问题: 已观测到的生产事故,有明确证据——关联 issue #7164 描述了进程 A 记录 tool result 后 daemon 重新加载同一会话、两个 writer 产生未标记 sibling branches 的完整时序。设计文档和 PR 正文对事故时序的重构令人信服。这不是理论性加固。

方向: 直接对齐——会话数据完整性是产品核心。P0a 的范围界定(从 #7166 中拆出)很合理:只保护产生事故的 ACP/daemon 追加路径,不引入更广泛的 interactive/headless 切换。CHANGELOG 没有直接提及 writer fencing,但会话持久化完整性领域明确相关。

规模: 本 PR 触及 4 个包(core、cli、acp-bridge、desktop)的核心路径。分解如下:

  • 生产逻辑: 3,179 行(增删合计,不含测试/生成/schema)
  • 测试代码: 3,914 行
  • 文档: 80 行(设计文档)

⚠️ 3,179 行生产代码超过 500 行维护者关注阈值和 1,000 行大 PR 建议阈值。已标记维护者关注。PR 作者在风险与范围中已说明。

方案: 租约协议设计良好——原子 hard-link 获取、通过进程启动身份实现的死进程回收、transcript 指纹校验、以及全程 fail-closed 语义。错误分类(4 种稳定 RPC kind 映射到脱敏 HTTP 409/503)清晰,设计文档的不变量明确。

关于范围的一点观察:Desktop 文本元素持久化变更(从直接重写 JSONL 改为 ACP ext-method)增加了约 475 行生产代码。最初我认为是范围溢出,但实际上这是租约的必要后果——一旦 transcript 被围栏保护,Desktop 直接重写会触发 session_transcript_changed。因此耦合是合理的,但确实扩大了审查面。

createAndStoreSession 的行为变更(对重复 session ID 抛异常而非 dispose 现有会话)是一个有意义的语义转变。对租约保护来说是正确的,但值得维护者关注。

进入代码审查 🔍

Qwen Code · qwen3.7-max

Reviewed at 4892001da6ce071dabaa3906e0dc531a5228cbb6 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal: For this problem, I would have designed a simpler file-based lock using O_EXCL creation with PID-based staleness detection and a basic transcript byte-length check before append. The PR's approach is materially more robust: atomic hard-link based locking (survives crashes that leave partial writes), process-start identity for PID reuse detection (Linux boot_id + start ticks, Darwin normalized ps output), full transcript fingerprinting (dev/ino/birthtime/ctime/mtime/byteLength), and a write barrier for read operations. The additional complexity is justified by the production incident and the cross-process coordination requirement.

Findings: No critical blockers. The implementation is thorough and follows project conventions (ESM, TypeScript strict, kebab-case .ts files, collocated tests). A few observations:

  1. Lease module (session-writer-lease.ts, 859 lines): Well-structured with clear separation of concerns. The appendJsonLine method correctly re-verifies ownership and transcript state after opening the file handle, closing the TOCTOU window. The reclaim guard mechanism handles crashed reclaimers with bounded generations.

  2. Recorder lifecycle (chatRecordingService.ts): The state machine (inactive → active → closing → closed | integrity_failed) is clean. The operationTail serialization ensures appends and read barriers don't interleave. The writerLeaseRequired flag correctly preserves legacy behavior for interactive/headless CLI.

  3. Config integration (config.ts): Lease acquisition happens early in initialize(), before extension/hook/tool/model initialization. The cleanup on failure is thorough — releasing the lease, unregistering the project dir, and closing the recorder. The sessionRuntimeBaseDir pinning prevents async context drift.

  4. Session close gate (Session.ts): The beginClose() / beginCloseIfAvailable() pattern with explicit gate release is a solid coordination mechanism. The #drainCronQueue and #drainNotificationQueue guards now check closing and call assertCanStartTurn() before starting automatic turns.

  5. Bridge close semantics (bridge.ts): The distinction between definitive refusal (RequestError → session stays live, retryable) and unknown outcome (transport failure → kill channel, leases become stale) is well-reasoned. The closing flag prevents concurrent operations on a closing session.

  6. Desktop text-element routing (qwen-agent.ts): Moving from direct JSONL rewriting to an ACP ext-method is a necessary consequence of the lease — direct rewriting would trip session_transcript_changed. The qwenPersistenceCwd tracking correctly handles logical /cd by pinning persistence operations to the original working directory.

  7. createAndStoreSession behavioral change: Now throws on duplicate session ID instead of disposing the existing session. This is correct for lease protection but is a meaningful semantic shift that maintainers should be aware of.

sequenceDiagram
    participant P1 as Process A (ACP)
    participant P2 as Process B (ACP)
    participant L as Lock File
    participant T as Transcript JSONL
    P1->>L: acquire (atomic hard-link)
    L-->>P1: owner token + transcript fingerprint
    P1->>T: reload authoritative tail
    P1->>P1: activate recorder
    P2->>L: acquire (same session)
    L-->>P2: session_writer_conflict (409)
    P1->>T: appendJsonLine (verify owner + fingerprint + byteLength)
    T-->>P1: advance expected state
    P1->>L: release (unlink)
    P2->>L: acquire (after release)
    L-->>P2: owner token + reloaded fingerprint
    P2->>T: reload authoritative tail (includes P1 final answer)
Loading
Files changed (30 of 36 shown)
File What changed
docs/design/session-writer-lease-p0a.md New design doc: problem, scope, invariants, lease protocol, error contract
packages/core/src/services/session-writer-lease.ts New 859-line lease module: atomic lock, dead-process recovery, transcript fencing
packages/core/src/services/session-writer-lease.test.ts 22 tests covering contention, reclaim, malformed locks, transcript changes
packages/core/src/services/session-writer-lease.test-helper.ts Shared test utilities for lease tests
packages/core/src/services/chatRecordingService.ts Lease-based recorder: activation, write barrier, close lifecycle, state machine
packages/core/src/services/chatRecordingService.test.ts Updated for activation and lease-aware append behavior
packages/core/src/config/config.ts Lease acquisition during init, runtime base pinning, cleanup on failure
packages/core/src/config/config.test.ts Tests for lease activation, init cleanup, and runtime pinning
packages/core/src/config/storage.ts Instance-pinned runtimeBaseDir instead of async-context-only resolution
packages/core/src/config/storage.test.ts Test for instance-pinned runtime dir
packages/core/src/core/client.ts Minor: export addition
packages/core/src/index.ts Export session-writer-lease module
packages/core/src/services/sessionService.ts Accept runtimeBaseDir option for pinned storage
packages/cli/src/acp-integration/acpAgent.ts Session lifecycle: lease-aware close, drain, cleanup, live session reuse
packages/cli/src/acp-integration/acpAgent.test.ts 297 tests covering session lifecycle, error mapping, cleanup
packages/cli/src/acp-integration/session/Session.ts Close gate, turn admission, queue drain coordination
packages/cli/src/acp-integration/session/Session.test.ts 382 tests covering close gate, drain, and admission
packages/acp-bridge/src/bridge.ts Closing flag, definitive vs unknown close outcome, kill coordination
packages/acp-bridge/src/bridge.test.ts 413 tests covering close semantics and session lifecycle
packages/acp-bridge/src/bridgeTypes.ts Type additions for close options
packages/cli/src/serve/acp-http/dispatch.ts Session writer RPC error sanitization, close retry logic
packages/cli/src/serve/acp-http/transport.test.ts 275 tests covering error sanitization and close retry
packages/cli/src/serve/server/error-response.ts HTTP 409/503 mapping for session writer errors
packages/cli/src/serve/server/error-response.test.ts 5 tests for error response mapping
packages/cli/src/config/config.ts Minor: config adjustment
packages/cli/src/gemini.tsx Minor: import adjustment
packages/cli/src/commands/mcp/reconnect.ts Minor: import adjustment
packages/desktop/packages/shared/src/agent/qwen-agent.ts Text-element routing via ACP, writer conflict surfacing, persistence cwd pinning
packages/desktop/packages/shared/src/agent/tests/qwen-agent-slash-history.test.ts 39 tests for text-element routing and conflict handling
…and 6 more files

Real-Scenario Testing

This is a non-UI persistence and lifecycle change (PR states "Evidence: N/A"). The session writer lease operates in the ACP/daemon path, not the interactive CLI. Testing approach: build verification, focused unit tests, and daemon smoke test.

Build: npm run build succeeded on the PR branch.

Focused unit tests (all passing):

session-writer-lease.test.ts:  21 passed | 1 skipped (platform-conditional)
chatRecordingService.test.ts:  48 passed
config.test.ts:               408 passed
acpAgent.test.ts:             297 passed
Session.test.ts:              382 passed
bridge.test.ts:               413 passed
transport.test.ts:            275 passed
error-response.test.ts:         5 passed

Daemon smoke test (tmux capture):

$ npm run dev -- serve --port 18923

qwen serve: daemon log → /home/github-runner/actions-runner-4/_work/_temp/qwen-home/debug/daemon/daemon.log
qwen serve: Web Shell UI served from .../packages/web-shell/dist
qwen serve listening on http://127.0.0.1:18923 (mode=http-bridge, workspace=...)
qwen serve: bound to workspace "..."
qwen serve: startup timing: processToListenMs=1366 runQwenServeToListenMs=910
qwen serve: bearer auth disabled (loopback default). Set QWEN_SERVER_TOKEN to enable.
qwen serve: session reaper started (interval 60000ms, idle threshold 1800000ms)
qwen serve: /acp WebSocket transport enabled on /acp
qwen serve: /acp connection established 08be174c (loopback=true, active=1)

Session writer lock verification:

After creating a session via ACP, the lock file was created at <runtime>/tmp/session-writer-locks/<session-id>.lock with the expected record format:

{
    "schema_version": 1,
    "session_id": "5580ee60-2e63-4ef7-b0d3-623833139b31",
    "owner_id": "fe3cb963-791c-4ec3-9670-45832f10a666",
    "pid": 184535,
    "process_start_identity": "linux:a593921c-...:27769821",
    "hostname": "...",
    "process_kind": "acp",
    "acquired_at": "2026-07-20T16:24:37.207Z",
    "qwen_version": "0.20.0"
}

Headless CLI verification:

$ npm run dev -- -p "Say exactly: LEASE_TEST_OK" --output-format text
LEASE_TEST_OK

The interactive/headless CLI path (which does not acquire a P0a lease) continues to work correctly.

中文说明

代码审查

独立方案: 对于这个问题,我会设计一个更简单的基于文件的锁,使用 O_EXCL 创建和基于 PID 的过期检测,以及在追加前进行基本的 transcript 字节长度检查。PR 的方案在健壮性上有实质性提升:原子 hard-link 锁(能在崩溃后存活)、进程启动身份用于 PID 复用检测(Linux boot_id + start ticks,Darwin 标准化 ps 输出)、完整的 transcript 指纹(dev/ino/birthtime/ctime/mtime/byteLength)、以及读操作的写屏障。额外的复杂度由生产事故和跨进程协调需求所证明。

发现: 无关键阻塞项。实现彻底,遵循项目规范(ESM、TypeScript strict、kebab-case .ts 文件、同目录测试)。几点观察:

  1. 租约模块(859 行): 结构良好,关注点分离清晰。appendJsonLine 方法在打开文件句柄后正确重新验证所有权和 transcript 状态,关闭了 TOCTOU 窗口。
  2. 记录器生命周期: 状态机(inactive → active → closing → closed | integrity_failed)清晰。writerLeaseRequired 标志正确保留了交互式/无头 CLI 的遗留行为。
  3. Config 集成: 租约获取发生在 initialize() 早期。失败时的清理很彻底。sessionRuntimeBaseDir 固定防止异步上下文漂移。
  4. Session 关闭门控: beginClose() / beginCloseIfAvailable() 模式是可靠的协调机制。
  5. Bridge 关闭语义: 明确拒绝(RequestError → 会话保持活跃,可重试)与未知结果(传输失败 → 终止通道)的区分合理。
  6. Desktop 文本元素路由: 从直接重写 JSONL 改为 ACP ext-method 是租约的必要后果。
  7. createAndStoreSession 行为变更: 对重复 session ID 抛异常而非 dispose 现有会话。对租约保护正确,但是有意义的语义转变。

真实场景测试

这是非 UI 的持久化与生命周期变更。测试方法:构建验证、聚焦单元测试、守护进程冒烟测试。

构建成功。所有聚焦测试通过(共 1,849 项)。守护进程成功启动并创建会话,租约锁文件以预期格式创建。无头 CLI 路径继续正常工作。

Qwen Code · qwen3.7-max

Reviewed at 4892001da6ce071dabaa3906e0dc531a5228cbb6 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — clean review across every stage, but the core-infrastructure gate (3,179 production lines across 4 packages from a fork author) requires a maintainer's sign-off before merge.

This is a well-executed PR that addresses a real production incident with a sound design. The lease protocol is robust — atomic hard-link acquisition, process-start identity for PID reuse detection, full transcript fingerprinting, and consistent fail-closed semantics. The error taxonomy (4 stable RPC kinds mapped to sanitized HTTP 409/503) is clean, and the design doc's invariants are clear and verifiable.

Going back to my independent proposal: I would have built a simpler O_EXCL-based lock with PID staleness detection. The PR's approach is materially more robust — the hard-link protocol survives partial-write crashes, the process-start identity handles PID reuse, and the transcript fingerprint catches same-length file replacement. The additional complexity is justified by the production incident and the cross-process coordination requirement. I did not find a simpler path that covers the same guarantees.

The test coverage is extensive — 1,849 focused tests passing across the lease module, recorder, config, agent, session, bridge, transport, and error-response layers. The daemon smoke test confirms the lease mechanism works end-to-end: lock files are created with the expected record format, and the headless CLI path (which intentionally does not acquire a P0a lease) continues to work.

Reservations for the maintainer's attention:

  1. Scale and blast radius. 3,179 production lines across core, cli, acp-bridge, and desktop. The change touches the session persistence path — one of the most sensitive areas of the codebase. The PR author correctly flags this under the core-infrastructure gate.

  2. createAndStoreSession semantic shift. Duplicate session IDs now throw instead of disposing the existing session. Correct for lease protection, but a behavioral change that could affect callers relying on the old replace semantics.

  3. Desktop text-element routing. Moving from direct JSONL rewriting to an ACP ext-method is a necessary consequence of the lease (direct rewriting would trip session_transcript_changed), but it changes how Desktop persists slash-command metadata. Worth a focused look from whoever owns the Desktop agent.

  4. Mixed-version deployment. The PR is explicit that old binaries ignore the lease, so deployment and rollback must drain old daemon/ACP processes first. This is a real operational constraint that should be captured in release notes.

  5. Windows/Linux runtime not validated. The author tested on macOS only. The process-start identity code has platform-specific paths (Linux /proc, Darwin ps, Win32 PowerShell) — the Linux path is exercised here, but Windows remains untested by the author.

⏸️ Deferring to the maintainer — the core-infrastructure gate (3,179 production lines, cross-package, fork author) requires a human sign-off. The review found no blocking issues; this is a policy escalation, not a quality concern.

中文说明

置信度:3/5 —— 各阶段审查均干净,但核心基础设施门禁(来自 fork 作者的跨 4 个包 3,179 行生产代码)需要维护者签字后才能合入。

这是一个执行良好的 PR,以可靠的设计解决了真实的生产事故。租约协议健壮——原子 hard-link 获取、进程启动身份用于 PID 复用检测、完整的 transcript 指纹、以及一致的 fail-closed 语义。错误分类清晰,设计文档的不变量明确且可验证。

回到我的独立方案:我会构建一个更简单的基于 O_EXCL 的锁和 PID 过期检测。PR 的方案在健壮性上有实质性提升——hard-link 协议能在部分写入崩溃后存活,进程启动身份处理 PID 复用,transcript 指纹捕获等长文件替换。额外的复杂度由生产事故和跨进程协调需求所证明。我没有找到覆盖相同保证的更简路径。

测试覆盖广泛——1,849 项聚焦测试通过。守护进程冒烟测试确认租约机制端到端工作。

供维护者关注的保留意见:

  1. 规模与影响面。 跨 core、cli、acp-bridge、desktop 的 3,179 行生产代码。触及会话持久化路径——代码库最敏感的区域之一。
  2. createAndStoreSession 语义转变。 重复 session ID 现在抛异常而非 dispose 现有会话。对租约保护正确,但是行为变更。
  3. Desktop 文本元素路由。 从直接重写 JSONL 改为 ACP ext-method 是租约的必要后果,但值得 Desktop 负责人关注。
  4. 混合版本部署。 旧二进制忽略租约,部署和回滚必须先 drain 旧进程。应写入发布说明。
  5. Windows/Linux 运行时未验证。 作者仅在 macOS 上测试。

⏸️ 转交维护者——核心基础设施门禁需要人工签字。审查未发现阻塞问题;这是策略性上报,而非质量顾虑。

Qwen Code · qwen3.7-max

Reviewed at 4892001da6ce071dabaa3906e0dc531a5228cbb6 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

⏸️ Deferring to @wenshao — this PR touches core infrastructure at scale (3,179 production lines across packages/core, packages/cli, packages/acp-bridge, and packages/desktop) and comes from a fork author, so the core-infrastructure gate requires a maintainer's sign-off before merge. The staged review (above) found no blocking issues — the lease design is sound, test coverage is extensive (1,849 focused tests passing), and the daemon smoke test confirms the mechanism works end-to-end. This is a policy escalation, not a quality concern. Needs a human call on this one.

中文说明

⏸️ 转交 @wenshao —— 本 PR 大规模触及核心基础设施(跨 packages/corepackages/clipackages/acp-bridgepackages/desktop 共 3,179 行生产代码),且来自 fork 作者,核心基础设施门禁需要维护者签字后才能合入。分阶段审查(见上)未发现阻塞问题——租约设计可靠、测试覆盖广泛(1,849 项聚焦测试通过)、守护进程冒烟测试确认机制端到端工作。这是策略性上报,而非质量顾虑。需要人工判断。

Qwen Code · qwen3.7-max

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the remaining review threads in f157490d2b.

  • Documented that a failed close attempt may already have cancelled in-flight work.
  • Clarified the cancellation boundary around writer admission without changing ordering.
  • Added internal lease-acquisition diagnostics while preserving the sanitized external error contract, with a malformed-lock regression test.
  • Deferred torn-tail repair because P0a does not yet fence interactive writers; truncation under the ACP-only lease could race another writer.
  • Deferred error-message centralization because it adds cross-package API and mock churn without a P0a correctness benefit; RPC error codes remain centralized.

Verification: targeted core, CLI, and ACP bridge regressions passed; lint, build, typecheck, and diff checks passed. An independent verification pass also covered the HTTP sanitization paths and confirmed a clean worktree.

Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/cli/src/acp-integration/session/Session.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
Comment thread packages/core/src/config/config.ts
Comment thread packages/core/src/services/chatRecordingService.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the latest review batch in dd9cff866b.

Review item Action
Missing-channel close could leave the session permanently closing Fixed by resetting the close gate before rethrowing when no channel can be recovered.
Session.worktree.test.ts used an obsolete Config mock Fixed the instance storage and turn-admission mocks; 5/5 tests pass.
acpAgent.worktree.test.ts omitted the Storage export Fixed the core mock; 3/3 tests pass without teardown rejections.
Failed initialization was hidden by a failed recorder close Preserved both failures in an AggregateError cause with regression coverage.
User text element persistence lacked direct server-side coverage Added validation/routing and strict record-construction tests.
Duplicate live-session registration guard lacked coverage Added a regression for the stable rejection and single Session construction.

Verification: core config and recording tests 447/447; CLI ACP and worktree tests 300/300; ACP bridge full suite 413/413; build, lint, typecheck, diff checks, two clean self-audit passes, and independent post-fix verification all passed. The unrelated repository-baseline NOTICES.txt CI failure remains outside this PR.

@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. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here.

— qwen3.8-max-preview via Qwen Code /review

Comment thread packages/core/src/services/session-writer-lease.ts Outdated
Comment thread packages/core/src/config/config.ts
Comment thread packages/cli/src/acp-integration/acpAgent.ts Outdated
Comment thread packages/core/src/config/config.ts Outdated
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review follow-up is pushed in 8a55da2 and all four threads are resolved. This update creates new transcript directories with mode 0700, adds focused Config.startNewSession ownership-guard coverage, preserves primary ACP request failures across new/load/resume and shared initialization cleanup, force-removes failed bulk-load Sessions while retaining ownership-aware deferred cleanup, and retains both activation/release causes while intentionally keeping the external session_writer_unavailable fail-closed contract. Verification: clean full build, full typecheck, full lint, core affected suites 425 passed / 1 skipped, ACP suite 294 passed, git diff check clean, and independent post-fix audit CLEAN. The existing vscode-ide-companion NOTICES check failure remains an unrelated deterministic base-branch issue.

doudouOUC added a commit to doudouOUC/qwen-code that referenced this pull request Jul 20, 2026
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Accepted the legacy-recorder regression-coverage follow-up in c2e5bd4890.

Feedback Action
Legacy/unleased recorder guards removed during the lease-mode test conversion Restored all five applicable regressions: retry after directory setup failure, no write-failure notification for synchronous file creation failure, steady-state directory caching, attribution dedup rollback, and strict custom-title retry/cache recovery.
Production behavior Unchanged; this commit is test-only and explicitly exercises writerLeaseRequired=false.

Verification: 72/72 focused recorder tests passed; Prettier and ESLint passed for both files; full build and full typecheck passed.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Review: fix(core): Fence concurrent ACP session writers

I read the full diff (36 files, +5885/−671), the lease primitive end to end, and traced the integration points through config.ts, chatRecordingService.ts, bridge.ts, acpAgent.ts, and the Desktop agent. I also read the existing review threads so I'm not relitigating what's already settled.

Overall: the approach is right and the execution is careful. The design doc is unusually good — explicit invariants, an honest scope boundary, a stated rollout hazard. The lock itself is sound: I went looking specifically for a double-ownership window in the reclaim sequence and did not find one. Every install goes through an exclusive fs.link (session-writer-lease.ts:398) and every removal is owner-checked (:443). Reclaim generations so a crashed reclaimer doesn't wedge the lock, verify-after-rename before discarding a stale lock, post-write path re-verification, UTF-8 byte accounting rather than String.length — these are the details that usually get missed, and they're all here. The multi-process test suite (real SIGKILL reclaim, crashed reclaimer, concurrent release, equal-length atomic replacement, platform-conditional identity) is well beyond what this class of change usually ships with.

My substantive feedback is almost entirely about the other side of the tradeoff, and it's one theme rather than eight unrelated bugs.


1. CI is red for an unrelated reason — the branch is stale

Test (ubuntu-latest, Node 22.x) fails at Check VS Code companion notices are up-to-date (Error: NOTICES.txt is out of date). That's not this PR: NOTICES.txt was regenerated on main in #7161, and this branch's merge base is 43 commits behind it. Merging main in should clear it. Everything else is SKIPPED behind that gate, so no real signal has run yet — worth doing before a final pass. The branch merges cleanly otherwise.

2. The main ask: fail-closed needs an escape hatch

Failing closed is the right default here, and I'm not arguing with it. But the current design fails closed permanently, with no TTL, no force flag, no GC of its own artifacts, and no lock path in any user-visible message (only debugLogger.debug at :501). Config.initialize() awaits activateChatRecording() as its first statement and rethrows (config.ts:2743-2759), so there is no fallback path — a lease failure means the session cannot be opened at all.

That's fine when the cause is a genuine live writer. It is not fine for these, all of which are ordinary rather than adversarial:

  • Hostname change permanently orphans a dead owner's lock. :264if (record.hostname !== os.hostname()) return { kind: 'live' } — is unconditional and checked before liveness. acquired_at is recorded (:552) and validated (:246) but never compared against Date.now(). So: qwen in a devcontainer with -v ~/.qwen:/root/.qwen, hostname = random container ID, docker stop kills the process without release. On rebuild the hostname differs → the lock reads live → 409 "This session is already open in another Qwen process", forever, for a process that is definitively dead. Same shape for k8s pods, NFS/SMB homes, machine rename, and macOS .local.lan DHCP flips. Recording a machine-id, or allowing TTL-based reclaim for foreign hosts, would close this.

  • An orphaned .reclaim.* guard can deterministically brick the session with a 503. acquireReclaimGuard throws SessionWriterUnavailableError on a live guard (:430), and that call site (:578) sits outside the try block at :588, so it escapes acquireInternal with no retry. If a reclaimer is SIGKILLed between :578 and the rename at :601, the primary lock still holds stale owner S, so every future acquirer computes the identical basePath (:424) — and if that recorded pid has since been reused, the result is an identical hard 503 on every attempt. Recovery means manually deleting ~/.qwen/tmp/session-writer-locks/<id>.lock.reclaim.<uuid>, a path the user is never shown. The test at session-writer-lease.test.ts:770 covers only the dead-orphan variant, which takes the recovering :434 branch.

  • Nothing ever garbage-collects .tmp, .stale.*, or .reclaim.* files. Cleanup exists only in in-process finally/catch blocks (:411, :617, :628, :637), none of which survive SIGKILL. I confirmed this directly: the string session-writer-locks appears in exactly one place in the repo — the path builder at :457. Nothing lists or sweeps that directory. Worth noting the older sibling lock in this same directory holds itself to a higher standard: cronTasksFile.test.ts:464 explicitly asserts entries.filter(e => e.includes('.lock.stale.')) is empty.

  • ctimeMs in sameTranscriptState (:308) is strictly redundant and adds only false positives. Any content write bumps mtime and ctime, so ctimeMs catches nothing mtimeMs misses — but it does fire on pure-metadata changes with byte-identical content: chmod/chown, xattr writes (macOS quarantine/Finder tags, AV agents), SELinux restorecon, hardlink-based backups (cp -al, Time Machine, Borg/rsnapshot all bump ctime on the original inode). When it fires, assertOwnedAndUnchanged throws, expectedTranscriptState never re-syncs (:818 is only reached on a fully successful append), and chatRecordingService.ts:832 latches integrity_failed for the recorder's lifetime. Recovery is a full process restart — recurring immediately if the AV/backup agent is still running. I checked the tests: :677 (atomic replacement) is caught by ino and :526 (external append) by byteLength, so dropping ctimeMs weakens no tested guarantee. That looks like the cheapest high-value change in this list.

  • A partial trailing line makes a session permanently unopenable (:340-351). I see this was raised in self-review and deferred, and I agree with the reasoning against auto-truncating while interactive stays unleased. Two narrower asks. First, this is self-inflicting: an ENOSPC/EIO mid-handle.writeFile at :800 leaves partial bytes with no trailing newline, so one disk-full event bricks the session. Second, the user sees the same generic session_transcript_changed they'd get from an ordinary concurrent-writer conflict — a distinct message (or a documented manual fix) would stop someone in an unrecoverable state from thinking they just need to close another window. Worth an explicit comment at :348 noting the deliberate asymmetry with the reader, which is hardened to recover exactly this shape (_recoverObjectsFromLine, No saved session found <guid>. Run qwen --resume without an ID to choose from existing sessions. #3606/chore(core): jsonl reader/writer follow-ups from #3656 #3681).

None of these individually is a merge blocker. Collectively they're the difference between "fails closed" and "fails closed and stays there" — I'd want at least a documented manual recovery procedure, and ideally a TTL or force path, before this reaches users on Windows/containers.

3. The interactive CLI can kill a live ACP session, and never knows it

There is exactly one SessionWriterLease.acquire() call site (config.ts:2749), gated on experimentalZedIntegration, and getSessionWriterLockPath() is never called anywhere outside the lease module. So the interactive/headless CLI neither takes the lock nor reads it.

With a session open in Desktop, qwen --resume <that-id> in a terminal appends via the legacy jsonl.writeLine path, bumping byteLength/mtimeMs/ctimeMs. The daemon owner's next assertOwnedAndUnchanged() then fails sameTranscriptState()SessionTranscriptChangedErrorenterWriteFailure() latches integrity_failed (chatRecordingService.ts:820) → every later top-level turn is rejected until the session is closed and reloaded.

To be clear, this is not a regression — it's the intended fail-closed behaviour, and it's strictly better than the silent branch corruption it replaces. The gap is that the loop isn't closed on the side that causes it: no warning where the damage happens, and the ACP side dies with a message that doesn't implicate the second terminal.

A mitigation that stays inside P0a scope: have the unleased interactive path do a read-only inspectExistingLock() at startup and warn (or refuse) when a live owner exists. No lock taken, no protocol change, no impact on the P0b boundary — it just converts a confusing remote failure into a local, actionable one.

4. readProcessStartIdentity() is uncached, and on Windows that's expensive

Called at :541 for the current process on every acquire, and at :267 for a remote pid on every lock inspection. No memoization.

  • Linux: two /proc reads, sub-ms. Fine.
  • macOS: a /bin/ps fork+exec per acquire.
  • Windows: powershell.exe -NoProfile -NonInteractive -Command (:215) against a timeout: 1_000 (:167). PowerShell cold start routinely exceeds 1s under AV, so the common corporate-Windows outcome is pay the full second and still get null — which then silently degrades the pid-reuse defence to a bare kill(pid, 0) via the !record.process_start_identity branch at :266.

It compounds: inspectExistingLock can call lockStateForRecord up to MALFORMED_RETRY_COUNT (3) times, inside an ACQUIRE_ATTEMPTS (8) loop, plus acquireReclaimGuard's own 8-iteration loop. One contended acquisition on Windows can fan out to dozens of 1-second PowerShell spawns.

The self-identity is immutable for the process lifetime — memoizing it in a module-level let (computed once, shared by concurrent callers) is a few lines and removes the per-acquire cost entirely. The remote-pid probe has to stay uncached, but capping or short-circuiting the retry fan-out on win32 seems worth it too.

5. An inactive recorder drops every record with zero signal

appendRecord() returns early on state !== 'active' with no logging at all (chatRecordingService.ts:896). Combined with the constructor default writerLeaseRequired = config.getExperimentalZedIntegration?.() ?? true, any path that reaches true but never reaches activate() records nothing, silently and permanently.

In-tree this is currently safe — createChatRecordingService() always passes the flag explicitly, and I checked all three non-test new Config( sites (cli/src/config/config.ts:2279, mcp/reconnect.ts:69, extensionManager.ts:357), two of which this PR correctly hardened with chatRecording: false. But total silent transcript loss is a bad thing to leave undetectable. A one-time debugLogger.error on the first drop from a non-active state would make any future regression obvious.

Related: invariant 5 in the design doc ("an ownership or transcript-integrity failure permanently rejects later top-level turns") holds for integrity_failed, but not after close() takes the SessionWriterLostError branch (chatRecordingService.ts:1100), which clears binding. Config.assertCanStartTurn() (config.ts:6352) gates on hasWriteOwnership(), so it becomes a no-op. closeStoredSession() removes the session right after, so I couldn't construct a reachable path — latent, not live — but gating on a sticky "was supposed to own a lease" flag would make the invariant hold unconditionally for free.

6. Desktop: the completion path now blocks on a 30s ACP round-trip

Replacing the whole-file read-modify-renameSync with an appended qwen/session/recordTextElements record is the right call and removes a real corruption source.

But persistTranscriptTextElements became async and is now awaited before eventQueue.enqueue({type:'complete'}) and, more importantly, before {type:'error'} (qwen-agent.ts:1907, :1920, :1926), with a 30s callAcp timeout (:3456). On a wedged channel — precisely the state that lands you in the .catch branch — the user now waits up to 30 seconds before seeing the error. Since the call is already best-effort (failures are swallowed to debug), consider not blocking the terminal event on it, or using a much shorter timeout here.

Smaller, related: against an older CLI lacking the ext-method this silently stops persisting text elements, where the old local write worked against any version. Consistent with the PR's mixed-version caveat, but silent.

7. Design question: is the hard-link requirement worth its cost?

cronTasksLock.ts already implements a strikingly similar cross-process file lock in this same directory — exclusive create, PID liveness, rename-aside stale takeover, verify-then-restore-on-race. The new lease is a strictly stronger version of that pattern. Two things follow:

  • Consolidation deserves a follow-up issue. Two hand-rolled locks with the same shape and different rigor in one directory will drift. Not for this PR — it would balloon the scope.
  • fs.link (:398) is what introduces the "FS must support atomic same-directory hard links, else fail closed" caveat. cronTasksLock.ts:84 gets by with writeFile({flag:'wx'}), which works on exFAT/FAT32, FUSE mounts (sshfs, Drive/OneDrive), and WSL1 /mnt/c — where fs.link gives EPERM/ENOTSUP and, per the catch at :398, means no session can be opened at all. The link-from-tmp dance buys atomic visibility of a fully-fsync'd record, which MALFORMED_RETRY_COUNT already backstops. Was wx considered and rejected, or is the hard link belt-and-braces? Worth a sentence in the design doc either way, and possibly an open(wx) fallback.

Smaller notes

  • session-writer-lease.ts:800-816appendJsonLine verifies after writing, so on mismatch the bytes are already fsync'd and expectedTranscriptState is never updated (:818 unreachable). The lease's baseline is then permanently behind the file, so every later append fails at :792. One benign interleave permanently degrades a recorder whose own write actually succeeded. Re-baselining from disk on the "our bytes landed but the file grew more than expected" branch would let the lease survive.
  • session-writer-lease.ts:781'a+' creates, so if the transcript was deleted externally an empty file is silently resurrected before :797 throws. A later fresh acquire then succeeds against the empty file (size 0 skips the newline check) and the session presents as empty.
  • session-writer-lease.ts:430 — the loser of a stale-reclaim race gets a 503 session_writer_unavailable rather than the accurate 409 session_writer_conflict, and Config never retries. test.ts:748 asserts exactly one winner but never asserts the loser's errorKind, so this is unspecified.
  • bridge.ts:4315 — all closes now await the child ack with throwOnFailure: true, so an ordinary session/close that previously always succeeded can now throw. CloseSessionOpts documents it, but it's a client-visible contract change worth listing in the PR body's breaking-changes section alongside the binary-drain note.
  • bridge.ts:4312permissionMediator.forgetSession() and pendingInteractions.clear() now run before the agent close, so the "definitive refusal stays retryable" path leaves a session that survived but had its in-flight permission prompts destroyed. Documented in bridgeTypes.ts; just noting it isn't a clean rollback.
  • dispatch.ts:1497 — using getSessionSummary() in a try/catch as an existence probe means any other failure from that getter is misread as "session gone" and tears down the stream. A dedicated hasSession() predicate would be clearer.
  • acpAgent.ts:331SESSION_CLOSE_DRAIN_TIMEOUT_MS = 30_000 is hardcoded. A session with a genuinely long-running tool will fail close repeatedly with no escalation short of killSession, which reaps the whole channel. Worth making configurable.
  • qwen-agent.ts:2909, :3631 — two pure Prettier reflows unrelated to this change. Trivial, but in a 5.8k-line PR every unrelated hunk costs a reviewer a diff-read.
  • File naming: session-writer-lease.ts correctly follows the kebab-case.ts rule in AGENTS.md for new packages/core files. 👍

Security

No concerns. Error surfaces are properly sanitized — error-response.ts and dispatch.ts both map to fixed message strings rather than echoing the error, so lock paths, transcript paths, PIDs, hostnames, and owner tokens stay internal (the cause chain carrying raw fs errors is only ever read by the debug logger). Lock dir 0o700, lock and transcript files 0o600, symlink/non-regular-file rejection on both the lock and the transcript, and encodeURIComponent(sessionId) in the lock path prevents traversal via a hostile session id.

Testing

I couldn't run the suite locally (no node_modules in my checkout), so this is from reading the tests plus CI. Coverage of the lease primitive is strong — see the list in the design doc's Verification section, which matches what's actually there. Gaps worth closing, roughly in value order:

  • A metadata-only touch (chmod/xattr, no size or inode change). Nothing currently mutates metadata without touching size or inode — which is also why removing ctimeMs would break no test.
  • Orphan cleanup: no test lists the lock directory or asserts any .tmp/.stale.*/.reclaim.* is collected (cf. cronTasksFile.test.ts:464, which does exactly this for the older lock).
  • Hostname mismatchlive (:264). Zero coverage.
  • Orphaned .reclaim.* guard whose pid is alive/reused — only the dead variant is tested (test.ts:770).
  • Post-hoc size mismatch (:803): no test asserts the bytes are on disk and the lease is dead afterwards; no ENOSPC/partial-write test.
  • The interactive-vs-ACP interaction in §3, if you take that suggestion.

Nothing here is an objection to the fencing protocol itself. §1 and the ctimeMs removal in §2 I'd fix before merge; §2's recovery story, §3, §4, and §5 I'd like addressed or explicitly deferred with a follow-up issue; the rest are judgement calls that are yours.


Reviewed with Claude Code (Opus 4.8).

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Follow-up: lease lifecycle in acpAgent.ts / Session.ts

My first pass went deep on the lease primitive and the recorder but was thin on the ACP session lifecycle. I went back over that specifically. Everything below is verified against the code, and it's all new — no overlap with the earlier comment except where noted.

First, the good news: I audited every runWithWriteBarrier() call site (acpAgent.ts:3308, :9052, :9116). In all three the operation() is getSessionService().loadSession(sessionId) — a pure read, no recorder append, no nested barrier. No self-deadlock, which was my main worry given the barrier chains on the same operationTail as enqueueRecordWrite. Turn gating is also clean for the paths the design doc claims: user (Session.ts:1919, :2217), cron (:4224, :4348), notification (:4833, :4922), all with correct post-await re-checks.

The issues are concentrated in shutdown and close.

1. beginClose() throws outside the try, so a close that loses a race leaks the lease

acpAgent.ts:3414:

const cancelClose = session.beginClose();   // ← line 3414, outside
let removedFromStore = false;
try {                                        // ← line 3416

Session.beginClose() throws RequestError.invalidParams('Session close is already in progress') whenever closing is already true (Session.ts:1535). Thrown from :3414, it escapes before cancelClose is even assigned — so recorder.close(), removeStoredSessionEntry(), and config.shutdown() all never run, and the lease is never released.

disposeSessions() wraps every close in Promise.allSettled (:3486), so the rejection is silently discarded and the SIGTERM handler proceeds to process.exit(0) (:2770).

Concrete path: a client has session/load in flight (withLiveSessionRestore holds beginClose, :3301) when SIGTERM arrives → disposeSessions()closeStoredSession()beginClose() throws → swallowed → lock file left on disk with a still-live pid. lockStateForRecord classifies a same-host live pid as {kind:'live'} (session-writer-lease.ts:264), so the next process to open that session gets session_writer_conflict for a session nobody owns — and per my earlier comment there's no TTL or force path to recover.

Moving beginClose() inside the try (or catching it explicitly) is the minimal fix.

2. SIGTERM shutdown is unbounded, with no watchdog and no exit-time lease release

The handler does call await agentInstance?.disposeSessions() (:2746), which is right. But:

  • waitForSessionCloseDrain uses the 30s default (SESSION_CLOSE_DRAIN_TIMEOUT_MS, :331) and rejects on timeout — swallowed by allSettled, lease kept. Only the qwen/control/session/close ext-method path can pass a smaller drainTimeoutMs (:7934).
  • Even past the drain, removeStoredSessionEntryconfig.shutdown() (:3260) is unbounded — it awaits toolRegistry.stop(), cleanupArenaRuntime(), cleanupTeamRuntime(), and chatRecordingService.close()flush()operationTail.

There's no watchdog timer before process.exit(0), and I confirmed by grep that no lease-releasing process.on('exit'/'beforeExit') handler exists anywhere — the handlers that do exist are nonInteractiveCli.ts:610, sandbox.ts (proxy teardown), and TUI cleanup, none of which touch leases. So a supervisor SIGKILL after the usual 10s grace leaks every held lease, and recovery depends entirely on stale-lock reclaim — which, per finding 1 and the hostname/reclaim-guard issues in my earlier comment, isn't guaranteed to work.

A bounded overall shutdown budget plus a best-effort synchronous lock unlink on exit would make this a lot more robust.

3. withLiveSessionRestore has no drain timeout at all

acpAgent.ts:3301-3303 calls beginClose() then await session.waitForActiveTurnsToSettle() with no timeout — unlike closeStoredSession, which explicitly got one. A wedged MCP tool or stalled model stream parks the RPC forever with closing === true, which blocks user prompts (Session.ts:1914), cron and notification drains (:4215, :4822), rewind and restoreHistory (:1719, :1796) — and blocks closeStoredSession, which can then no longer even beginClose(). That's exactly finding 1's precondition, so these two compose into a permanently wedged session.

4. Concurrent loadSession for the same id reports "already open in another Qwen process" — about itself

this.sessions.get(params.sessionId) is read at :3756, then two awaits follow (existence probe, then newSessionConfigConfig.initialize()activateChatRecording()). There's no per-sessionId in-flight promise map; the only serialization is createAndStoreSession's if (this.sessions.has(sessionId)) throw at :10127, which runs after the loser has already paid a full loadCliConfig + Config.initialize() (MCP discovery, tool registry, Gemini client) and attempted lease acquisition.

The loser then sees a lock held by our own live pid → {kind:'live'}session_writer_conflict, whose message is "This session is already open in another Qwen process." That statement is false — it's the same process. Losing the other way yields a plain Error('Session ... is already active.'), not a RequestError, so it surfaces as an unstructured internal error with no errorKind. An in-flight promise map keyed by sessionId would fix both the wasted initialization and the misleading error.

5. The two teardown paths use different ownership predicates

  • cleanupUnstoredConfigacpAgent.ts:462 uses config.hasSessionWriteOwnership() — which is pendingSessionWriterLease !== undefined || recorder.hasWriteOwnership() (config.ts:6358).
  • closeStoredSessionacpAgent.ts:3450 uses the weaker recorder?.hasWriteOwnership() (recorder binding only).

So a lease stranded in pendingSessionWriterLease after a partially-failed activation passes closeStoredSession's check, the session is removed from the registry, no pendingConfigCleanup entry is created, and the lease leaks with only a debug log (config.ts:4315-4327 logs and swallows). These should be the same predicate — the stronger one.

6. retryPendingConfigCleanup is unbounded on the session-creation critical path

Every newSessionConfig awaits it (:9825) before creating a Config, and it serially awaits cleanupUnstoredConfigConfig.shutdown() for each leaked Config — with no timeout. One Config whose flush() never settles makes every subsequent newSession/loadSession/resumeSession in that workspace hang.

Also, pendingConfigCleanup entries are removed only on success (:3333), the map is unbounded and retains whole Config objects, and it filters on getSessionRuntimeBaseDir() (:3369) — so a Config whose runtime base dir no longer matches any incoming request is never retried at all and leaks its lease for the process lifetime.

7. Four recorder-writing ext-methods report lease loss as a benign no-op

Only qwen/session/recordTextElements calls await session.assertCanStartTurn() (:7806). sessionTitle (:7842), sessionParent (:7868), and sessionSource (:7896) call recordCustomTitle / recordParentSession / recordSessionSource, which catch the strict-append failure internally and return false (chatRecordingService.ts:1622, :1667, :1697). The RPC returns {persisted: false}, making a lease loss indistinguishable from a benign no-op. (sessionArtifactsPersist at :7770 is fine by accident — appendRecordStrict throws and the extMethod wrapper maps it.)

Smaller

  • acpAgent.ts:3397closeStoredSession aborts and deletes every matching generation controller before beginClose() at :3414. A close that then rejects has already killed the session's in-flight generations while leaving the session open and nominally retryable. Side effects should follow the gate.
  • Session.ts:1541beginClose()'s release closure sets closing = false unconditionally, but dispose() sets it permanently (:1592). A late release on a disposed session makes isIdle() report it as idle and stops assertCanStartTurn() rejecting. Guard on !this.disposed.

Test gaps

acpAgent.test.ts does cover the drain timeout (:10863), pending-cleanup retry (:10902), live-owner reuse (:12440), and cross-runtime rejection (:12497). Not covered:

  • beginClose() throwing, anywhere. Every mock is beginClose: vi.fn().mockReturnValue(vi.fn()) (:2249, :11434, :12416), so finding 1's whole class is untested.
  • disposeSessions() with a stuck drain — only the ext-method path with an explicit drainTimeoutMs is exercised; the 30s default on the shutdown path, and the fact that its failure is swallowed and the lease kept, has no test.
  • Concurrent loadSession TOCTOU:10553 is two sequential newSession calls; nothing overlaps two loadSession calls in the :3756:10127 window.
  • pendingSessionWriterLease leaking through closeStoredSession (finding 5) — the mocked recorders only implement hasWriteOwnership, never the Config-level predicate.
  • No test that the ungated ext-methods return persisted:false rather than a writer RPC error under lease loss.

Findings 1–3 are the ones I'd want fixed before merge — they're the difference between "a crashed writer's lock is reclaimable" and "an orderly SIGTERM can leave an unreclaimable lock." The rest are yours to weigh.


Reviewed with Claude Code (Opus 4.8).

Add an ACP-only cross-process writer lease, authoritative reload, append fencing, turn admission, and acknowledged close semantics for persisted sessions.

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

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Review: fix(core) — Fence concurrent ACP session writers

Thanks for the detailed writeup and for splitting P0a out of #7166. The incident analysis is right, and a per-(runtime base, session ID) writer lease is the correct primitive. I reviewed this by running the branch, not just reading it — findings marked [verified] were reproduced at runtime. Scope is 18 production files, +2693/−487, past the AGENTS.md 1000-line advisory (fix-type, so not hard-blocked, but this needs maintainer sign-off).

What I confirmed works — worth stating up front, because the core is sound:

  • Mutual exclusion holds. 8 real concurrent processes racing the same (runtime base, session ID): exactly 1 winner, 7 clean session_writer_conflict. [verified]
  • I could not construct a double-ownership interleaving. The fs.link-EEXIST primitive plus the pre-rename (:589), post-rename (:603), and post-reclaim re-install (:619) rechecks do close the reclaim windows.
  • A rogue unleased append is detected on the next leased write. [verified]
  • The Linux /proc/<pid>/stat parse is correct — slicing after lastIndexOf(')') makes fields[19] = proc(5) field 22 (starttime), and lastIndexOf is right given comm may contain parens. [verified]
  • The error-sanitization claim holds for the four SessionWriterError classes; I traced error-response.ts:171-177/567-587, dispatch.ts:144-189, acpAgent.ts:415-457 and the SDK fallback and found no PID/host/token/path leak. No RPC code collision — −32020..−32023 are free with clean gaps either side.
  • Perf is fine. A leased append is ~11× a legacy fsynced append (0.82 ms vs 0.075 ms), but fsync is not new (jsonl.writeLine already flushes per line), appends are O(1) in transcript size, and nothing is per-token — ~2N + 2B + 1 appends/turn, ~13 ms added on a 5-tool turn. [verified]

Overall: requesting changes. Not on direction. My concern is (a) the test suite has essentially no teeth on the concurrency core, and (b) too many paths end in a permanently dead session with no recovery.


Blockers

B1. Every race-critical step can be deleted without failing a single test

This is the one I'd fix first. Five mutations, each passing the entire suite unchanged:

Mutation Result
acquireReclaimGuard gives every reclaimer a private slot (exclusion fully removed) all pass
drop ctimeMs + mtimeMs from sameTranscriptState all pass
delete post-rename recheck (:603-616) all pass
delete pre-rename recheck (:589-600) all pass
ignore EEXIST on post-reclaim re-install (:619-621) — the double-ownership guard all pass

The test literally named elects only one stale-lock reclaimer across processes (:748) passes with the reclaim guard disabled. It passes for the wrong reason: the two forked tsx children start hundreds of ms apart and never overlap in the reclaim window, so "only one succeeds" comes from the primary fs.link EEXIST at :557, not the guard — and it never asserts the loser's error kind.

Similarly detects an equal-length atomic transcript replacement (:677) passes with the timestamp comparison removed, because fs.rename changes the inode and is already caught by dev/ino.

The good news: all the missing tests are cheap and deterministic in-process, no forking needed — plant a stale record with a dead pid and Promise.allSettled two acquire() calls; use onOwnershipAcquired as a hook to reach :628; pre-create 8 guard slots; two overlapping appendJsonLine calls.

Also untested: restoreMovedLock (both paths), guard-slot exhaustion, the missing → continue retries (:429/:569), and the :663 error masking.

B2. Failing to delete the reclaim guard destroys the just-acquired primary lock

:628 → catch :630:634-636. Reproduced deterministically: acquire() rejects 503, the primary .lock is deleted, and another process immediately acquires. [verified]

Reachable without the test hook, because the guard lives under <runtimeBase>/tmp/ (tmp reapers, NFS/EIO) and removeOwnedLock doesn't tolerate ENOENT at :443 — unlike releaseOnce, which does at :847. Guard cleanup is bookkeeping; it must not roll back a successful acquisition.

B3. appendJsonLine has no serialization — concurrent appends both land, both report failure, lease permanently poisoned

:760-831. Two overlapping calls read the same expectedBefore at :772, compute the same nextByteLength, both writeFile succeeds at :800, then afterStat.size !== nextByteLength (:803) fails for both — and since the throw precedes :818, expectedTranscriptState is never resynced, so every subsequent append fails forever. Caller sees failure for a write that succeeded → retries → duplicate records. [verified]

The only current caller serializes via operationTail (chatRecordingService.ts:861), but this is an exported primitive with an async method, an undocumented contract, and recorder.activate(lease, …) accepts externally-created leases (your own test at :865 does this). Please either serialize internally or document the contract loudly.

B4. A lock written under a different hostname is permanently unreclaimable

:264 — foreign host returns live, so session_writer_conflict forever, with no TTL and no override. [verified]

This hits the daemon/acp kinds hardest: containers get a fresh hostname per docker run, K8s pods per restart, macOS laptops via DHCP/Bonjour. With runtimeBaseDir on a mounted volume, every session locked by the previous container is unopenable forever. Needs an escape hatch (TTL, explicit break-lock, or host+boot-id equivalence).

Related false-lives: :266 (dead owner + reused PID + no recorded identity → live forever [verified]) and :268 (a null current identity reads as live, so a process exiting between kill(0) at :265 and the /proc read at :267 yields a spurious 409).

B5. A transcript not ending in \n becomes permanently unopenable

getTranscriptState:340-351 throws SessionTranscriptChangedError; that fires inside finishAcquisition:656, so acquire()activateChatRecordingConfig.initialize() all fail with −32022/409, identically on every retry, forever. [verified]

Reachable through your own documented rollout (a SIGKILLed pre-lease writer leaves exactly a torn tail) and via power loss between writeFile :800 and sync :801. And the legacy reader is explicitly tolerant of torn lines (parseLineTolerant, jsonl-utils.ts:126-148) — so the same file still opens fine interactively, but is permanently dead in the daemon after upgrade.

I see the deliberate test at :656, but "fail closed" should mean "refuse this write", not "brick the session". Suggestion: at acquire time only, under the lease, append the missing \n or truncate to the last one before snapshotting — self-healing rather than terminal.

B6. One slow close reaps every session in the workspace

bridge.ts:4327-4343: any non-RequestError close failure calls killChannelWithLog. There is one channel per bridge multiplexing up to DEFAULT_MAX_SESSIONS = 20, and the exit handler tears down Array.from(info.sessionIds) wholesale (bridge.ts:2069). Pre-PR, a non-strict close never killed anything.

Concrete: POST /sessions/archive with 5 live sessions. Session A's child-side await recorder?.flush() (acpAgent.ts:3431) is unbounded and runs before the drain timer, so it can exceed the bridge's 10s withTimeoutBridgeTimeoutError (no code) → non-definitive → channel killed. B–E then get BridgeChannelClosedError, also non-definitive, and all five fail. One slow flush, workspace emptied.

Note the drainTimeoutMs = 0.8 × initTimeoutMs headroom doesn't actually bound the child: only waitForSessionDrain is bounded — the pre-drain flush() and post-drain finalize()/flush()/close() are not. So the 8s < 10s invariant the design rests on isn't enforced, and this branch is reachable in normal operation.

Please scope the kill to the failing session, or gate it behind an explicit opt-in and document sibling reaping in CloseSessionOpts — the updated doc comment says only "may cancel in-flight turns".


High

H1. Sessions can wedge with closing === true and never leave byId

bridge.ts:4335-4343 leaves closing = true with the entry still in byId, relying on channel.exited to reap it. But killChannelWithLog swallows kill errors, killChild gives up at KILL_HARD_DEADLINE_MS = 10_000 and resolves while the child still lives, and exited fires only on a real 'exit'/'error'. For a child in uninterruptible sleep — exactly why that deadline exists — the entry is unrecoverable: closeSession/prompt/rewindSession/loadSession/spawnOrAttach all reject on the closing guard, and killSession's closing branch (:7551-7559) re-kills and returns true without deleting byId.

True on the happy path too: killSession now defers byId.delete to the exit handler on both error returns, so DELETE /session/:id can report success while GET /sessions still lists the session. The non-error path deletes eagerly with the comment "Remove from the state eagerly so concurrent spawnOrAttach can't reattach" — the error paths violate that stated invariant. Please add a fallback that clears closing when exited doesn't fire within a deadline.

H2. ctimeMs in the fingerprint bricks live sessions on benign metadata ops

sameTranscriptState:299-311 compares ctimeMs/mtimeMs on top of dev/ino/birthtime/size. ctime moves on any metadata op. Reproduced on a live lease: a bare chmod (content byte-identical) → SessionTranscriptChangedError, and 3/3 subsequent appends also fail — transcript frozen. [verified, independently twice] A bare utimes does the same. Real-world triggers: security agents, chmod -R in $HOME, SELinux relabel, xattr writes by backup tools, cloud-sync clients.

And it doesn't buy what the doc claims: a same-length in-place overwrite (same inode) went undetected in 9/10 trials — detection depends on landing outside the timestamp tick. [verified] Combined with B1 (deleting both terms breaks zero tests), the field with the highest false-positive rate is untested and its stated purpose is unreliably served.

dev+ino+birthtime+byteLength already fences identity and length. I'd drop ctimeMs; if in-place same-length detection matters, hash the tail rather than trust timestamp granularity. Also inconsistent: :337 and :813 treat sameFileIdentity alone as "same file" while :299 adds ctime/mtime.

H3. Writer conflict silently kills cron and notification turns forever

Session.ts:4259-4266 and :4868-4875 both do try { await this.assertCanStartTurn(); } catch { debugLogger.warn(…); return; }. The item stays queued, and the only re-triggers are prompt completion or a new enqueue — neither happens in a conflicted session. enterWriteFailure sets a sticky integrity_failed, so this is permanent.

User opens the same session in a second window → the first window's cron jobs and <<loop.md>> loops stop firing forever, with evidence only in a QWEN_DEBUG_LOG_FILE-gated debug log. The recording-degraded notification carries only reason: 'write_failed' with no errorKind, so a client can't distinguish "another process owns this" from "disk full". Please plumb errorKind through — it's already on the event.

H4. Guard files are never garbage-collected; 8 of them brick a session

session-writer-locks appears only in this module — no sweeper, no TTL, no startup cleanup. A crashed reclaimer's .reclaim.<owner> survives forever; I planted one and it was still the only file left after a successful acquire and release. [verified] That's exactly the scenario in your test at :770, which asserts nothing about cleanup.

Since acquireReclaimGuard's loop is bounded by ACQUIRE_ATTEMPTS = 8 and :434 is flat (${basePath}.${owner}, not cumulative), 8 occupied guard slots make acquire() fail permanently. Reaching 8 needs 8 hard-crashed reclaimers against the same stale owner — plausible in an OOM/SIGKILL crash-loop, since a failed reclaim never retires the stale owner id. .stale.* and .tmp leak the same way.

H5. -32601 from an older agent makes a session permanently uncloseable

isDefinitiveAcpRequestError is structurally sound — I checked BridgeTimeoutError, BridgeChannelClosedError, and Node errors (string code); none false-positive. But methodNotFound is classified definitive, so a child without qwen/control/session/closeclosing = false and closeSession throws → bridge state never removed, idle reaper retries every 60s forever, child leaks for the daemon's lifetime. Pre-PR the non-strict close logged and proceeded. Suggest treating -32601 as "proceed with teardown".

Also, the comment at :4328-4332 asserts a RequestError means "the child kept the session live". That holds today only by accident of closeStoredSession's finally, and nothing asserts it — yet the bridge nukes a channel based on it.

H6. Legacy writers are unfenced, and the violator wins

A plain unleased append succeeds; the leased owner's next append then dies permanently. [verified] The design doc calls concurrent interactive access "unsupported", but protection is one-directional — the process that breaks the invariant is the one that survives, and the compliant one is the casualty. Minimum: have the legacy recorder read the lock at startup and warn.


Medium / smaller

  • Desktop shows a raw JSON blob [verified]. The rethrow at qwen-agent.ts:1862 correctly bypasses the silent fresh-session fallback — good fix — but getAcpErrorDetail JSON-stringifies any data it doesn't recognize, and data is {errorKind}. Executing the real logic gives the user-facing string for this PR's headline scenario:
    This session is already open in another Qwen process.: {"errorKind":"session_writer_conflict"}
    
    It also never tells them to close the other window. Please early-return the four session_writer_* kinds with actionable copy.
  • Prompt admission is unbounded and uncancellable. Session.ts:1954 awaits assertCanStartTurn() before pendingPrompt is installed (your comment notes cancel can't target it). The gate does lstat + readFile on the lock plus a transcript stat, with no timeout — on a stalled NFS mount every session/prompt hangs and session/cancel is a no-op. Pre-PR, prompt() reached the abort controller synchronously. Suggest racing against a bounded timeout rejecting as session_writer_unavailable.
  • Windows pays up to 1s per acquire and then degrades. :541 recomputes an invariant on every acquire(); :167 spawns powershell.exe with timeout: 1_000, and a cold PowerShell start routinely exceeds that — so Windows blocks acquisition for up to a second and returns null, omitting process_start_identity and degrading all future liveness checks to bare PID liveness (feeding B4). Memoize per process; darwin's /bin/ps spawn is ~8.7 ms per acquire for the same immutable value. [verified]
  • Concurrent reclaimers get 503, not 409, and never retry. :578 calls acquireReclaimGuard outside the try at :588, and :430 throws Unavailable when the guard holder is live; nothing retries — control leaves the for loop. Identical situations report different error kinds purely by timing.
  • finishAcquisition ordering + error masking. :655 fires onOwnershipAcquired before :656 sets the snapshot, so the handed lease is unusable — transcriptExistedAtAcquire throws inside the callback. Contract is effectively "stash the reference only" and is undocumented (config.ts:2786 happens to comply). Separately :663-665 replaces the original error with a bare SessionWriterUnavailableError() with no cause, so the kind degrades 409 → 503 depending on whether an unlink succeeded — losing the diagnostic cause this module otherwise threads carefully.
  • release() caches a rejected promise. The ??=/.catch ordering is correct and transient failures do reset, but when releaseOnce sets released = true before throwing (:848, :852) the reset is skipped — three successive release() calls all reject, so finally { await release() } keeps throwing on a normal reclaim. [verified] A void lease.release() raises an unhandled rejection (process-fatal under Node defaults); latent only, since all three production sites await. if (this.released) return; at :842 is unreachable.
  • NFS: fs.link EEXIST isn't disambiguated with an nlink check (:397-403). On NFS a lost LINK reply makes the retry return EEXIST after the link actually succeeded; the canonical fix is fstat on the temp file and treat nlink === 2 as success. Without it, on an NFS home dir a process reads back its own record, sees its own live pid → 409, and orphans a lock owned by a live process that never releases it. Flagging as a design gap given runtimeBaseDir is typically under $HOME.
  • Ungated append site. acpAgent.ts:7803-7819 (sessionArtifactsPersist) records without assertCanStartTurn, unlike the adjacent recordTextElements at :7838. Not a corruption risk — the lease re-verifies every write — but it reaches the late failure that trips enterWriteFailure and poisons the recorder, where the gate would reject cleanly.
  • Mid-turn failure loses the rest of the turn silently. chatRecordingService.ts:896 returns early once writeFailure is set, so every subsequent fire-and-forget record is dropped with no throw. The user sees a complete answer in-memory while the transcript ends at the failing record.
  • Concurrent close returns 400, not 409. Session.ts:1533-1538RequestError.invalidParams → −32602 → HTTP 400. A retried close is a conflict, not malformed input.
  • Four copies of the error-message table (session-writer-lease.ts:52-57, acpAgent.ts:415-422, dispatch.ts:144-160, error-response.ts:71-78) with nothing keeping them in sync, and dispatch.ts:181 silently returns undefined on code/kind disagreement — drift would degrade conflicts into opaque 500s. Worth one shared exported table plus a note in errorCodes.ts reserving the range.
  • Narrow the sanitization claim. It holds for the four SessionWriterError classes, but adjacent recorder errors are raw — chatRecordingService.ts:721 embeds the full transcript path, and errorPayload forwards data verbatim (with an in-code acknowledgement that it leaks paths). Pre-existing posture, not a regression; just scope the wording in the description.
  • ?? true at chatRecordingService.ts:640 — a Config-shaped object missing getExperimentalZedIntegration silently gets leased mode with dropped records. Prefer false.
  • Dead code: Storage.hasRuntimeBaseDirContext() (storage.ts:134-136) has zero references repo-wide. And restoreSessionState:737's lastRecordUuid assignment is dead whenever the message list is non-empty, since rebuildTurnBoundaries (:1524-1527) overwrites it — two competing sources for the append tail resolved silently by statement order is the exact drift this PR exists to eliminate.

CI

macOS and Windows jobs are skipped on this PR, and you've flagged both as unvalidated. The darwin (/bin/ps) and win32 (powershell.exe) identity paths therefore have zero coverage anywhere — for a cross-platform locking primitive that's the highest-risk gap, and B4/the PowerShell timeout above are both Windows-shaped. Worth forcing those jobs on here.

(Note for anyone reproducing: classifies an unreadable owned lock as unavailable and exposes the owned lease when transcript inspection cleanup must be retried fail under uid 0, which ignores the chmod-based setup. Artifact of running as root, not a defect — CI is green.)


Happy to re-review. The lease itself is well built and the exclusion property genuinely holds under real process contention — my concern is concentrated in what happens after it says no, and in the fact that the suite wouldn't catch a regression in any of the race-critical steps.

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

Reviewed. Suggestions are inline. 1 Suggestion-level finding(s) could not be anchored to a changed line and were dropped; nothing further to act on here. Not reviewed: chunk 1, chunk 2, chunk 3, chunk 4, chunk 5, chunk 6, chunk 7, chunk 8, chunk 9, chunk 10, chunk 11, chunk 12, chunk 13, chunk 14, chunk 15, chunk 16, chunk 17, chunk 18, chunk 19, chunk 20, chunk 21, chunk 22, chunk 23, chunk 24, chunk 25, chunk 26 — no agent reported covering these; nobody read them. Not reviewed: every dimension — none of the 34 required agents is on record as launched with a prompt this skill built, so this diff was reviewed, if at all, from prompts the run wrote for itself: no record shows the severity bar, the finding format or this project's own rules reaching an agent. Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries. Not reviewed: verification — the review posts findings, but no verifier was launched with a prompt this skill builds — they were ruled on, if at all, without the verdict bar its brief carries.

— bailian/glm-5.2 via Qwen Code /review

Comment on lines +663 to +666
} catch {
throw new SessionWriterUnavailableError();
}
throw error;

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.

[Suggestion] finishAcquisition swallows the original acquisition error when lock cleanup also fails — throws cause-less SessionWriterUnavailableError(). Every other construction site in this file passes { cause: error }. — Failure scenario: transcript corruption triggers SessionTranscriptChangedError, removeOwnedLock fails with EIO → operator sees only "ownership could not be verified" with no root cause.

Suggested change
} catch {
throw new SessionWriterUnavailableError();
}
throw error;
throw new SessionWriterUnavailableError({ cause: error });

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. Preserving the original diagnostic cause when both acquisition and cleanup fail would improve operability, but it does not change lock ownership, fail-closed behavior, or data safety.

Comment on lines +855 to +859
if (error instanceof SessionWriterError) throw error;
throw new SessionWriterUnavailableError();
}
}
}

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.

[Suggestion] releaseOnce throws cause-less SessionWriterUnavailableError() for unexpected removeOwnedLock errors — same inconsistency as finishAcquisition above and readOwnedLock (line 728). inspectExistingLock and appendJsonLine both pass { cause: error } for the same class of fs errors.

Suggested change
if (error instanceof SessionWriterError) throw error;
throw new SessionWriterUnavailableError();
}
}
}
throw new SessionWriterUnavailableError({ cause: error instanceof Error ? error : undefined });

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. This is diagnostic-cause preservation for an already failed release; it does not alter whether ownership is retained, lost, or released.

Comment on lines +4333 to +4334
if (isDefinitiveAcpRequestError(error)) {
entry.closing = false;

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.

[Suggestion] No bridge-level test for the definitive-close-refusal path (isDefinitiveAcpRequestError → reset entry.closing). The three new bridge tests cover timeout and kill paths but not the definitive-refusal path. If isDefinitiveAcpRequestError regresses (e.g., always returns false), retryable close refusals become channel-wide kills with no test to catch it. — Failure scenario: a RequestError refusal to session/close should preserve the channel and reset closing; a regression would fall through to killChannelWithLog, terminating all sessions on the channel.

— bailian/glm-5.2 via Qwen Code /review

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.

Not accepted: this path is already covered by preserves bridge state when required agent close fails so retry can flush. The fake child failure crosses the ACP connection as a definitive request refusal; the test asserts the first close rejects while the same session remains usable, then a retry succeeds. That would fail if the refusal fell through to channel-wide kill.

Comment on lines +1048 to +1050
const result = await operation();
await lease?.assertOwnedAndUnchanged();
return result;

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.

[Suggestion] runWithWriteBarrier failure path (when assertOwnedAndUnchanged rejects) is untested. If the catch path regresses, the recorder stays active after an integrity violation — the exact stale-branch bug this PR prevents. — Failure scenario: external process modifies the transcript while the owner is inside runWithWriteBarrierassertOwnedAndUnchanged throws SessionTranscriptChangedError → should set integrityFailure and transition to integrity_failed; a regression would allow subsequent appendJsonLine calls to append to a corrupted transcript.

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. This is additional regression coverage, not an uncovered production safety path: the catch already routes ownership and transcript-integrity failures through enterWriteFailure, and subsequent writes fail closed.

Comment on lines +1088 to +1091
if (this.state === 'active') this.state = 'closing';
let flushFailure: unknown;
try {
await this.flush();

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.

[Suggestion] ChatRecordingService.close() has three untested failure paths (flush-fail+release-ok, release-fail+Lost, release-fail+generic). A regression could leak the lease lock file or leave the recorder in closing state indefinitely. — Failure scenario: flush() rejects (disk I/O) and lease.release() also rejects with a non-SessionWriterLostError → recorder should end in integrity_failed; a regression could leave it stuck in closing or leak the lock.

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. Expanding the close failure matrix is useful coverage, but the suggestion does not identify a current incorrect ownership transition or a Critical data-loss path.

Comment on lines +7954 to +7956
const rawDrainTimeoutMs = params['drainTimeoutMs'];
if (
rawDrainTimeoutMs !== undefined &&

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.

[Suggestion] drainTimeoutMs parameter validation is untested — the two existing tests use valid values (5 and 50 ms); no test passes an invalid value (0, -1, 3.5, NaN, Infinity) to verify RequestError.invalidParams. — Failure scenario: a regression that removes the guard would pass the raw value to setTimeout, which coerces 0/negative to fire immediately, silently turning a close-drain timeout into a no-op.

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. The validation is present and this asks for additional invalid-input coverage; repository guidance classifies missing tests for otherwise correct behavior as a Suggestion.

Comment on lines +9084 to +9086
const sessionData = recording
? await recording.runWithWriteBarrier(loadAuthoritative)
: await loadAuthoritative();

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.

[Suggestion] Rewind path's catch (line 9125) swallows ALL errors from runWithWriteBarrier (including permanent SessionWriterError subclasses) into soft artifactSnapshotUnavailable, returning success: true. The sibling loadUpdates path propagates writer errors correctly. Before this PR, the rewind path used runWithAcpRuntimeOutputDir (no writer barrier), so writer errors could not originate here. — Failure scenario: Process A holds the lease, Process B takes it, Process A calls rewind → runWithWriteBarrier throws SessionWriterUnavailableError → catch swallows it → response says success: true → next operation fails confusingly.

Suggested change
const sessionData = recording
? await recording.runWithWriteBarrier(loadAuthoritative)
: await loadAuthoritative();
const writerError = getSessionWriterError(err);
if (writerError) throw new RequestError(writerError.rpcCode, writerError.message, { errorKind: writerError.errorKind });

— bailian/glm-5.2 via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. The logical rewind has already completed before artifact snapshot reconstruction; on writer-ownership failure, runWithWriteBarrier still transitions the recorder to integrity-failed and prevents subsequent writes, while this response reports artifactSnapshotUnavailable. Propagating the typed writer error would improve error presentation, but it does not permit stale writes or data loss.

Keep reclaim cleanup failures from rolling back an acquired writer lock, and let a slow per-session close finish without reaping multiplexed sibling sessions.

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

Copy link
Copy Markdown
Collaborator Author

@wenshao Thanks for the current-SHA review. I independently reproduced the two close/lock failure paths and pushed 4892001.

Accepted and fixed

  • B6 — multiplexed sibling loss on a slow close: accepted as Critical. Ordinary close no longer applies the bridge-side initialize timeout to the agent close RPC. The child drain deadline remains, but filesystem flush/close may finish later; while it does, only the target stays closing, and sibling sessions on the shared channel remain live. Explicit killSession still keeps its bounded force-kill behavior. The regression test now creates two sessions, holds A beyond the old timeout, verifies the channel is not killed and B remains usable, then completes A and verifies only A is removed.
  • B2 — reclaim-guard cleanup rolling back the acquired primary: accepted as a scoped correctness/availability hardening. Independent reproduction confirmed the primary was removed when the guard disappeared after acquisition. It does not create two active production writers because the first caller has not received/activated the lease, but acquisition must not fail after ownership is established. Guard cleanup is now best-effort after successful acquisition; the deterministic test deletes the guard at that exact callback window, verifies the primary remains, and verifies another writer is rejected.

Not accepted in this round

This PR is beyond five review-fix rounds, so the remaining items are being judged under the Critical-only cutoff.

  • B1: the accepted failure paths now have deterministic fault-injection tests. Expanding the full mutation matrix is valuable but non-Critical and would widen this P0a further.
  • B3: concurrent direct appendJsonLine calls are not a production path. The only non-test caller is ChatRecordingService, whose operationTail serializes every append and barrier. Internal lease serialization can be a follow-up hardening.
  • B4 / H6: cross-host stale-owner recovery and fencing legacy writers require the broader P0/P0b protocol and are intentionally outside this host-local P0a.
  • B5 / H4: torn-tail self-healing and reclaim-artifact GC are recovery/maintenance follow-ups; current behavior deliberately fails closed.
  • H1: an explicit force-kill that cannot kill its shared process remains a process-lifecycle failure, not a new scoped session-writer correctness defect.
  • H2: ctime is intentional: it detects same-inode, same-length replacement even when mtime is restored.
  • H3: stopping scheduled writes after ownership conflict is deliberate fail-closed behavior; continuing would permit an unfenced writer.
  • H5: the bridge and ACP child are launched from the same installed release; mixed-version bridge/child control-method compatibility is not a supported live topology here.
  • The remaining medium/nit items (metadata validation, permissions/ACL hardening, symlink policy, Windows hard-link coverage, error taxonomy, comments/dead fields, and broader smoke-test quality) are non-Critical hardening and are deferred rather than expanding this PR again.

Verification

  • packages/core: session-writer lease suite — 21 passed, 1 platform skip.
  • packages/acp-bridge: all 12 closeSession-focused tests passed.
  • Scoped ESLint and Prettier checks passed.
  • Repository npm run build passed.
  • Repository npm run typecheck passed.

The branch is clean after commit and GitHub CI is running on 4892001.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — built and tested locally on Linux 🐧

I built this PR from source at head 4892001da and ran a real verification pass on Linux, since the PR marks Linux as ⚠️ not-validated. Everything in the reviewer test plan reproduces, and the fix does what it claims. Verdict: LGTM — good to merge, with two non-blocking notes below.

Environment: Linux 6.12.63 (Debian 13), Node v22.22.2, npm 10.9.7, ext4. Model traffic served by a local mock OpenAI-compatible server so real turn machinery (and therefore real transcript writes) executes without network auth.


1. The headline: this actually fixes the incident, and base actually reproduces it

I ran the same two-process scenario against the merge-base (102c69217) and against this PR.

BASE — second writer is not fenced, and the transcript branches:

base differential

Process B session/loads the live session with no objection, both processes write, and the result is an unmarked sibling branch: parent 2bc296e7 (A's turn-1 answer) has two children — a13cbf83 (idx 4, A's continuation) and 4813a2a4 (idx 8, B's user message). Restart follows the physical tail into the 4813a2a4 chain, so A's completed answer at idx 7 is absent from the restored active chain. That is precisely the incident described in the PR body.

PR — B is rejected before model work, and the chain stays linear:

e2e part 1
e2e part 2

17/17 checks against real qwen --experimental-acp child processes. Notably:

  • B is rejected with session_writer_conflict / -32020, and I confirmed no model call was made (mock request count unchanged across B's attempt) — the fence really is ahead of model invocation, per test-plan step 1.
  • After A appends its final answer and exits, the successor acquires the lease, reloads, and appends. The resulting chain shows the successor's user record (idx 8) parented on A's final answer (idx 7) — the exact regression from the incident, now correct.
  • Zero divergent branches, zero orphaned records.
  • Same-process reload reuses the owner without self-conflict (test-plan step 3).

2. Cross-process lease invariants — 32/32

A multi-process harness driving the compiled packages/core/dist/.../session-writer-lease.js from genuinely separate OS processes on a real filesystem:

lease 1
lease 2

Covered: live-owner conflict; 12 concurrent acquirers → exactly 1 winner, 11 clean conflicts (the hard-link atomicity claim); SIGKILL'd owner reclaimed with transcript preserved; PID-reuse fencing (a forged process_start_identity on a live PID is correctly treated as stale, while a genuinely live owner is never falsely reclaimed — this is the case a naive kill(pid,0) check gets wrong); external append detected by both the read barrier and the append fence; lock replacement and lock deletion → session_writer_lost with nothing written; clean handoff preserving a 4-record linear chain; torn (non-newline-terminated) tail refused; symlinked transcript refused; cross-session isolation; and 18 records across 6 process handoffs in exact order.

I also verified the documented hard-link-hostile filesystem risk: when link() fails, acquisition returns session_writer_unavailable (-32023/503) and leaves no lock behind — it fails closed, never open.

3. HTTP mapping through a live qwen serve — 8/8

Not just the unit test: a real daemon, real REST calls, with an external process holding the lease.

http mapping

POST /session/:id/load409 session_writer_conflict, 409 session_transcript_changed (torn tail), 503 session_writer_unavailable (malformed lock), and a clean 200 once the interference is removed (errors are not sticky at the acquisition layer). Response bodies are properly sanitized — no lock paths, owner ids, errno or stack traces leak. I also confirmed DELETE /session/:id → 204 releases the lease (test-plan step 5's success path).

4. Tests / static checks

Suite Result
packages/core (8 changed files) 860 pass, 1 skip, 2 fail — root-only artifact, see F1
packages/acp-bridge bridge.test.ts 413 pass
packages/cli acp-integration + serve (6 files) 967 pass
packages/desktop shared (bun) 39 pass
npm run typecheck / npm run lint / prettier clean

Repo CI on this head is green (no failing checks; BLOCKED is only the pending review gate).


Non-blocking findings

F1 — Two lease tests fail when the suite runs as root (test portability, not a product bug)

session-writer-lease.test.ts has two cases that depend on chmod producing EACCES:

  • classifies an unreadable owned lock as unavailable (chmod(lockPath, 0o000), ~line 563)
  • exposes the owned lease when transcript inspection cleanup must be retried (chmodSync(lockDir, 0o500), ~line 410)

Root holds CAP_DAC_OVERRIDE, so the chmod is a no-op and both assertions fail. The guard is platform-only:

it.runIf(process.platform !== 'win32')(

I confirmed this is purely an euid artifact by exercising the identical code paths against the compiled module twice:

########## AS ROOT ##########
euid=0 (root)
  FAIL  unreadable owned lock -> unavailable      observed=no-throw
  FAIL  cleanup retry exposes owned lease         observed=... + lockSurvived=false

########## AS NOBODY (unprivileged) ##########
euid=65534 (unprivileged)
  PASS  unreadable owned lock -> unavailable      observed=SessionWriterUnavailableError
  PASS  cleanup retry exposes owned lease         observed=SessionWriterUnavailableError + lockSurvived=true

GitHub-hosted runners are non-root so CI stays green, but rootful Docker/devcontainer runs will see two red tests. Cheap fix if you want it:

const canDropPrivileges = process.platform !== 'win32' && process.geteuid?.() !== 0;
it.runIf(canDropPrivileges)(...)

F2 — Interactive/headless writers still bypass the lease, and the wedged ACP session only recovers via a new process

This is in-scope per the PR body (interactive/headless is explicitly P0b), so I'm recording it as confirmation plus one recovery detail worth knowing before rollout.

Acquisition is gated at packages/cli/src/config/config.ts:2102:

experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,

and Config.activateChatRecording() returns early unless that flag is set. So a plain qwen -p ... --resume <acp-owned-session> takes no lease. I verified live: while an ACP writer held the lease, a headless run wrote 5 records into that same transcript and exited 0.

The good news — no branch was created, because the ACP owner's byte/identity fence then refuses to advance. The nuance is what happens next:

step 3: live ACP owner next turn     -> session_transcript_changed
step 4: same live session, retry     -> session_transcript_changed (sticky)
step 5: session/load in same process -> session_transcript_changed
step 7: fresh process session/load   -> SUCCEEDED
step 8: fresh process turn           -> SUCCEEDED

The recorder's integrity_failed state is permanent for that live session, and session/load in the same process cannot clear it (activate() requires state === 'inactive'). Only a brand-new process recovers. That is consistent with the fail-closed design and I'd not block on it — but for a daemon serving many sessions it means an affected session stays unusable until the client reconnects through a fresh agent process, so it may be worth an explicit client-facing recovery hint (or a P0b note) rather than leaving it to look like a hang.


Bottom line: the mechanism is sound, the atomicity and fencing claims hold under real concurrency, the incident reproduces on base and is prevented here, and Linux can now be marked ✅. I'd merge it.

中文版本(点击展开)

Maintainer 本地构建与真实测试验证 🐧

我在 head 4892001da 上从源码构建并在 Linux 上做了一次真实验证(PR 中 Linux 标记为 ⚠️ 未验证)。Reviewer 测试计划中的场景全部可复现,修复确实达到了它声称的效果。 结论:LGTM,可以合入,另附两条非阻塞说明。

环境:Linux 6.12.63(Debian 13)、Node v22.22.2、npm 10.9.7、ext4。模型流量由本地 mock 的 OpenAI 兼容服务提供,因此真实的 turn 机制(以及真实的 transcript 写入)会实际执行,而无需网络认证。

1. 核心结论:这确实修复了事故,且 base 确实能复现该事故

我用同一套双进程场景分别跑了 merge-base(102c69217)和本 PR。

BASE —— 第二个 writer 未被拦截,transcript 发生分叉:

进程 B session/load 同一个 live session 完全没有被拒绝,两个进程同时写入,结果产生了未标记的 sibling branch:parent 2bc296e7(A 第一轮的回答)有两个子节点 —— a13cbf83(idx 4,A 的后续)和 4813a2a4(idx 8,B 的 user message)。重启会沿物理尾部进入 4813a2a4 这条链,因此 idx 7 处 A 已完成的回答不在恢复后的 active chain 中。这正是 PR 描述的事故时序。

PR —— B 在模型调用前被拒绝,链保持线性:

针对真实 qwen --experimental-acp 子进程的 17/17 项检查。其中值得注意的:

  • B 被 session_writer_conflict / -32020 拒绝,并且我确认没有发生任何模型调用(B 尝试前后 mock 的请求计数不变)—— 拦截确实发生在模型调用之前,符合测试计划第 1 步。
  • A 追加 final answer 并退出后,后继进程取得租约、重载并追加。最终链中后继进程的 user record(idx 8)的 parent 正是 A 的 final answer(idx 7)—— 事故中出错的那一点现在是正确的。
  • 零分叉、零孤儿记录。
  • 同进程内重复 load 会复用 owner,不会自我冲突(测试计划第 3 步)。

2. 跨进程租约不变量 —— 32/32

一个多进程 harness,从真正独立的操作系统进程、在真实文件系统上驱动已编译的 packages/core/dist/.../session-writer-lease.js

覆盖内容:live owner 冲突;12 个并发申请者 → 恰好 1 个成功、11 个干净冲突(即 hard-link 原子性这一claim);被 SIGKILL 的 owner 可回收且 transcript 完整保留;PID 复用防护(在存活的 PID 上伪造 process_start_identity 会被正确判定为 stale,而真正存活的 owner 绝不会被误回收 —— 这正是朴素的 kill(pid,0) 检查会出错的情形);外部追加会被读屏障和写入围栏同时检测到;锁被替换或被删除 → session_writer_lost 且不写入任何内容;干净交接后保持 4 条记录的线性链;截断(结尾无换行)的尾部被拒绝;符号链接 transcript 被拒绝;跨 session 隔离;以及 6 次进程交接共 18 条记录顺序完全正确。

我还验证了文档中提到的「文件系统不支持 hard link」风险:当 link() 失败时,acquisition 返回 session_writer_unavailable-32023/503)且不会残留锁文件 —— 是 fail-closed,而不是 fail-open。

3. 通过真实 qwen serve 验证 HTTP 映射 —— 8/8

不只是单元测试:真实 daemon、真实 REST 调用,并由一个外部进程持有租约。

POST /session/:id/load409 session_writer_conflict409 session_transcript_changed(截断尾部)、503 session_writer_unavailable(畸形锁),干扰移除后又能干净地返回 200(说明错误在 acquisition 层不是粘滞的)。响应体已正确脱敏 —— 没有泄漏锁路径、owner id、errno 或堆栈。我也确认了 DELETE /session/:id → 204 会释放租约(测试计划第 5 步的成功路径)。

4. 测试与静态检查

套件 结果
packages/core(8 个改动文件) 860 通过、1 跳过、2 失败 —— 仅 root 环境产物,见 F1
packages/acp-bridge bridge.test.ts 413 通过
packages/cli acp-integration + serve(6 个文件) 967 通过
packages/desktop shared(bun) 39 通过
npm run typecheck / npm run lint / prettier 干净

该 head 上仓库 CI 为绿(无失败项;BLOCKED 仅是待评审的门禁)。

非阻塞发现

F1 —— 以 root 运行测试时有两个租约测试会失败(测试可移植性问题,非产品缺陷)

session-writer-lease.test.ts 中有两个用例依赖 chmod 产生 EACCES

  • classifies an unreadable owned lock as unavailablechmod(lockPath, 0o000),约 563 行)
  • exposes the owned lease when transcript inspection cleanup must be retriedchmodSync(lockDir, 0o500),约 410 行)

root 拥有 CAP_DAC_OVERRIDE,因此 chmod 实际不生效,两处断言都会失败。而当前的 guard 只判断平台:it.runIf(process.platform !== 'win32')

我通过对已编译模块执行完全相同的代码路径两次,确认这纯粹是 euid 造成的:root 下两项 FAIL,切换到非特权用户(nobody,euid=65534)后两项均 PASS。

GitHub 托管的 runner 是非 root,所以 CI 仍然是绿的;但在 rootful 的 Docker / devcontainer 环境下会看到两个红色测试。如果想修,代价很小:

const canDropPrivileges = process.platform !== 'win32' && process.geteuid?.() !== 0;
it.runIf(canDropPrivileges)(...)

F2 —— 交互式/headless writer 仍然绕过租约;被卡住的 ACP session 只能通过新进程恢复

这一点在 PR 描述的范围之内(交互式/headless 明确属于 P0b),因此我把它记录为确认信息,外加一个上线前值得知道的恢复细节。

租约获取的开关在 packages/cli/src/config/config.ts:2102experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,且 Config.activateChatRecording() 在该 flag 未设置时会直接 return。所以普通的 qwen -p ... --resume <ACP 持有的 session> 不会取得租约。我做了实测:在 ACP writer 持有租约期间,一次 headless 运行向同一个 transcript 写入了 5 条记录并以 0 退出。

好消息是 —— 并没有产生分叉,因为 ACP owner 的字节/身份围栏随后拒绝继续推进。需要注意的是接下来的行为:

step 3: live ACP owner 下一轮        -> session_transcript_changed
step 4: 同一 live session 重试        -> session_transcript_changed(粘滞)
step 5: 同进程内 session/load         -> session_transcript_changed
step 7: 全新进程 session/load         -> 成功
step 8: 全新进程发起 turn             -> 成功

recorder 的 integrity_failed 状态对该 live session 是永久的,且同一进程内session/load 无法清除它(activate() 要求 state === 'inactive')。只有全新进程才能恢复。这与 fail-closed 的设计是一致的,我不会因此阻塞合入;但对于同时服务多个 session 的 daemon 来说,这意味着受影响的 session 在客户端通过新的 agent 进程重连之前会一直不可用 —— 因此也许值得给客户端一个明确的恢复提示(或在 P0b 中记一笔),以免它表现得像是卡死。

结论: 机制是可靠的,原子性与围栏在真实并发下均成立,事故在 base 上可复现、在本 PR 上被阻止,Linux 现在可以标记为 ✅。我认为可以合入。

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@wenshao Thank you for the full real-process Linux verification and the base/PR differential. I am recording this as independent confirmation that the P0a fence prevents the incident and that Linux validation is green on 4892001.

No code change is needed from the two non-blocking notes in this round:

  • F1 is a root-only test portability issue; GitHub CI and supported non-root execution are unaffected. Under the post-five-round Critical-only cutoff, the euid guard is deferred.
  • F2 confirms the documented P0b boundary. The active ACP writer fails closed after an external legacy write, and recovery through a fresh agent process is safe; broader legacy-writer fencing and client recovery guidance remain P0b follow-up work.

The current SHA remains unchanged while the automatic review gate finishes.

@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: reverse audit — an auditor ran and opened its brief, but no agent was launched with the prompt the CLI built — the launch was written by hand, and what the agent was actually asked is not what this skill certifies.

— qwen3.7-max via Qwen Code /review

Comment on lines +2806 to +2808
const persistedTitleInfo = authoritative
? this.getSessionService().getSessionTitleInfo(this.sessionId)
: undefined;

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] getSessionTitleInfo is a synchronous tail-read that can throw on I/O errors between the async loadSession() and this sync call, causing the entire activateChatRecording() to fail and the lease to be released — even though the transcript was successfully loaded moments earlier. The legacy path in readPersistedTitleInfo() (chatRecordingService.ts:~599) wraps the same call in a try-catch as best-effort. — Failure scenario: transcript file is unlinked or corrupted between async load and sync title read → activateChatRecording fails → session activation blocked on a title cache that should be best-effort.

Suggested change
const persistedTitleInfo = authoritative
? this.getSessionService().getSessionTitleInfo(this.sessionId)
: undefined;
let persistedTitleInfo: { title?: string; source?: TitleSource } | undefined;
if (authoritative) {
try {
persistedTitleInfo = this.getSessionService().getSessionTitleInfo(this.sessionId);
} catch {
// Best-effort — don't block activation on a title read
}
}

— qwen3.7-max via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. A filesystem change between loadSession() and the title tail-read is still rejected by the subsequent lease integrity assertion, so swallowing this read would not make that race recoverable; other read failures affect activation availability/diagnostics, not writer exclusivity or data safety.

Comment on lines +1103 to +1106
} else {
this.state = 'integrity_failed';
}
throw error;

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] When close() captures a flushFailure but lease.release() then throws a non-SessionWriterLostError, the flush failure is silently discarded — only the release error reaches the caller. The flushFailure often carries the more actionable diagnostic (e.g. SessionTranscriptChangedError — "the transcript was modified externally"). — Concrete cost: operator debugging close failures sees only the less-specific release error, losing the transcript-modification signal.

Suggested change
} else {
this.state = 'integrity_failed';
}
throw error;
} else {
this.state = 'integrity_failed';
}
if (flushFailure !== undefined) {
throw new AggregateError([flushFailure, error], 'Session writer close failed');
}
throw error;

— qwen3.7-max via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. Aggregating flush and release failures would preserve more diagnostic context, but the current path already transitions to integrity_failed and propagates the release failure; it does not alter ownership, stale-write prevention, or data safety.

@wenshao

wenshao commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

Reviewed. Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI and its suite did not run locally. Not reviewed: chunk 24, PR #7237 fences concurrent ACP session writers using an a..., chunk 21, chunk 23, chunk 15, chunk 25, chunk 14, chunk 11, chunk 10, chunk 20, chunk 5, chunk 2, chunk 9, chunk 8, chunk 17, chunk 6, chunk 7, chunk 22, chunk 18, chunk 3, chunk 19, chunk 12, chunk 16, chunk 13, chunk 26, chunk 1, chunk 4 — pointed at diff lines it never opened: it made tool calls, but none of them read the diff. Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries.

— qwen3.8-max-preview via Qwen Code /review

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Triage re-run completed without a new review.

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review timed out. Qwen review timed out after 300 seconds (of the 180-minute budget). For large PRs, retry with a longer timeout by commenting: @qwen-code /review --timeout=240. See workflow logs.

@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: Invariant agent A: state, timers, collections — packages/cli/src/acp-integration/acpAgent.ts, Invariant agent B: counters, return values, error taxonomies — packages/cli/src/acp-integration/acpAgent.ts, Invariant agent C: config fields, early returns — packages/cli/src/acp-integration/acpAgent.ts — its prompt was built, but no agent on record was launched with it.

— qwen3.7-max via Qwen Code /review

Comment on lines +392 to +394
handle = await fs.open(temporaryPath, 'wx', 0o600);
await handle.writeFile(JSON.stringify(record), 'utf8');
await handle.sync();

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] installLockRecord does not handle EEXIST from fs.open on the temp file. A stale temp file from a failed cleanup permanently kills the enclosing acquisition instead of just failing that retry iteration. — Failure scenario: within acquireInternal's retry loop, fs.link returns EEXISTinstallLockRecord returns falsefinally's fs.unlink fails transiently → next iteration's fs.open(temporaryPath, 'wx') hits EEXIST from the stale temp file → falls through to outer catch → wrapped as SessionWriterUnavailableError → acquisition fails even though the lock file itself may now be available.

Suggested change
handle = await fs.open(temporaryPath, 'wx', 0o600);
await handle.writeFile(JSON.stringify(record), 'utf8');
await handle.sync();
let handle: fs.FileHandle;
try {
handle = await fs.open(temporaryPath, 'wx', 0o600);
} catch (openError) {
if ((openError as NodeJS.ErrnoException).code === 'EEXIST') {
await fs.unlink(temporaryPath).catch(() => {});
handle = await fs.open(temporaryPath, 'wx', 0o600);
} else {
throw openError;
}
}

— qwen3.7-max via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. The stale temporary path can make this acquisition attempt fail closed, but it cannot grant ownership, append without a lease, or fork the transcript; a fresh activation uses a new random owner ID. Handling the transient cleanup failure would improve availability, not data safety.

Comment on lines +4258 to +4265
try {
await this.assertCanStartTurn();
} catch (error) {
debugLogger.warn(
`Cron turn rejected [session ${this.sessionId}]: ${error instanceof Error ? error.message : String(error)}`,
);
return;
}

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] Cron and notification turn rejection by writer admission at the Session level has no direct test. client.test.ts covers admission at the client level, and Session.test.ts covers user prompts — but no test verifies that #drainCronQueue or #drainNotificationQueue skip a turn when assertCanStartTurn() rejects. A regression that bypasses the admission check in the drain path would allow a cron/notification turn to start after writer ownership is lost, producing the unmarked branch this PR prevents. — Concrete cost: the exact incident shape (process A writes while process B's cron fires against a lost lease) is silently possible at this layer without test coverage.

— qwen3.7-max via Qwen Code /review

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.

Deferred under the post-five-round Critical-only cutoff. Both queue drains currently call assertCanStartTurn() before setting their processing flags or executing a queued turn, and existing client/session tests cover the admission primitive. This asks for additional regression coverage rather than identifying a current stale-write path.

@wenshao

wenshao commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Review: fix(core): Fence concurrent ACP session writers

Overview

This PR introduces a cross-process writer lease for ACP/daemon sessions: an atomic hard-link lock per (runtime base, session ID) under <runtimeBase>/tmp/session-writer-locks/, with PID + process-start-identity liveness (boot-id/starttime on Linux, ps lstart on macOS, PowerShell StartTime on Windows), dead-owner reclaim via a guard-lock election, and per-append fencing in ChatRecordingService (owner token, file identity via dev/ino/birthtime, byte length, trailing-newline check). The daemon reuses live owners on load/resume, gates user/cron/notification/teammate turns on assertCanStartTurn(), serves live replay through a write barrier, and only removes a live entry after an acknowledged close drains and releases the lease. Desktop switches from rewriting the JSONL to appending user_text_elements records through the owning ACP session. Errors surface as four stable sanitized kinds (-32020..-32023, HTTP 409/503).

I read the full diff (36 files) and cross-checked integration points against the head branch. Verdict up front: this is careful, well-tested work and I found no correctness bug in the lease protocol or its integration. The findings below are tradeoffs, sharp edges, and small improvements — none blocking on their own.

Strengths

  • The lease protocol is genuinely TOCTOU-hardened. Install via wx-tmp + link() (atomic, EEXIST-safe), reclaim requires winning a guard election, re-inspects the stale record before and after rename(), restores the moved lock on any race, and re-verifies ownership before and after every append. I walked the two-reclaimer, reclaimer-dies-holding-guard, and fresh-acquirer-during-reclaim interleavings and they all converge to exactly one owner or fail closed.
  • Scoping is disciplined. writerLeaseRequired is keyed off experimentalZedIntegration, so the interactive TUI path is byte-for-byte legacy behavior (hasWriteOwnership() is false → Config.assertCanStartTurn() no-ops). The ACP bootstrap config gets chatRecording: false while per-session configs keep the user's setting (runAcpAgent receives the unmodified argv — verified).
  • The incident ordering is actually fixed, not just papered over: activation reloads the authoritative tail after acquisition and restoreSessionState() re-derives lastRecordUuid from it, so a replacement writer can't append from a stale parent.
  • Error taxonomy is clean: stable kinds, fixed sanitized messages (no paths/session content), duck-typed detection that survives package boundaries, consistent ACP→HTTP mapping. New RPC codes -32020..-32023 don't collide with existing usage.
  • Test coverage is exceptional for this kind of change: real child-process SIGKILL reclaim, guard-death recovery, single-reclaimer election, equal-length atomic replacement detection, truncated-tail detection, UTF-8 byte accounting, close-gate/drain semantics, retryable close refusal, and the Desktop conflict-surfacing regression.
  • Security posture is right: 0o700 lock dir / 0o600 files, symlink rejection on lock and transcript, pid validated as a positive integer before interpolation into the PowerShell probe.

Findings

1. closeSession now has no bridge-side timeout (behavior change; medium).
notifyAgentSessionClose is called with throwOnFailure: true and no timeoutMs, so the bridge awaits the raw close RPC racing only against transport-close. The child bounds drain via drainTimeoutMs (initTimeoutMs * 0.8), but the recorder flush()/close() after drain are unbounded child-side — a hung fsync (dying disk; network FS is excluded by the PR but not enforced) hangs the close RPC forever, leaves entry.closing = true, and every subsequent attach/prompt on that session returns "session is closing" until someone issues an explicit killSession. Previously this path was bounded by initTimeoutMs. The kill escape hatch exists and is tested ("force-kills the channel when kill follows a stuck acknowledged close"), so this may be intentional — but consider an outer backstop (e.g. 2 × initTimeoutMs) that routes into the same kill-channel recovery, so a wedged child self-heals without operator action.

2. Windows lease acquisition spawns PowerShell on every session create/load (medium, perf).
readProcessStartIdentity(process.pid) runs on every acquire(); on Windows that's a powershell.exe spawn (typically 0.5–2s) added to every ACP session open, and the 1s execFileText timeout means a slow spawn silently degrades the lock to PID-only liveness (process_start_identity omitted → PID reuse can make a dead owner look live → spurious conflicts until manual cleanup). The own-process identity is constant for the process lifetime — compute it once at module level and cache it. That removes the per-acquire cost entirely and makes the omission case much rarer; only stale-lock inspection still needs a live probe of the foreign PID.

3. Per-append verification cost (low, worth measuring).
Each appendJsonLine is now ~15 syscalls including an fsync and two full getTranscriptState round trips (open/stat/lstat/1-byte read) plus two lock-file reads. For chatty recording (per-chunk/tool-event records under the daemon) this may be measurable versus the old buffered jsonl.writeLine. The strictness is justified by the incident, but if profiling shows it hot, the post-write re-getTranscriptState + second readOwnedLock (lines after handle.close()) are the most redundant layer — the size check on afterStat already catches interleaved writes.

4. Maintenance ops aren't lease-aware yet (known P0b gap — worth a doc note).
SessionService.renameSession still appends custom_title directly to the JSONL. Within one daemon the sessionTitle ext-method routes live sessions through the recorder (verified), but a rename/fork/archive issued by another process against a session with a live owner elsewhere will trip that owner's fence and permanently degrade it (integrity_failed) until close. That is the documented fail-closed behavior, but the failure will be attributed to the innocent writer, not the maintenance op. Suggest listing this explicitly in docs/design/session-writer-lease-p0a.md under out-of-scope hazards.

5. Shutdown latency (low).
disposeSessions() is now async and each close waits for active turns up to SESSION_DRAIN_TIMEOUT_MS (30s); the SIGTERM handler awaits it. Closes run in parallel via allSettled so worst case is ~30s, not 30s × N, and drain timers are unref'd — but a daemon under supervisor kill timeouts shorter than 30s will now be SIGKILLed mid-drain where the old sync dispose exited quickly. Fine if deliberate; worth one sentence in the deployment notes.

6. Nits.

  • A dead reclaimer's guard file (<lock>.reclaim.<owner>) is never deleted by its successor (which installs at a nested path), so guard garbage accumulates in tmp/session-writer-locks/. Bounded and harmless, but a cleanup unlink after a successful reclaim would keep the directory tidy.
  • sameTranscriptState includes ctimeMs, so a metadata-only touch (chmod, a backup tool creating a hard link) permanently degrades the session. Intentional strictness, but when it fires nothing records which field mismatched — a debug log naming the failing component (size vs identity vs ctime vs mtime) would save support time.
  • The four fixed error messages are now duplicated in four places (session-writer-lease.ts, acpAgent.ts, dispatch.ts, error-response.ts). Exporting a single SESSION_WRITER_ERROR_MESSAGES map from core would keep them from drifting.
  • appendRecord (fire-and-forget) silently drops records while the recorder is inactive (pre-activate()). Today nothing appends in that window, but a debugLogger.warn there would catch future ordering regressions loudly instead of losing records quietly.
  • assertLiveSessionScope compares path.resolve of projectRoot vs cwd; a symlinked cwd alias fails closed as "another workspace". Fail-closed is the right direction — just noting it in case Desktop ever passes realpath'd cwds inconsistently.

Risk assessment

  • The mixed-binary constraint (old writers ignore the lease) and the hard-link filesystem requirement are the two real deployment risks; both are stated clearly in the PR description with the right mitigation (drain old processes first; fail closed with session_writer_unavailable).
  • The unknown-close-outcome → kill-shared-channel tradeoff reaps sibling sessions, which is aggressive but defensible — no uncertain writer survives, and it's covered by "keeps multiplexed siblings live while one close is still draining" for the non-error path.
  • CI: Ubuntu tests, Serve A/B, and web-shell E2E all pass; Windows/macOS runs were skipped, and the Windows-specific code paths (PowerShell probe, no /proc) are exactly where the platform-conditional skip lives — a maintainer with a Windows machine exercising scenario 4 of the test plan before merge would close the biggest validation gap.

Conclusion

Solid engineering on a hard problem, with the failure interleavings actually enumerated and tested rather than hand-waved. Items 1 and 2 are the ones I'd most like addressed (or explicitly acknowledged as deliberate) before merge; everything else can be follow-up.

@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@wenshao Thanks for the thorough review. I rechecked these findings against 4892001.

Given that this PR has passed more than five review-fix rounds, I am keeping the current Critical-only freeze and will not expand this PR for these non-blocking items:

  • Close timeout: valid availability tradeoff, but an automatic outer timeout would enter unknown-close recovery and can reap multiplexed siblings. The current explicit kill escape hatch preserves the fail-closed safety semantics. This is follow-up hardening, not a correctness, security, data-loss, or regression fix.
  • Windows process-start identity cache: valid performance and false-conflict improvement. The current timeout fallback can fail closed as a spurious conflict, but cannot grant concurrent ownership or fork the transcript. This should be handled with dedicated Windows validation in a follow-up.
  • Per-append profiling, maintenance-operation lease awareness, shutdown/deployment documentation, guard cleanup, diagnostics, shared messages, inactive-recorder logging, and symlink ergonomics are likewise follow-up items.

No Critical issue was identified, so the branch and SHA remain unchanged.

@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. Lease protocol is well-designed — atomic hard-link acquisition, multi-layer transcript fencing, and sanitized error taxonomy all look solid. Critical findings from prior rounds are resolved. CI green.

@doudouOUC
doudouOUC added this pull request to the merge queue Jul 21, 2026
Merged via the queue into QwenLM:main with commit 276094a Jul 21, 2026
374 of 382 checks passed
@doudouOUC
doudouOUC deleted the fix/session-writer-p0a branch July 21, 2026 07:37
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.20.1.

@yiliang114

yiliang114 commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Agent run timed out after 1800000ms


❌ failed

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