Skip to content

fix(core): Preserve ownership during session cleanup - #10300

Open
doudouOUC wants to merge 8 commits into
QwenLM:mainfrom
doudouOUC:fix/session-lifecycle-cleanup-main-followup
Open

fix(core): Preserve ownership during session cleanup#10300
doudouOUC wants to merge 8 commits into
QwenLM:mainfrom
doudouOUC:fix/session-lifecycle-cleanup-main-followup

Conversation

@doudouOUC

Copy link
Copy Markdown
Collaborator

What this PR does

This PR preserves exact daemon writer-lease ownership across the post-transcript cleanup phase of delete, archive, unarchive, and conflict repair. The primary transcript mutation remains guarded by the existing transcript snapshot and runtime-generation checks; once that mutation commits, auxiliary worktree, PR, prompt-ledger, file-history, and organization cleanup may continue only while the same active writer lock is still present and byte-for-byte owned by the lease holder.

The same ownership fence is composed with the selected-runtime check for standalone-session archive, unarchive, deletion, and deletion-journal recovery, so workspace routing remains fail closed. Missing, replaced, malformed, non-regular, or symlinked locks stop auxiliary cleanup. The protocol documentation also clarifies that conflict repair is limited to regular workspace-maintainable transcript copies and never bypasses ownership checks.

This replaces #10286 with a version based on the lifecycle implementation merged in #10179.

Why it's needed

On current main, a daemon runtime generation may close immediately after the transcript has already been unlinked, moved, or chosen as the losing conflict copy. The old generation fence then rejects auxiliary cleanup even though the daemon still owns the exact writer lease. Retrying the same lifecycle request sees the primary operation as already complete and does not revisit those sidecars, leaving stale worktree, PR, prompt-ledger, file-history, or organization state indefinitely.

Using the writer lease as the post-commit ownership boundary lets the transaction finish under the owner that began it without allowing a stale or foreign runtime to mutate another owner's state.

Reviewer Test Plan

How to verify

  1. For delete, archive, unarchive, archive conflict repair, and unarchive conflict repair, close the runtime generation immediately after the primary transcript mutation while leaving the writer lock unchanged. Confirm the primary mutation and all auxiliary cleanup complete successfully.
  2. Replace or invalidate the writer lock after the primary transcript mutation. Confirm auxiliary cleanup stops and reports the per-session writer error instead of mutating sidecars under uncertain ownership.
  3. Exercise standalone archive, unarchive, deletion, and committed deletion recovery through a selected workspace runtime. Confirm both runtime identity and exact lease ownership are checked before auxiliary cleanup.
  4. Confirm ordinary lifecycle behavior, damaged/empty transcript maintenance, foreign-workspace rejection, and default non-mutating conflict behavior remain unchanged.

Local verification passed 384 core assertions (3 skipped), 146 CLI assertions, the repository build, typecheck, lint, and exact changed-file formatting checks. An isolated real-filesystem baseline on current main reproduced permanent auxiliary residue in all five ordinary daemon lifecycle scenarios; the same matrix is covered by the post-fix verification.

Evidence (Before & After)

N/A — non-UI lifecycle correctness change.

Tested on

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

Environment (optional)

macOS 26.4.1, Node.js 22.22.3, npm 10.9.8, sandbox disabled.

Risk & Scope

  • Main risk or tradeoff: Auxiliary cleanup now outlives runtime-generation closure only while exact writer-lock ownership remains provable; losing that proof intentionally returns a per-session error after the primary mutation rather than risking cross-owner cleanup.
  • Not validated / out of scope: Scheduled-task updates, attachment cleanup, final route-response semantics, mixed-case lookup, transcript or list merging, and parent-child cascading deletion are unchanged. Windows and Linux were not tested locally and remain covered by CI.
  • Breaking changes / migration notes: None. The cleanup fence is wired internally by the daemon and standalone lifecycle owners; request and response schemas are unchanged.

Linked Issues

Supersedes #10286. Builds on #10179.

中文说明

本 PR 做了什么

本 PR 在删除、归档、取消归档和冲突修复的转录主操作之后,继续保留精确的 daemon writer lease 所有权校验。转录主操作仍由已有的转录快照与 runtime generation 校验保护;一旦主操作提交,只有在同一个活跃 writer lock 仍存在且其原始内容与 lease 持有者完全一致时,才允许继续清理辅助 worktree、PR、prompt ledger、file history 和 organization 状态。

对于 standalone session 的归档、取消归档、删除和删除日志恢复,同一个所有权 fence 会与 selected runtime 校验组合,因此 workspace 路由仍然保持 fail closed。writer lock 缺失、被替换、格式损坏、不是普通文件或为符号链接时,辅助清理都会停止。协议文档同时明确:冲突修复仅适用于当前 workspace 可维护的普通转录文件,并且不会绕过所有权校验。

本 PR 基于 #10179 已合入的生命周期实现,替代 #10286

为什么需要

当前 main 上,daemon runtime generation 可能在转录已经被删除、移动或作为冲突中的失败副本移除后立即关闭。旧的 generation fence 随后会拒绝辅助清理,即使 daemon 仍然持有精确的 writer lease。重试相同生命周期请求时,主操作已被识别为完成,不会再次清理这些 sidecar,从而使陈旧的 worktree、PR、prompt ledger、file history 或 organization 状态永久残留。

将 writer lease 作为提交后的所有权边界,可以让发起事务的所有者安全完成清理,同时不会允许陈旧或外部 runtime 修改其他所有者的状态。

Reviewer 测试计划

如何验证

  1. 分别执行删除、归档、取消归档、归档冲突修复和取消归档冲突修复,并在转录主操作后立即关闭 runtime generation,同时保持 writer lock 不变。确认主操作和全部辅助清理都成功完成。
  2. 在转录主操作后替换或破坏 writer lock。确认辅助清理停止并返回该 session 的 writer 错误,而不是在所有权不确定时继续修改 sidecar。
  3. 通过选中的 workspace runtime 执行 standalone 归档、取消归档、删除和已提交删除的恢复。确认辅助清理前同时校验 runtime identity 和精确 lease 所有权。
  4. 确认普通生命周期行为、损坏或空转录维护、外部 workspace 拒绝以及默认不修改冲突的行为保持不变。

本地验证已通过 384 个 core 断言(3 个跳过)、146 个 CLI 断言、仓库 build、typecheck、lint 以及精确变更文件的格式检查。在当前 main 的隔离真实文件系统基线中,五个普通 daemon 生命周期场景都能稳定复现永久辅助残留;修复后验证覆盖同一矩阵。

证据(变更前后)

N/A — 非 UI 的生命周期正确性变更。

测试平台

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

环境(可选)

macOS 26.4.1、Node.js 22.22.3、npm 10.9.8、禁用 sandbox。

风险与范围

  • 主要风险或权衡:只有在能够证明精确 writer lock 所有权时,辅助清理才会在 runtime generation 关闭后继续;如果无法证明,系统会在主操作之后按 session 返回错误,避免跨所有者清理。
  • 未验证或不在范围内:scheduled task 更新、attachment 清理、最终路由响应语义、大小写混合查找、转录或列表合并以及父子级联删除均未改变。Windows 和 Linux 未在本地验证,继续由 CI 覆盖。
  • 破坏性变更或迁移说明:无。cleanup fence 由 daemon 和 standalone 生命周期所有者在内部接入;请求与响应 schema 不变。

关联 Issue

替代 #10286,基于 #10179

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

Copy link
Copy Markdown
Collaborator Author

E2E and verification report

Baseline verification on the exact current main reproduced permanent auxiliary residue after the primary transcript mutation in five ordinary daemon lifecycle scenarios: delete, archive, unarchive, archive conflict repair, and unarchive conflict repair.

Post-fix real-filesystem verification confirmed that all five scenarios converge when the original writer lease remains owned. Missing, malformed, non-regular, symlinked, replaced, and byte-identical atomically replaced writer locks all fail closed; the byte-identical replacement case preserves the new generation's worktree, PR, prompt-ledger, file-history, and organization state.

Local automated verification:

  • Core focused tests: 384 passed, 3 skipped.
  • CLI focused tests: 146 passed.
  • Repository build, typecheck, lint, changed-file Prettier check, and clean diff check: passed.

Deliberately deferred because they are not required for this ownership fix: scheduled-task updates, attachment cleanup, final route-response semantics, mixed-case lookup, transcript/list merging, parent-child cascading deletion, and the existing standalone exact-retry result bucket behavior. None of these deferrals broadens or weakens the writer-lease ownership boundary implemented here.

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Re-run after the author pushed 429da3ca — a test-only CI fix on top of the original commit (the production change is untouched), so this gate pass covers the current head.

Template looks good ✓

Problem: real, and the mechanism is confirmed in code. On current main, the auxiliary cleanup that follows the primary transcript mutation (worktree / PR / prompt-ledger / file-history / organization sidecars) is re-guarded by the runtime-generation fence. If a generation closes right after the mutation commits, cleanup aborts mid-way — and a retry sees the primary operation as already complete (the session now classifies as notFound / already-archived), so the residue is never revisited. The interleaving is structurally reachable (daemon shutdown or runtime unselect mid-lifecycle). To be precise about the evidence: the baseline reproduction of permanent residue in all five lifecycle scenarios is the author's own report in this thread; the abort-then-never-revisit wiring itself is visible directly in the base code. This is a follow-up to the lifecycle machinery merged in #10179, superseding #10286.

Direction: aligned. A correctness fix for recently merged daemon session lifecycle code; the writer lease — which the daemon still holds across a generation closure — is a strictly more precise post-commit ownership signal than the generation counter. No external CHANGELOG signal applies (qwen-serve-internal), but the area is squarely in scope.

