fix(serve): Repair persisted session lifecycle - #9626
Conversation
E2E Test ReportEnvironment: macOS 26.4.1 arm64, Node.js v22.22.3, npm 10.9.8, case-sensitive APFS fixture.
Known local-only limitation: CLI typecheck is blocked by the shared checkout's pre-existing Ink selection typings mismatch; no diagnostic points at the changed #9488 session files. |
🩺 serve daemon A/BBuilt the PR base vs this PR head
|
| field | PR base (before) | this PR (after) |
|---|---|---|
features[] |
— | "session_storage_conflict_repair" |
— Qwen Code · serve A/B
b93771c to
d58d179
Compare
|
Please do not rebase or force-push to an active PR as it invalidates existing review comments. Note for future reference, the bots always squash all changes into a single commit automatically as part of the integration. 中文请勿对活跃的 PR 执行 rebase 或 force-push,因为这会使已有的评审评论失效。另外,供日后参考:作为集成流程的一部分,机器人始终会自动将所有改动压缩(squash)为单个提交。 |
Rebase and verification updateRebased this Draft PR onto the current #9513 head Current-head verification:
Case-sensitive APFS lifecycle matrices previously passed for REST routes, storage mutation, and organization alias reconciliation, covering empty/damaged/orphaned transcripts, foreign and non-regular fail-closed behavior, active/archive default conflict behavior, deterministic archive/unarchive repair, transcript/export logical identity, and auxiliary-state cleanup. The final rebase does not add broad mixed-case session-list projection; normal generated UUID session IDs remain lowercase and #9513's list behavior is unchanged. |
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
d58d179 to
3c1bdf4
Compare
Updated minimal-scope E2E reportThis report supersedes the earlier broad-scope verification comments. The Draft PR is now rebased onto the current
Environment: macOS 26.4.1 arm64, Node.js v22.22.3, npm 10.9.8. Windows and Linux were not run locally. |
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Re-run note: no commits landed since the last pass — this pass re-attests the same head Template looks good ✓ Problem: unchanged — an observed, well-evidenced bug. Issue #9488 (open, P1) documents the root cause: lifecycle admission goes through the loadability classifier, so empty, torn, or orphaned transcripts that physically exist are reported as not found and cannot be deleted/archived/unarchived. Confirmed on Direction: aligned, unchanged — the PR implements the direction the issue suggested for its second work item (decouple lifecycle mutation from provenance classification while keeping ownership fences). The scope caveat stands: #9488 tracks two work items and the first (mixed-case spelling resolution at the storage boundary) is explicitly out of scope here, yet the body says "Fixes #9488" and would auto-close it. Whoever merges must make that call explicitly. Size: recomputed at this head — 1459 production-logic lines / 2414 test lines / 101 docs lines across 30 files. Still touches core paths ( Approach: the two-commit delta from the previous pass remains exactly the requested shape, nothing extra. The feedback commit resolves R9-4 by committing each copy's salvaged usage immediately after that copy's unlink instead of after all unlinks — one Map keyed by file path, explicit null handling, and a test pinning the ordering and failure propagation. The merge is clean: main moved one commit, 15 of the 17 touched files are byte-identical to the main side, and the two files both sides edited ( Risk: no elevated revert-risk paths matched (Stage 1e patterns re-checked against the 30 changed files this pass — no hits). CI at this head re-fetched this pass and still fully green, including both required checks. Moving on to code review. 🔍 中文说明重跑说明:上次通过后没有新提交——本次继续以同一头部 模板完整 ✓ 问题:未变 —— 已观测、证据充分的 bug(Issue #9488,开放,P1,已在 方向:对齐,未变。范围 caveat 仍然成立:#9488 含两个工作项,第一项(混合大小写)明确不在本 PR 范围,而正文写 "Fixes #9488" 会自动关闭 issue;合入者必须明确作出这个决定。 规模:本头部重新计算 —— 30 个文件中生产逻辑 1459 行、测试 2414 行、文档 101 行。仍触及核心路径( 方案:上一轮的两个增量仍恰好是所要求的形状,没有多余内容(R9-4 按副本即时提交 usage 抢救 + 干净的 main 合并,详见 Stage 2)。当前未决的不再是范围,而是本 PR 引入的栅栏布点的正确性——见 Stage 2 的第 11 轮核实。 风险:未命中高回滚风险路径(本次已对 30 个变更文件重查 Stage 1e 模式,无命中)。本头部 CI 本次重新抓取,仍全绿,包括两个必填检查。 进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewRe-run at Round 11 — R11-1, org-entry leak on a post-deletion fence trip: verified in the head code, and introduced by this diff. The PR adds Round 11 — R11-2, permanent sidecar leak on a mid-cleanup fence trip: verified in the head code. R10-1 (carried since round 1 as R1-4), pathname-vs-inode TOCTOU: unchanged — declined by explicit decision, and the follow-up issue the maintainer asked for (review 5006725473) still does not exist, re-checked this pass. Residual on record for the human approver. Round 11 also records eight probe-level items deferred under its convergence posture (recorded, not requested) and re-rates the earlier R9-2 protocol-doc finding to Suggestion; those live in the round-11 review's own ledger. No other new findings at this head. Carried open items, unchanged and non-blocking: the dead Maintainer evidence on the record (theirs, not mine): @wenshao's second full A/B — both arms rebuilt from scratch at the new merge-base, 25-case lifecycle matrix bit-for-bit stable across the rebase, and the usage-salvage question settled by measurement with a control row. Verdict unchanged: good to merge. That evidence covers everything up to sequenceDiagram
participant P1 as Client REST or ACP
participant P2 as Lifecycle route
participant P3 as Runtime registry
participant P4 as Archive coordinator
participant P5 as Writer lease
participant P6 as SessionService
participant P7 as Filesystem
P1->>P2: delete or archive or unarchive
P2->>P3: resolve runtime by physical ownership
P3-->>P2: one unambiguous runtime or fail closed
P2->>P4: take per session coordinator lock
P2->>P5: take lease, maintenance fallback for damaged heads
P5-->>P2: lease granted or live lease conflict rejects
P2->>P6: snapshot active and archived file identity
P6->>P7: open nofollow, stat dev and ino, read first record
P6-->>P2: snapshot, or non regular or foreign or ambiguous rejection
P2->>P6: revalidate identity and generation then unlink or rename
P6->>P7: verify same file, fence each sidecar, then mutate
P6-->>P1: per id buckets plus resolvedConflicts
Test evidence — the PR's own CI at
|
| Check | Conclusion |
|---|---|
| Test (ubuntu-latest, Node 22.x) | ✅ success (required) |
| Serve A/B (ubuntu-latest, Node 22.x) | ✅ success (required) |
| web-shell E2E Smoke (ubuntu-latest, Node 22.x) | ✅ success |
| Real daemon E2E / Java 11 | ✅ success |
| Desktop Shell (ubuntu-22.04) | ✅ success |
| Desktop Shell (windows-2022) | ✅ success |
| Live Host (macos-latest) | ✅ success |
| SDK Java (ubuntu Java 11/17/21, macOS Java 21, windows Java 21) | ✅ success |
| Secret scan (TruffleHog) | ✅ success |
| Dependency CVE audit | ✅ success |
| Classify PR / label / precheck | ✅ success |
| Test (macos-latest, Node 22.x) | ⏭️ skipped (fork PRs need runner approval) |
| Test (windows-latest, Node 22.x) | ⏭️ skipped (fork PRs need runner approval) |
| Integration Tests (CLI, No Sandbox) | ⏭️ skipped |
Sandboxed verification: a sponsored /verify run pinned to this head is in flight right now, triggered by the same comment that started this triage pass (run 32801005267); its report will post in this thread when it completes. The author lacks write access, so this is a sponsored run (pre-execution risk screen plus full workspace wipe before any PR code runs) — read its report with the same skepticism as the fork's own CI logs. One caveat: the round-11 leaks require a runtime-generation trip mid-operation, so a scripted endpoint A/B may not provoke them even when green — the scratch-tree probes attached to the round-11 comments are the evidence that pins those paths, in either direction.
Real-scenario testing: unattended CI run (issue_comment re-run) — no tmux is driven by this lane; the live-behavior signal is the sandboxed lane above plus @wenshao's two A/B rounds cited under maintainer evidence. Not verified by this pass: live behavior at exactly 49b67ebe (default paths covered by unit tests + green CI + the one-commit-earlier live A/B; the fence-trip race paths rest on the round-11 probes).
中文说明
代码审查
在 49b67ebe 重跑 —— 头部与上次通过时相同,上次对两个增量的审查(R9-4 按副本即时提交 usage 抢救的 782dd86、干净的单提交 main 合并 49b67ebe)原样沿用。本次新增内容:核实上次 triage 更新之后 /review 环节第 11 轮在同一头部上发布的发现。仅通过 API 对头部树做静态阅读,未执行任何 PR 代码。
第 11 轮 —— R11-1,删除完成后栅栏中断导致组织条目泄漏:已在头部代码中核实,且由本 diff 引入。 本 PR 在 removeSessionOrganization 的 catch 中新增 assertCanMutate?.()(头部 sessionService.ts:1357),并把栅栏作为 assertCanCommit 传入 org store 原子写入(1350–1352);diff 显示本 PR 之前该 catch 仅告警。当运行时代际在 org store 提交期间被替换(正是这些栅栏要防的竞态)时,会话文件已全部删除,但 removeSession 现在会在删除后抛错。重试时 location === undefined(2280)返回 false,if (removed) 守卫(2233)永远跳过组织清理;批量路径同样设守(2633 → 2652)。已核对所有组织删除调用点——全部以文件删除成功为前提,因此文件已不存在的组织条目永远无人清理:store 永久不一致,且错误失败响应会诱发永远无法收敛的重试。/review 环节的 HEAD 隔离探针逐步佐证;候选修复(恢复该删除后清理的仅告警契约,或先做组织清理再删文件)可使探针翻转。
第 11 轮 —— R11-2,清理过程中栅栏中断导致 sidecar 永久泄漏:已在头部代码中核实。 removeSessionFiles 先逐副本 unlink transcript(2299 循环),随后才执行四个清理助手,且每步之间都有 assertCanMutate 栅栏(2304–2311)。在该窗口内代际被替换时,transcript 已删除而 sidecar 原封未动;重试在 2280 处返回 false,永远到不了清理助手——这些助手在本文件中没有其他调用点,被中断的清理不可达。<id>.worktree.json、<id>.pr.json、<id>.ledger.jsonl 永久泄漏(file-history 备份有按房龄清扫器,这些没有)。探针佐证与候选修复(在 location === undefined 分支中回收——幂等且严格按 sessionId 匹配)见第 11 轮评论。
R10-1(自第 1 轮 R1-4 沿承),路径名-vs-inode TOCTOU:未变——经决定不做;维护者要求的跟进 issue(review 5006725473)本次复查仍未创建。残留记录留给人类批准者。
第 11 轮还在其收敛姿态下记录了 8 条探针级延后项(记录在案、本轮不要求修改),并把早先的 R9-2 协议文档发现降为 Suggestion;详见该轮评审自身台账。
本头部无其他新发现。遗留未决项,未变且不阻塞:死代码 knownLocation 选项与死代码 persistUsageBeforeTranscriptDeletion 包装——搜索确认两者仍未建跟进 issue,请补建以免清理蒸发;R9-2 协议文档措辞由作者按文档澄清处理、记录在案。
记录在案的维护者证据(是他们的,不是我的):@wenshao 的第二轮完整 A/B(两条 arm 从头重建、25 用例矩阵逐位稳定、usage 抢救问题以带对照行的实测定性),结论不变:可以合。该证据覆盖到 21da77c;@wenshao 随后批准了本头部 49b67ebe。@yiliang114 的重新批准已被推送在分支保护下撤销,需要重新取得。这些证据均不覆盖第 11 轮的竞态窗口——那是 A/B 端点不会触发的栅栏中断路径。
测试证据 —— 本头部 49b67ebe 上 PR 自己的 CI,本次重新抓取
所有检查已完成、无进行中、零失败——与上次相同:必填项在本头部全绿。(2026-08-25 重新抓取;唯一非成功非跳过的结论是一个被取消的机器人编排 route 任务,非本 PR 的 CI。)
沙箱验证:一次钉在本头部上的代跑 /verify 此刻正在运行(由触发本次 triage 的同一条评论发起),完成后报告会发布在本线程。作者无写权限,这是代跑(执行前风险筛查加全量工作区清空),报告请保持与 fork 自身 CI 日志同等的怀疑。注意:第 11 轮的泄漏需要操作中代际被替换才会出现,脚本化端点 A/B 即使全绿也未必能触发——钉住这些路径的证据是第 11 轮评论附带的隔离探针,两个方向都是。
真实场景测试:无人值守 CI 运行(issue_comment 重跑)——本车道不驱动 tmux;实测信号为上述沙箱车道与 @wenshao 的两轮 A/B。本次未验证:49b67ebe 精确头部上的实机行为(默认路径由单测 + 全绿 CI + 前一个提交的实测 A/B 覆盖;栅栏中断竞态路径以第 11 轮探针为准)。
— Qwen Code · qwen3.8-max
Reviewed at 49b67ebef391e96148828da9845dd740919edfa3 · re-run with @qwen-code /triage
CI fix updateThe Ubuntu failure was a deterministic test-fixture drift after lifecycle owner resolution switched to the authoritative maintainable-storage classifier. The production behavior was correct; the inherited Live-runtime test mocked only the old readable-session classifier, so its synthetic internal session had no physical-owner proof and the request correctly fell back to primary. Updated the test fixture to model maintainable storage ownership explicitly. No production code changed. Verification:
The separate Web Shell E2E failure was runner infrastructure: checkout could not remove a stale permission-owned |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 2304 passed · 0 failed · 2304 total Flakiness gate: ✅ 7 changed test file(s) x 5 identical rounds, no divergence 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:2304 通过 · 0 失败 · 2304 总计 抖动门:✅ 7 changed test file(s) x 5 identical rounds, no divergence Verification reportPR #9626 Deep Verification — fix(serve): Repair persisted session lifecycleVerdict: 中文摘要
Scope selectionCentral claim — delete/archive/unarchive can maintain owned regular transcripts that are empty, torn/malformed-headed, or legacy orphans, which base reports as Secondary claims
Out of scope (listed under Not covered): transcript loading/listing/export behavior, case-insensitive lookup, Live/task identity, Windows/macOS platforms, per-commit attribution. A/B load-bearing proof (central claim)Environment per arm:
Both arms' harnesses assert the loaded modules' realpaths stay inside their own tree before running (quoted in Methodology). Harness:
Counts: head arm 54/54, base arm 42/42 scripted expectations (base expectations encode "fails/stranded as predicted" — a base cell failing as predicted is a passed assertion). No cell mutated storage in any fence case on either arm. Note on row 20: base cannot even reach the sealed lock because the empty file is invisible to load-based classification — the stranding is total; head's Secondary-claim wire oracle (SDK surface)
Mutation matrix (vacuity / positive controls)Every mutant is a fine-grained, interface-preserving revert applied to head source, run against the suite that should catch it, then restored (sha256-verified byte-identical restore after each). Positive-control rule satisfied: each mutant landed in the same file whose tests kill it, and failures are behavioral (expected-vs-actual), not import/collection breakage.
No survivors. Witnesses: M4's kill set is informative beyond vacuity: the route-level hunk is responsible for the empty/damaged flips, while orphan route cells survive load-based classification because their heads are readable — the unloadable-orphan flip is carried by the same classifier (M1), consistent with the design doc ("cleanup of legacy orphan children without making them loadable"). Targeted gates (head, clean tree)
FindingsNo blockers.
CorrectionsNone required — no inaccurate prior-review claims were observed in this round. Not covered
MethodologySingle container ( Flakiness gate logEvidence imagesHarness scripts and raw logs are in the workflow run artifacts (7-day retention). — Qwen Code · sandboxed verification |
|
Confidence: 2/5 — two new Critical findings landed on this head after the last pass, and I verified both mechanisms in the head code; not mergeable as-is, but the fix directions are small, named, and probe-verified. Stepping back: the last pass deferred at 3/5 on the belief that the review had converged — zero new findings, required CI green end to end, a maintainer approval standing. Round 11 broke that premise the same evening: the fences this PR adds to the delete path create two trip windows the cleanup cannot survive — an org entry leaked post-deletion (R11-1) and sidecars leaked mid-cleanup (R11-2) — each witnessed by a scratch-tree probe at HEAD, each leaving a retry that can never converge because the retry path returns before reaching the cleanup code. I did not take the findings on faith: re-reading the head code shows the catch's new Why there is no bot approval — and no new bot review either:
Still true from the last pass: CI is fully green at this head with nothing pending, and @wenshao's two rebuilt-from-scratch A/B rounds remain the strongest behavioral evidence on record (covering through Open items for the author: fix (or rebut with an equally concrete probe) R11-1 and R11-2; file the two dead-code follow-up issues so the cleanup doesn't evaporate. Open items for the maintainer: the ⏸️ Deferring to @wenshao and @yiliang114 — the review re-opened on substance, not procedure. The bot's gate stays closed until the round-11 Criticals are resolved on a new head. 中文说明置信度:2/5 —— 上次通过之后同一头部上新增两个 Critical,且两者机制均已在头部代码中核实;当前形态不宜合入,但修复方向小、明确、并已有探针验证。 退一步看:上次以 3/5 延迟决定,前提是评审已收敛——零新发现、必填 CI 全绿、维护者批准在案。第 11 轮在当晚打破了这一前提:本 PR 为删除路径添加的栅栏制造了两个清理无法幸存的中断窗口——删除完成后组织条目泄漏(R11-1)、清理过程中 sidecar 泄漏(R11-2)——各有 HEAD 隔离探针佐证,且重试永远无法收敛,因为重试路径在到达清理代码之前就已返回。我没有照单全收:重读头部代码,catch 中新增的 为何没有机器人批准——也没有新的机器人评审:
上次仍然成立的部分:本头部 CI 全绿且无待处理;@wenshao 两轮从头重建的 A/B 仍是在案最强行为证据(覆盖到 留给作者:修复(或以同样具体的探针反驳)R11-1 与 R11-2;为两处死代码补建跟进 issue。留给维护者:"Fixes #9488" 自动关闭与 issue 两个工作项的取舍;R1-4 TOCTOU 跟进 issue 仍未创建;@wenshao A/B 的三条备注;满意后撤销过期的 request-changes 评审。 ⏸️ 转交 @wenshao 与 @yiliang114 —— 评审因实质问题重开,不是程序性的。在第 11 轮 Critical 于新头部上解决之前,机器人门禁保持关闭。 — Qwen Code · qwen3.8-max Reviewed at |
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
[codex] Thanks — deferring both non-blocking loose ends to maintainer follow-up. This PR has already exceeded five review rounds, so the current pass is limited to correctness/security/data-loss/regression fixes. The mixed-case work item is intentionally outside this PR’s narrowed scope, so whether |
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
CI fix update
Commit: Verification:
|
|
@qwen-code /triage |
|
Sandboxed verification: ❌ not passed — the PR could not be built - workflow run The PR could not be built because 中文 — 判定:❌ 不通过 · PR 构建失败由于 Install/build log— Qwen Code · sandboxed verification |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
11 Suggestion-level finding(s) this review confirmed are already reported on this PR and are not repeated:
- knownLocation dead option on Archive/UnarchiveSessionsOptions — already reported (comment 3836892037), author deferred
- removeSessions batch assertCanCommit dead switch — already reported (comment 3837907185), author deferred
- ledger-fence regression test throws one fence too early — already reported (round-9 deferred list, corruption.test.ts:1016)
- maintenance lease cannot take over a sealed writer lock / takeoverPolicy undeclared — resolved as R1-2 (documented intentional fail-closed) and already reported as R1-6 (comment 3836892042), author deferred
- FIFO test wall-clock flake risk — same test already reported as R5-8 (comment 3838888962), author deferred
- findOrdinarySessions double scan on the mixed-batch not-found path — already reported (round-8 deferred list, routes/session.ts:2122)
- enable/removeTasksForSessions assertCanCommit forwarding untested — already reported (comment 3837907184), author deferred
- resolveConflicts untested on workspace-qualified REST routes — already reported as R1-12 (comment 3836892055), author deferred
- integration-tests capability assertion unreachable from workspace suites — already reported as R5-9 (comment 3838888965), author deferred; the gate is failing at the reviewed head (branch lag per maintainer analysis)
- over-window non-object-prefix heads remain maintainable by documented design (foreign-head residual) — adjudicated in the R6-1 cluster (comment 3839400922); making all over-window heads fail closed would regress the resolved blocker at comm…
- fence trip after the point of no return orphans sidecars/org entry (delete retry does not repair) — already reported (comment 3838367757), author deferred: safe healing needs already-state reconciliation semantics
Not reviewed: build-and-test — the changed integration-tests assertion is collected only by the Integration Tests (CLI, No Sandbox) gate, which is FAILING at the reviewed head (missing typecheck:integration script — branch lag per maintainer analysis); the scoped workspace run could not collect it. Test efficacy also unvalidated (harness control never ran; mutants and hunk probes unprobed).
Not reviewed: reverse audit — stopped before round 1 by the review time budget.
Deferred under the convergence posture (round 10, not a blocker) — recorded, not requested in this round:
packages/core/src/services/usageHistoryService.ts:314 — [probe] commit silently drops salvage on unreadable usage history, inverting the documented write-anyway policy; transcript deletion makes the loss permanent
[Critical] R1-4: Still stands — carried since round 1; re-posted by the parallel round-9 review; the mechanism is present at this byte-identical head. The final identity assertion validates a pathname, but the subsequent unlink operates on that pathname without being bound to the validated inode: in removeSessionFiles (packages/core/src/services/sessionService.ts), assertMaintainableSessionUnchanged lstats the path and removeFileIfExists then unlinks that same path — a concurrent process can swap in an unvalidated replacement between the final stat and the mutation, and delete/archive/unarchive destroys a file no identity check ever validated. Witness: round-8 scratch-tree probe (applicable unchanged — the file is byte-identical at this head): removeSession returned true while the swapped-in file's inode differed from the validated identity and its content did not survive. The author's wontfix rationale (a correct fix needs a cross-platform atomic claim/no-replace protocol plus crash recovery) is substantive, but only code retires a blocker; the maintainer asked that this residual be tracked in a follow-up issue (review 5006725473). Fix direction: atomically claim the source into a private staging path (rename), verify the staged inode, then delete or publish — or land the documented diagnostic and file the follow-up before merge.
中文说明
仅完成部分审查,审查缺口已披露。
本轮确认的 11 条建议级发现已在 PR 上报告过,不再重复发布(列表见上方英文部分)。
未审查:build-and-test — the changed integration-tests assertion is collected only by the Integration Tests (CLI, No Sandbox) gate, which is FAILING at the reviewed head (missing typecheck:integration script — branch lag per maintainer analysis); the scoped workspace run could not collect it. Test efficacy also unvalidated (harness control never ran; mutants and hunk probes unprobed)。
未审查:反向审计——评审时间预算不足,未能开始第 1 轮。
收敛姿态下延后(第 10 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
[Critical] R1-4: Still stands — carried since round 1; re-posted by the parallel round-9 review; the mechanism is present at this byte-identical head. The final identity assertion validates a pathname, but the subsequent unlink operates on that pathname without being bound to the validated inode: in removeSessionFiles (packages/core/src/services/sessionService.ts), assertMaintainableSessionUnchanged lstats the path and removeFileIfExists then unlinks that same path — a concurrent process can swap in an unvalidated replacement between the final stat and the mutation, and delete/archive/unarchive destroys a file no identity check ever validated. Witness: round-8 scratch-tree probe (applicable unchanged — the file is byte-identical at this head): removeSession returned true while the swapped-in file's inode differed from the validated identity and its content did not survive. The author's wontfix rationale (a correct fix needs a cross-platform atomic claim/no-replace protocol plus crash recovery) is substantive, but only code retires a blocker; the maintainer asked that this residual be tracked in a follow-up issue (review 5006725473). Fix direction: atomically claim the source into a private staging path (rename), verify the staged inode, then delete or publish — or land the documented diagnostic and file the follow-up before merge.
— qwen3.8-max via Qwen Code /review (v0.22.0)
| ``` | ||
|
|
||
| `errors` entries have `{ "sessionId": "<uuid>", "error": "message" }`. Active and archived files with the same id are treated as a conflict and reported in `errors`; no file is overwritten. | ||
| `resolveConflicts` is optional and defaults to `false`. By default, active and archived files with the same id are reported in `errors`, and neither copy is moved, removed, or overwritten. Archiving a live session still performs the strict close described above before classifying the conflict, so that close may flush queued records to the active transcript. With `resolveConflicts: true`, archive keeps the archived copy, removes the active copy, and reports the id in both `archived` and `resolvedConflicts`. `errors` entries have `{ "sessionId": "<uuid>", "error": "message" }`. |
There was a problem hiding this comment.
[Critical] R9-2: Still stands — the head is unchanged since this finding was verified (60118c3fe0). This sentence (and its unarchive mirror below) states unconditionally that resolveConflicts: true repairs a conflict, but for mixed local/foreign archive states — a state this PR itself defines, classifies, and tests — classification throws ambiguous_project before any repair branch, regardless of the flag. The id lands in errors as the redacted 'Session operation failed.' with both copies untouched; the PR's own test 'does not fall back to primary for mixed foreign and local internal storage' posts resolveConflicts: true for exactly this shape and asserts that outcome. 08-session-lifecycle.md carries the fail-closed hedge; this reference, which integrators build clients against, omits it in both sections. A client built on this prose treats the permanent, operator-intervention-required state as a transient failed repair and retries forever, and the redacted message gives no hint that retrying cannot help. Witness: the pinned test passes at this head with archived: [], the redacted error, and both transcripts byte-identical. Add the hedge to both sections, mirroring 08-session-lifecycle.md: ownership-safety checks still fail closed with this option — mixed local/foreign states are reported in errors and neither copy is moved, with or without resolveConflicts.
中文说明
R9-2:仍然存在——自本发现被验证以来 head 未变(60118c3fe0)。该句(以及下方 unarchive 一节的对应句)无条件声称 resolveConflicts: true 会修复冲突;但对于 local/foreign 混合归档状态——本 PR 自己定义、分类并测试了的状态——分类会在任何修复分支之前抛出 ambiguous_project,与该选项无关。该 id 会落入 errors,错误信息为脱敏后的 'Session operation failed.',两份副本均不被移动;本 PR 自己的测试 'does not fall back to primary for mixed foreign and local internal storage' 正是对这种形态发送 resolveConflicts: true 并断言了该结果。08-session-lifecycle.md 写有 fail-closed 限定,而集成方据以开发客户端的本协议参考文档在两处小节中都遗漏了该限定。按此措辞实现的客户端会把这种需要人工介入的永久状态当作可重试的临时失败而无限重试,且脱敏错误信息没有给出任何"重试无用"的线索。证据:上述测试在本 head 通过,断言 archived: []、脱敏错误、两份副本逐字节不变。建议在两个小节都补上限定,与 08-session-lifecycle.md 一致:该选项下所有权安全检查仍会失败关闭——local/foreign 混合状态会进入 errors,无论是否携带 resolveConflicts,两份副本都不会被移动。
— qwen3.8-max via Qwen Code /review (v0.22.0)
There was a problem hiding this comment.
Deferring after independent triage: this is a documentation clarification, not a Critical correctness or data-loss defect. Runtime behavior already fails closed and is covered by the mixed local/foreign ownership tests. This PR is in review round 10 and is restricted to Critical fixes, so the prose clarification should be handled separately.
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Synced the PR branch with the latest
No review threads were addressed by this main-sync commit (0/0 to resolve). |
|
@qwen-code /triage |
|
Sandboxed verification: ✅ passed — merge-ready (agent verdict) - workflow run Ran the PR in an isolated, token-free container: A/B against the base build, mock-free harness assertions, targeted gates. Advisory evidence for human reviewers — not a review, an approval, or a CI check. Scripted assertions: 47 passed · 0 failed · 47 total Flakiness gate: 中文 — 判定:✅ 通过 · 可合入(agent 判定)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:47 通过 · 0 失败 · 47 总计 抖动门: Verification reportSandboxed verification: ✅ merge-ready — 47/47 scripted assertions passed, verified head 中文摘要
Previous-round statusThe previous round's file was a build-failure notice, not a substantive report —
Central claim and A/B proofCentral claim: delete/archive/unarchive maintain owned persisted transcripts that are empty, torn-headed, or legacy orphans — classification is by physical storage ownership, not conversation loadability. Secondary claims: (1) default conflict behavior stays non-mutating, Core-level A/B (
|
| Cell | base (1fffa5108d) |
head (49b67ebe) |
flip |
|---|---|---|---|
| delete healthy (control) | removed, file gone | removed, file gone | — (parity) |
| delete empty | removed:false, stranded |
removed:true, gone |
✅ |
| delete torn head | removed:false, stranded |
removed:true, gone |
✅ |
delete identity-less {} head |
refused, intact | refused (unknown_project), intact |
parity, fail-closed |
| delete 1.5 MiB unterminated JSON line | not-found, intact | refused (SessionTranscriptIdentityUnavailableError), intact |
parity, fail-closed |
| archive empty | notFound, unmoved | archived, 0 bytes moved verbatim | ✅ |
| unarchive torn | notFound, unmoved | unarchived, bytes verbatim | ✅ |
| archive conflict, default | error, both copies byte-identical | error, both copies byte-identical | parity |
archive conflict, resolveConflicts:true |
option ignored, conflict stands | archived copy kept, active removed, resolvedConflicts set |
✅ additive |
unarchive conflict, resolveConflicts:true |
option ignored | active kept, archived removed | ✅ additive |
| delete conflict | both copies removed | both copies removed | parity (compat) |
| symlink entry | followed: link removed (target intact) | typed non_regular, link+target untouched |
✅ fail-closed |
| directory entry | tolerant not-found, intact | typed non_regular, intact |
parity on safety |
| FIFO entry | blocked (8 s budget, SIGTERM-unkillable open) | typed non_regular in 0 ms |
✅ fail-closed |
| foreign-workspace head | not-found, untouched | not-found, untouched | parity (fence held) |
Head 18/18, base 15/15 — every base cell failed exactly as the PR's premise predicts.
Route-level A/B (route-lifecycle-ab.mjs, compiled createServeApp on a real loopback listener, ordinary primary + internal Conversations secondary)
Witness: 02-route-ab-orphan-flip.png. The ACP bridge is a call-logging fake honoring the bridge contract (getSessionSummary throws SessionNotFoundError for non-live sessions); routes, registry, SessionService, and filesystem are the real compiled code.
| Cell | base | head |
|---|---|---|
| orphan, qualified delete | 404 session_not_found, file stranded |
200, removed, file gone |
| orphan, unqualified delete | 200 but notFound (misrouted to primary), file stranded |
200, removed via internal owner |
| orphan, qualified archive | 404, unmoved | 200, archived, bytes verbatim |
| healthy internal delete (control) | 200 removed | 200 removed |
| foreign, qualified delete | 404, untouched | 404, untouched |
| conflict, qualified archive default | HTTP 409 session_conflict envelope, both preserved |
HTTP 200 + per-session error naming resolveConflicts, both preserved |
6/6 both arms. The base unqualified-orphan cell is the sharpest: the file existed, was owned, and was readable — yet the daemon reported not-found after silently falling back to the primary runtime. The 409→200 envelope change matches the PR's stated breaking change, and the conflict reason survives in the per-session error (exempt from redaction).
Vacuity / mutation matrix (03-vacuity-mutation-kills-lifecycle-tests.png)
Mutation: in readMaintainableSessionIdentity, unreadable head ⇒ state absent (exactly the base's loadability dependence), interface preserved.
| Guard | Suite | Result |
|---|---|---|
| unreadable regular files still occupy their state (classifier) | sessionService.corruption.test.ts lifecycle block |
killed — 16 tests fail on behavioral assertions (expected false to be true on removeSession(...).resolves.toBe(true), expected [] to have a length of 1, …); restore → 50/50 green |
| orphan classification at core level | same block | survived by design: an orphan has a readable head, so the core classifier was never its gate — the gate is the route resolver's loadability check, pinned instead by the route A/B above and the multi-workspace suite (143/143). Coverage distribution, not a gap |
The orphan test staying green under the classifier mutation is the evidence the two layers are independently pinned.
Findings
No blocking findings. Non-blocking observations:
- Directory entries change response shape, not safety. Base reported a directory in the chats dir as not-found; head reports a typed per-session error (
non_regular). Both leave the directory and its contents untouched (asserted). Clients that treated "not in notFound and not in errors" as absent should note the entry now lands inerrors. - Base hazards documented by the control cells (not PR defects): base follows symlinks during classification and blocks indefinitely opening a FIFO — the base arm's FIFO cell required SIGKILL because the blocked
open()ignores SIGTERM. Head'sO_NOFOLLOW|O_NONBLOCKopen fixes both. - Fail-closed shapes for unclassifiable heads (
{}first record →unknown_project; >1 MiB unterminated JSON-looking line →SessionTranscriptIdentityUnavailableError) are deliberate and storage-safe; both refuse without mutating.
Not covered
- Per-commit attribution: shallow depth-2 checkout (18 commits in metadata, only the merge triple reachable); the aggregate
HEAD^1..HEADdiff was verified instead. - Windows: the fence skips
O_NOFOLLOW|O_NONBLOCKon win32; not exercised here (Linux container). - TOCTOU revalidation (
assertMaintainableSessionUnchanged): not driven by an independent race harness; pinned by the PR's own tests ("replaced after validation", "disappears and reappears during classification", "in-place rewrite during ownership classification") — all green. - ACP transport / SDK wire behavior: covered by their suites (cli transport+server 1424 passed 0 failed; SDK 349/349), no independent wire A/B.
- Full-repo suite and typecheck: left to the PR's CI; targeted workspaces only were run here.
- Base-arm teardown artifact: the base core harness hangs after printing all results (blocked FIFO open in the tolerant reader); runs were wrapped in
timeout -kand all output was captured before the kill. Harness-level, not PR code.
Methodology
Environment: node:22-bookworm container, merge-ref checkout (HEAD = merge, HEAD^1 = base 1fffa5108d, HEAD^2 = verified head 49b67ebe), npm ci + npm run build pre-completed at HEAD. Core A/B drove packages/core/dist SessionService directly; the base arm used a tmp/base-tree worktree rebuilt with scripts/build.js --cli-only (nested per-workspace node_modules mirrored from the lockfile layout; @qwen-code/* overlay symlinks repointed into the base tree and verified by ESM import probe). Route A/B booted each tree's compiled createServeApp on a real HTTP listener with the repo's two-workspace registry topology. Raw per-arm JSON logs, harness scripts, and base-build.log live in this artifact directory; images were produced with scripts/verify-capture.mjs.
Flakiness gate log
integration test, out of gate scope: integration-tests/cli/qwen-serve-routes.test.ts
rounds=5 files=11 skipped=1
file packages/acp-bridge/src/sessionAttachments.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/sessionAttachments.test.ts
file packages/cli/src/serve/acp-http/transport.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/transport.test.ts
file packages/cli/src/serve/multi-workspace-sessions.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/multi-workspace-sessions.test.ts
file packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/scheduled-task-session-lifecycle.test.ts
file packages/cli/src/serve/server.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server.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/services/session-organization-service.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/session-organization-service.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
file packages/core/src/services/usageHistoryService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/usageHistoryService.test.ts
file packages/sdk-typescript/test/unit/DaemonClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonClient.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/acp-bridge/src/sessionAttachments.test.ts: PPPPP
packages/cli/src/serve/acp-http/transport.test.ts: PPPPP
packages/cli/src/serve/multi-workspace-sessions.test.ts: PPPPP
packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: PPPP
packages/cli/src/serve/server.test.ts: PPPP
packages/cli/src/serve/server/session-archive.test.ts: PPPP
packages/core/src/services/session-organization-service.test.ts: PPPP
packages/core/src/services/sessionService.corruption.test.ts: PPPP
packages/core/src/services/sessionService.test.ts: PPPP
packages/core/src/services/usageHistoryService.test.ts: PPPP
packages/sdk-typescript/test/unit/DaemonClient.test.ts: PPPP
verdict: timeout
summary: only 4 of 5 rounds fit the 15-minute budget; the completed rounds agreed
--- per-invocation detail (full copy in the artifact) ---
round 1 · packages/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server/session-archive.test.ts: P (exit 0)
round 1 · packages/core/src/services/session-organization-service.test.ts: P (exit 0)
round 1 · packages/core/src/services/sessionService.corruption.test.ts: P (exit 0)
round 1 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 1 · packages/core/src/services/usageHistoryService.test.ts: P (exit 0)
round 1 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 2 · packages/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 2 · packages/cli/src/serve/server/session-archive.test.ts: P (exit 0)
round 2 · packages/core/src/services/session-organization-service.test.ts: P (exit 0)
round 2 · packages/core/src/services/sessionService.corruption.test.ts: P (exit 0)
round 2 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 2 · packages/core/src/services/usageHistoryService.test.ts: P (exit 0)
round 2 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 3 · packages/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 3 · packages/cli/src/serve/server/session-archive.test.ts: P (exit 0)
round 3 · packages/core/src/services/session-organization-service.test.ts: P (exit 0)
round 3 · packages/core/src/services/sessionService.corruption.test.ts: P (exit 0)
round 3 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 3 · packages/core/src/services/usageHistoryService.test.ts: P (exit 0)
round 3 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 4 · packages/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 4 · packages/cli/src/serve/server/session-archive.test.ts: P (exit 0)
round 4 · packages/core/src/services/session-organization-service.test.ts: P (exit 0)
round 4 · packages/core/src/services/sessionService.corruption.test.ts: P (exit 0)
round 4 · packages/core/src/services/sessionService.test.ts: P (exit 0)
round 4 · packages/core/src/services/usageHistoryService.test.ts: P (exit 0)
round 4 · packages/sdk-typescript/test/unit/DaemonClient.test.ts: P (exit 0)
round 5 · packages/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 5 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |
|
Latest main sync and review fixes are now on the branch. Current head:
Validation on the final tree:
No product decision is needed for the landed Critical fixes. |
Re-verification at
|
| previous run | this run | |
|---|---|---|
| base | 867ded5 |
65c2bb01 (new merge-base) |
| head | 60118c3 |
21da77c6 (PR tip) |
Verdict unchanged: this is still good to merge, and the new commit is a correct narrowing. One item from my earlier report is now resolved by evidence rather than left open.
The lifecycle matrix is bit-for-bit stable across the rebase
I diffed all 25 REST cases four ways (old base / new base / old head / new head). After normalising for fixture path length, which shifted my transcript sizes by one byte:
- base
867ded5vs base65c2bb01: identical on every case.main's 106 commits touched none of the lifecycle files, so none of my "base is broken here" claims were an artifact of a stale base. - head
60118c3vs head21da77c6: identical on every case. The new commit changes no lifecycle route behaviour.
The ACP transport probe (6 cases) and the FIFO availability demo reproduce exactly as before — base still hangs and burns libuv threadpool threads until all filesystem I/O on the daemon stalls; the PR tip fails closed in ~0.05 s.
The new commit: dropping usage salvage from conflict repair
21da77c6 removes the prepareUsageBeforeTranscriptDeletion / commit… pair from both conflict-repair branches — the ones that salvaged the retained copy. I measured what that actually does to a session's usage, reading the aggregate through the same code path as GET /usage/dashboard (in a fresh process each time, because the route caches for 60 s).
The control row is what settles it. Plain archiving of an ordinary healthy session already takes its usage to 0 on all three arms — rebuildFromSessionJsonl does a flat readdir of chats/ and never descends into chats/archive/. So archive-repair going 1000 → 0 on the new tip is archive-repair behaving exactly like ordinary archive, not a regression this PR introduces.
What the previous tip did was worse in two ways at once: on archive-repair it reported 7 — the retained archived copy's tokens, having silently discarded the deleted active copy's 1000 — and on unarchive-repair it reached the right total only by pinning a frozen final snapshot into usage_record.jsonl for a session whose transcript is still on disk and still growing. Persisted records win over live replay in loadUsageHistoryWithLive, so that snapshot would have shadowed the session's real usage forever if it were later resumed. The new tip replays the live transcript instead and reaches the same total.
delete still salvages correctly on every arm (pinned=1000), so the #7384 guarantee is intact where it belongs.
So: the change is right, and it makes the repair paths consistent with the archive semantics that already existed. The residual oddity — archived transcripts being invisible to the usage aggregate — is pre-existing, affects ordinary archive equally, and is not this PR's to fix.
Tests
All focused suites pass at 21da77c6, including server.test.ts this round:
| suite | tests |
|---|---|
Core (sessionService, .corruption, usageHistoryService, session-organization-service) |
312 |
CLI (session-archive, multi-workspace-sessions, acp-http/transport, scheduled-task-session-lifecycle, server) |
1636 |
SDK (DaemonClient) |
349 |
acp-bridge (sessionAttachments) |
47 |
Core is up 2 from my last run — exactly the two new tests asserting no salvage happens during repair.
Status of my earlier three points
a) The "breaking change" note still over-states its blast radius. Unchanged at 21da77c6: the target.kind === 'ordinary' early return is still there (now routes/session.ts:1189), so the 409 session_conflict throw only ever fires for internal Conversations runtimes. On the new base an ordinary workspace still returns 200 + errors[]. The migration note should say so — ordinary-workspace clients need to do nothing.
b) The DoS vector is still narrowed, not closed. The session-list route still hangs on a FIFO on both new arms. Pre-existing, not a regression, out of this PR's scope — but worth a follow-up issue.
c) Repaired storage is still undiscoverable. Empty and torn transcripts still do not appear in GET /workspace/:id/sessions on either arm.
Still not independently reproduced in the live stack (unchanged from last time, covered by the passing unit suites): the writer-lease-occupied fence, runtime-generation replacement, and ambiguous internal/ordinary ownership. macOS only.
Evidence for this run is on pr-assets/9626-verify under maintainer-local-verify/2026-08-25/ — wire ledgers, ACP frames, and the usage measurements for all three arms.
中文说明
在 21da77c6 上重新验证 —— 两条 arm 全部重新构建
接上一份报告。PR 更新了:一个新 commit 加上合并了最新 main,merge-base 往前挪了 106 个提交。我把两条 arm 都按新提交重新构建并重跑了整套 harness,没有沿用旧的 base。
| 上次 | 本次 | |
|---|---|---|
| base | 867ded5 |
65c2bb01(新 merge-base) |
| head | 60118c3 |
21da77c6(PR 顶端) |
结论不变:仍然可以合,而且新 commit 是一次正确的收窄。 上一份报告里悬着的一项,这次有了实测证据。
生命周期矩阵在 rebase 前后逐位稳定
见上方第一张截图。我把 25 条 REST 用例做了四向比对(旧 base / 新 base / 旧 head / 新 head)。在扣除夹具路径长度导致的 1 字节差异后:
- base
867ded5与 base65c2bb01:所有用例完全一致。main的 106 个提交没有碰到任何生命周期文件,所以我之前那些「base 在这里是坏的」的结论,不是陈旧 base 造成的假象。 - head
60118c3与 head21da77c6:所有用例完全一致。 新 commit 没有改变任何生命周期路由行为。
ACP 传输探针(6 条用例)和 FIFO 可用性演示复现结果与之前完全一致 —— base 仍然挂起并耗尽 libuv 线程池,直到 daemon 上所有文件系统 I/O 停摆;PR 顶端仍然在约 0.05 秒内 fail closed。
新 commit:从冲突修复路径里去掉 usage salvage
21da77c6 把两个冲突修复分支里的 prepareUsageBeforeTranscriptDeletion / commit… 去掉了 —— 那两处 salvage 的是被保留的那份副本。我实测了这对会话的用量意味着什么:走的是和 GET /usage/dashboard 完全相同的代码路径(每次开新进程读,因为该路由有 60 秒缓存)。
见上方第四张截图。
对照行是定性的关键。 普通健康会话的常规归档,在三条 arm 上本来就会把用量打到 0 —— rebuildFromSessionJsonl 对 chats/ 做的是平铺 readdir,从不进入 chats/archive/。所以新顶端上归档修复的 1000 → 0,是归档修复表现得和普通归档完全一样,不是本 PR 引入的回归。
之前那版顶端其实同时错了两处:归档修复时它报的是 7,即保留下来的 archived 副本的 token 数,而被删掉的 active 副本那 1000 被静默丢弃了;恢复修复时它虽然拿到了正确的总数,但靠的是往 usage_record.jsonl 里钉进一份冻结的最终快照——而那个会话的 transcript 还在磁盘上、还会继续增长。在 loadUsageHistoryWithLive 里持久化记录优先级高于实时回放,所以一旦该会话之后被恢复使用,那份快照会永久遮蔽它的真实用量。新顶端改为回放实时 transcript,拿到的是同一个总数。
delete 在所有 arm 上仍然正确 salvage(pinned=1000),#7384 的保证在该生效的地方完好无损。
所以:这个改动是对的,并且让修复路径与既有的归档语义保持了一致。剩下那点别扭 —— 归档后的 transcript 对用量聚合不可见 —— 是既有问题,普通归档同样如此,不该由本 PR 来修。
测试
21da77c6 上所有聚焦套件全绿,这轮还带上了 server.test.ts:
| 套件 | 数量 |
|---|---|
Core(sessionService、.corruption、usageHistoryService、session-organization-service) |
312 |
CLI(session-archive、multi-workspace-sessions、acp-http/transport、scheduled-task-session-lifecycle、server) |
1636 |
SDK(DaemonClient) |
349 |
acp-bridge(sessionAttachments) |
47 |
Core 比上次多 2 项,正好是新增的那两条「修复时不做 salvage」的断言。
上一份报告三点的现状
a)「破坏性变更」说明仍然夸大了影响面。 在 21da77c6 上没有变化:target.kind === 'ordinary' 的提前 return 仍在(现在是 routes/session.ts:1189),所以 409 session_conflict 只会对内部 Conversations runtime 触发。在新 base 上普通 workspace 依然返回 200 + errors[]。迁移说明应当写明这一点 —— 普通 workspace 的客户端什么都不用改。
b)DoS 面仍然是收窄而非关闭。 会话列表路由在两条新 arm 上仍会被 FIFO 挂住。既有问题、非回归、不在本 PR 范围内 —— 但值得开个 follow-up issue。
c)修好的存储仍然发现不了。 空文件和撕裂的 transcript 在两条 arm 的 GET /workspace/:id/sessions 里依然不出现。
仍然没能在真实栈里独立复现的部分(与上次相同,由通过的单测覆盖):writer lease 占用围栏、运行时代际替换、ordinary/internal 所有权歧义。仅测了 macOS。
本轮证据在 pr-assets/9626-verify 的 maintainer-local-verify/2026-08-25/ 下 —— 线协议台账、ACP 帧,以及三条 arm 的用量测量数据。
|
@qwen-code /triage |
|
Triage re-run completed without submitting a new review. Head unchanged since the last attested pass ( No new review submitted: the bot's round-11 重跑完成,未提交新评审。头部与上次通过时相同( — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI; the changed integration file was run directly in the review (36/36 passed), the full integration suite was not run.
Not reviewed: build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the workspace suites ran on Linux only.
Not reviewed: chunk 4 — the new multi-workspace lifecycle tests could not be executed during the fan-out (no installed dependencies in the worktree at that point); they were covered afterwards by Agent 7's full suite run (green) — stopped at the agent tool budget.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 4)": novelty/intent check for resolveSessionBatchRuntime's whole-request SessionNotFoundError when an owner-routed batch mixes a known session with an id that has no…; chunk 4: could not execute the new tests — the shared worktree has no installed dependencies or built workspace packages, so vitest cannot run without a full install/bui….
Not reviewed: reverse audit — stopped before round 5 by the review time budget.
Deferred under the convergence posture (round 11, not a blocker) — recorded, not requested in this round:
packages/core/src/services/sessionService.ts:2365 — [probe] Conflict-repair fence trip after the losing-transcript unlink strands loser-state sidecars; retry masks the losspackages/cli/src/serve/server/session-archive.test.ts:825 — [probe] Conflict repair's losing-side worktree/ledger sidecar deletions are pinned by no testpackages/acp-bridge/src/sessionAttachments.test.ts:766 — [probe] Attachment-store fence test pins only the first of three assertCanCommit sitespackages/core/src/services/sessionService.test.ts:2709 — [review] Unarchive fence trip after the rename strands sidecars in chats/archive/; alreadyActive retry masks itpackages/core/src/services/sessionService.test.ts:2166 — [review] movePrSidecar's own assertCanMutate fences are exercised by no throwing-fence testpackages/cli/src/serve/server/session-archive.test.ts:595 — [review] FIFO test's 500ms unblock timer can throw uncaught ENXIO and crash the vitest workerpackages/cli/src/serve/multi-workspace-sessions.test.ts:2494 — [review] Scoped-failure tests never assert the absence of an archived copypackages/core/src/services/sessionService.test.ts:2592 — [probe] Conflict-repair success tests never pin which copy was unlinked
Convergence: round 11 posted 3 inline comment(s), 2 of them reported for the first time; the previous round posted 3 (0 new). Findings keep coming back to the same files: packages/core/src/services/sessionService.ts (findings in round 9; 2 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. (Observation only — nothing was withheld from this review because of this observation.)
Mechanism health: this round did not close cleanly, so it withholds the incremental anchor — and the round it recovered had none either, so the next review re-reads the whole diff and will keep doing so until a round's marker carries an anchor again. (Stated, not acted on — this changes nothing about what the round posts.)
[Critical] Ledger rulings for the previous round (round 10, review 5008879557 at 60118c3; the commits since are 21da77c, 782dd86, and a merge of main): R9-3 — fixed by this diff: commit 21da77c removed the final usage-snapshot write from both conflict-repair branches (verified at HEAD: neither repair branch prepares or commits any salvage; the added tests 'does not persist a final usage snapshot during conflict repair' and '...for the retained active copy' pin the absence), so a persisted record prepared from the retained copy can no longer shadow live usage. R9-1 — fixed as filed: the mechanism as filed ('repair prepares usage salvage only from the retained copy', whose final snapshot could shadow or undercount) was removed by the same commit; the residual shape (a readable doomed copy's telemetry is not salvaged when the retained copy is damaged) is the round-9 adjudicated trade-off — usage records are final per-sessionId snapshots, and persisting the doomed copy would reproduce a permanent undercount as soon as the retained copy grows — and matches archive's pre-existing no-salvage semantics. R9-2 — the mechanism stands but its severity is Suggestion, not Critical: the runtime is fail-closed and covered by the mixed local/foreign ownership tests, and the doc sentence is silent (not false) about the mixed-ownership edge — an integrator following the docs receives a per-item error entry, never a silent wrong mutation; recorded under the round-11 convergence deferral rather than re-posted. R10-1 — still stands; re-posted inline under its ledger id.
中文说明
仅完成部分审查,审查缺口已披露。
未审查:build-and-test — Integration Tests (CLI, No Sandbox) was skipped in CI; the changed integration file was run directly in the review (36/36 passed), the full integration suite was not run。
未审查:build-and-test — Test (macos-latest, Node 22.x) and Test (windows-latest, Node 22.x) were skipped in CI; the workspace suites ran on Linux only。
未审查:chunk 4 — the new multi-workspace lifecycle tests could not be executed during the fan-out (no installed dependencies in the worktree at that point); they were covered afterwards by Agent 7's full suite run (green) — stopped at the agent tool budget。
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 4)":novelty/intent check for resolveSessionBatchRuntime's whole-request SessionNotFoundError when an owner-routed batch mixes a known session with an id that has no…;chunk 4:could not execute the new tests — the shared worktree has no installed dependencies or built workspace packages, so vitest cannot run without a full install/bui…。
未审查:反向审计——评审时间预算不足,未能开始第 5 轮。
收敛姿态下延后(第 11 轮,非阻断)——已记录,本轮不要求修改:共 8 条(原文未翻译,列表见上方英文部分)。
收敛情况:第 11 轮发布了 3 条行内评论,其中 2 条是首次提出;上一轮发布了 3 条(其中 0 条首次提出)。发现反复回到同一批文件:packages/core/src/services/sessionService.ts(第 9 轮已出过发现,本轮又有 2 条)。一个不断再生兄弟发现的簇,通常意味着逐条修复只在处理同一根因的实例——先定位并处理该根因,或把独立的簇拆成单独的 PR,通常比逐条修复更快结束循环。(仅为观察——本轮评审未因此扣留任何内容。)
机制健康:本轮未能干净收尾,因而扣留了增量锚点,而它恢复到的那一轮也没有锚点,因此下一次评审将重读整个 diff——并会一直如此,直到某一轮的标记重新带上锚点。(仅陈述,不据此行动——这不改变本轮发布的任何内容。)
[Critical] Ledger rulings for the previous round (round 10, review 5008879557 at 60118c3; the commits since are 21da77c, 782dd86, and a merge of main): R9-3 — fixed by this diff: commit 21da77c removed the final usage-snapshot write from both conflict-repair branches (verified at HEAD: neither repair branch prepares or commits any salvage; the added tests 'does not persist a final usage snapshot during conflict repair' and '...for the retained active copy' pin the absence), so a persisted record prepared from the retained copy can no longer shadow live usage. R9-1 — fixed as filed: the mechanism as filed ('repair prepares usage salvage only from the retained copy', whose final snapshot could shadow or undercount) was removed by the same commit; the residual shape (a readable doomed copy's telemetry is not salvaged when the retained copy is damaged) is the round-9 adjudicated trade-off — usage records are final per-sessionId snapshots, and persisting the doomed copy would reproduce a permanent undercount as soon as the retained copy grows — and matches archive's pre-existing no-salvage semantics. R9-2 — the mechanism stands but its severity is Suggestion, not Critical: the runtime is fail-closed and covered by the mixed local/foreign ownership tests, and the doc sentence is silent (not false) about the mixed-ownership edge — an integrator following the docs receives a per-item error entry, never a silent wrong mutation; recorded under the round-11 convergence deferral rather than re-posted. R10-1 — still stands; re-posted inline under its ledger id.
— qwen3.8-max via Qwen Code /review (v0.22.0)
| } catch (error) { | ||
| assertCanMutate?.(); | ||
| this.warn( |
There was a problem hiding this comment.
[Critical] This diff added assertCanMutate?.() to removeSessionOrganization's catch and forwards the fence as assertCanCommit into the org-store atomic write. When the workspace generation is replaced during the org-store commit — the exact race these fences exist for — removeSessionFiles has already deleted every session file, but removeSession now throws post-deletion instead of warning (the catch was warn-only before this PR). The daemon then reports mutationApplied: true plus an errors entry for a fully-applied deletion, and the client retry hits removeSessionFiles' location === undefined → false, so the if (removed) guard skips organization cleanup forever — no code path removes an org entry whose files are already gone (every org-removal call site is gated on successful file removal). The session's organization entry persists permanently in the store with no removal path (the organized view only renders rows for persisted/live sessions, so the harm is the permanent store inconsistency plus the false-failure response that invites retries which can never converge).
Witness (scratch-tree probe at HEAD, fence tripped at the org-store commit):
CONTROL: clean delete clears the org entry (6 fence calls)
FENCED first attempt threw: generation closed (fence calls: 7)
FENCED transcript gone after throw: true
FENCED org entry survives first attempt: true
FENCED retry returned: false
FENCED org entry survives retry: true
CANDIDATE FIX (org cleanup before file deletion):
FENCED retry returned: true / org entry survives retry: false
Fix direction: restore the warn-only contract for this post-deletion cleanup (the transcript is already gone; org removal is idempotent and transcript-independent), or run org cleanup before removeSessionFiles, or make it retryable when the files are already absent.
中文说明
本 diff 在 removeSessionOrganization 的 catch 中新增了 assertCanMutate?.(),并把 fence 作为 assertCanCommit 传入 org store 的原子写入。当工作区 generation 在 org store 提交期间被替换(正是这些 fence 要防的竞态)时,removeSessionFiles 已经删除了全部会话文件,但 removeSession 现在会在删除完成之后抛错而不是仅告警(本 PR 之前该 catch 只告警)。daemon 会对一个已完整应用的删除返回 mutationApplied: true 加 errors 条目;客户端重试时 removeSessionFiles 因 location === undefined 返回 false,if (removed) 守卫永远跳过组织清理——没有任何代码路径能删除文件已不存在的组织条目(已核对:所有 org 删除调用点都以文件删除成功为前提)。该会话的组织条目将永久滞留在 store 中且没有任何移除路径(组织视图只为持久/存活会话渲染行,因此实际危害是永久性的 store 不一致,以及会诱发永远无法收敛的重试的错误失败响应)。
证据(隔离工作树探针,HEAD 代码,fence 在 org store 提交点触发):对照组干净删除会清除组织条目(6 次 fence 调用);触发组首次尝试抛 generation closed,抛错后 transcript 已删除:true,组织条目在首次尝试后仍存在:true,重试返回 false,重试后组织条目仍存在:true。候选修复(把组织清理移到文件删除之前)可使探针翻转:重试返回 true,重试后组织条目不再存在。
修复方向:恢复该删除后清理的「仅告警」契约(transcript 已删除,组织删除是幂等且不依赖 transcript 的);或把组织清理移到 removeSessionFiles 之前;或在文件已不存在时允许重试组织清理。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| options.assertCanMutate?.(); | ||
| this.removeWorktreeSidecars(sessionId); |
There was a problem hiding this comment.
[Critical] removeSessionFiles unlinks the transcript(s) first and only then runs removeWorktreeSidecars / removePrSidecars / removePromptLedgers / removeFileHistoryBackups, with this diff's assertCanMutate fences between the steps. A generation trip in that window leaves the transcript deleted but the chats-dir sidecars untouched; the retry resolves location === undefined and returns false before reaching the four reap helpers, whose only call sites are these fenced lines. The leak is permanent for <id>.worktree.json, <id>.pr.json and <id>.ledger.jsonl (file-history backups are reclaimed by the age sweeper; these sidecars are not).
Witness (scratch-tree probe at HEAD):
PRISTINE: fenceCallsAtTrip=2
afterTrip chats dir: ["<id>.ledger.jsonl","<id>.pr.json","<id>.worktree.json"] // transcript gone
retry removeSession returned: false
afterRetry chats dir: ["<id>.ledger.jsonl","<id>.pr.json","<id>.worktree.json"] // leak persists
CANDIDATE FIX (reap sidecars in the location===undefined branch):
afterRetryChatsDir: [] // probe flips
Fix direction: reap the sidecars in the location === undefined branch of removeSessionFiles (they are idempotent and keyed strictly on the session-id pattern), so a retried delete completes the interrupted cleanup.
中文说明
removeSessionFiles 先 unlink transcript,随后才执行 removeWorktreeSidecars / removePrSidecars / removePromptLedgers / removeFileHistoryBackups,而本 diff 在这些步骤之间插入了 assertCanMutate fence。generation 在该窗口内被替换时,transcript 已删除但 chats 目录下的 sidecar 原封未动;重试时 location === undefined,在这四个清理助手之前就返回 false——而它们的唯一调用点正是这些被 fence 的行。对 <id>.worktree.json、<id>.pr.json、<id>.ledger.jsonl 而言,泄漏是永久的(file-history 备份有按房龄的清扫器回收,这些 sidecar 没有)。
证据(隔离工作树探针,HEAD 代码):触发后 chats 目录仍保留全部三个 sidecar(transcript 已删除);重试 removeSession 返回 false;重试后 chats 目录不变(泄漏持续)。候选修复(在 location === undefined 分支中回收这些以 sessionId 为键的 sidecar)可使探针翻转:重试后 chats 目录为空。
修复方向:在 removeSessionFiles 的 location === undefined 分支中回收这些 sidecar(它们幂等且严格按 session-id 模式匹配),使重试的删除能完成被中断的清理。
— qwen3.8-max via Qwen Code /review (v0.22.0)
| for (const identity of physicalSnapshot.identities) { | ||
| this.removeFileIfExists(identity.filePath); |
There was a problem hiding this comment.
[Critical] R10-1: Still stands — carried since round 1 (as R1-4), re-posted under the round-10 ledger id. The final identity assertion validates a pathname, but the subsequent unlink operates on that pathname without being bound to the validated inode: in removeSessionFiles, assertMaintainableSessionUnchanged lstats the path and removeFileIfExists then unlinks that same path — a concurrent process can swap in an unvalidated replacement between the final stat and the mutation, and delete/archive/unarchive destroys a file no identity check ever validated. The mechanism is present at this head inside the rewritten per-copy deletion loop.
Witness: the round-8 scratch-tree probe applies to the unchanged mechanism — removeSession returned true while the swapped-in file's inode differed from the validated identity and its content did not survive.
The author's wontfix rationale (a correct fix needs a cross-platform atomic claim/no-replace protocol plus crash recovery) is substantive, and the maintainer asked that this residual be tracked in a follow-up issue (review 5006725473); no such issue exists yet (verified this round). Fix direction: atomically claim the source into a private staging path (rename), verify the staged inode, then delete or publish — or land the documented diagnostic and file the follow-up before merge.
中文说明
R10-1:仍然存在——自第 1 轮(R1-4)起持续,按第 10 轮台账 id 重新发布。最终身份断言验证的是路径名,但随后的 unlink 直接作用于该路径名,而未与已验证的 inode 绑定:removeSessionFiles 中 assertMaintainableSessionUnchanged 对路径执行 lstat 后,removeFileIfExists 对同一路径执行 unlink——另一进程可在最后一次 stat 与变更之间换入未经验证的文件,生命周期操作将摧毁一个从未经过身份检查验证的文件。该机制在当前 head 的重写后逐副本删除循环中依然存在。
证据:第 8 轮隔离工作树探针适用于未改变的机制——removeSession 返回 true,而换入文件的 inode 与已验证身份不同且内容未幸存。
作者的 wontfix 理由(正确修复需要跨平台原子认领/禁止替换协议加崩溃恢复)成立,维护者已要求用后续 issue 跟踪该残留(review 5006725473);本轮核实该 issue 尚未创建。修复方向:把源文件原子地认领到私有暂存路径(rename),验证暂存 inode 后再删除或发布——或在合并前落实文档化诊断并创建后续 issue。
— qwen3.8-max via Qwen Code /review (v0.22.0)
|
@qwen-code /triage |
|
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: 51 passed · 0 failed · 51 total Flakiness gate: ❌ 2 of 11 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: 中文 — 判定:❌ 不通过 · 测试结果不确定(抖动门)沙箱验证在隔离、无凭证的容器中执行了该 PR 的代码(与 base 构建 A/B 对照、无 mock harness 断言、定向门禁)。仅作为评审证据,不构成评审、批准或 CI 检查。 脚本断言:51 通过 · 0 失败 · 51 总计 抖动门:❌ 2 of 11 changed test file(s) returned different results across identical re-runs (5 full round(s)) 确定性抖动门将本 PR 改动的测试文件原样重跑了多轮,得到了不一致的结果(agent 判定: Verification reportSandboxed verification: ✅ merge-ready — 51/51 scripted assertions passed, verified head 中文摘要
Previous-round statusThe previous substantive round verified head
No declined or deferred rows existed to re-measure. Central claim and A/B proofCentral claim: delete/archive/unarchive maintain owned persisted transcripts that are empty, torn-headed, oversized-unreadable, or legacy orphans — classification is by physical storage ownership, not conversation loadability. Secondary claims: (1) default conflict behavior stays non-mutating, Core-level A/B (
|
| Cell | base (1fffa5108d) |
head (49b67ebe) |
flip |
|---|---|---|---|
| delete healthy (control) | removed, file gone | removed, file gone | parity |
| delete empty | not-removed, stranded | removed, gone | ✅ |
| delete torn head | not-removed, stranded | removed, gone | ✅ |
delete {} head |
refused (TypeError), intact | refused (unknown_projection), intact |
parity, fail-closed |
| delete 1.5 MiB unterminated JSON | not-removed, intact | refused (IdentityUnavailable), intact |
parity, fail-closed |
| archive empty | notFound, unmoved | archived, 0 bytes verbatim | ✅ |
| unarchive torn | notFound, unmoved | unarchived, bytes verbatim | ✅ |
| archive 1.5 MiB non-JSON blob | notFound, unmoved | archived, 1.5 MiB verbatim | ✅ |
| archive conflict, default | conflict error, both copies byte-identical | conflict error, both copies byte-identical | parity |
archive conflict, resolveConflicts:true |
option ignored, conflict stands | archived kept, active removed, resolvedConflicts set |
✅ additive |
unarchive conflict, resolveConflicts:true |
option ignored | active kept, archived removed | ✅ additive |
| delete conflict | both copies removed | both copies removed | parity (compat) |
| symlink entry | followed: link removed (target intact) | typed non_regular, link+target untouched |
✅ fail-closed |
| directory entry | not-removed, intact | typed non_regular, intact |
parity on safety |
| FIFO entry | blocked (open hangs; killed by timeout, exit 124) | typed non_regular in 0 ms |
✅ fail-closed |
| foreign-workspace | not-removed, intact | not-removed, intact | parity (fence held) |
Head 16/16, base 15/15 + FIFO-timeout = 16/16 — every base cell failed exactly as the PR's premise predicts.
Route-level A/B (route-lifecycle-ab.mjs, compiled createServeApp on a real loopback listener; ordinary primary + internal Conversations secondary)
Witness: 02-route-ab-orphan-flip.png. The ACP bridge is a call-logging Proxy fake honouring the contract (getSessionSummary/closeSession/killSession throw SessionNotFoundError for non-live sessions; deleteSessionAttachments/markSessionCatalogChanged are no-ops); routes, registry, SessionService, and filesystem are the real compiled code.
| Cell | base | head |
|---|---|---|
| orphan, qualified delete | 404 session_not_found, file stranded |
200, removed, file gone |
| orphan, owner-routed delete | 200 but notFound (misrouted to primary), file stranded |
200, removed via internal owner |
| orphan, qualified archive | 404, unmoved | 200, archived, bytes verbatim |
| healthy internal delete (control) | 200 removed | 200 removed |
| foreign, qualified delete | 404, untouched | 404, untouched |
| conflict, qualified archive default | HTTP 409 session_conflict, both preserved |
HTTP 200 + per-session error naming resolveConflicts, both preserved |
6/6 both arms. The base owner-routed-orphan cell is the sharpest: the file existed, was owned, and was readable — yet the daemon reported not-found after silently falling back to the primary runtime. The 409→200 envelope change matches the PR's stated breaking change, and the conflict reason survives in the per-session error.
Vacuity / mutation matrix (03-vacuity-mutation-kills-lifecycle-tests.png)
Mutation: in readMaintainableSessionIdentity, unreadable head ⇒ state absent (exactly the base's loadability dependence), interface preserved.
| Guard | Suite | Result |
|---|---|---|
| unreadable regular files still occupy their state (classifier) | sessionService.corruption.test.ts lifecycle block |
killed — 16 tests fail on behavioral assertions (16 failed | 49 passed); restore → 65/65 green, git diff empty |
| orphan classification at core level | same block | survived by design: an orphan has a readable head, so the core classifier was never its gate — the gate is the route resolver's loadability check, pinned by the route A/B above and the multi-workspace suite (143/143) |
Findings
No blocking findings. Non-blocking observations (all re-measured this round):
- Directory entries change response shape, not safety. Base reported a directory in the chats dir as not-found; head reports a typed per-session error (
non_regular). Both leave the directory and its contents untouched (asserted). Clients that treated "not in notFound and not in errors" as absent should note the entry now lands inerrors. - Base hazards documented by the control cells (not PR defects): base follows symlinks during classification and blocks indefinitely opening a FIFO — the base arm's FIFO cell required
timeout -kbecause the blockedopen()ignores SIGTERM. Head'sO_NOFOLLOW|O_NONBLOCKopen fixes both. - Fail-closed shapes for unclassifiable heads (
{}first record →unknown_projection; >1 MiB unterminated JSON-looking line →SessionTranscriptIdentityUnavailableError) are deliberate and storage-safe; both refuse without mutating.
Not covered
- Per-commit attribution: shallow depth-2 checkout (18 commits in metadata, only the merge triple reachable); the aggregate
HEAD^1..HEADdiff was verified instead. - Windows: the fence skips
O_NOFOLLOW|O_NONBLOCKon win32; not exercised here (Linux container). - TOCTOU revalidation (
assertMaintainableSessionUnchanged): not driven by an independent race harness; pinned by the PR's own tests ("replaced after validation", generation-rejection fences) — all green, and the mutation run showed those tests fail when the classifier regresses. - ACP transport / SDK wire behavior: covered by their suites (cli transport+server 1636 passed 0 failed; SDK 349/349), no independent wire A/B.
- Full-repo suite and typecheck: left to the PR's CI; targeted workspaces only were run here.
- Base-arm teardown artifact: the base FIFO cell is run isolated under
timeout -k; the blockedopen()is a base defect, not harness or PR code.
Methodology
Environment: node:22-bookworm container, merge-ref checkout (HEAD = merge c865a1f, HEAD^1 = base 1fffa5108d, HEAD^2 = verified head 49b67ebe), npm ci + npm run build pre-completed at HEAD. Core A/B drove packages/core/dist SessionService directly; the base arm used a tmp/base-tree worktree rebuilt with scripts/build.js --cli-only (root node_modules shared, nested per-package node_modules linked, @qwen-code/* overlay symlinks repointed into the base tree and verified by import.meta.resolve + ESM import probe). Route A/B booted each tree's compiled createServeApp on a real loopback listener with the repo's two-workspace registry topology (ordinary primary + provenance: 'live-conversation' secondary). Assertion tally: 32 core A/B cells (16 head + 15 base + 1 FIFO-timeout) + 12 route A/B cells + 2 vacuity runs + 5 targeted-gate runs = 51, all passing. Raw per-arm JSON logs, harness scripts, gate logs, and base-build.log live in this artifact directory; images were produced with scripts/verify-capture.mjs.
Flakiness gate log
integration test, out of gate scope: integration-tests/cli/qwen-serve-routes.test.ts
rounds=5 files=11 skipped=1
file packages/acp-bridge/src/sessionAttachments.test.ts: (cd packages/acp-bridge) npx --no-install vitest run ./src/sessionAttachments.test.ts
file packages/cli/src/serve/acp-http/transport.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/acp-http/transport.test.ts
file packages/cli/src/serve/multi-workspace-sessions.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/multi-workspace-sessions.test.ts
file packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/scheduled-task-session-lifecycle.test.ts
file packages/cli/src/serve/server.test.ts: (cd packages/cli) npx --no-install vitest run ./src/serve/server.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/services/session-organization-service.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/session-organization-service.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
file packages/core/src/services/usageHistoryService.test.ts: (cd packages/core) npx --no-install vitest run ./src/services/usageHistoryService.test.ts
file packages/sdk-typescript/test/unit/DaemonClient.test.ts: (cd packages/sdk-typescript) npx --no-install vitest run ./test/unit/DaemonClient.test.ts
per-file results (P=pass F=fail I=infra-exit, one letter per run):
packages/acp-bridge/src/sessionAttachments.test.ts: PPPPP
packages/cli/src/serve/acp-http/transport.test.ts: PPPPP
packages/cli/src/serve/multi-workspace-sessions.test.ts: PPPPP
packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: PPPPP
packages/cli/src/serve/server.test.ts: PPPPP
packages/cli/src/serve/server/session-archive.test.ts: PPFFP
packages/core/src/services/session-organization-service.test.ts: PPPPP
packages/core/src/services/sessionService.corruption.test.ts: FPFFF
packages/core/src/services/sessionService.test.ts: PPPPP
packages/core/src/services/usageHistoryService.test.ts: PPPPP
packages/sdk-typescript/test/unit/DaemonClient.test.ts: PPPPP
verdict: flaky
summary: 2 of 11 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/acp-bridge/src/sessionAttachments.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/acp-http/transport.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/multi-workspace-sessions.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/scheduled-task-session-lifecycle.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server.test.ts: P (exit 0)
round 1 · packages/cli/src/serve/server/session-archive.test.ts: P (exit 0)
round 1 · packages/core/src/services/session-organization-service.test.ts: P (exit 0)
round 1 · packages/core/src/services/sessionService.corruption.test.ts: F (exit 1)
--- output tail · round 1 · packages/core/src/services/sessionService.corruption.test.ts ---
e�[2m > �[22mdoes not overwrite a damaged archived copy when the active copy is readable�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mrepairs an archive conflict when only the archived copy is readable�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not overwrite a damaged active copy when the archived copy is readable�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mrepairs an unarchive conflict when only the active copy is readable�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mreclassifies a conflict that appears during archive validation�[32m 2�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mreclassifies a conflict that appears during unarchive validation�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a readable archived transcript replaced after validation�[32m 2�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a readable archived transcript replaced after validation�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a transcript that disappears and reappears during classification�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not archive a transcript that disappears and reappears during classification�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a transcript that disappears and reappears during classification�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a readable transcript whose record identifies another session�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not archive a readable transcript whose record identifies another session�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a readable transcript whose record identifies another session�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on a record with missing cwd�[32m 2�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on a record with non-string cwd�[32m 2�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on a foreign record with missing session id�[32m 3�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on a foreign record with non-string session id�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on mixed foreign and local storage during delete�[32m 2�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on mixed foreign and local storage during archive�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on mixed foreign and local storage during unarchive�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed when delete sees another session id without cwd ownership�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed when archive sees another session id without cwd ownership�[32m 0�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed when unarchive sees another session id without cwd ownership�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mmaintains an oversized first physical record without buffering the whole file�[32m 17�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a just-over-limit readable transcript from another workspace�[32m 15�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not archive a just-over-limit readable transcript from another workspace�[32m 13�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a just-over-limit readable transcript from another workspace�[32m 13�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mfails closed on a readable transcript whose first record exceeds the bounded read window�[32m 20�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not swallow a generation rejection at the archive ledger fence�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not swallow a generation rejection at the unarchive ledger fence�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mrejects an in-place rewrite during ownership classification�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a readable transcript symlink�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not archive a readable transcript symlink�[32m 0�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a readable transcript symlink�[32m 0�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mrejects a transcript FIFO without waiting for a writer�[32m 5�[2mms�[22m�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not delete a damaged transcript replaced after validation�[32m 1�[2mms�[22m�[39m
�[31m �[31m�[31m SessionService lifecycle maintenance�[2m > �[22mdoes not archive a damaged transcript replaced after validation�[39m�[32m 9�[2mms�[22m�[39m
�[31m → expected '{"uuid":"torn-head"' to be 'replacement' // Object.is equality�[39m
�[32m✓�[39m SessionService lifecycle maintenance�[2m > �[22mdoes not unarchive a damaged transcript replaced after validation�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService.reconstructHistory (history-gap detection)�[2m > �[22mreports the gap but does NOT reconstruct the earlier island (detectGaps on)�[32m 1�[2mms�[22m�[39m
�[32m✓�[39m SessionService.reconstructHistory (history-gap detection)�[2m > �[22mpreserves today truncation behavior when de
...truncated -- full content in the run artifacts.
Evidence images
Harness scripts and raw logs are in the workflow run artifacts (7-day retention).
— Qwen Code · sandboxed verification
Already have 2 approves,3ks
|
Triage re-run completed without a new review.
The stage comments above were updated with the latest result. View workflow run. 上方各阶段评论已更新为最新结果。查看工作流运行。 |















What this PR does
This PR lets delete, archive, and unarchive maintain an owned persisted transcript even when the file is empty, has a torn or malformed head, or is a legacy orphan whose parent session no longer exists. Lifecycle classification is based on exact regular files in the selected runtime instead of requiring the transcript to load as a conversation.
Archive and unarchive remain non-mutating on conflicts by default. Batch lifecycle routes return those conflicts as per-session errors in an HTTP
200response; for workspace-qualified routes, this replaces the earlier HTTP409 session_conflictenvelope. Clients may opt intoresolveConflicts: true: archive keeps the archived copy and removes the active copy, while unarchive keeps the active copy and removes the archived copy. Successful repairs are reported through the additiveresolvedConflictsresponse field. Delete keeps its existing compatibility behavior and removes both copies of an active/archive conflict.Workspace-qualified requests stay inside the selected trusted runtime. Ordinary workspace-less lifecycle requests remain primary-scoped; only an unambiguous internal Conversations owner may be selected outside the primary runtime. Ambiguous ownership and unavailable runtime generations fail closed. The option and response field are exposed consistently through REST, ACP, capability discovery, protocol documentation, and the TypeScript SDK.
The scope is intentionally narrow: this PR does not add general case-insensitive session lookup, list merging, transcript/export changes, organization changes, or new Live/task identity behavior.
Why it's needed
#9513 restored load compatibility for persisted sessions, but lifecycle operations could still report an existing empty, damaged, or orphaned transcript as not found because they depended on conversation loadability or provenance classification. Those files then had no safe delete/archive/unarchive path.
The new maintenance classifier separates physical storage ownership from conversation readability. It rejects foreign-workspace and non-regular entries, preserves writer-lease and runtime-generation fences, snapshots both active and archived paths, and verifies the same files immediately before unlink or rename. This gives owned damaged storage a safe management path without broadening normal routing or changing default conflict behavior.
Reviewer Test Plan
How to verify
git diff --checkalso passed.Evidence (Before & After)
Before: existing empty, damaged, and orphaned transcripts were reported as not found by lifecycle APIs and remained stranded on disk.
After: owned regular transcripts can be deleted, archived, or unarchived without being loadable; default conflict behavior is unchanged, and explicit keep-one repair is available when the capability is advertised.
Tested on
Environment (optional)
macOS 26.4.1 arm64, Node.js v22.22.3, npm 10.9.8. Filesystem lifecycle behavior and fresh REST E2E fixtures were verified locally; the final commit was rebuilt and typechecked after rebasing onto the current
main.Risk & Scope
409 session_conflictmust now inspect the HTTP200batcherrorsarray. The mutation behavior remains non-mutating by default. Ordinary workspace-less lifecycle requests remain primary-scoped, delete retains its delete-both behavior, andresolveConflicts,resolvedConflicts, andsession_storage_conflict_repairare additive; clients should preflight the capability before using the option.Linked Issues
Fixes #9488
Builds on #9513
中文说明
本 PR 做了什么
本 PR 让删除、归档和恢复操作可以维护属于当前 runtime 的持久化 transcript,即使文件为空、头部撕裂或损坏,或者是父会话已经不存在的历史孤儿会话。生命周期分类改为依据所选 runtime 中精确路径的普通文件,而不是要求 transcript 必须能够加载成完整会话。
归档和恢复遇到冲突时默认仍不修改文件。批量生命周期路由通过 HTTP
200响应中的单会话错误返回这些冲突;对于 workspace-qualified 路由,这取代了原来的 HTTP409 session_conflictenvelope。客户端可以显式传入resolveConflicts: true:归档保留 archived 副本并删除 active 副本;恢复保留 active 副本并删除 archived 副本。成功修复的 ID 通过增量字段resolvedConflicts返回。删除继续保留既有兼容行为,在 active/archive 冲突时删除两份副本。workspace-qualified 请求始终限制在所选可信 runtime 内。普通 workspace-less 生命周期请求继续只作用于 primary;只有唯一且无歧义的 internal Conversations owner 才可以在 primary 之外被选择。所有权歧义和不可用的运行时代际都会 fail closed。该选项和响应字段在 REST、ACP、能力发现、协议文档和 TypeScript SDK 中保持一致。
本 PR 有意保持范围最小:不会增加通用的大小写不敏感会话查找、列表合并、transcript/export 改动、组织信息改动或新的 Live/任务身份行为。
为什么需要它
#9513 恢复了持久化会话的加载兼容性,但生命周期操作仍可能因为依赖会话可加载性或 provenance 分类,把实际存在的空、损坏或孤儿 transcript 报告为 not found,导致这些文件没有安全的删除、归档或恢复入口。
新的维护分类器把物理存储所有权与会话可读性分离。它拒绝外部工作区和非普通文件,保留 writer lease 与运行时代际 fence,对 active 和 archived 两个路径做快照,并在 unlink 或 rename 前立即验证仍是同一文件。这样可以安全管理属于当前 runtime 的损坏存储,同时不扩大普通路由范围,也不改变默认冲突行为。
Reviewer 测试计划
如何验证
git diff --check也全部通过。证据(Before & After)
Before:实际存在的空、损坏和孤儿 transcript 会被生命周期 API 报告为 not found,并滞留在磁盘上。
After:属于当前 runtime 的普通 transcript 无需可加载即可删除、归档或恢复;默认冲突行为保持不变,并在能力被声明时提供显式保留一侧的修复操作。
测试平台
环境(可选)
macOS 26.4.1 arm64、Node.js v22.22.3、npm 10.9.8。本地验证了文件系统生命周期行为和全新的 REST E2E fixture;最终 commit 在 rebase 到当前
main后重新完成构建和 typecheck。风险与范围
409 session_conflict处理 workspace-qualified 归档/恢复冲突的客户端,现在必须检查 HTTP200批量响应中的errors数组。默认变更行为仍然是不修改文件。普通 workspace-less 生命周期请求继续只作用于 primary,删除继续保留删除两份的行为;resolveConflicts、resolvedConflicts和session_storage_conflict_repair都是增量接口,客户端使用该选项前应预检能力。关联问题
Fixes #9488
基于 #9513