Size: core paths touched (packages/core/src/services/**, crossing into packages/cli/src/serve/**). 200 production lines (session-writer-lease.ts 80, sessionService.ts 89, standalone-session-service.ts 16, session-archive.ts 15), 411 test lines, 4 docs lines — under the 500-line awareness threshold.

Approach: the scope feels right. One new ownership primitive (assertCleanupOwned — lock present, same dev/ino, byte-identical record) composed into the existing fences, fail-closed on every ambiguous lock state, with a backward-compatible fallback for callers that don't pass it. One thing worth naming because it is intentional: this inverts the previously deliberate behavior where a closed generation stopped post-commit cleanup — the renamed tests pin both directions. The follow-up commit is minimal and necessary: a Config test globally mocked every synchronous read to return undefined, which broke the lease's new fd-based lock read; the fix delegates only numeric-descriptor reads to the real node:fs while keeping the existing mock for path reads.

Risk: no elevated risk signals — none of the changed files match the repo's revert-correlated high-risk paths.

Moving on to code review. 🔍

中文说明

作者在原提交之上推送了 429da3ca(仅测试的 CI 修复,生产代码未变)后触发本次重跑,以下准入门检查覆盖当前 head。

模板完整 ✓

问题:真实存在,且机制已在代码中确认。当前 main 上,转录主操作提交之后的辅助清理(worktree / PR / prompt-ledger / file-history / organization sidecar)由 runtime generation fence 重新保护。如果 generation 在主操作提交后立即关闭,清理会中途中止——而重试会认为主操作已完成(session 被归类为 notFound / already-archived),残留状态永远不会被重新处理。这个交错在结构上是可达的(生命周期进行中的 daemon 关闭或 runtime 取消选择)。关于证据需要说明准确:五个生命周期场景全部出现永久残留的基线复现是作者在本线程中的自我报告;而"中止后不再重访"的接线本身可以直接在基线代码中看到。本 PR 是 #10179 合入的生命周期机制的后续修复,替代 #10286

方向:对齐。这是对近期合入的 daemon session 生命周期代码的正确性修复;daemon 在 generation 关闭后仍然持有的 writer lease 是比 generation 计数更精确的提交后所有权信号。外部 CHANGELOG 无参考信号(qwen-serve 内部机制),但该领域完全在范围内。

规模:触及核心路径(packages/core/src/services/**,跨入 packages/cli/src/serve/**)。生产代码 200 行(session-writer-lease.ts 80、sessionService.ts 89、standalone-session-service.ts 16、session-archive.ts 15),测试 411 行,文档 4 行——低于 500 行关注阈值。

方案:范围合理。一个新的所有权原语(assertCleanupOwned——锁存在、相同 dev/ino、逐字节一致的记录)组合进现有 fence,对所有模糊锁状态保持 fail-closed,并为未传入该回调的调用方保留向后兼容回退。有一点需要点名(这是有意为之):这反转了之前"generation 关闭即停止提交后清理"的刻意行为——重命名的测试锁定了双向语义。后续提交是最小且必要的:某个 Config 测试把所有同步读全局 mock 成 undefined,破坏了租约新的基于文件描述符的锁读取;修复只把数字描述符读委托给真实的 node:fs,路径读保持原有 mock。

风险:无升级风险信号——变更文件均未命中与 revert 相关的高风险路径。

进入代码审查 🔍

Qwen Code · qwen3.8-max

Reviewed at 429da3ca863b04479143db8e71dbb1921c7c952d · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Before reading the diff I wrote down my own solution: after the primary transcript mutation commits, gate auxiliary cleanup on the writer lease instead of the runtime generation — the lease is the one ownership token that survives a generation closure. It would need to capture the lock file's identity at acquisition (so a byte-identical replacement is detectable), re-assert cheaply between cleanup steps, and fail closed on any ambiguous lock state. The PR does exactly this, and the implementation matches the approach I'd have taken.

What I verified at the current head (429da3ca, i.e. including the test-only CI-fix commit):

  • The new check is genuinely strong. readVerifiedLockIdentity opens the lock with O_NOFOLLOW, compares the opened handle's fstat against the path lstat (dev/ino), and requires an active record with matching owner and byte-identical content. This closes the hole a pure byte comparison has against an atomic, same-content lock replacement — and the PR adds the exact tests for it (byte-identical replacement rejected during cleanup and during acquisition, symlinked lock rejected).
  • Lock identity is captured in finishAcquisition before onOwnershipAcquired and re-verified by the final readOwnedLock(). If the capture fails, the existing acquisition cleanup path releases the lease and surfaces the error — same treatment as the other late acquisition checks. The dev/ino comparison added to readOwnedLock also flows into every downstream assertion and release path — strictly tighter ownership everywhere, no looser path anywhere.
  • In sessionService.ts, ownership loss is no longer demoted to a warning: the sidecar/ledger/org catch blocks rethrow SessionWriterError before the warn-and-continue fallback, and removeSessionOrganization does the same. Non-ownership errors keep their previous warn semantics.
  • Backward compatibility holds, and I re-checked the caller graph on the base rather than taking it on faith: the only callers passing fence options into removeSession / archiveSessions / unarchiveSessions / cleanupRemovedSessionStateForLifecycle are the two lifecycle owners this PR updates (daemon session-archive.ts, standalone standalone-session-service.ts). The route and ACP-over-HTTP batch handlers look like extra callers but funnel into deleteDaemonSessions / archiveDaemonSessions / unarchiveDaemonSessions, which the PR wires up; every other removeSession call site (TUI delete, scheduled tasks, live sessions, ACP agent) passes no options and is untouched.
  • The standalone path composes assertRuntimeCurrent + the lease check, so workspace routing stays fail-closed on both axes; the protocol doc update matches the code.
  • The CI-fix commit is exactly what it claims: test-only, 8 lines in config.test.ts, delegating numeric-descriptor reads to the real node:fs while preserving the global mock's undefined for path reads. The production failure mode it fixes (the old mock made the new fd-based lock read see undefinedSessionWriterLostError → timeout) matches the failing job's behavior.

Three non-blocking observations:

  1. assertCleanupOwned() reads synchronously and bypasses the lease's runExclusive serialization that assertOwnedAndUnchanged / release use. Lifecycle flows call these strictly sequentially today and the re-assert-between-steps pattern closes the check-then-act gap, so this is fine — just worth knowing if cleanup ever becomes concurrent.
  2. Lease acquisition now requires a verifiable inode on the lock file. On filesystems reporting ino === 0 (FAT/exFAT, some SMB mounts), acquisition fails with 503 where it previously succeeded — the old code tolerated unverifiable inodes for the lock (only the transcript required identity). Consistent fail-closed posture and the same hasVerifiableInode rule the transcript already applies, but it is a new failure mode relative to main.
  3. Small asymmetry: the removeSession cleanup path falls back via assertCleanupOwned ?? assertCanMutate, while the archive/unarchive post-commit paths call assertCleanupOwned?.() with no fallback. No behavioral difference today — the only two callers passing options pass both — but the inconsistency is a quiet footgun for a future caller that passes only assertCanMutate.

The flow after the change:

sequenceDiagram
    participant D as Daemon lifecycle
    participant L as Writer lease
    participant S as SessionService
    D->>L: acquire lease, capture lock dev-ino and bytes
    D->>S: primary transcript mutation (generation fence still guards)
    S-->>D: committed
    loop each auxiliary cleanup step
        D->>L: assertCleanupOwned, lock identity only
        L-->>D: owned, continue
    end
    D->>L: release
Loading

CI test evidence

Unattended CI run — evidence below is the PR's own CI read through the API, plus the repo's serve A/B job. No PR code was built or executed here. The previously red Test (ubuntu-latest, Node 22.x) now passes on the current head; the root cause was the Config-test mock described above, and the fix is test-only, so the green is credible rather than a flake suppression. macOS/Windows unit legs are skipped by repo convention for fork PRs; the review-pr check still in flight belongs to the bot's own issue_comment orchestration run (this triage plus the sandboxed verify), not the PR's CI. No failing job log to quote. Separately, the serve daemon A/B job built base vs this head and reported no response changes across 12 scenarios.

Check Conclusion
Test (ubuntu-latest, Node 22.x) ✅ success
Classify PR ✅ success
Dependency CVE audit ✅ success
Desktop Shell (ubuntu-22.04) ✅ success
Desktop Shell (windows-2022) ✅ success
Live Host (macos-latest) ✅ success
Real daemon E2E / Java 11 ✅ success
Secret scan (TruffleHog) ✅ success
Serve A/B (ubuntu-latest, Node 22.x) ✅ success
macos-latest / Java 21 ✅ success
ubuntu-latest / Java 11 ✅ success
ubuntu-latest / Java 17 ✅ success
ubuntu-latest / Java 21 ✅ success
web-shell E2E Smoke (ubuntu-latest, Node 22.x) ✅ success
windows-latest / Java 21 ✅ success

The suite pins the changed behavior at the unit level — the renamed tests previously asserted that a closed generation stops post-commit cleanup and now assert cleanup finishes, so they fail without the diff — and the nasty lease cases (byte-identical replacement, symlink) run against the real filesystem. Not verified here: the live-daemon interleaving itself (the unit suite models it with mocked fence callbacks) and Windows/Linux runtime behavior beyond the legs above (the author tested macOS only).

Sandboxed verification is already in flight on this head — @qwen-code /verify (triggered by the author, run 33093222079) will settle exactly the claim the unit suite cannot: that the generation-closing interleaving actually leaves residue on the base build and converges with this PR. Its report will post to this thread when it completes.

中文说明

代码审查

在读 diff 之前我先写下了自己的方案:主转录操作提交之后,把辅助清理改为由 writer lease 而非 runtime generation 把关——lease 是唯一能在 generation 关闭后存活的所有权凭证。它需要在获取时捕获锁文件身份(以便识别字节一致的替换)、在清理步骤之间低成本地重新断言、并对任何模糊锁状态失败关闭。本 PR 正是这样做的,实现与我独立提出的方案一致。

在当前 head(429da3ca,含仅测试的 CI 修复提交)上核实的内容:

  • 新检查的强度是真实的。readVerifiedLockIdentityO_NOFOLLOW 打开锁文件,将已打开句柄的 fstat 与路径 lstat(dev/ino)比对,并要求记录为 active、owner 匹配且内容逐字节一致。这堵住了纯字节比较面对"原子替换为相同内容锁文件"的漏洞——PR 也补上了对应测试(清理期间和获取期间的字节一致原子替换都被拒绝、符号链接锁被拒绝)。
  • 锁身份在 finishAcquisition 中、onOwnershipAcquired 之前捕获,并由最后的 readOwnedLock() 再次校验。若捕获失败,走已有的获取清理路径(释放租约并抛出错误)——与其他获取后期检查的处理一致。加入 readOwnedLock 的 dev/ino 比对传导到所有下游断言与释放路径——所有权校验在所有路径上只紧不松。
  • sessionService.ts 中,所有权丢失不再被降级为警告:sidecar/ledger/org 的 catch 块会先重新抛出 SessionWriterError,再走"警告后继续"的回退;removeSessionOrganization 同理。非所有权错误保留原有的警告语义。
  • 向后兼容成立,且我在基线上重新核对了调用方图,而不是沿用上次结论:向 removeSession / archiveSessions / unarchiveSessions / cleanupRemovedSessionStateForLifecycle 传入 fence 选项的调用方只有本 PR 更新的两个生命周期所有者(daemon 的 session-archive.ts、standalone 的 standalone-session-service.ts)。路由与 ACP-over-HTTP 批处理入口看似额外调用方,实际都汇入 deleteDaemonSessions / archiveDaemonSessions / unarchiveDaemonSessions,PR 已接线;其余所有 removeSession 调用点(TUI 删除、定时任务、live session、ACP agent)都不传选项,行为不变。
  • standalone 路径将 assertRuntimeCurrent 与 lease 检查组合,workspace 路由在两个维度上都保持 fail-closed;协议文档更新与代码一致。
  • CI 修复提交与其声明完全一致:仅测试、config.test.ts 8 行,把数字描述符读委托给真实 node:fs,路径读保留原有全局 mock 的 undefined。它修复的失败模式(旧 mock 让新的基于文件描述符的锁读到 undefinedSessionWriterLostError → 超时)与失败任务的行为吻合。

三点非阻塞观察:

  1. assertCleanupOwned() 是同步读取,绕过了 assertOwnedAndUnchanged / release 所使用的 runExclusive 串行化。目前生命周期流程严格顺序调用这些检查,且"步骤之间重新断言"的模式闭合了 check-then-act 间隙,所以没有问题——只是如果未来清理变为并发执行,需要了解这一点。
  2. 租约获取现在要求锁文件的 inode 可验证。在 ino === 0 的文件系统(FAT/exFAT、部分 SMB 挂载)上,获取会以 503 失败,而之前是成功的——旧代码对锁容忍不可验证的 inode(只有转录要求身份)。这与 fail-closed 姿态一致,也与转录已适用的 hasVerifiableInode 规则相同,但相对 main 是一个新的失败模式。
  3. 小的不对称:removeSession 清理路径通过 assertCleanupOwned ?? assertCanMutate 回退,而 archive/unarchive 提交后路径直接调用 assertCleanupOwned?.()、没有回退。今天没有行为差异——仅有的两个传选项的调用方两者都传——但这种不一致对未来只传 assertCanMutate 的调用方是一个安静的陷阱。

变更后的流程(见上方时序图):获取租约并捕获锁的 dev/ino 与字节内容 → 主转录操作(仍由 generation fence 保护)→ 提交后每一步辅助清理前只做锁身份断言(assertCleanupOwned)→ 释放。

CI 测试证据

无人值守 CI 运行——以下证据是通过 API 读取的 PR 自身 CI 结果,以及仓库的 serve A/B 任务;此处未构建或执行任何 PR 代码。此前变红的 Test (ubuntu-latest, Node 22.x) 在当前 head 上已通过;根因是上文所述的 Config 测试 mock,修复是仅测试的,因此这个绿色是可信的,而不是压制了 flake。macOS/Windows 单元测试分支按仓库惯例在 fork PR 上跳过;仍在进行的 review-pr 检查属于 bot 自身的 issue_comment 编排运行(本次 triage 与沙箱验证),不是 PR 的 CI。没有失败任务日志可引用。另外,serve daemon A/B 任务以 base 对比本 head 构建,12 个场景下响应无任何变化。

CI 表(上方,自动更新区域)显示当前 head 上所有完成的检查均为绿色。

测试套件在单元测试层面锁定了变更行为——被重命名的测试此前断言 generation 关闭会停止提交后清理,现在断言清理会完成,因此去掉 diff 测试即失败——棘手的租约场景(字节一致替换、符号链接)在真实文件系统上执行。此处未验证:真实 daemon 的交错本身(单元测试用 mock 的 fence 回调建模)以及上述分支之外的 Windows/Linux 运行时行为(作者仅在 macOS 上测试)。

沙箱验证已在当前 head 上运行——@qwen-code /verify(作者触发,run 33093222079)将恰好了结单元测试无法覆盖的主张:generation 关闭交错在 base 构建上是否真的留下残留、在本 PR 下是否收敛。报告完成后会发布到本线程。

Qwen Code · qwen3.8-max

Reviewed at 429da3ca863b04479143db8e71dbb1921c7c952d · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Confidence: 4/5 — a careful, well-tested fix with fail-closed semantics throughout; CI is fully green on the current head, and only non-blocking observations remain.

Stepping back: I wrote down my own solution before reading the diff — gate post-commit cleanup on the writer lease, capture the lock's identity so a same-content replacement is detectable, fail closed on ambiguity — and this PR matches that approach, then improves on it: the dev/ino capture is exactly the piece a naive byte comparison misses. I didn't find a simpler path it missed.

The defect is real: the code on main genuinely aborts auxiliary cleanup when the generation closes after the primary mutation commits, and a retry classifies the session as already handled and never revisits the sidecars — that wiring is confirmed first-hand in the base code, not accepted from the PR's framing. Flipping the old "generation closed → stop cleanup" behavior is the right tradeoff here, because the lease is the truer ownership signal, and every ambiguous lock state (missing, replaced, malformed, non-regular, symlinked, unverifiable inode) still fails closed. On re-run I re-verified the caller graph rather than trusting the prior pass: the only callers passing fence options are the two lifecycle owners this PR updates, so nothing else silently loses its post-commit fence.

Every change in the diff earns its place — production edits are exactly the fence plumbing across the two owners, the CI-fix commit is a minimal 8-line test repair, and the rest is tests and a matching doc update. If I had to maintain this in six months I'd thank the author: one well-named primitive, composed where needed, covered by tests that pin both directions of the behavior. CI on the reviewed head is fully green (the previously red ubuntu unit leg fixed test-only, root cause understood), and the serve A/B job found no response drift across 12 scenarios.

What keeps this at 4 rather than 5: the three non-blocking observations in the review above (the sync assertion living outside runExclusive, the new acquisition failure on ino === 0 filesystems, and the fallback asymmetry between the removeSession and archive/unarchive paths), plus the fact that the central claim — the live daemon interleaving — is still substantiated by mocked-fence unit tests until the in-flight sandboxed verification lands. That run posts its own report; nothing here depends on it.

Approving now, pinned to the exact commit reviewed: 429da3ca863b04479143db8e71dbb1921c7c952d.

中文说明

置信度:4/5 —— 谨慎、测试充分、全程保持 fail-closed 的修复;当前 head 上 CI 全绿,只剩非阻塞观察项。

回顾:我在读 diff 之前写下了自己的方案——提交后的清理由 writer lease 把关、捕获锁身份以便识别相同内容的替换、模糊状态一律失败关闭——本 PR 与该方案一致,并且更进一步:dev/ino 捕获恰恰是纯字节比较会遗漏的部分。我没有找到更简的路径被它错过。

缺陷是真实的:当前 main 上的代码确实会在主操作提交后 generation 关闭时中止辅助清理,而重试会把 session 归类为已处理、不再重访 sidecar——这一接线是我在基线代码中亲自确认的,不是照单接受 PR 的说法。反转旧的"generation 关闭 → 停止清理"行为在这里是正确的取舍,因为 lease 是更真实的所有权信号,且所有模糊锁状态(缺失、被替换、损坏、非常规文件、符号链接、不可验证 inode)仍然失败关闭。重跑时我重新核对了调用方图,而不是沿用上次结论:传入 fence 选项的调用方只有本 PR 更新的两个生命周期所有者,因此没有其他路径悄悄失去提交后 fence。

diff 中每一处改动都有其价值——生产代码改动恰好是两个所有者的 fence 接线,CI 修复提交是最小的 8 行测试修补,其余是测试和一致的文档更新。六个月后维护这段代码我会感谢作者:一个命名良好的原语、按需组合、测试锁定了行为的双向语义。被审 head 上 CI 全绿(此前变红的 ubuntu 单元测试分支以仅测试方式修复,根因清楚),serve A/B 任务在 12 个场景下未发现任何响应差异。

之所以是 4 而不是 5:上面审查中的三点非阻塞观察(同步断言位于 runExclusive 之外;ino === 0 文件系统上新的获取失败模式;removeSession 与 archive/unarchive 路径之间的回退不对称),加上核心主张——真实 daemon 交错——在沙箱验证落地之前仍由 mock fence 的单元测试支撑。该运行会自行发布报告;本结论不依赖它。

现在批准,并固定到所审查的确切提交:429da3ca863b04479143db8e71dbb1921c7c952d

Qwen Code · qwen3.8-max

Reviewed at 429da3ca863b04479143db8e71dbb1921c7c952d · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head 4a42ea1, 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 12 scenario(s).

Qwen Code · serve A/B

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

Copy link
Copy Markdown
Collaborator Author

CI failure diagnosed and fixed in 429da3ca863b04479143db8e71dbb1921c7c952d.

The failed Ubuntu unit job was branch-related: the new lock-identity verification correctly reads the owned lock through a numeric file descriptor, while the existing Config test globally mocked every synchronous file read to return undefined. That caused SessionWriterLostError before the test's gated transcript read began, so the test timed out.

The fix is test-only. The real-filesystem test now delegates numeric-descriptor reads to the actual node:fs implementation while preserving the existing mocked behavior for path reads. Production ownership and fail-closed semantics are unchanged.

Verification on the pushed commit:

  • Exact previously failing test: passed.
  • Full Config and session-writer-lease test files: 682 passed, 3 skipped.
  • Repository build, typecheck, lint, changed-file Prettier check, and clean diff check: passed.
  • Independent reproduction confirmed the pre-fix timeout twice and the post-fix pass, including adjacent acquisition/replacement coverage.

No review thread required a reply or resolution (resolved 0/0). The bot's two observations remain non-blocking and were not expanded into this CI-only fix; positive/status comments were ignored. No decision is needed from maintainers at this point; CI is re-running on the new head.

chiga0
chiga0 previously approved these changes Aug 27, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Deep-tier review of the writer-lease ownership fence. No blocking findings.

Core mechanism — readVerifiedLockIdentity(): Opens the lock file with O_RDONLY | O_NOFOLLOW | O_NONBLOCK, calls fstatSync on the file descriptor, then lstatSync on the path and compares {dev, ino} pairs. This is the standard TOCTOU mitigation: the fd-open prevents symlink following at open time; the subsequent lstat verifies the path still resolves to the same inode after open. Atomic replacement is caught because rename changes the inode under the path. The initial identity is captured once at acquisition time and stored in lockFileIdentity.

assertCleanupOwned() vs assertOwnedAndUnchanged(): Correctly separates two invariants. After the primary transcript move/unlink, the snapshot has changed by design so assertOwnedAndUnchanged would always fail. Switching cleanup steps to assertCleanupOwned lets worktree, PR sidecar, prompt-ledger, file-history, and organization cleanup complete under the owner that began the transaction, fixing the stale-sidecar indefinite leak.

SessionWriterError propagation in catch blocks: Updated catch paths correctly re-throw instanceof SessionWriterError immediately (stops cleanup) while warning on other filesystem errors (cleanup continues). A writer-lease error should abort; a sidecar-specific error should not prevent the next sidecar from proceeding.

Backwards compat: assertCleanupOwned ?? assertCanMutate fallback ensures callers that don't pass the new option are unaffected.

Platform handling: O_NOFOLLOW ?? 0 gracefully degrades on Windows. Symlink and atomic-replacement tests correctly gated runIf(process.platform !== 'win32').

config.test.ts mock fix: The fd-based readFileSync delegation to actualFs is necessary because readVerifiedLockIdentity() calls nodeFs.readFileSync(descriptor, 'utf8') with a numeric fd, which would otherwise hit the blanket mock.

CI: Full green — unit tests, Desktop Shell (ubuntu+windows), web-shell E2E, Serve A/B, Real daemon E2E, and Live Host (macos) all pass.

Reviewed with AI assistance.

@doudouOUC
doudouOUC enabled auto-merge August 27, 2026 16:04
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

@qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Sandboxed verification: ❌ not passed — non-deterministic tests (flakiness gate) - workflow run

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

Scripted assertions: 105 passed · 0 failed · 105 total

Flakiness gate: ❌ 2 of 6 changed test file(s) returned different results across identical re-runs (5 full round(s))

The deterministic flakiness gate re-ran the test files this PR changes and got different outcomes from identical runs (agent verdict: merge-ready). A test that can fail with no code changing lands as intermittent red on unrelated PRs, so this run is reported as not passed regardless of the agent verdict — the per-round matrix is in the flakiness gate log below.

中文 — 判定:❌ 不通过 · 测试结果不确定(抖动门)

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

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

抖动门:❌ 2 of 6 changed test file(s) returned different results across identical re-runs (5 full round(s))

确定性抖动门将本 PR 改动的测试文件原样重跑了多轮,得到了不一致的结果(agent 判定:merge-ready)。一个在代码不变时也会失败的测试会以间歇性红灯落在无关的 PR 上,因此无论 agent 判定如何,本次运行按不通过报告——各轮结果矩阵见下方抖动门日志。

Verification report

PR #10300 Deep Verification — fix(core): Preserve ownership during session cleanup

Verdict: merge-ready — scripted assertions 105 pass / 0 fail (A/B harness, stable across two runs); targeted gates green (core 385 passed | 2 skipped; cli 146 passed); mutation matrix shows every load-bearing guard is pinned by the new tests.
Verified head: 429da3ca863b04479143db8e71dbb1921c7c952d (git rev-parse HEAD^2), merged onto base tip 40c77fce90 (merge commit e894911554, conflict-free).

中文摘要
  • 结论merge-ready。A/B 行为证明 105 条脚本断言全部通过(两次运行结果一致);定向门禁全绿(core 385 通过 / 2 跳过,cli 146 通过);变异矩阵证明新增测试确实钉住了每一个关键守卫。
  • A/B 结论(见下表与 01-ab-matrix-base-vs-head.png):
    • 在“主转录操作提交后 runtime generation 立即关闭”的场景下,base 残留 3 个 sidecar + file-history 并报错head 完整清理(delete 与 archive 均验证)——中心主张成立且 load-bearing。
    • 在“主操作后 writer lock 被替换/改写”的场景下,base 照常清理(在无所有权证明下变更 sidecar)head 以 SessionWriterLostError 失败关闭并保留现场——新 fence 按设计生效。
  • Findings:仅 1 条 Suggestion(完整性)——standalone 组合回调中 assertRuntimeCurrent 那一半没有任何测试断言(变异 VM4b 存活);行为本身正确,属测试覆盖缺口,不构成合并条件。
  • 未覆盖:逐提交归因(浅克隆仅可达 2 个提交中的 1 个,已验证聚合 diff);standalone 运行时组合未做端到端执行(仅代码走查 + 变异刻画);unarchive/冲突修复的 genclose 单元未单独跑(与已测 delete/archive 同构);Windows 路径未在本容器验证;全仓门禁未跑(仅目标文件)。

Central claim and A/B proof

Central claim (PR body): after the primary transcript mutation commits in delete/archive lifecycle operations, auxiliary cleanup (worktree / PR sidecars, prompt ledger, file history, organization) completes iff the daemon still owns the exact writer lease (same inode, byte-identical lock content) — instead of the old runtime-generation fence, which rejected cleanup whenever the generation closed mid-operation and left permanent residue.

Secondary claims: (2) a replaced/invalidated lock after the primary mutation stops auxiliary cleanup with a per-session writer error (fail closed); (3) standalone-session lifecycle composes the same fence with the selected-runtime check.

The harness (harness-ab.mjs) drives the real built SessionService + SessionWriterLease (compiled dist/ of each arm) against a real filesystem sandbox, wiring the options exactly as the daemon does in session-archive.ts (base: assertStorageUnchanged + assertCanMutate; head: plus assertCleanupOwned). "Generation closes right after the primary mutation" is modeled by a self-synchronizing fence keyed on the primary mutation's physical effect; lock tampering is injected at each arm's first post-primary checkpoint (generation-fence call sites on base, cleanup-ownership assertion on head). No mocks anywhere on the code path under test.

cell scenario base @​ HEAD^1 head @​ HEAD^2
D-nominal delete, generation open, lock intact resolved; residue 0 resolved; residue 0 (identical)
D-genclose delete, generation closes after unlink rejected runtime generation closed; 3 sidecars + file-history left resolved; residue 0
D-lockswap delete, lock replaced (new inode) after unlink resolved; cleanup proceeded under foreign lock rejected SessionWriterLostError; residue preserved
D-lockcontent delete, lock rewritten in place (foreign owner bytes) resolved; cleanup proceeded under foreign lock rejected SessionWriterLostError; residue preserved
A-nominal archive, generation open, lock intact archived; 3 sidecars moved identical
A-genclose archive, generation closes after rename rejected; transcript archived, 3 active sidecars never moved resolved; 3 sidecars moved to archived
A-lockswap archive, lock replaced after rename resolved; sidecars moved under foreign lock rejected SessionWriterLostError; sidecars untouched

Result: 105/105 scripted assertions pass (driver-ab.mjs, expectation table per arm/cell), identical across two runs. Witness: evidence/01-ab-matrix-base-vs-head.png. All five non-nominal cells flip between arms: two (D/A-genclose) from "residue forever" to "cleanup completes" (the fix), three (D-lockswap, D-lockcontent, A-lockswap) from "mutates sidecars with no ownership proof" to "fail closed" (the safety fence). The two nominal cells are byte-equivalent across arms (no regression). Base-cell failures are the expected control outcomes and count as passing assertions.

Reviewer Test Plan, per step: step 1 (generation closes after primary mutation → everything completes) — measured for delete and archive on head (D/A-genclose); unarchive and the two conflict-repair paths share the identical assertCleanupOwned call sites and were verified by code walk, not separate cells. Step 2 (replace/invalidate lock → cleanup stops with per-session writer error) — measured (D-lockswap, D-lockcontent, A-lockswap: SessionWriterLostError, sidecars preserved). Step 3 (standalone through selected workspace runtime checks both runtime identity and lease) — code walk confirms the composition at all four call sites; end-to-end execution not done (see Not covered), and the mutation matrix shows the runtime half of that composition is unpinned (finding below). Step 4 (ordinary behavior unchanged) — D/A-nominal byte-equivalent outcomes on both arms plus the full changed-file gates below.

Findings

S1 (Suggestion, completeness): the runtime-identity half of the standalone composed cleanup check is asserted by no test

The standalone service composes assertCleanupOwned: () => { assertRuntimeCurrent(runtime); lease.assertCleanupOwned(); } at four sites. Mutation VM4b deleted the assertRuntimeCurrent line from one of them and the whole standalone suite stayed green (81/81), while the sibling mutation VM4a (delete the lease.assertCleanupOwned() line) killed exactly one test. So the lease half is pinned; the fail-closed routing half that the PR description's step 3 rests on is not. The runtime behavior itself is correct in code and assertRuntimeCurrent is pre-existing, tested machinery — this is a completeness gap, not a defect: the fixture that would pin it is one that calls the composed callback and asserts the runtime assertion fired. (Mutation-matrix convention: completeness reporting, not a merge condition.)

No higher-severity findings. Specifically checked and not found: callers of archiveSessions/unarchiveSessions/removeSessions that lost a fence (the archive/unarchive cleanup sites call options.assertCleanupOwned?.() without an assertCanMutate fallback — every production caller is routed through session-archive.ts / standalone-session-service.ts, which both pass assertCleanupOwned; the remove path keeps the ?? assertCanMutate fallback for plain callers, which is behavior-identical to base for them); divergent issue/accept ends (the fence is checked where the cleanup executes); lock-tamper shapes beyond swap/rewrite (symlink and byte-identical-rename shapes are pinned by the new lease tests, confirmed non-vacuous via VM1/VM5).

Mutation matrix (vacuity of the new tests)

Unmutated controls green first (core 385 passed | 2 skipped; cli 146 passed). Witness: evidence/02-mutation-matrix.png.

mutation suite result classification
VM1: assertCleanupOwned() → no-op lease suite killed 3 (expected function to throw an error, but it didn't) pinned
VM2: all options.assertCleanupOwnedoptions.assertCanMutate sessionService + corruption suites killed 14 (promise rejected "Error: generation changed" instead of resolving) pinned
VM3 (control): delete the pre-existing pre-mutation assertCanMutate sessionService suite killed 2 (expected "spy" to not be called at all, but actually been called 1 times) positive control — the existing guard test plus one whose fence-call sequence shifted; same file, proves the harness turns tests red
VM4a: composed standalone callback, drop lease half standalone suite killed 1 (expected "spy" to be called once, but got 0 times) pinned
VM4b: composed standalone callback, drop runtime half standalone suite survived (81/81) coverage gap (S1 above)
VM5: readOwnedLock minus the new dev/ino clause lease suite killed 2 (promise resolved "SessionWriterLease{…}" instead of rejecting) pinned

Layered-guard check: VM1 and VM5 each kill tests the other cannot (cleanup-phase assertion vs acquisition/release-phase identity clause) — the two identity checks defend different phases; neither is redundant, and no survivor was misread as vacuity of a guarded hunk. All quoted failures are expected-vs-actual behavioral assertions, not import/compile breakage.

Targeted gates

gate result
vitest run core: sessionService.test.ts, sessionService.corruption.test.ts, session-writer-lease.test.ts 385 passed | 2 skipped (387) — 3 files green
vitest run cli: standalone-session-service.test.ts, session-archive.test.ts 146 passed (146) — 2 files green, matching the PR body's CLI count

Not covered

  • Per-commit attribution: the checkout is depth 2; only the second commit (429da3ca, "codex: fix CI failure on PR fix(core): Preserve ownership during session cleanup #10300") is reachable — the first (3171d115, the fix itself) sits behind the shallow boundary (git rev-list HEAD^1..HEAD^2 returns 1 vs 2 in the metadata snapshot). The aggregate HEAD^1..HEAD diff is what was verified; which test adjustments belonged to which commit could not be split.
  • Trial merge: the workflow's merge commit already places the PR on the current base tip (40c77fce, ahead of the metadata baseRefOid a3ec41a2), conflict-free — so the A/B verifies what actually lands. No separate re-merge was needed; main's delta between the two bases was not itself re-examined.
  • Standalone-session lifecycle was not executed end to end (step 3): verified by code walk + mutation characterization only.
  • Unarchive-genclose and the two conflict-repair-genclose cells were not run as separate A/B cells; their cleanup paths call the same assertCleanupOwned sites measured for delete/archive.
  • Windows behavior: readVerifiedLockIdentity requires a verifiable inode (hasVerifiableInode = ino ≠ 0) for the lock file; this run proves the Linux path end to end. The PR marks Windows/Linux as locally untested (⚠️); on a filesystem returning inode 0 the lease now fails closed at acquisition — consistent with the PR's stated fail-closed intent, but not exercised here.
  • Repo-wide test/lint gates were not run (scope: changed files only).
  • Replay calibration: N/A — not a workflow/CI PR.

Methodology

Environment: CI verify container (node:22-bookworm), working tree at refs/pull/10300/merge (depth 2), npm ci + npm run build pre-run at HEAD. Base control: git worktree at HEAD^1 with only packages/core rebuilt (scripts/build_package.js, root node_modules reused — PR changes no package.json/lockfile, so the control is dependency-clean; core's nested node_modules symlinked since it is dependency-identical). Harnesses import each arm's compiled dist/ by absolute file URL, so no workspace symlink can cross-contaminate arms; a symbol control confirmed the base dist contains zero assertCleanupOwned/lockFileIdentity occurrences while head contains them. One pre-existing oddity: worktree builds report a single TS7016 (@lydell/node-pty declaration resolution, shellExecutionService.ts — untouched by this PR) yet emit complete JS; it reproduces identically when building base sources, so it is an artifact of building a worktree against the root install, not a PR effect (A/A-equivalent observation). Mutation runs used a scratch worktree at HEAD, rebuilding the mutated package before each run so both relative and package-entry imports saw the mutant; every mutant was restored afterward (tree verified clean). Raw per-cell logs (logs/base-*.log, logs/head-*.log), the driver run (logs/driver-ab-run.txt), mutation logs (logs/vm*.log), build logs, and both harness scripts live in this artifact directory alongside evidence/01-ab-matrix-base-vs-head.png and evidence/02-mutation-matrix.png.

Flakiness gate log

rounds=5 files=6 skipped=0
file packages/cli/src/serve/conversations/standalone-session-service.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/conversations/standalone-session-service.test.ts
file packages/cli/src/serve/server/session-archive.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server/session-archive.test.ts
file packages/core/src/config/config.test.ts: (cd packages/core) npx --no-install vitest run ./src/config/config.test.ts
file packages/core/src/services/session-writer-lease.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/session-writer-lease.test.ts
file packages/core/src/services/sessionService.corruption.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/sessionService.corruption.test.ts
file packages/core/src/services/sessionService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/sessionService.test.ts


per-file results (P=pass F=fail I=infra-exit, one letter per run):
  packages/cli/src/serve/conversations/standalone-session-service.test.ts: PPPPP
  packages/cli/src/serve/server/session-archive.test.ts: FPFFF
  packages/core/src/config/config.test.ts: PPPPP
  packages/core/src/services/session-writer-lease.test.ts: FFFFF
  packages/core/src/services/sessionService.corruption.test.ts: FPFFF
  packages/core/src/services/sessionService.test.ts: PPPPP

verdict: flaky
summary: 2 of 6 changed test file(s) returned different results across identical re-runs (5 full round(s))

--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/cli/src/serve/conversations/standalone-session-service.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server/session-archive.test.ts: F (exit 1)
--- output tail · round 1 · packages/cli/src/serve/server/session-archive.test.ts ---
n one batch to a single archive�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdisables a scheduled task bound to the archived session�[32m 16�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mreports task maintenance failure after archiving the transcript�[32m 15�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdoes not acquire writer leases for ids already archived or missing�[32m 3�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdoes not archive while another writer holds the lease�[32m 27�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mtakes over a sealed empty transcript before maintenance�[32m 30�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mrejects a transcript FIFO without waiting for a writer�[32m 7�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mkeeps independent batch sessions moving when one writer conflicts�[32m 18�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mreports a gate race per session after another batch item was archived�[32m 9�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mkeeps independent batch sessions moving when one classification fails�[32m 14�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdoes not acquire a lease or mutate when closing the owner fails�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22muses the classification made after acquiring the lease�[32m 12�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdoes not lock an active/archive conflict�[32m 3�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mrepairs an active/archive conflict by keeping the archived copy�[32m 17�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mdoes not report success after release fails but reconciles the task to the applied archive�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mreleases the lease when scheduled-task reconciliation fails�[32m 19�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mchecks only the selected runtime root for transcripts and locks�[32m 21�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mrejects with DaemonDrainingError after the coordinator is sealed�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m archiveDaemonSessions�[2m > �[22mrecovers an enabled task whose session is already archived�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mdeduplicates ids and does not lock already active or missing ids�[32m 17�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mcollapses case-variant spellings in one batch to a single unarchive�[32m 11�[2mms�[22m�[39m
�[31m   �[31m�[31m unarchiveDaemonSessions�[2m > �[22mrepairs an active/archive conflict by keeping the active copy�[39m�[32m 19�[2mms�[22m�[39m
�[31m     → expected { unarchived: [], …(4) } to match object { …(3) }
(2 matching properties omitted from actual)�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mdoes not unarchive while another writer holds the lease�[32m 14�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mreports a single error per archived id when unarchive batch fails�[32m 12�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mkeeps independent unarchive sessions moving when one classification fails�[32m 11�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mreports a gate race per session after another batch item was unarchived�[32m 13�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mre-enables an archive-disabled task bound to the unarchived session�[32m 187�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mrecovers a stranded task on an ALREADY-active session�[32m 15�[2mms�[22m�[39m
   �[32m✓�[39m unarchiveDaemonSessions�[2m > �[22mrejects with DaemonDrainingError after the coordinator is sealed�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mdeletes both copies of an exact active/archive conflict�[32m 22�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mremoves a scheduled task bound to the deleted session�[32m 18�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mrepairs task maintenance on retry after deleting the transcript�[32m 17�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mcollapses case-variant spellings in one batch to a single delete�[32m 12�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mdoes not delete while another writer holds the lease�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mreports attachment cleanup failures and allows an idempotent retry�[32m 9�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mreports a gate race per session after another batch item was deleted�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mskips orphan deletion when a new owner attached�[32m 2�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mrejects with DaemonDrainingError after the coordinator is sealed�[32m 1�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mdeletes the transcript when killSession resolves true�[32m 8�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mreturns true when task maintenance fails after orphan deletion�[32m 15�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mdeletes the transcript when killSession throws SessionNotFoundError�[32m 14�[2mms�[22m�[39m
   �[32m✓�[39m deleteDaemonSessions�[2m > �[22mthrows when the lease is held by another writer�[32m 9�[2mms�[22m�[39m

�[31m⎯⎯⎯⎯⎯⎯⎯�[39m�[1m�[41m Failed Tests 1 �[49m�[22m�[31m⎯⎯⎯⎯⎯⎯⎯�[39m

�[41m�[1m FAIL �[22m�[49m src/serve/server/session-archive.test.ts�[2m > �[22munarchiveDaemonSessions�[2m > �[22mrepairs an active/archive conflict by keeping the active copy
�[31m�[1mAssertionError�[22m: expected { unarchived: [], …(4) } to match object { …(3) }
(2 matching properties omitted from actual)�[39m

�[32m- Expected�[39m
�[31m+ Received�[39m

�[2m  {�[22m
�[32m-   "errors": [],�[39m
�[32m-   "resolvedConflicts": [�[39m
�[32m-     "550e8400-e29b-41d4-a716-446655440117",�[39m
�[32m-   ],�[39m
�[32m-   "unarchived": [�[39m
�[32m-     "550e8400-e29b-41d4-a716-446655440117",�[39m
�[31m+   "errors": [�[39m
�[31m+     {�[39m
�[31m+       "error": SessionTranscriptChangedError {�[39m
�[31m+         "message": "The session transcript changed outside its active writer.",�[39m
�[31m+         "name": "SessionTranscriptChangedError",�[39m
�[31m+         "rpcCode": -32022,�[39m
�[31m+         "errorKind": "session_transcript_changed",�[39m
�[31m+         "httpStatus": 409,�[39m
�[31m+       },�[39m
�[31m+       "sessionId": "550e8400-e29b-41d4-a716-446655440117",�[39m
�[31m+     },�[39m
�[2m    ],�[22m
�[31m+   "resolvedConflicts": [],�[39m
�[31m+   "unarchived": [],�[39m
�[2m  }�[22m

�[36m �[2m❯�[22m src/serve/server/session-archive.test.ts:�[2m1150:20�[22m�[39m
    �[90m1148| �[39m    })�[33m;�[39m
    �[90m1149| �[39m
    �[90m1150| �[39m    �[34mexpect�[39m(result)�[33m.�[39m�[34mtoMatchObject�[39m({
    �[90m   | �[39m                   �[31m^�[39m
    �[90m1151| �[39m      unarchived�[33m:�[39m [sessionId]�[33m,�[39m
    �[90m1152| �[39m      resolvedConflicts�[33m:�[39m [sessionId]�[33m,�[39m

�[31m�[2m⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯�[22m�[39m


�[2m Test Files �[22m �[1m�[31m1 failed�[39m�[22m�[90m (1)�[39m
�[2m      Tests �[22m �[1m�[31m1 failed�[39m�[22m�[2m | �[22m�[1m�[32m64 passed�[39m�[22m�[90m (65)�[39m
�[2m   Start at �[22m 16:37:29
�[2m   Duration �[22m 19.38s�[2m (transform 5.58s, setup 141ms, collect 8.43s, tests 767ms, environment 417ms, prepare 138ms)�[22m

JUNIT report written to /__w/qwen-code/qwen-code/packages/cli/junit.xml

round 1 · packages/core/src/conf

...truncated -- full content in the run artifacts.

Evidence images

01-ab-matrix-base-vs-head

02-mutation-matrix

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

Qwen Code · sandboxed verification

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed — no blockers. Suggestions are inline.

中文说明

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

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

Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Comment thread packages/cli/src/serve/server/session-archive.ts
Comment thread packages/core/src/services/session-writer-lease.ts
Comment thread packages/core/src/services/sessionService.ts Outdated
Comment thread packages/core/src/services/sessionService.ts Outdated
Comment thread packages/core/src/services/sessionService.ts Outdated
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC
doudouOUC dismissed stale reviews from qwen-code-ci-bot and chiga0 via e773f6c August 27, 2026 17:34
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Review follow-up for e773f6cc56

Item Decision Action
R1-8 terminal lost lease remains pending forever Agree — correctness regression Fixed. SessionWriterLostError now evicts only the matching pending lease and still returns failure for the current cleanup; the next recovery performs a fresh authoritative acquisition.
R1-1 extract ownership assertion closure Defer Non-blocking maintainability extraction; the explicit scoped call sites are correct and an abstraction would widen this critical-fix round.
R1-5 add daemon forwarding tests Defer Test hardening without a demonstrated exact-head defect; current forwarding is verified and this round is limited to the reproduced regression.
R1-6 add ownership-loss diagnostics Defer Useful observability hardening, but not a correctness fix; it also expands identity logging beyond this round.
R1-3 unify partial-options fallback policy Defer No production caller uses the partial-options shape; changing the policy is a separate API decision.
R1-4 rename the sidecar callback parameter Defer Non-behavioral naming cleanup.
R1-2 centralize catch guards Defer Maintainability refactor; the explicit guards are currently correct.

Verification: npm run build, npm run typecheck, npm run lint, changed-file Prettier check, and standalone-session-service.test.ts (82/82) all pass. An independent reproduction also confirmed the stale pending entry is gone while retryable durability-pending release behavior remains unchanged.

Resolved review threads: 7/7 after the individual replies. Decisions needed: none.

@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) this review confirmed are already reported on this PR and are not repeated:

  • R1-1 four-way duplicated assertCleanupOwned closure in standalone-session-service.ts — already reported in round 1 (inline comment 3874017847), author explicitly deferred; re-derived this round on unchanged code and not re-posted

Not explored to full depth (tool budget reached): "agent 1a": none — but note I could not run npm run typecheck /lint on the changed files (install's prepare fails only on a missing husky binary; I built core and ran …; "agent 6b": executing packages/cli/src/serve/conversations/standalone-session-service.test.ts to confirm the new tests pass (worktree has no node_modules or built core d….

中文说明

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

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

未探索到全部深度(达到工具调用预算):"agent 1a"none — but note I could not run npm run typecheck /lint on the changed files (install's prepare fails only on a missing husky binary; I built core and ran …"agent 6b"executing packages/cli/src/serve/conversations/standalone-session-service.test.ts to confirm the new tests pass (worktree has no node_modules or built core d…

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

Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Comment thread packages/core/src/services/session-writer-lease.ts
Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the current review feedback in commit 79f5c29:

  • A parked lease that becomes terminally lost is now evicted and the same lifecycle operation proceeds to a fresh authoritative acquisition.
  • Writer-lock cleanup ownership now revalidates pathname identity after descriptor-record validation, closing the reported atomic-replacement window.
  • Archive and unarchive errors now reconcile the durable target location in the selected runtime and invalidate catalog/list state when the transcript move already committed, while preserving the cleanup error.
  • Added exact regression coverage for parked-then-lost recovery, the lock replacement race, and post-commit archive/unarchive invalidation.

Verification: focused CLI tests 85/85, focused core tests 97 passed with 3 platform skips, independent focused verification 8/8, repository build, typecheck, lint, scoped Prettier check, and clean diff audits. Resolved 4/4 handled review threads.

chiga0
chiga0 previously approved these changes Aug 28, 2026

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Tier: Deep(并发安全 + 文件身份校验)— 增量 429da3ca79f5c29f

已核验内容

R2-1(TOCTOU:path identity check 与 content read 之间的替换窗口)

readVerifiedLockIdentity 现在对 assertPathMatchesDescriptor() 做了两次调用:

  1. readFileSync(descriptor, 'utf8') 之前:确认 path 指向已打开的 fd(inode 匹配)
  2. 在 content 验证(parseLockRecord + owner_id/lockRecordRaw 校验)之后:再次确认 path 仍指向同一 inode

第二次检查关闭了这个窗口:攻击者在 fstat+内容读取之间原子替换 lockPath 后,重新 lstat 会发现 inode 不匹配并抛 SessionWriterLostError。配套测试通过 descriptorReadHook.afterReadreadFileSync 返回后同步执行 renameSync 来重现此时序,并验证 assertCleanupOwned() 会抛出。✓

R2-2(清理失败后 catalog/cache 未更新)

archive 和 unarchive 的错误路径现在都调用 reconcileCatalogAfterLifecycleError(即 markSessionCatalogChanged + invalidateSessionListCache)。两个新测试分别验证:move 完成但 cleanup 失败时,catalog 和 cache 均被 invalidate。✓

CI

1 success,1 in_progress(review-pr),28 skipped。核心测试无失败。

无阻塞项。

@qqqys

qqqys commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

E2E verification report — head 79f5c29f

Independent code review of this head found no Critical issues. Verified: readVerifiedLockIdentity() double-asserts path↔descriptor identity (before and after content verification) closing the earlier TOCTOU window; assertCleanupOwned() composes the runtime-generation check with byte-for-byte lease identity and fails closed on missing/replaced/malformed/non-regular/symlinked locks; terminally-lost lease eviction uses identity-checked pending-map deletion; catalog reconciliation after lifecycle errors is read-only and swallow-safe; the assertCanMutateassertCleanupOwned rewiring keeps the old option as fallback. Per the review workflow, I also ran an execution check at this exact head.

独立评审未发现 Critical:锁身份双重断言(内容校验前后各一次 path↔fd 身份比对)封堵了此前指出的 TOCTOU 窗口;cleanup 栅栏将 runtime-generation 检查与租约字节级身份组合、缺失/被替换/非常规/符号链接锁一律失败关闭;终态丢失租约的驱逐使用身份比对的 pending-map 删除;生命周期错误后的目录对账只读且吞错安全。以下为该 head 的实测结果。

What was done / 执行内容

  • Environment: official head tarball of 79f5c29f, fresh npm ci to completion.
  • CLI suites (this PR's focus): npx vitest run src/serve/conversations/standalone-session-service.test.ts src/serve/server/session-archive.test.ts in packages/cli150/150 passed.
  • Core suites: session-writer-lease.test.ts + sessionService.test.ts + sessionService.corruption.test.ts382 passed, including all 11 tests added by this PR. The 5 failures in session-writer-lease.test.ts are pre-existing environment artifacts, not PR-introduced: the identical 5 tests fail identically when the same suite runs on a current-main-based tree in this environment (they rely on chmod-000 unreadability and permission/ctime semantics that differ when running as root).
  • TUI boot smoke (tmux): built CLI booted in a detached tmux session; banner and > Type your message prompt rendered, no crash.

Evidence / 证据

 Test Files  2 passed (2)            (packages/cli)
      Tests  150 passed (150)
 Test Files  1 failed | 2 passed (3) (packages/core)
      Tests  5 failed | 382 passed | 1 skipped (388)

The same 5 failures reproduce on the main-based baseline tree in this environment (83 passed / same 5 failed there), proving they are unrelated to this PR.

CI note / CI 说明: the red Test (ubuntu-latest, Node 22.x) lane at this head fails only in the Run .github/scripts helper tests step (release note classification), identical on unrelated PRs (e.g. #10340) — a broken-base issue, not attributable to this PR. That step short-circuits the job before the unit-test step runs, so this local run is currently the only unit-level execution evidence at this head.

No action requested — informational only. / 仅信息同步,无需操作。

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

Partially reviewed — gaps disclosed.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • test-matrix daemon-layer assertCleanupOwned-forwarding finding (session-archive.ts removeSession/archiveSessions/unarchiveSessions option wiring) — already reported as R1-5 (comment 3874017852), author explicitly deferred

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

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

  • packages/core/src/services/sessionService.ts:2608 — [probe] new cleanup fence in cleanupRemovedSessionStateForLifecycle is pinned by no test (deferred — code-age rule: anchored on code unchanged since the previous round)

Convergence: round 3 posted 2 inline comment(s), 2 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/cli/src/serve/conversations/standalone-session-service.ts (findings in rounds 1, 2; 1 more now). The rate of new findings is not falling. (Evidence: the previous round was recovered from a marker this account did not post and merged over this account's own entries, so some of those rounds and its counts may not be this account's own.) A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. Batching the remaining fixes and verifying them before the next push, or dropping this PR's reviews to --severity-floor critical, keeps the loop from re-deriving the same set. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

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

收敛情况:第 3 轮发布了 2 条行内评论,其中 2 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/serve/conversations/standalone-session-service.ts(第 1、2 轮已出过发现,本轮又有 1 条)。新发现的产出速度没有下降。(证据说明:上一轮的数据来自并非本账号发布的标记,并与本账号自己的条目合并,上述轮次与其计数中的部分可能不属于本账号。)一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。把剩余修复攒成一批、验证后再推送,或将本 PR 的评审降到 --severity-floor critical,可以避免循环反复推导同一组发现。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment thread docs/developers/qwen-serve-protocol.md
Comment thread packages/cli/src/serve/conversations/standalone-session-service.ts
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Addressed the new Critical review feedback in 02bfccaf55.

Item Decision Resolution
Post-commit archive/unarchive errors can omit success buckets Agree Documented that a committed transcript move or conflict repair is not rolled back by a later cleanup ownership failure, may be returned only in errors, and requires an authoritative same-workspace state re-read. The existing wire result buckets remain unchanged.
Standalone deletion continued destructive cleanup after writer ownership was lost Agree Stop remaining attachment and staged-directory cleanup after SessionWriterError in normal deletion, and stop remaining attachment cleanup in committed deletion recovery. The journal remains for a later authoritative retry. Added regression coverage for both paths.
Previously reported forwarding-matrix and unchanged cleanup-fence probe suggestions Deferred These remain non-blocking Suggestions already recorded on the PR; this late review round is limited to correctness, security, data-loss, and regression fixes.
Existing failed Ubuntu Node 22 check on the prior SHA Ignored for this batch The failure is in an unrelated release-note helper test and the new commit retriggers CI, so the obsolete SHA was not rerun or patched.

Verification: full build, typecheck, lint, targeted Prettier check, and all 87 standalone-session-service tests passed. Independent frozen-head probes reproduced both findings and confirmed the fixed deletion call ordering.

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

Partially reviewed — gaps disclosed. Suggestions are inline.

1 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R4-2 four-way duplicated assertCleanupOwned fence closure plus duplicated cleanupOwnershipLost catch classification in standalone-session-service.ts — already reported as R1-1 (comment 3874017847); author explicitly deferred

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

Convergence: round 4 posted 1 inline comment(s), 1 of them reported for the first time; the previous round posted 2 (2 new). Findings keep coming back to the same files: packages/cli/src/serve/conversations/standalone-session-service.ts (findings in round 3; 1 more now). A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. No Critical finding is open on this round, so merging and moving the remaining Suggestion threads to a follow-up issue is available as an ending — a merged pull request cannot diverge further. (Observation only — nothing was withheld from this review because of this observation.)

中文说明

仅完成部分审查,审查缺口已披露。 建议见行内评论。

本轮确认的 1 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

收敛情况:第 4 轮发布了 1 条行内评论,其中 1 条是首次提出;上一轮发布了 2 条(其中 2 条首次提出)。发现反复回到同一批文件:packages/cli/src/serve/conversations/standalone-session-service.ts(第 3 轮已出过发现,本轮又有 1 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。本轮没有未决的 Critical,因此"合入后把剩余 Suggestion 线程转到后续 issue"是一个可选的结束方式——已合入的 PR 不会继续发散。(仅为观察——本轮评审未因此扣留任何内容。)

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

Comment on lines +1528 to +1533
if (
durable.kind !== 'standalone' ||
durable.location !== expectedLocation
) {
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] R4-1: The early-return guard in the new reconcileCatalogAfterLifecycleError — invalidate the catalog and session-list cache only when the session actually moved to the expected location — has no test pinning its negative case. The two "invalidates the catalog…" tests cover only the positive path, and the existing "does not misreport an unexpected batch failure as a session conflict" test already exercises this guard branch (the archive fails while the durable location is still active, not the expected archived) but asserts nothing about catalog invalidation. As it stands, deleting the guard is a mutation no assertion catches: both invalidation tests would still observe markSessionCatalogChanged called once through the same reconcile call, so a future refactor could ship unconditional invalidation — every failed archive/unarchive where nothing moved (including standalone_session_not_found and plain storage failures) would call markSessionCatalogChanged() and drop the session-list cache, churning catalog-change markers and forcing needless client re-lists, with the suite green.

Verified by a mutation probe in a scratch tree:

control (unmodified):        87/87 pass
mutant (guard deleted):      87/87 pass  — the mutation survives
mutant + negative assertion: fails — expected "spy" to not be called at all, but actually been called 1 times
guard restored + assertion:  passes

Suggested fix: add the negative assertions where the error path is already exercised — in "does not misreport an unexpected batch failure as a session conflict":

expect(harness.bridge.markSessionCatalogChanged).not.toHaveBeenCalled();
expect(harness.invalidateSessionListCache).not.toHaveBeenCalled();

(an unarchive-side variant would also cover the 'active' expectation). Fix witness: those assertions must go red when the guard is removed — removing the durable.location !== expectedLocation early return and running that test must fail.

中文说明

新的 reconcileCatalogAfterLifecycleError 中的提前返回守卫(仅当会话确实移动到预期位置时才失效目录和会话列表缓存)没有针对其反向场景的测试固定。两个 "invalidates the catalog…" 测试只覆盖了正向路径;已有的 "does not misreport an unexpected batch failure as a session conflict" 测试实际已经进入了该守卫分支(归档失败时持久化位置仍为 active,不等于预期的 archived),但没有对目录失效做任何断言。目前删除该守卫是一个没有任何断言能捕获的突变:两个失效测试仍会观察到 markSessionCatalogChanged 经由同一次 reconcile 调用被执行一次,因此未来的重构可能会带上无条件的失效逻辑——所有未发生移动的失败归档/取消归档(包括 standalone_session_not_found 和普通存储失败)都会调用 markSessionCatalogChanged() 并清空会话列表缓存,导致目录变更标记被反复触发、客户端被迫无谓地重新拉取列表,而测试套件仍然全绿。

已在隔离 scratch tree 中通过突变探针验证:对照组(未修改)87/87 通过;突变体(删除守卫)87/87 通过——突变存活;突变体加上负向断言后失败(expected "spy" to not be called at all, but actually been called 1 times);恢复守卫后同一断言通过。

建议修复:在已经覆盖该错误路径的测试中补充负向断言——在 "does not misreport an unexpected batch failure as a session conflict" 中加入 expect(harness.bridge.markSessionCatalogChanged).not.toHaveBeenCalled();expect(harness.invalidateSessionListCache).not.toHaveBeenCalled();(取消归档侧的变体可同时覆盖 'active' 预期)。修复验证:移除该守卫后这些断言必须变红——删除 durable.location !== expectedLocation 提前返回并运行该测试必须失败。

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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thanks — deferring. This is a valid Suggestion-level negative-assertion gap, not a current correctness defect. After four review rounds, we are limiting this PR to Critical correctness fixes and keeping the verified guard unchanged.

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

Copy link
Copy Markdown
Collaborator Author

Main synchronization update

Merged the latest origin/main (4b5396c69a) into this branch without conflicts and pushed merge commit 86f45c7a8c. This brings the PR onto the workflow revision that requires check:tui-dep-direction; that check now passes locally.

Verification on the exact pushed commit:

  • npm run check:tui-dep-direction
  • npm run build
  • npm run typecheck
  • npm run lint
  • Prettier check for all PR-changed files
  • CLI lifecycle tests: 152/152 passed
  • Core session/config/lease tests: 972 passed, 3 skipped
  • Two clean diff audits; the merge tree exactly matches Git's conflict-free merge result, and the PR remains limited to its existing 11 files

Review disposition: no new Critical issue was discovered or fixed during this synchronization. The remaining unresolved R4-1 item already has an operator reply and remains deferred because it is a Suggestion-level negative-test gap rather than an exact-head correctness defect.

No maintainer decision is needed. GitHub checks have restarted for the new head.

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

Partially reviewed — gaps disclosed.

3 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:

  • R1-3 archive/unarchive assertCleanupOwned fallback asymmetry — already reported (comment 3874017866), author explicitly deferred
  • R1-5 daemon-layer assertCleanupOwned forwarding unpinned — already reported (comment 3874017852), author explicitly deferred
  • R4-1 reconcileCatalogAfterLifecycleError negative-branch assertion gap — already reported (comment 3878373091), author explicitly deferred this round

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

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

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

  • packages/core/src/services/sessionService.ts:1554 (+5 locations) — [probe] six of seven new SessionWriterError rethrow guards unpinned by tests (code-age rule: unchanged since the previous round)
  • packages/core/src/services/session-writer-lease.ts:2103 — [probe] zero-inode lock-identity gate unreachable by fault-injection tests (code-age rule: unchanged since the previous round)
中文说明

仅完成部分审查,审查缺口已披露。

本轮确认的 3 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。

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

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

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

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

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

Independent pass at 86f45c7. One P2 inline: the archive/unarchive fences were swapped to assertCleanupOwned without the assertCanMutate fallback the cleanup paths keep, and the production callers only pass assertCanMutate — so those fences currently never fire on the runtime-generation check. The lease/identity direction otherwise looks right (readVerifiedLockIdentity at acquire time narrows the check-then-act window); two residuals worth a look noted below. Not re-reporting the open R4-1 thread.

this.removeFileIfExists(active.filePath);
try {
options.assertCanMutate?.();
options.assertCleanupOwned?.();

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.

[P2] The mid-mutation fences in archiveSessions/unarchiveSessions (here and ~2831/2836/2840/2876/2894/2900/2906, plus the unarchive twins) were changed from options.assertCanMutate?.() to bare options.assertCleanupOwned?.() — unlike the cleanup paths in this same PR, which use (options.assertCleanupOwned ?? options.assertCanMutate)?.() (2608, 2751-2752, 2765-2766). The public options still advertise assertCanMutate, and routes/session.ts passes ONLY assertCanMutate to its archive/unarchive wrappers (~2500-2553, endpoints 5569/5597/5658/5690; acp-http/dispatch.ts too), so on those production paths the fences are silent no-ops: a runtime going stale mid-archive is no longer aborted between the session-file / pr-sidecar / prompt-ledger moves. Suggestion: resolve once at the top — const assertCleanupOwned = options.assertCleanupOwned ?? options.assertCanMutate; — matching the cleanup-path pattern, and add a regression test that passes only assertCanMutate.

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 taking — the route and ACP callers pass assertCanMutate into archiveDaemonSessions/unarchiveDaemonSessions, which acquire the writer lease and then pass both assertCanMutate and the lease-backed assertCleanupOwned into SessionService (session-archive.ts:923-927 and 1121-1125). The standalone callers also pass both. Thus these post-commit fences are active for every production caller; adding the fallback would only harden the optional direct-call API shape already deferred as Suggestion R1-3, not fix a current production regression.

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

Partially reviewed — gaps disclosed.

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

Not reviewed: build-and-test — required Node 22 configuration was not verified because the local run used Node 24 and macOS/Windows Node 22 checks were skipped.

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

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

  • docs/developers/qwen-serve-protocol.md:2503 — [review] D6-1 conflict-repair partial-success semantics are not pinned by tests

Convergence: round 6 posted 3 inline comment(s), 3 of them reported for the first time. Findings keep coming back to the same files: packages/core/src/services/session-writer-lease.ts (findings in round 2; 1 more now). (Evidence: the previous round was recovered from a marker this account did not post and merged over this account's own entries, so some of those rounds may not be this account's own.) A cluster that keeps producing siblings usually means the fixes are treating instances of a shared root cause — triaging that cause before the next round, or splitting an independent cluster into its own pull request, tends to end the loop faster than fixing them one at a time. (Observation only — nothing was withheld from this review because of this observation.)

[Critical] R6-1 [certifies-falsely] [new-surface] packages/cli/src/serve/conversations/standalone-session-service.ts:2023 — Runtime ownership loss still permits unfenced staged-directory cleanup. If assertRuntimeCurrent(runtime) throws ConversationRuntimeOwnershipError, cleanupOwnershipLost remains false; attachment cleanup fails and is swallowed, then fresh deletion can call removeStagedStandaloneDirectory without either ownership fence. Witness: not run — the code trace shows this error fails the instanceof SessionWriterError classification and enters the destructive branch. Fix constraint: conversation-runtime-manager.ts:61-66 throws ConversationRuntimeOwnershipError, not SessionWriterError. Add fresh-deletion and recovery tests that lose runtime ownership during cleanup and require every destructive cleanup to be skipped.

[Critical] R6-2 [certifies-falsely] [new-surface] packages/core/src/services/session-writer-lease.ts:2051 — Async lock ownership checks accept a byte-identical replacement. readOwnedLock() checks pathname identity before a pathname-based fs.readFile() but never re-checks it afterward, so an atomic byte-identical replacement can pass owner/raw checks and be falsely certified for append, sealing, or release. Witness: not run — the unchecked interval is directly present and the adjacent descriptor implementation demonstrates the missing post-read identity check. Add a coordinated replacement-during-read regression; release must not remove the replacement.

[Critical] R6-4 [certifies-falsely] [new-surface] packages/core/src/services/sessionService.ts:2805 — Retries after post-commit ownership loss leave source-state sidecars stranded. After the transcript move commits and ownership loss aborts sidecar cleanup, a retry returns alreadyArchived/alreadyActive before moving PR, worktree, or ledger sidecars. Probe: archive retry kept {source:true,target:false}; unarchive retry did the same, while temporary retry reconciliation flipped both to {source:false,target:true}. Add archive/unarchive regressions that retry under fresh ownership and require every stranded sidecar to reconcile before returning already-target.

中文说明

仅完成部分审查,审查缺口已披露。

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

未审查:build-and-test — required Node 22 configuration was not verified because the local run used Node 24 and macOS/Windows Node 22 checks were skipped。

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

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

收敛情况:第 6 轮发布了 3 条行内评论,其中 3 条是首次提出。发现反复回到同一批文件:packages/core/src/services/session-writer-lease.ts(第 2 轮已出过发现,本轮又有 1 条)。(证据说明:上一轮的数据来自并非本账号发布的标记,并与本账号自己的条目合并,上述轮次中的部分可能不属于本账号。)一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)

[Critical] R6-1 [certifies-falsely] [new-surface] packages/cli/src/serve/conversations/standalone-session-service.ts:2023 — Runtime ownership loss still permits unfenced staged-directory cleanup. If assertRuntimeCurrent(runtime) throws ConversationRuntimeOwnershipError, cleanupOwnershipLost remains false; attachment cleanup fails and is swallowed, then fresh deletion can call removeStagedStandaloneDirectory without either ownership fence. Witness: not run — the code trace shows this error fails the instanceof SessionWriterError classification and enters the destructive branch. Fix constraint: conversation-runtime-manager.ts:61-66 throws ConversationRuntimeOwnershipError, not SessionWriterError. Add fresh-deletion and recovery tests that lose runtime ownership during cleanup and require every destructive cleanup to be skipped.

[Critical] R6-2 [certifies-falsely] [new-surface] packages/core/src/services/session-writer-lease.ts:2051 — Async lock ownership checks accept a byte-identical replacement. readOwnedLock() checks pathname identity before a pathname-based fs.readFile() but never re-checks it afterward, so an atomic byte-identical replacement can pass owner/raw checks and be falsely certified for append, sealing, or release. Witness: not run — the unchecked interval is directly present and the adjacent descriptor implementation demonstrates the missing post-read identity check. Add a coordinated replacement-during-read regression; release must not remove the replacement.

[Critical] R6-4 [certifies-falsely] [new-surface] packages/core/src/services/sessionService.ts:2805 — Retries after post-commit ownership loss leave source-state sidecars stranded. After the transcript move commits and ownership loss aborts sidecar cleanup, a retry returns alreadyArchived/alreadyActive before moving PR, worktree, or ledger sidecars. Probe: archive retry kept {source:true,target:false}; unarchive retry did the same, while temporary retry reconciliation flipped both to {source:false,target:true}. Add archive/unarchive regressions that retry under fresh ownership and require every stranded sidecar to reconcile before returning already-target.

— gpt-5.6-sol@954e5164 via Qwen Code /review (v0.22.2)

Comment thread docs/developers/qwen-serve-protocol.md Outdated
Comment thread packages/core/src/services/session-writer-lease.ts Outdated
Comment thread packages/core/src/services/sessionService.ts
doudouOUC and others added 2 commits August 29, 2026 03:51
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
@doudouOUC

Copy link
Copy Markdown
Collaborator Author

Round 6 follow-up is complete on head 4a42ea1.

Discovered issues and actions:

  • R6-1 accepted: runtime ownership loss during fresh deletion or recovery could fall through to destructive auxiliary cleanup. ConversationRuntimeOwnershipError is now treated like writer-lease loss, so attachment, staged-directory, and journal cleanup stop fail-closed. Fresh and recovery regressions were added.
  • R6-2 accepted: asynchronous lock verification had an identity gap across pathname reading. It now performs a bigint lstat after the read and rejects identity replacement; a coordinated byte-identical replacement regression covers the interval.
  • R6-3 accepted: session listing cannot represent empty or unrecoverably damaged transcripts after a partial lifecycle commit. The protocol now prescribes exact lifecycle retry, and route tests cover empty archive and damaged unarchive retries.
  • R6-4 accepted: exact retries could report already-target while leaving worktree, PR, and prompt-ledger sidecars in the source state. The daemon now acquires a fresh writer or maintenance lease for already-target sessions; Core reconciles sidecars while repeatedly checking runtime generation and cleanup ownership.
  • R6-6 accepted: number dev/ino storage could collapse distinct Windows 64-bit identities. Lock identity is now bigint end to end, with a regression using two inode values that collide as numbers.
  • R6-5 rejected after exact-head review: an unreadable exact-path regular transcript has no observable embedded foreign identity, and the selected workspace storage namespace is the intentional physical provenance for the PR scoped empty, torn, and legacy-orphan maintenance. Readable foreign or mixed records already fail closed. Changing this would revoke the existing Session lifecycle operations are gated on provenance classification, making unclassifiable sessions unmanageable #9488 contract rather than repair an ownership bypass.

Deferred/ignored:

  • D6-1 remains deferred as the reviewer explicitly marked it non-blocking in round 6; no additional conflict-repair semantics or scope were added.
  • Positive/status bot noise, resolved older threads, and comments with prior operator replies were ignored.

Verification:

  • Core focused tests: 168 passed, 3 skipped; the final corruption-only rerun passed 69.
  • CLI focused tests: 156 passed; the final daemon route rerun passed 67.
  • npm run build, npm run typecheck, and npm run lint all passed after the final changes.
  • Targeted Prettier, clean diff checks, and two final broad audits passed.

Resolved review threads: 3. Decisions needed: none.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants