Skip to content

feat(daemon): persist session artifacts across restarts#6259

Open
chiga0 wants to merge 73 commits into
QwenLM:mainfrom
chiga0:feat/session-artifacts-persistence-v2-design
Open

feat(daemon): persist session artifacts across restarts#6259
chiga0 wants to merge 73 commits into
QwenLM:mainfrom
chiga0:feat/session-artifacts-persistence-v2-design

Conversation

@chiga0

@chiga0 chiga0 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Implements V2 daemon session artifact persistence as a follow-up to #5895. The PR now restores restorable artifact metadata across daemon restart/session replay, adds durable tombstone and snapshot handling, supports explicit pin/unpin content retention through a managed content store, and keeps the original live-session V1 behavior for ephemeral artifacts.

It also hardens the persistence boundary: managed content is stored under content-addressed IDs with manifest validation, sha256 verification, quota enforcement, atomic manifest writes, best-effort GC, malformed contentRef rejection during restore, and cleanup on session close. Fork/load behavior intentionally keeps pinned content from leaking into remapped sessions.

Why it's needed

V1 artifact APIs exposed only live-session state, so page refresh could recover from a live daemon but daemon restart or historical load could not restore artifact metadata. V2 gives clients a stable metadata restore path while keeping content retention explicit and bounded by security, quota, hash, manifest, and GC rules.

Reviewer Test Plan

How to verify

Review that ephemeral artifacts still remain live-only, restorable artifacts survive JSONL rebuild/snapshot restore, explicit remove tombstones prevent deleted artifacts from coming back, pin/unpin transitions preserve the intended retention state, content retention is only available for valid workspace files, malformed persisted content references are ignored, and close-session cleanup does not leave managed content behind.

Evidence (Before & After)

Before: artifact state was daemon-memory-only and the PR contained only a design document. After: daemon sessions persist artifact metadata/events/snapshots and can retain explicitly pinned workspace content with quota, hashing, manifests, and garbage collection.

Tested on

OS Status
🍏 macOS ✅ targeted tests, typecheck, build
🪟 Windows N/A
🐧 Linux ⏳ GitHub Actions

Environment (optional)

Validated locally with cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts, cd packages/core && npx vitest run src/services/session-artifact-persistence.test.ts, cd packages/acp-bridge && npm run typecheck, cd packages/core && npm run typecheck, npx prettier --check docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md packages/acp-bridge/src/sessionArtifactContentStore.ts packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts packages/acp-bridge/src/bridge.ts packages/core/src/services/session-artifact-persistence.ts packages/core/src/services/session-artifact-persistence.test.ts, git diff --check, and npm run build.

Risk & Scope

  • Main risk or tradeoff: This adds persistent artifact state and managed content storage, so the implementation keeps content retention explicit, quota-limited, hash-checked, and garbage-collected instead of treating every artifact as durable content.
  • Not validated / out of scope: Cross-machine portability, UI affordances for pin/save, user-configurable quota controls, and long-running production quota telemetry.
  • Breaking changes / migration notes: Existing V1 live-session artifact behavior remains compatible; older sessions without artifact persistence records simply restore with an empty artifact list.

Linked Issues

Follow-up to #5895.

中文说明

What this PR does

这个 PR 在 #5895 之后实现 V2 daemon session artifact 持久化。现在会在 daemon 重启或 session replay 后恢复 restorable artifact metadata,记录 durable tombstone 和 snapshot,支持通过 managed content store 显式 pin/unpin 内容保留,同时保持 ephemeral artifact 的 V1 live-session 语义。

实现也补齐了安全和稳定性边界:managed content 使用 content-addressed ID、manifest 校验、sha256 校验、quota、原子 manifest 写入、best-effort GC、恢复时丢弃非法 contentRef,并在 close session 时清理托管内容。fork/load 场景不会把 pinned content 泄露到 remap 后的新 session。

Why it's needed

V1 artifact API 只有 live-session 状态:页面刷新且 daemon 还活着时能恢复,但 daemon 重启或历史 load 后不能恢复 artifact metadata。V2 提供稳定的 metadata restore,同时把 content retention 设计成显式、受安全边界和配额控制的能力。

Reviewer Test Plan

How to verify

请重点检查:ephemeral artifact 是否仍然只存在于 live session;restorable artifact 是否能通过 JSONL rebuild/snapshot restore 恢复;显式删除的 tombstone 是否阻止旧 artifact 回来;pin/unpin 状态转换是否符合预期;content retention 是否只接受合法 workspace 文件;非法持久化 contentRef 是否会被忽略;close session 是否会清理 managed content。

Evidence (Before & After)

Before:artifact state 只在 daemon 内存中,且 PR 只有设计文档。After:daemon session 会持久化 artifact metadata/events/snapshots,并能在 quota、hash、manifest、GC 约束下保留显式 pinned workspace content。

Tested on

OS Status
🍏 macOS ✅ targeted tests、typecheck、build
🪟 Windows N/A
🐧 Linux ⏳ GitHub Actions

Environment (optional)

本地已验证:cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.tscd packages/core && npx vitest run src/services/session-artifact-persistence.test.tscd packages/acp-bridge && npm run typecheckcd packages/core && npm run typechecknpx prettier --check docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md packages/acp-bridge/src/sessionArtifactContentStore.ts packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts packages/acp-bridge/src/bridge.ts packages/core/src/services/session-artifact-persistence.ts packages/core/src/services/session-artifact-persistence.test.tsgit diff --checknpm run build

Risk & Scope

  • Main risk or tradeoff: 这个 PR 增加了 artifact 持久化状态和 managed content storage,所以 content retention 保持显式、受 quota 限制、hash 校验并可 GC,而不是把所有 artifact 都当作 durable content。
  • Not validated / out of scope: 跨机器可移植性、UI pin/save 入口、用户可配置 quota、长期生产 quota telemetry。
  • Breaking changes / migration notes: V1 live-session artifact 行为保持兼容;没有 artifact persistence record 的旧 session 会恢复为空 artifact 列表。

Linked Issues

#5895 的后续实现。

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the design doc!

Template looks good ✓

Direction: Clean follow-up to #5895 (merged today). Lives in the existing docs/design/daemon-session-artifacts/ directory alongside the V1 API design, which is exactly where it should. Persistence is a natural next question once V1 is live, and writing the design before touching code is the right order. CHANGELOG has no direct analog to cite, but the area is clearly on the roadmap given V1 just landed.

A few things worth surfacing before we get to line-level review:

  • The doc proposes V2 ship as one complete phase but internally defines two capability tiers (session_artifacts_persistence and session_artifacts_content_retention) plus five milestones A→E. The tension: if content retention slips (quota/hash/manifest/GC is the heavier half), does metadata persistence alone ship first, or does the whole phase wait? Worth naming the fallback plan now so the milestone gating is explicit, not implicit.
  • Fork remap as release gate is the right call — it's the part most likely to silently corrupt data if half-done.
  • ~/.qwen/tmp/.../chats/<sessionId>.artifacts.json for the sidecar: tmp in the path signals "safe to delete," but this is a cache over JSONL. If users or cleanup scripts wipe tmp, rebuild-from-JSONL must actually be a tested path, not a "should work" path. Worth naming that explicitly in the testing plan (Milestone C or §10) — "delete sidecar, verify rebuild" is one of the listed tests, so this is fine, just flagging it as load-bearing.

Approach: Scope feels right for a design doc — 671 lines covering data model, storage, API, security, quota/GC, milestones, and test plan. No runtime code, no drive-by refactors, no unrelated edits. The document is deliberately opinionated (e.g., "don't fetch remote URLs," "don't treat clientId as auth") which is what you want from a design doc — it gives future implementers clear guardrails instead of hand-waving.

One question worth thinking about: §2.2 defines three retention tiers (ephemeral/restorable/pinned), but §6.2 says clients can only request restorable via POST and pinned requires the separate pin API. The two-tier-at-API-layer vs three-tier-at-model-layer split is intentional and well-explained — just confirming that's the design, not a gap.

Moving on to document review. 🔍

中文说明

感谢设计文档!

模板完整 ✓

方向: 作为 #5895(今天刚合并)的后续,方向清晰。文档放在已有的 docs/design/daemon-session-artifacts/ 目录下,位置正确。V1 落地后,持久化是自然的下一步;先写设计再动代码,顺序合理。CHANGELOG 没有可直接引用的条目,但从 V1 刚合并按,这个方向明显在路线图上。

在进入逐行 review 之前,有几点值得提前点名:

  • 文档建议 V2 作为一个完整 phase 交付,但内部又定义了两层 capability(session_artifacts_persistencesession_artifacts_content_retention)以及 A→E 五个 milestone。矛盾点在于:如果 content retention 延期(quota/hash/manifest/GC 是更重的那一半),metadata persistence 是否单独先发,还是整个 phase 一起等?建议现在就写明 fallback 计划,让 milestone 的 gating 显式而不是隐含。
  • fork remap 作为发布门槛是正确的——这是最容易在未完成时悄悄搞坏数据的部分。
  • sidecar 路径 ~/.qwen/tmp/.../chats/<sessionId>.artifacts.json:路径中的 tmp 暗示“可以删”,但它其实是 JSONL 之上的缓存。如果用户或清理脚本清掉了 tmp,从 JSONL 重建必须是真正测试过的路径,而不是“应该能用”。§10 测试计划里已经列出“删除 sidecar、验证重建”,所以这点没问题,只是点一下这是承重路径。

方案: 作为设计文档,范围合理——671 行覆盖数据模型、存储、API、安全、配额/GC、里程碑、测试计划。没有运行时代码,没有顺手重构,没有无关改动。文档刻意给出明确立场(例如“不抓取远端 URL”“不把 clientId 当授权”),这正是设计文档该做的事——给未来实现者清晰的边界,而不是含糊其辞。

一个问题值得再确认:§2.2 定义三层 retention(ephemeral/restorable/pinned),但 §6.2 说 client 只能通过 POST 请求 restorablepinned 必须走单独的 pin API。模型层三层 vs API 层两层,这个拆分是有意设计,确认一下不是缺口。

进入文档审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code review

Independent proposal (before reading): For a V2 persistence layer on top of V1's live session artifacts, I'd expect — append-only event log as source of truth (JSONL fits the existing chat recording naturally), a fast-load cache for session startup latency, explicit retention tiers so users opt into content persistence rather than having it happen by default, restore-time revalidation that doesn't trust on-disk state, fork/branch handling that remaps IDs, and quota/GC before any content API ships. The doc hits all of those.

Comparison with the diff: The document is well-structured and covers the design space thoroughly. The two-layer storage (JSONL source of truth + sidecar cache), three-tier retention (ephemeral/restorable/pinned), and the explicit separation between metadata restore (default) and content retention (opt-in via pin API) are the right calls. Security model is sound — using an internal ArtifactPrincipal instead of trusting clientId is correct, and the "don't fetch remote URLs" principle avoids a class of SSRF/privacy issues upfront.

Findings:

  1. §3.2 status values — the doc defines available / missing but §2.3 also uses a blocked string as a status. If blocked is a valid status value, add it to §3.2's enumeration; otherwise clarify it's only a restoreState. Minor consistency gap.

  2. §3.1 expiresAt vs §6.3 ttlDays — the artifact model stores an absolute expiresAt, but the pin body takes a relative ttlDays. Worth a one-liner in §6.3 saying the daemon computes expiresAt = now + ttlDays at pin time, so the conversion is explicit.

  3. §5.5 fork remap — says tombstone records must be rewritten with the target session's artifact id, but doesn't say what happens to a tombstone whose source artifact id has no matching upsert in the target session (e.g., the forked session never had that artifact in the first place). Safe default is probably "drop the orphan tombstone" — worth naming.

  4. §10 test plan — the rebuild-from-corrupt-sidecar test is listed, but there's no explicit test for "sidecar looks valid but is stale (source metadata matches but sequence is behind)." The validation rules in §4.3 catch this, but a test case naming it directly would be useful.

  5. Cross-references — the doc references "V1" behavior throughout but doesn't link to the V1 design doc (session-artifacts-daemon-api-implementation-design.md) in the same directory. Adding a one-line link in the intro would help future readers who land here first.

None of these are blockers — they're all polish-level gaps in an otherwise thorough design.

Testing

Docs-only PR — no runtime changes, no user-visible behavior to drive in tmux. Verification is limited to:

$ gh pr diff 6259 --name-only
docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md
$ wc -l docs/design/daemon-session-artifacts/*.md
  1586  session-artifacts-daemon-api-implementation-design.md  (V1, existing)
   671  session-artifacts-persistence-v2-design.md           (V2, this PR)

Markdown fences are balanced (verified by reading the rendered output above). The diff contains only the new design document, no stray edits.

中文说明

代码审查

独立方案(读 diff 之前): 在 V1 live session artifacts 之上做 V2 持久化,我会期待——append-only event log 作为 source of truth(JSONL 天然适配已有 chat recording)、一个用于 session 启动延迟的快速加载缓存、显式 retention 分层让用户主动选择内容持久化而不是默认发生、恢复时重新校验而不信任磁盘状态、fork/branch 处理要重写 ID、content API 上线前先有 quota/GC。文档覆盖了所有这些。

与 diff 对比: 文档结构良好,覆盖设计空间充分。两层存储(JSONL source of truth + sidecar cache)、三层 retention(ephemeral/restorable/pinned)、以及 metadata restore(默认)和 content retention(通过 pin API 主动选择)的显式分离,都是正确的判断。安全模型可靠——使用内部 ArtifactPrincipal 而不是信任 clientId,"不抓取远端 URL" 原则提前规避了一类 SSRF/隐私问题。

发现:

  1. §3.2 status 值 —— 文档定义了 available / missing,但 §2.3 也把 blocked 字符串当作 status 使用。如果 blocked 是合法 status 值,请补充进 §3.2 枚举;否则说明它只属于 restoreState。轻微一致性缺口。

  2. §3.1 expiresAt vs §6.3 ttlDays —— artifact 模型存绝对 expiresAt,但 pin body 用相对 ttlDays。建议在 §6.3 加一句说明 daemon 在 pin 时计算 expiresAt = now + ttlDays,让转换显式化。

  3. §5.5 fork remap —— 说明 tombstone 必须用目标 session 的 artifact id 重写,但没说当 tombstone 的源 artifact id 在目标 session 中不存在时如何处理(例如 fork 出来的 session 本来就没有那个 artifact)。安全缺省可能是"丢弃孤儿 tombstone"——值得点名。

  4. §10 测试计划 —— 已列出"重建损坏的 sidecar"测试,但没有显式测试"sidecar 看似有效但已过时(source metadata 匹配但 sequence 落后)"。§4.3 的校验规则能捕获这个,但加一个专门命名的测试用例会更有用。

  5. 交叉引用 —— 文档全程引用"V1"行为,但没有链接同目录下已有的 V1 设计文档(session-artifacts-daemon-api-implementation-design.md)。在引言加一行链接,能帮助以后先到这篇文档的读者。

都不是阻塞问题——都是整体扎实设计之上的润色级缺口。

测试

仅文档 PR——无运行时变更,无用户可见行为可用于 tmux 驱动。验证范围仅限于:

$ gh pr diff 6259 --name-only
docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md
$ wc -l docs/design/daemon-session-artifacts/*.md
  1586  session-artifacts-daemon-api-implementation-design.md  (V1,已有)
   671  session-artifacts-persistence-v2-design.md           (V2,本 PR)

Markdown 代码块闭合正常(已通过上方渲染输出确认)。diff 只包含新增设计文档,没有顺手改动。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection on the whole PR.

Stepping back — this is a design document, not code, so the bar is different. The question isn't "does it work" but "will future implementers ship something coherent from this spec." On that measure, it's strong.

The document's best quality is how often it says "no." §11's explicit out-of-scope list, §7.1's refusal to use clientId as an authorization boundary, §4.4's explanation of why sidecar alone isn't enough — these are the kind of opinions that prevent the slow creep of scope during implementation. A weaker design doc would hedge; this one commits.

My independent proposal (Stage 2a) landed in roughly the same place as the PR: JSONL as source of truth, sidecar as cache, explicit retention tiers, restore-time revalidation. The PR actually goes further than I would have in a couple of spots — the ArtifactPrincipal union is more expressive than the simpler "session owner vs connection" split I'd have started with, and the fork remap release gate (§5.5) is a sharper constraint than I would have written. Both are improvements.

The Stage 2 findings are all polish: a missing blocked status enumeration, an implicit ttlDaysexpiresAt conversion, an orphan-tombstone edge case on fork, a missing cross-reference link. Nothing that would bite during implementation — an implementer who read this doc cold would still build the right thing.

The two Stage 1 flags I raised still stand as questions for the author, not blockers:

  • What's the fallback plan if content retention slips past metadata persistence? The doc argues for "one complete phase," which is defensible, but if milestone E takes twice as long as A-D, does the whole phase really wait?
  • The ~/.qwen/tmp/ sidecar path is correctly flagged as a cache, and §10 already has the rebuild test. No action needed, just confirming it's load-bearing.

On the approval-guardrail check: title is docs: add session artifacts persistence v2 designdocs type, not refactor. Guardrail does not block.

Verdict: Approving. The design is opinionated in the right places, the scope is tight for a spec doc, and the open questions I raised are the kind you answer during implementation rather than block on now. The author can address the Stage 2 polish items in a follow-up commit or leave them for the implementer to pick up — either is fine.

中文说明

对整体 PR 的反思。

退一步看——这是设计文档,不是代码,所以评判标准不同。问题不是"它能不能用",而是"未来实现者能不能根据这份 spec 交付出一致的东西"。在这个标准下,它是扎实的。

文档最大的优点在于它频繁地说"不"。§11 显式的 out-of-scope 列表、§7.1 拒绝把 clientId 当授权边界、§4.4 解释为什么 sidecar 单独不够——这些观点能在实现过程中阻止 scope 缓慢蔓延。弱一点的设计文档会含糊其辞;这篇是给出明确立场。

我的独立方案(Stage 2a)和 PR 落在大致相同的位置:JSONL 作为 source of truth、sidecar 作为缓存、显式 retention 分层、恢复时重新校验。PR 在几个地方实际上比我预想的走得更远——ArtifactPrincipal union 比我会先写的简单"session owner vs connection"拆分更具表达力,fork remap 发布门槛(§5.5)也是比我会写的更锐利的约束。两者都是改进。

Stage 2 的发现都是润色级:缺少的 blocked status 枚举、隐含的 ttlDaysexpiresAt 转换、fork 时的孤儿 tombstone 边界情况、缺少的交叉引用链接。没有会在实现时反咬人的问题——一个从零开始读这份文档的实现者仍然会构建出正确的东西。

我在 Stage 1 提出的两个问题仍然作为给作者的问题存在,不是阻塞:

  • 如果 content retention 比 metadata persistence 延期更久,fallback 计划是什么?文档主张"一个完整 phase",这是可辩护的;但如果 milestone E 花费的时间是 A-D 的两倍,整个 phase 真的要等吗?
  • ~/.qwen/tmp/ sidecar 路径被正确地标为缓存,§10 已经有重建测试。无需动作,只是确认这是承重路径。

Approval guardrail 检查:标题是 docs: add session artifacts persistence v2 design —— docs 类型,不是 refactor。Guardrail 不阻塞。

结论: Approve。设计在该有立场的地方有立场,作为 spec 文档 scope 紧凑,我提出的开放问题是那种在实现过程中回答而不是现在就阻塞的类型。作者可以在后续 commit 里处理 Stage 2 的润色项,或者留给实现者自己去拿——都可以。

Qwen Code · qwen3.7-max

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

@chiga0 chiga0 requested a review from wenshao July 3, 2026 10:00
@wenshao

wenshao commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

Design review — checked against the V1 implementation on main

Thorough doc, and most of its V1 claims verified accurate against the code (live-store cap 200, 4096-byte metadata limit, workspace status batch 20 + TTL, status: available|missing, artifact_changed event name, operationQueue serialization, upsertMany()/remove(), ChatRecord.subtype/systemPayload extensibility, /branch copying the active record chain with per-record sessionId rewrite, the chatRecording setting gate). However, three claims about the existing code are wrong — two of them on load-bearing parts of the design (the restore integration point and the sidecar location) — plus five spec-internal issues. Line links point to this PR's file.

Where the doc mis-describes current code

1. §5.3 step 6 / §9 Milestone C — restore seeding targets the wrong process (L283, L615)
The doc seeds SessionArtifactStore from AcpAgent.createAndStoreSession(). No class AcpAgent exists (it's class QwenAgent, packages/cli/src/acp-integration/acpAgent.ts:2572), but the substantive problem is the process: the only production new SessionArtifactStore(...) is daemon-side — packages/acp-bridge/src/bridge.ts:2268 inside createSessionEntry — and GET /session/:id/artifacts reads exactly that store (packages/cli/src/serve/routes/session.ts:526bridge.getSessionArtifacts). The ACP child process where createAndStoreSession runs has zero references to SessionArtifactStore, so a seed implemented as written populates nothing the HTTP API can see. The existing child→daemon pathway (qwen/notify/session/artifact-event) can't substitute as-is either: handleArtifactEvent stamps source:'hook' and treats input as fresh ingestion (V2 fields like restoreState wouldn't round-trip), and it drops events until the SessionEntry is registered — which only happens after loadSession resolves. Suggest specifying the seed at the bridge (createSessionEntry / restore completion), or an explicit post-registration snapshot notification.

2. §4.3 — sidecar path is modeled on the legacy chats location (L176)
~/.qwen/tmp/<project_hash>/chats/<sessionId>.artifacts.json assumes chat JSONLs live under tmp/<hash>/chats/. They don't: the writer is packages/core/src/services/chatRecordingService.ts:627storage.getProjectDir() + 'chats', and getProjectDir() is ~/.qwen/projects/<sanitized-cwd> (packages/core/src/config/storage.ts:313-320, keyed by sanitized cwd, not a hash); tmp/<hash> is getProjectTempDir() (tool-results etc.). The tmp/<project_id>/chats/ strings survive only in stale comments (sessionService.ts:269, chatRecordingService.ts:476, sessionStorageUtils.ts:22) — the doc appears to have copied one. Consequence: sidecars would be written/validated in a directory the session loader never reads, and §4.3's "move together with the chat JSONL on archive" premise breaks. The projectHash cache key (L186/L200) inherits the same outdated model.

3. §5.4 — the http/https-only restore rule blocks every locally-published artifact (L293)
V1 deliberately allows file: URLs for trusted published artifacts (packages/acp-bridge/src/sessionArtifacts.ts:1241 normalizeArtifactUrl(raw, allowFile); call site :392 passes trustedPublisher), the default publisher returns file:// URLs (packages/core/src/tools/artifact/local-publisher.ts:40 pathToFileURL(...); local is the default), and url is the only locator for storage:'published' (validateLocator, sessionArtifacts.ts:1017-1027). Since §2.2 defaults published artifacts to restorable, every locally-published artifact would restore blocked/unopenable under §5.4 as written. Compounding: §4.2 forbids persisting trustedPublisher, so the restore path has no signal left to re-allow file: even after publisher-manifest re-validation — and §7.3's "不保存 host 绝对路径" is itself in tension with persisting file:///... URLs. Needs an explicit rule: allow file: when publisher-manifest trust re-validates, or rewrite published URLs to daemon-served ones.

Spec-internal issues

4. §7.1/§6.5 — session_owner and "用户确认" aren't mintable in the daemon's actual auth model (L452)
The daemon's HTTP trust boundary is one shared bearer token (run-qwen-serve.ts:1019; the artifact REST routes gate on global mutate({strict:true}) only). The one per-session primitive that exists, conn.ownsSession, can't ground session_owner: it's connection-lifetime (empty after daemon restart — exactly the persistence scenario), self-mintable by any token holder via session/load (sibling co-ownership is by design), and absent on the REST surface where §6.3's pin endpoint is specified. There is also no mechanism to hold a client-initiated REST mutation pending human confirmation (the permission-vote flow only covers agent-initiated tool calls during a prompt turn). As specified, the owner tier collapses to "holds the token". The doc should define how session_owner is established and persisted across restarts (a durable per-session capability minted at creation? an explicit downgrade to token-holder?) and what 用户确认 means mechanically for headless clients.

5. §6.2 + Milestone D vs §2.2 — the POST retention whitelist excludes the mandated ephemeral opt-out (L367, L621)
§6.2 "client 不能请求 pinned,只能请求 restorable" and Milestone D "POST /session/:id/artifacts 支持 retention: "restorable"" both enumerate {restorable} as the whitelist — but §2.2 (L56) requires background clients to explicitly request retention: "ephemeral" on that same endpoint (plus an SDK ephemeral helper), and §2.3 (L74) requires honoring explicit retention. An implementer deriving request validation from §6.2/Milestone D rejects exactly the request §2.2 mandates. Fix both spots to "ephemeralrestorable".

6. §6.4 — unpin's downgrade parameter is undefined, and unpin→ephemeral resurrects on next load (L411)
The target ("restorableephemeral,由请求参数决定") has no body/query schema (contrast §6.3's {"mode": ...}). Worse, by the doc's own rules — §5.1 journals only restorable/pinned changes, §8.1 "ephemeral 本来不写 journal", §5.3 replay applies only tombstones, and §4.2 requires tombstones only for deletion (which §6.4 says unpin is not: "不删除 metadata") — an unpin-to-ephemeral leaves the artifact's prior pinned upsert as its last journaled state, so it comes back on the next session load, contradicting §2.2 "ephemeral…不恢复". §4.2's reason:'unpin' enum value is consumed by the default (→restorable) upsert and assigns nothing to the ephemeral variant; §10 tests only the DELETE-tombstone case. Define the parameter, require a tombstone (or an explicit restore-skip rule) for the ephemeral target, and add a test.

7. §8.3 — TTL'd pinned content is excluded from the only expiry GC rule (L540)
§6.3's ttlDays produces pinned content with expiresAt (§3.1: "pinned 默认不设置" = settable), and §8.2 explicitly says pinned content is auto-deleted "除非用户设置 TTL" — but §8.3's only expiry-based rule is "删除超过 expiresAt 的 non-pinned content", which excludes pinned by its own wording, and the other rules all require a tombstone or session delete. Net: ttlDays is a dead parameter and expired pinned content accumulates until QUOTA_EXCEEDED. Add a GC rule for expired pinned content (or route TTL through unpin semantics).

8. §4.3/§4.4 — the sidecar buys almost nothing by the doc's own analysis, and its two sequence fields are redundant (L174, L205)
§8.5(4) concedes SessionService.loadSession() already parses the entire JSONL and can extract artifact records "在同一轮 parse 中顺手提取" (that is exactly how file_history_snapshot restore ships today, via readAllRecords), and fallback replay is already bounded by the 100-mutation/256 KB snapshot threshold; §4.4 concedes "sidecar 只解决性能,不承载协议正确性". Against that near-zero win, the spec spends: atomic writes, 6-field staleness validation, corruption fallback, archive/unarchive/delete co-movement, orphan-sidecar GC (§8.3), crash-consistency clauses (§8.4), and 3 dedicated tests (§10). Consider shipping V2 JSONL-only and adding the cache later if session load is measured slow. Separately, sequence vs sourceArtifactSequence: the doc admits "正常情况下两者相同" and no scenario in the doc makes them diverge (rebuild and compaction recompute both to the same value); moreover, validating sourceArtifactSequence against the JSONL's actual latest sequence requires parsing the JSONL — which defeats the cache. One field suffices (or drop the sidecar and the question disappears).

Minor

  • §3.1 (L100) "缺省按 V1 兼容处理为 live-only" vs the restorable defaults in §2.2/§2.3/§6.1: these coexist only if L100 is reader-side (interpreting V1-era records) while §2.2 governs ingest — the doc never states that scoping, and never requires V2 writers to materialize retention in §4.2 journal records (which reuse the same optional shape), so a field-less V2-written record would legitimately be read as live-only and dropped from restore. One clarifying sentence fixes it.
  • §4.5/§8.1: content gets a per-project cap but persisted metadata has no cross-session bound, and each 100-mutation snapshot appends another ≤1 MB copy into the same JSONL — the replay-cost mitigation grows the very file §4.5 admits "可能继续增长". Worth one sentence stating the accepted policy (metadata rides the chat-transcript lifecycle) or adding a journal-bytes ceiling.

Also for the record: doc language, directory placement, and formatting are all consistent with existing docs/design/ conventions, and the file passes prettier.

中文(完整对应)

设计评审 — 已对照 main 上的 V1 实现核查

文档很扎实,绝大多数关于 V1 的描述与代码一致(live store 上限 200、metadata 4096 字节上限、workspace 状态 batch 20 + TTL、status: available|missingartifact_changed 事件名、operationQueue 串行化、upsertMany()/remove()ChatRecord.subtype/systemPayload 可扩展、/branch 复制 active record chain 并逐条重写 sessionIdchatRecording 设置门控)。但有三处对现有代码的描述是错的 — 其中两处落在设计的承重部位(恢复集成点、sidecar 位置)— 另有五个规范内部问题。行号链接指向本 PR 的文件。

与现有代码不符

1. §5.3 第 6 步 / §9 Milestone C — 恢复 seeding 指向了错误的进程(L283、L615)
文档让 AcpAgent.createAndStoreSession() 用 snapshot 初始化 SessionArtifactStore。首先不存在 AcpAgent 类(实际是 class QwenAgentpackages/cli/src/acp-integration/acpAgent.ts:2572),但实质问题在进程:生产代码里唯一的 new SessionArtifactStore(...) 在 daemon 侧 — packages/acp-bridge/src/bridge.ts:2268createSessionEntry 内 — 而 GET /session/:id/artifacts 读的正是这个 store(packages/cli/src/serve/routes/session.ts:526bridge.getSessionArtifacts)。运行 createAndStoreSession 的 ACP 子进程里没有任何 SessionArtifactStore 引用,按文档实现的 seed 不会进入 HTTP API 可见的任何状态。现有的子进程→daemon 通路(qwen/notify/session/artifact-event)也不能直接顶上:handleArtifactEvent 会打上 source:'hook' 并按新摄入处理(restoreState 等 V2 字段无法往返),且在 SessionEntry 注册前(loadSession resolve 之后才注册)事件会被丢弃。建议把 seed 规定在 bridge 侧(createSessionEntry / 恢复完成时),或定义显式的注册后 snapshot 通知。

2. §4.3 — sidecar 路径按过时的 chats 位置建模(L176)
~/.qwen/tmp/<project_hash>/chats/<sessionId>.artifacts.json 假设 chat JSONL 在 tmp/<hash>/chats/ 下。实际不是:写入方为 packages/core/src/services/chatRecordingService.ts:627storage.getProjectDir() + 'chats',而 getProjectDir()~/.qwen/projects/<sanitized-cwd>packages/core/src/config/storage.ts:313-320,键是 sanitize 后的 cwd,不是 hash);tmp/<hash>getProjectTempDir()(tool-results 等)。tmp/<project_id>/chats/ 只残留在过时注释里(sessionService.ts:269chatRecordingService.ts:476sessionStorageUtils.ts:22)— 文档看起来是照抄了其中一条。后果:sidecar 会被写到/校验于 session loader 根本不读的目录,§4.3"archive 时与 chat JSONL 一起移动"的前提也不成立。示例中的 projectHash 缓存键(L186/L200)继承了同一个过时模型。

3. §5.4 — 只允许 http/https 的恢复规则会封死所有本地 published artifact(L293)
V1 有意允许 trusted published artifact 使用 file: URL(packages/acp-bridge/src/sessionArtifacts.ts:1241 normalizeArtifactUrl(raw, allowFile);调用点 :392 传入 trustedPublisher),默认 publisher 返回的就是 file:// URL(packages/core/src/tools/artifact/local-publisher.ts:40 pathToFileURL(...);local 是默认),且 urlstorage:'published'唯一 locator(validateLocator,sessionArtifacts.ts:1017-1027)。既然 §2.2 把 published 默认为 restorable,按 §5.4 原文实现,所有本地发布的 artifact 恢复后都会 blocked/不可打开。雪上加霜:§4.2 禁止持久化 trustedPublisher,恢复路径即便重新通过 publisher manifest 校验也没有任何信号可以重新放行 file: — 而 §7.3 的"不保存 host 绝对路径"本身也与持久化 file:///... URL 相冲突。需要显式规则:manifest 信任重新校验通过时允许 file:,或把 published URL 重写为 daemon 服务的 URL。

规范内部问题

4. §7.1/§6.5 — session_owner 和"用户确认"在 daemon 现有认证模型下无法铸造(L452)
daemon 的 HTTP 信任边界是单一共享 bearer token(run-qwen-serve.ts:1019;artifact REST 路由只有全局 mutate({strict:true}) 门)。现存的唯一 per-session 原语 conn.ownsSession 撑不起 session_owner:它只有连接生命周期(daemon 重启后为空 — 恰是持久化要解决的场景)、任何 token 持有者可通过 session/load 自铸(sibling 共同所有是设计使然)、且在 §6.3 pin endpoint 所在的 REST 面上根本不存在。也没有任何机制把 client 发起的 REST 变更挂起等待人工确认(permission-vote 流程只覆盖 prompt turn 中 agent 发起的工具调用)。按现文实现,owner 层级塌缩为"持有 token"。文档应定义 session_owner 如何建立并跨重启持久化(创建时铸造持久的 per-session capability?还是显式降级为 token-holder?),以及 headless client 的"用户确认"机制上指什么。

5. §6.2 + Milestone D vs §2.2 — POST retention 白名单排除了被强制要求的 ephemeral 退出口(L367、L621)
§6.2"client 不能请求 pinned,只能请求 restorable"与 Milestone D"POST /session/:id/artifacts 支持 retention: "restorable""都把白名单枚举为 {restorable} — 但 §2.2(L56)要求后台 client 在同一 endpoint 显式请求 retention: "ephemeral"(且 SDK 要提供 ephemeral helper),§2.3(L74)要求按显式 retention 处理。照 §6.2/Milestone D 写请求校验的实现者,会恰好拒绝 §2.2 强制要求的请求。两处都应改为"ephemeralrestorable"。

6. §6.4 — unpin 的降级参数未定义,且 unpin→ephemeral 会在下次 load 复活(L411)
降级目标("restorableephemeral,由请求参数决定")没有任何 body/query schema(对比 §6.3 的 {"mode": ...})。更糟的是,按文档自己的规则 — §5.1 只对 restorable/pinned 变更写 journal、§8.1"ephemeral 本来不写 journal"、§5.3 replay 只应用 tombstone、§4.2 只要求删除写 tombstone(而 §6.4 明确 unpin 不是删除:"不删除 metadata")— unpin 到 ephemeral 后,artifact 在 journal 里的最后状态仍是之前的 pinned upsert,下次 session load 就会复活,与 §2.2"ephemeral…不恢复"矛盾。§4.2 的 reason:'unpin' 枚举值被默认路径(→restorable 的 upsert)消耗掉了,并没有给 ephemeral 变体指定任何记录;§10 也只测了 DELETE tombstone 场景。应定义参数、要求 ephemeral 目标写 tombstone(或显式的恢复跳过规则),并补测试。

7. §8.3 — 设了 TTL 的 pinned content 被唯一的过期 GC 规则排除在外(L540)
§6.3 的 ttlDays 产生的是带 expiresAtpinned content(§3.1"pinned 默认不设置"= 可设置),§8.2 也明说 pinned content 会被自动删除"除非用户设置 TTL"(即设了 TTL 就该删)— 但 §8.3 唯一基于过期时间的规则是"删除超过 expiresAt 的 non-pinned content",措辞上恰好排除 pinned,其余规则又都要求 tombstone 或 session delete。结果:ttlDays 是死参数,过期的 pinned content 一直累积到 QUOTA_EXCEEDED。应补一条回收过期 pinned content 的 GC 规则(或让 TTL 走 unpin 语义)。

8. §4.3/§4.4 — 按文档自己的分析 sidecar 几乎没有收益,且两个 sequence 字段冗余(L174、L205)
§8.5(4) 自己承认 SessionService.loadSession() 已完整读取 JSONL,可"在同一轮 parse 中顺手提取"artifact records(今天的 file_history_snapshot 恢复正是这样通过 readAllRecords 实现的),且 fallback replay 已被 100 mutations / 256 KB snapshot 阈值限定;§4.4 也承认"sidecar 只解决性能,不承载协议正确性"。为了这个近乎为零的收益,规范却要付出:原子写、6 字段陈旧校验、损坏 fallback、archive/unarchive/delete 联动、orphan sidecar GC(§8.3)、crash 一致性条款(§8.4)和 §10 的 3 个专项测试。建议 V2 先只上 JSONL,实测 session load 慢了再加缓存。另外 sequence vs sourceArtifactSequence:文档自己说"正常情况下两者相同",全文找不到二者合法分叉的场景(rebuild 和 compaction 都会把两者重算成同一值);而且要校验 sourceArtifactSequence 是否匹配源 JSONL 的最新序号,必须先解析 JSONL — 这恰恰使缓存失去意义。留一个字段即可(或干脆去掉 sidecar,问题自然消失)。

次要

  • §3.1(L100)"缺省按 V1 兼容处理为 live-only"与 §2.2/§2.3/§6.1 的 restorable 默认值:两者只有在"L100 是读取侧(解释 V1 时代的记录)、§2.2 管摄入侧"的划分下才自洽 — 但文档从未说明这个划分,也从未要求 V2 写入方在 §4.2 journal record(复用同一 optional shape)里显式落 retention 字段,于是一条缺字段的 V2 记录会被合法地按 live-only 解读、从恢复中丢掉。加一句话即可修复。
  • §4.5/§8.1:content 有 per-project 上限,但 persisted metadata 没有任何跨 session 上限,且每 100 次 mutation 的 snapshot 都会往同一个 JSONL 追加一份 ≤1 MB 的完整拷贝 — 用来控制 replay 成本的机制本身在增长 §4.5 承认"可能继续增长"的那个文件。值得加一句明确接受的策略(metadata 随 chat transcript 生命周期)或补一个 journal 字节上限。

另外说明:文档语言、目录位置与格式均符合现有 docs/design/ 惯例,prettier 检查通过。

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
@chiga0

chiga0 commented Jul 4, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review feedback in commits 9b3a00ca1 and c376e1ff8.

Summary:

  • Fixed the restore integration point: the design now seeds SessionArtifactStore on the daemon-side bridge (createSessionEntry / restore completion), not in the ACP child process.
  • Removed sidecar from the V2 design. V2 is now JSONL-only; sidecar is explicitly deferred until measured load cost requires a separate cache design. This makes the stale sidecar path/cache-validation comments no longer applicable.
  • Preserved the V1 published-artifact exception: ordinary external_url stays http/https-only, while trusted manifest-validated published artifacts may restore file: locators when they resolve to daemon-managed storage.
  • Reworked auth language to match the current bearer-token route model; no unmintable durable session_owner is assumed in V2.
  • Allowed Client POST to request ephemeral or restorable, rejecting only pinned on that endpoint.
  • Defined unpin body semantics and required tombstone/restore-skip behavior for unpin-to-ephemeral.
  • Added durable-first/rollback semantics for DELETE, eviction, unpin-to-ephemeral, and restore_pruned tombstones.
  • Added contentRef ingest/restore validation through daemon-managed manifests with size/hash checks.
  • Added race-safe content-copy requirements, secret-bearing URL/metadata handling, V1-to-V2 live backfill, aligned persisted metadata cap with live cap, fork pinned-content downgrade, expired pinned-content GC, JSONL sequence/snapshot/tombstone invariants, observability, and expanded test coverage.

Validation run locally:

npx prettier --check docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md
git diff --check
awk '/```/{c++} END{if (c%2==0) print "fences_ok", c; else print "fences_bad", c}' docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md

All review threads have been replied to and resolved. reviewDecision may remain CHANGES_REQUESTED until a reviewer re-reviews.

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

代码审查总结

审查了完整的设计文档,以下是发现的问题。其中 F1 为 Critical 级别——V2 设计移除了 V1 现有的 per-client delete 保护机制,但未引入替代方案。

高置信度发现 (已作为 inline comment 提交)

# 严重度 主题 位置
F1 Critical V2 移除 V1 per-client delete 保护 Section 6.4
F4 Suggestion deleteContent 授权标准未定义 Section 6.4
F5 Suggestion GC 孤儿 grace period 未指定 Section 8.3
F6 Suggestion 缺少诊断/修复工具规范 Section 8.6
F7 Suggestion V2→V1 回滚未覆盖测试 Section 10
F9 Suggestion ttlDays 无上限约束 Section 6.2
F11 Suggestion 工作区重定位导致 artifacts 静默失效 Section 3.3
F15 Suggestion 裁剪顺序使用「例如」导致非规范 Section 8.1
F3 Suggestion "restore-skip 记录"未在 schema 定义 Section 5.3

终端可见 (低置信度)

  • F2: GC TOCTOU 竞态(跨 session pin)
  • F8: expiresAt 在 restore 路径未检查
  • F12: workspace_reference 是死枚举变体
  • F14: Fork snapshot 记录处理未定义

— qwen3.7-max via Qwen Code /review

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Session Artifacts Persistence v2 · 设计审计

本文档在 v1 内存 store 之上加持久化(journal upsert/remove event + snapshot + tombstone,靠 active-branch replay 恢复)。方向合理,但核心的「tombstone + active-branch replay」持久化模型存在多处会导致「删除项复活 / 持久化项静默消失」的正确性缺口,以及一处安全回归。高强度审计(24 agent,17 条确认)后按你的维度归纳如下,逐条 inline。

① 可行性 / 可实施性 🔴

  • restore 依赖 active-branch,但 reconstructHistory() 只走 leaf 链(sessionService.ts:897)——/rewind 后 turn N 的 artifact journal 记录落到废弃分支被丢弃:删除 tombstone 被丢而旧 upsert 存活 → 删除项复活;或持久化项静默消失。代码里 file_history_snapshot 已为此加了显式 「top up」(sessionService.ts:1402-1426),v2 artifact 记录需要等价机制但设计未写。
  • restore-prune tombstone 无 durable 落盘路径:prune 发生在 loadSession()(§5.4 step 1),而 store + operationQueue 到 step 6 才创建 → 首次 GET 与后续 replay 结果不一致。
  • ACP 进程边界未打通(PLAUSIBLE):snapshot 在 qwen --acp child 产出,但 daemon 侧 store 只能经 loadSession 的 ACP LoadSessionResponse RPC 拿到;Milestone C 只提到扩展 core 返回类型,未点名扩展 ACP wire 契约 → daemon 重启后 GET 返回空、v2 承诺失效。

② 副作用 / 安全回归 🔴

  • creator-only delete guard 每次重启失效:creator principal 故意不落盘(L210),restore 后 L496 分支必然命中 → 降级为「任意 session-mutate 持有者可删」,v1 的越权边界被静默移除(不是边界 case,是每次重启)。
  • v1 内存 eviction 现写永久 tombstone:v1 §7.1 eviction 只为限内存,v2 却给它写 durable remove tombstone → 一个仅因 source 超软配额被逐出的 restorable artifact 被永久删除、永不恢复,即便持久化配额(§8.1 的 200)还有空位 → 数据丢失。
  • live-first vs tombstone-first 自相矛盾(§5.2 vs §5.3):满 200 时 add 内触发 eviction,§5.2 先改内存、§5.3 要求先写 tombstone;tombstone 写失败时内存已改无法回滚 → 崩溃后复活、可能超 200。
  • unpin→ephemeral 被后续 upsert 复活:unpin 保留 live + 写 tombstone,但工具常重发同 identity → tombstone 之后又来 upsert → 下次 load 复活持久化,静默撤销用户「不要持久化」意图。
  • DELETE 耦合 durable tombstone → 可用性回归:v1 DELETE 是纯内存、保证成功;v2 journal 不可写(磁盘满/只读)时 DELETE 报错且 live 不变 → 用户无法移除误登记或敏感 artifact。
  • 内部字段丢失影响 eviction 公平性clientRetained/insertSeq 不入 journal(§4.2),restore 后重置为 false/新序号 → 重启后一波新 artifact 触顶会裁掉用户点过 keep 的项。

③ 性能消耗 🟠

  • tombstonedIds 无界增长:每个 snapshot「必须携带 tombstonedIds」但无过期/compaction → 频繁创建删除的长 session 让 snapshot 撑爆 100-400KB / 4MB working-set 预算(§4.4)→ journal_budget_exceeded、新 artifact 降级 ephemeral,即便 live artifact 远不足 200。

④ 是否有更好的替代方案

上面的复活/顺序/膨胀问题大多源于「把 artifact 事件塞进 chat-record 树、靠 active-branch replay 恢复」。可考虑:

  1. artifact 用独立的 per-session append-only log + 定期 compaction(不挂 chat-record 树),避开 /rewind 分支丢弃问题,tombstone 在 compaction 时 GC,天然解决 #1/#7;
  2. snapshot 为权威、event 只做增量并在每次 snapshot 后截断(snapshot-authoritative),tombstonedIds 只需覆盖「最近一次 snapshot 之后」的删除,不再无界;
  3. eviction 与持久化解耦:内存 eviction 只影响 live 视图、不写 tombstone;持久化用独立 GC/配额 —— 直接消除 #4
    建议在文档「Alternatives / 取舍」一节显式对比现方案与上述,尤其为何选择耦合 chat-record 树。

⑤ 生命周期 / GC

  • tombstone 无 compaction(#7)、restorable 配额与内存 eviction 语义纠缠(#4)——GC 未闭环。session 结束后持久化数据的保留/过期策略也未见明确 TTL。

⑥ v1→v2 迁移 / 兼容

  • 未见 schema 版本化与 v1(无持久化)→ v2 的升级路径说明;restore 对缺 sha256/缺内部字段的历史记录的兼容处理需补。

总评

尚不足以作为实现依据。 方向与数据模型基本成立,但 tombstone/replay 持久化模型有 3 类必须先解决的问题:(A) active-branch replay 与 /rewind 的复活/丢失(#1#2#5),(B) 安全回归 creator guard 失效(#3)+ eviction 永久删除 restorable(#4),(C) tombstonedIds 无界(#7)。建议先在文档层面:明确 restore 的 top-up/权威快照策略、把 eviction 与持久化 tombstone 解耦、给 creator principal 一个可验证的持久化/降级契约、给 tombstone 定 compaction,并补一节替代方案对比与 v1→v2 迁移。逐条见下方 inline。

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

Round-2 pass at e90c6c4 — I re-verified every earlier "Fixed in …" reply against the current text and against the V1 implementation on main; the prior findings are genuinely resolved. The new findings below are mostly architectural: the journal design models the chat JSONL as a linear log the daemon can append to, but the real transcript is a parentUuid tree written exclusively by the ACP child process — that assumption breaks the write path, rewind, and fork semantics (first three comments). The rest cover pinned-vs-eviction, the V1→V2 backfill, the delete-authorization model, and two spec-consistency items.

Two minor textual mismatches, no dedicated comment: §2.3 L77 / §5.5 L321 still describe restore as recovering the persisted published locator, while §7.2 L520 requires file: locators to be regenerated from the manifest and never taken from old JSONL — the older sections should say the persisted locator is opaque and the openable locator is manifest-derived. And the three content-quota surfaces disagree in naming/scope: §8.2 defines artifact/session/project tiers, §6.1 exposes maxPinnedArtifactBytes/maxPinnedSessionBytes (no project knob), §7.3's settings offer maxArtifactBytes/maxTotalBytes (1 GB matches the project tier but the name doesn't say so, and there is no session-level setting).

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

Review Summary

Scope: 1 file, +843 lines — design document for session artifacts persistence V2

Findings: 19 verified (13 high-confidence, 6 low-confidence), after deduplication against 15+ existing review comments.

Overall this is a thorough and well-structured design document. The three-tier retention model, snapshot/tombstone invariants, and restore validation are all well thought out. The findings below focus on specification gaps that could cause ambiguity during implementation.

Critical (7)

  1. Line 220clientRetained used for sorting/eviction but no API/SDK/ingest field sets it
  2. Line 219PersistedSessionArtifact inherits runtime fields (restoreState, persistenceWarning) that would replay stale values
  3. Line 636 — GC TTL downgrade has no journal event reason enum value
  4. Line 454 — Pin/Unpin/Delete endpoints lack response body structure and HTTP status codes
  5. Line 749 — V1→V2 backfill described in §2.1 but no milestone in §9 delivers it
  6. Line 280expiresAt not listed as daemon-only at ingest — client could inject it
  7. Line 105persistenceWarning.message is free-form with no content constraint

Important (8)

8–19. See inline comments for details.

Positive

  • Clear three-tier retention lifecycle with well-defined invariants
  • Strong restore validation with defense-in-depth
  • Tombstone/snapshot consistency model is thorough
  • Privacy and security considerations are comprehensive
  • Pragmatic milestone decomposition with capability gating

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

Qwen Code Review — PR #6259

V2 session artifacts 持久化设计文档整体非常详尽,覆盖了 metadata restore、content retention、crash consistency、安全边界、配额和 GC 等关键维度。以下是 review 发现的需要关注的问题。

感谢作者的深入设计工作 🙏

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

自动 Review 结果

本 PR 是一份详尽的 V2 持久化设计文档(1009 行)。整体架构清晰、安全边界考虑充分。以下是本轮 review 发现的新问题,不包含已有 18 条已修复评论的内容。

汇总: 5 Critical / 6 Suggestion

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated

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

Code Review — PR #6259

Model: qwen3.7-max | Mode: comment

Summary

This PR adds a comprehensive V2 design doc for daemon session artifact persistence. Overall the document is thorough and well-structured, covering a broad set of edge cases (fork semantics, crash consistency, authorization, quota/GC). Below are findings from automated review.


Findings delivered: 9 (3 Critical, 6 Suggestion)

  • Critical issues focus on restore correctness (snapshot ordering, status ambiguity) and observability gaps (operation queue stall).
  • Suggestions cover unbounded growth, degenerate ordering, API schema gaps, and cleanup protocols.

This review was generated automatically by Qwen Code.

Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
Comment thread docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md Outdated
@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up on the workspace sha256 cost concern:

I added a small stat fast path in 37fb6ccce so restored/listed workspace artifacts do not re-hash unchanged files on each refresh.

Current behavior:

  • registration still streams the file once to store sizeBytes, qwen.workspace.mtimeMs, and qwen.workspace.sha256;
  • refresh first stats the current file;
  • if size changed, the artifact is changed without hashing;
  • if size and mtime are unchanged, it stays available without re-reading the file;
  • only when mtime changed but size stayed the same do we stream and compare SHA-256 as the same-size-edit fallback.

So the normal GET/list refresh path is now stat-only for unchanged files, while still detecting ordinary same-size rewrites through the hash fallback. This remains metadata-only and does not reintroduce content retention into #6259.

Validation:

  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts
  • cd packages/acp-bridge && npm run typecheck
  • npx prettier --check docs/design/daemon-session-artifacts/session-artifacts-persistence-v2-design.md packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts
  • git diff --check
  • markdown fence balance check

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

Automated Review — PR #6259

Build: ✅ passes | Tests: ✅ 76/76 pass (12 core + 64 acp-bridge) | ESLint: ✅ clean

Overall this is a well-structured PR with solid normalization and defensive coding in the persistence layer. The normalizePersistedArtifact and normalizeContentRef validators are thorough, and the event-sourcing replay model in rebuildSessionArtifactSnapshot is clean.

Inline comments below cover 4 critical findings and several suggestions. The most impactful are the cross-client authorization gap in upsertMany and the fork remap that silently drops removal events.

Comment thread packages/acp-bridge/src/sessionArtifacts.ts
Comment thread packages/core/src/services/session-artifact-persistence.ts Outdated
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/bridge.ts
Comment thread packages/acp-bridge/src/sessionArtifacts.ts
Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
Comment thread packages/acp-bridge/src/sessionArtifacts.ts
Comment thread packages/core/src/services/session-artifact-persistence.ts Outdated
@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Handled the current unresolved #6259 review set in 703b3a97c.

What changed:

  • Added cross-client ownership enforcement for client artifact upserts, not only removes.
  • Kept runtime tombstone owner data and bounded tombstone tracking to prevent unbounded growth and cross-client tombstone bypass.
  • Preserved fork-remapped removed changes even when a legacy record omits artifact metadata, so deleted artifacts do not resurrect in forked sessions.
  • Removed the non-cancellable local timeout around sessionArtifactsPersist RPCs to avoid live-state downgrades racing with late JSONL writes.
  • Surfaced artifact restore warnings through artifactWarnings on load/resume and through the artifact list envelope.
  • Added more useful persist_failed logging with sequence and artifact ids.
  • Removed stale managed-content warning enum values from the metadata-only base PR and renamed the restore input type for clarity.

Validation run locally:

  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts
  • cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "restores artifact snapshots that omit marker arrays after fork remap"
  • cd packages/core && npx vitest run src/services/session-artifact-persistence.test.ts
  • cd packages/acp-bridge && npm run typecheck
  • cd packages/core && npm run typecheck
  • cd packages/sdk-typescript && npm run typecheck
  • npx prettier --check packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridge.test.ts packages/acp-bridge/src/bridgeTypes.ts packages/core/src/services/session-artifact-persistence.ts packages/core/src/services/session-artifact-persistence.test.ts packages/sdk-typescript/src/daemon/types.ts
  • git diff --check

I am now going through the remaining unresolved review threads and resolving the stale/scope-split ones with thread-level notes.

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

聚焦审计(head 37fb6ccce):上轮 4 条 fix 已修好原问题,但各自带出一个新边界

只审「上轮修复是否正确 + 是否引入新边界 + 测试是否到位」。结论:原 4 条(rewind reseed / remove 解耦 / updateActiveTail / 三态)确已修好,但三条结构性 fix 各自引入了一个新的 correctness/数据丢失边界,与前几轮「每次修复偶尔带出新边界」是同一规律。建议合入前再修一轮。

🔴 修复引入的新 correctness / 数据丢失(3)

  1. packages/acp-bridge/src/bridge.ts:5932 · rewind 重建失败被当成「无 artifact」→ 落空快照永久丢数据
    artifactSnapshot = restoredArtifactSnapshotFromState(response);agent 侧 loadSession 瞬时失败(FS 错/尾部损坏)被吞、返回不带 snapshot → undefined,与「目标 turn 本就无 artifact」无法区分。shouldRecordArtifactSnapshot 在 before 有非 ephemeral 项时为 true → restore(undefined,{preserveLiveEphemeral:true}) 清掉所有 restorable,再 recordSnapshot() durable 写入空快照,覆盖之前的 events → 下次 resume 永久丢失该 rewind 目标本应恢复的全部 restorable artifact。
    → 建议:区分「重建失败」与「目标无 artifact」——重建失败时不得 recordSnapshot(空),应保留 prior/live 状态或显式报错。

  2. packages/acp-bridge/src/sessionArtifacts.ts:447 · remove() 非 strict:删除事件写失败会丢 tombstone → 被删项复活
    remove() 已先内存删除 + persistChanges(changes,false);写失败时 downgradeDurableChanges(774)只置 removalNotPersisted、从不写 tombstonedIdsapplyDurableMarkers(704)只在成功路径。于是删除既不 durable 也不被 suppress:同 identity 的隐式 re-emit 不被 shouldSuppressTombstonedUpsert 拦(id 不在 tombstonedIds) → 同 session 内复活;daemon 重启 replay 仍见原 created 无 tombstone → 恢复。用户删了、拿到一句 warning、然后它又回来了——正是 tombstone 机制要防的复活。(上轮把「降级时删不掉」修成了「删了又复活」。)
    → 建议:写失败时把 removal 记入内存 tombstonedIds(pending 待补写),保证 re-emit/replay 期间仍被 suppress。

  3. packages/core/src/services/sessionService.ts:948 · reconstructHistory 取末条物理记录为 leaf,artifact 记录会成为 leaf
    currentUuid = leafUuid ?? records[length-1].uuid。artifact 记录用 updateActiveTail:false 不进会话链、却是物理记录;当无标题 session 以 session_artifact_event/snapshot 结尾(末尾加 artifact 且 auto-title 未落) → 末条 artifact 记录成为 reconstruct 起点 → 会话末尾挂一条游离 system artifact 记录、lastRecordUuid 被 seed 到 artifact 节点,下一 turn parent 挂错。(OpenAI API Error: 401 Incorecct API Key provided #6 的 updateActiveTail fix 绕回了最初的 leaf-walk 根因。)
    → 建议:选 leaf 时跳过 updateActiveTail:false 的 artifact/system 记录,取最后一条会话记录。

🟠 其它(metadata 预算 + 热路径性能)

  1. sessionArtifacts.ts:914 · 注入 sha256/mtime 后再查 4096 上限 → 近上限的合法 workspace artifact 被拒/丢
    caller metadata ~4000-4090B(<=4096 合法)经 withWorkspaceContentHashMetadata 追加 ~84B 后超限抛错:strict(client add)→400,非 strict(tool 批)→静默 dropped。用户合法 artifact 仅因 daemon 注入 hash 元数据而消失。→ 为注入键预留 headroom,或把注入键排除在用户 4096 预算外。
  2. sessionArtifacts.ts:2126 · GET 热路径全文件 sha256:workspace 文件 mtime 变、size 同(保存/格式化/touch 常见)时,refreshWorkspaceStatus 在 GET 内 await 流式全文件 hash;数百 MB/GB 文件让一次元数据读阻塞数秒(仅 5s TTL 门控)。→ 异步/后台重算,别在读路径同步 hash。
  3. sessionArtifacts.ts:1077 · restore 逐个重算全部 workspace hashgetInitialWorkspaceStatus 不传 expected size/mtime → 每个 restorable workspace 文件都全文件 hash、且串行 await,N 个大文件让 resume 阻塞 N 次全量 hash,而 journal 已存 sha256 可直接当 baseline。
  4. bridge.ts:4575 · removeSessionArtifact 冗余 get() 预检:remove() 自身已对 missing 返回空;这里多一次 get() 触发 stat/hash 纯为决定早返,对大 workspace artifact 每次删除都白算一次 hash。

🧪 关键漏测(fix 的边界未被测试锁定)

  • sessionArtifacts.test.ts:2411:只断言 remove 后内存空 + warning,未覆盖写失败后 re-emit/replay 复活(finding 2)。
  • sessionArtifacts.test.ts:2172(s11-restore-empty-rewind):只断言 durable 清空/ephemeral 保留,未覆盖空 rewind 清掉 tombstone 后隐式 upsert 复活(finding 2 的 replay 面)。
  • 无测试覆盖:rewind 重建失败落空快照(finding 1)、reconstructHistory 以 artifact 记录结尾(finding 3)、注入后超 4096(finding 4)。

最终合入建议:修 3 条 🔴 + 1 条 🟠(#4) 后可合

  • 必修:findings 1、2、3(数据丢失/复活/会话链错乱)+ 4(合法 artifact 被丢);
  • 强烈建议同批:5/6/7 的热路径 hash 性能(否则大文件 session 的 GET/resume/delete 会明显卡);
  • 合并前补上上述 5 项漏测,尤其 finding 2 的复活 replay。

原 4 条修复方向都对,且采纳了 hash 三态——整体已很接近;就差把这几条 fix 的新边界收口。内容留存仍在 #6346(draft)另行处理。

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Handled the latest focused audit from #6259 (comment) in 812c0634e, plus the current unresolved review-thread sweep in 77375fb5b.

What changed:

  • Rewind now distinguishes a real empty artifact snapshot from an unavailable snapshot rebuild. The ACP side returns an explicit empty snapshot when rebuild succeeds with no artifact records; if rebuild fails, it returns artifactSnapshotUnavailable, and the bridge keeps live artifact state instead of recording an empty snapshot.
  • Non-strict explicit remove persistence failures now still update in-memory tombstones, so deleted artifacts cannot be resurrected by same-session implicit re-emits while waiting for a later durable snapshot.
  • Session history reconstruction no longer chooses trailing artifact side records as the default conversation leaf.
  • Workspace hash metadata injected by the daemon no longer counts against the user-supplied 4096-byte metadata budget.
  • removeSessionArtifact no longer performs a redundant pre-get() before remove(), avoiding an extra workspace stat/hash pass.
  • Persisted artifact metadata now keeps clientId and restore feeds it back into the live store, preserving cross-client ownership after restart.
  • REST GET /session/:id/artifacts is read-compatible again when no X-Qwen-Client-Id is supplied; when supplied, the client id is still validated.

Validation run:

  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts
  • cd packages/acp-bridge && npx vitest run src/bridge.test.ts -t "artifact snapshot"
  • cd packages/core && npx vitest run src/services/sessionService.test.ts -t "artifact side records"
  • cd packages/core && npx vitest run src/services/session-artifact-persistence.test.ts
  • cd packages/core && npm run typecheck
  • cd packages/acp-bridge && npm run typecheck
  • cd packages/cli && npm run typecheck
  • cd packages/sdk-typescript && npm run typecheck
  • npx prettier --check ...
  • git diff --check

Latest review-thread check after the push reports unresolved=0 for #6259.

@doudouOUC

Copy link
Copy Markdown
Collaborator

Qwen Code Review — PR #6259

Summary: V2 daemon session artifact persistence — JSONL journal, snapshot compaction, managed content store with pin/unpin, tombstones, and restore-on-restart. Well-structured with strong security hygiene (restore-time revalidation, workspace containment, URL sanitization, quota enforcement).

Build & tests: All pass — npm run build + 1,265 tests across 8 test files green.
Verification stats: 27 findings reported by 9 agents, 24 confirmed after verification (3 rejected), 2 additional from reverse audit.


🔴 Critical

1. Rewind with failed artifact snapshot rebuild silently wipes all durable artifacts

  • File: packages/acp-bridge/src/bridge.ts:5938-5944
  • What: When the acpAgent rewind handler fails to rebuild the artifact snapshot (JSONL corruption, loadSession error), artifactSnapshot is undefined. The bridge then calls restore(undefined, {preserveLiveEphemeral: true}). Since preserveLiveEphemeral is true, restore() does NOT early-return — it clears the artifacts map, iterates over undefined?.artifacts ?? [] (empty), re-adds only ephemerals. Then recordSnapshot() persists the now-empty durable list, making the loss permanent across restarts.
  • Impact: A single rewind on a session with any restorable or pinned artifacts, where the JSONL rebuild fails for any reason, permanently destroys all durable artifact metadata.
  • Suggested fix: Guard restore() against destructive clears when snapshot is undefined and the store currently has non-ephemeral artifacts:
    if (!snapshot) {
      const hasDurable = Array.from(this.artifacts.values())
        .some(a => a.retention !== 'ephemeral');
      if (hasDurable) {
        warnings.push('skipped destructive restore with no snapshot');
        return warnings;
      }
    }

2. sessionArtifactsPersist handler: 4 of 5 validation branches untested

  • File: packages/cli/src/acp-integration/acpAgent.ts:6310-6350
  • What: The daemon-side sessionArtifactsPersist ext method handler has five validation branches. Only one test exists — "rejects a missing payload." The remaining branches and both happy paths are uncovered.
  • Impact: Silent misrouting or dropped records would break artifact persistence end-to-end with no test catching it.

🟡 Suggestions

3. Cross-package type drift: duplicate aliases + SDK omissions + type assertions

  • Files: packages/acp-bridge/src/sessionArtifacts.ts:27-46, packages/sdk-typescript/src/daemon/types.ts:626, packages/cli/src/acp-integration/acpAgent.ts:3150,3211
  • DaemonSessionArtifactKind/Storage/Source/Status are verbatim copies of core types. Adding a new kind to one silently diverges.
  • KnownDaemonSessionArtifactRetention omits 'pinned'; KnownDaemonSessionArtifactPersistenceWarning omits 3 of 7 codes.
  • as LoadSessionResponse / as ResumeSessionResponse casts hide that artifactSnapshot is not in SDK response types.

4. pin() accepts expiresAt and contentRef without validation

  • File: packages/acp-bridge/src/sessionArtifacts.ts:499-519
  • Date.parse("never") returns NaN, causing immediate pruning (opposite of intended "never expire"). contentRef is accepted without structure validation.

5. unpin() unconditionally deletes contentRef even for restorable target

  • File: packages/acp-bridge/src/sessionArtifacts.ts:560-574
  • When called with {retention: 'restorable'}, the artifact's content integrity reference is permanently destroyed.

6. Fork remap preserves orphaned persistedAt from source session

  • File: packages/core/src/services/session-artifact-persistence.ts:296-308
  • remapSessionArtifactForFork deletes contentRef and expiresAt but preserves the original persistedAt timestamp.

7. stickyEphemeralOverride bypassable with explicit retention: 'restorable'

  • File: packages/acp-bridge/src/sessionArtifacts.ts:220-222
  • A previously unpinned artifact can be re-upserted with retention: 'restorable', bypassing the sticky override.

8. Eviction clears stickyEphemeralIds, losing sticky-ephemeral intent

  • File: packages/acp-bridge/src/sessionArtifacts.ts:1133, packages/core/src/services/session-artifact-persistence.ts:211
  • A subsequent re-upsert with retention: 'restorable' will NOT be overridden back to ephemeral.

9. persistChanges mutates change objects before persistence

  • File: packages/acp-bridge/src/sessionArtifacts.ts:928-941
  • Sets persistedAt on change objects before recordEvent. If persistence fails, the caller's changes array retains stale persistedAt.

10. Partial restore doesn't rollback; no summary count in warnings

  • File: packages/acp-bridge/src/sessionArtifacts.ts:796-804

11. publicArtifactsEqual missing updatedAt comparison

  • File: packages/acp-bridge/src/sessionArtifacts.ts:1568-1599
  • Artifacts differing only in timestamp compare as equal, potentially suppressing change notifications after rewind.

12. recordSnapshot() catch doesn't increment consecutiveSnapshotFailures

  • File: packages/acp-bridge/src/sessionArtifacts.ts:843-849
  • Backoff won't kick in after rewind-triggered snapshot failures.

13. restoredArtifactSnapshotFromState duplicates core validation

  • File: packages/acp-bridge/src/bridge.ts:2833-2868
  • Performs 6 levels of manual typeof checks and as casts, duplicating normalizeSnapshotPayload from core.

🔵 Needs Human Review

  • No integration test for loadSession artifact snapshot wiring
  • No integration test for forkSession artifact record remap
  • sessionArtifactsPersist handler doesn't validate required payload fields (v, sessionId, sequence)
  • getString() has no length bounds on JSONL string fields
  • as ChatRecord['systemPayload'] unsafe cast in fork remap
  • Rewind artifact rebuild failures silently swallowed in acpAgent
  • Bridge removeSessionArtifact redundant get() before remove()

Verdict: Request changes — The rewind data-loss path (finding 1) can permanently destroy durable artifact metadata on a single failed rebuild.


Generated by qwen-code-review-plugin via QoderWork

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed the Ubuntu CI failures in 7a356a174.

What changed:

  • Updated the ACP rewind test fixture to provide the recording/project-root/session snapshot pieces used by the new artifact snapshot rebuild path, and asserted the returned empty artifact snapshot.
  • Updated the REST artifact list test to match the current metadata-only contract: GET /session/:id/artifacts may run without X-Qwen-Client-Id, while mutating routes still require client identity.

Validation run locally:

  • cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "rewindSession extension method rewinds the active session"
  • cd packages/cli && npx vitest run src/serve/server.test.ts -t "GET /session/:id/artifacts allows missing client id"
  • cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "rewindSession extension method"
  • cd packages/cli && npx vitest run src/serve/server.test.ts -t "session artifact routes"
  • git diff --check
  • npx prettier --check packages/cli/src/acp-integration/acpAgent.test.ts packages/cli/src/serve/server.test.ts
  • cd packages/cli && npm run typecheck

Comment thread packages/acp-bridge/src/bridge.ts Outdated
Comment thread packages/core/src/services/session-artifact-persistence.ts

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review of PR #6259 — feat(daemon): persist session artifacts across restarts

3 Critical issues found:

  1. bridge.ts:5936restore() called with potentially undefined artifactSnapshot + preserveLiveEphemeral: true wipes all durable artifacts on rewind (see inline comment)
  2. sessionArtifacts.ts:2075 (normalizeMetadata function) — Prototype pollution via __proto__ key not filtered before normalized[key] = value assignment. Fix: add if (key === '__proto__' || key === 'constructor' || key === 'prototype') continue; in the loop. Also apply to session-artifact-persistence.ts normalizeMetadata.
  3. session-artifact-persistence.ts:58PersistedSessionArtifact lacks clientId, causing cross-client authorization bypass after session restore (see inline comment)

Build passes ✅, core tests pass ✅. acp-bridge tests failed to load due to pre-existing @xterm/headless env issue (not PR-related).

Comment thread packages/acp-bridge/src/sessionArtifacts.ts Outdated
@wenshao

wenshao commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Suggestions — commit 703b3a97c

File Issue Suggested fix
packages/acp-bridge/src/bridge.ts:4570 removeSessionArtifact calls await entry.artifacts.get(artifactId) before remove(). The get() triggers refreshWorkspaceStatus (file stat + potential sha256 hash) for workspace artifacts. remove() already handles nonexistent artifacts gracefully by returning empty changes. Remove the get() pre-check, or add a synchronous has() method that doesn't trigger workspace status refresh.
packages/acp-bridge/src/bridge.ts:2645 artifactReseedChanges labels rewind-removed artifacts with reason: 'explicit', which misrepresents the actual removal cause to SDK clients. Rewind-induced removals are not explicit user deletions. Add a 'rewind' reason to SessionArtifactRemovalReason, or use 'eviction' as the closest existing semantic.

Needs Human Review

These findings were flagged by review agents but not confirmed with high confidence — they may be intentional design decisions:

  • packages/acp-bridge/src/sessionArtifacts.ts:951 — During restore(), every workspace artifact triggers hashFile() unconditionally (no expected parameter). Session restore latency scales with total workspace file sizes. May be intentional for integrity verification.
  • packages/acp-bridge/src/sessionArtifacts.ts:285-290 — In strict mode, restoreState rolls back in-memory state but not persisted JSONL records written by persistChanges. Unlikely scenario (requires maybeRecordSnapshot to throw after recordEvent succeeds).
  • packages/acp-bridge/src/sessionArtifacts.ts:2161-2178 — TOCTOU race between fs.realpath() containment check and hashFile() in getWorkspaceStatus. Limited threat model for local CLI (attacker needs workspace write access).
  • packages/acp-bridge/src/sessionArtifacts.ts:540-555 — Restore rollback only triggers when ALL artifacts fail (restoredCount === 0). Partial failures keep inconsistent state. Current behavior is a reasonable compromise.

— qwen3.7-max via Qwen Code /review

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Triage for #6259 (comment) on current head:

  • Critical 1 is stale on current head. The rewind path now distinguishes an explicit empty snapshot from rebuild failure: acpAgent returns artifactSnapshotUnavailable on rebuild failure, and the bridge only calls restore() / recordSnapshot() when a concrete artifactSnapshot is present. On unavailable rebuilds it keeps live artifact state and emits a rewind restore warning instead of persisting an empty snapshot. This was handled in 812c0634e.
  • Critical 2 was valid as a test coverage gap. Fixed in 306ae986d: sessionArtifactsPersist now has tests for missing session id, invalid kind, missing payload, missing live session, unavailable chat recording service, and event/snapshot happy paths.
  • Findings 4, 5, and the public pin/unpin/content-store parts of the summary are stale/out of current feat(daemon): persist session artifacts across restarts #6259 scope. This PR no longer exposes pin/unpin/content retention; current metadata-only code rejects new pinned retention and only downgrades legacy pinned/contentRef/expiresAt records during restore/remap compatibility.
  • Finding 6 is stale on current head: fork remap strips contentRef/expiresAt and downgrades legacy pinned records to restorable metadata-only records. Preserving persistedAt is metadata provenance, not a content-retention owner.
  • Findings 7 and 8 are stale with the current sticky-ephemeral handling: explicit restorable upserts are overridden when the id is in stickyEphemeralIds, and current tests cover sticky replay/eviction behavior.
  • Finding 9 was already covered by the live-first/remove persistence work: failed explicit remove persistence no longer loses the in-memory tombstone; the remaining persistedAt field is only journal metadata on successful persistence paths.
  • Finding 11 is stale: publicArtifactsEqual currently compares updatedAt and persistedAt.
  • Finding 12 is stale: snapshot failure paths now increment consecutiveSnapshotFailures in the shared snapshot persistence path.
  • Finding 13 is a low-risk duplication note; current local normalization is deliberately narrow for the bridge response envelope and does not change the metadata-only contract.

Validation for the new commit:

  • cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "sessionArtifactsPersist"
  • cd packages/cli && npx vitest run src/acp-integration/acpAgent.test.ts -t "rewindSession extension method"
  • git diff --check
  • npx prettier --check packages/cli/src/acp-integration/acpAgent.test.ts
  • cd packages/cli && npm run typecheck

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

聚焦审计(head 812c0634):3 🔴 已修,但 2 🟠 只修了 create 路径、restore 路径漏镜像

逐条核对上一轮 3🔴 + 2🟠 + 性能/漏测(15 验证 / 0 否决)。

✅ 已修(3 🔴)

🔴 仍未修 / 修不彻底(headline:修复只做了 create、没镜像到 restore)

  1. packages/core/src/services/session-artifact-persistence.ts:542 · restore 路径仍把注入 hash 键计入 4096 → 用户 metadata 每次重启静默丢失
    create 路径已正确把 qwen.workspace.sha256/mtimeMs 排除在 4096 之外(新增测试也断言了),所以 ~4096B 用户 metadata + ~130B 注入 ≈ 4226B 被 create 接受并持久化。但 reload 时 rebuildSessionArtifactSnapshot → normalizePersistedArtifact → normalizeMetadata完整 4226B>4096 → return undefined,整份 metadata(含用户 payload)被丢。每个近上限 workspace artifact 一次 reload 就永久丢 metadata。 → restore 路径必须复用 create 的「排除注入键」逻辑。

  2. packages/acp-bridge/src/sessionArtifacts.ts:507(getInitialWorkspaceStatus / 1082) · restore 不复用 journal 已存 sha256 baseline → 重算 + 破坏 'changed' 检测
    restore 走 normalizeInput → getInitialWorkspaceStatus → getWorkspaceStatus(...)不传 persisted size/mtime/sha256。后果两重:(a) 每个 workspace 文件 resume 时全量流式 hash(不受大小限制)→ 大文件 session resume 阻塞数秒~分钟;(b) 新算的 hash 覆盖已存 sha256 → daemon 停机期间被改过的文件恢复后报 available(新 hash)而非 changedoffline 改动检测失效。这是 correctness 不只是性能。→ restore 传入 journal baseline,一致再判 available、否则 changed,不重算。
    (无任何测试覆盖 workspacePath artifact 的 restore——现有 restore 测试全用 URL artifact。)

🔴 新引入 correctness

  1. packages/acp-bridge/src/sessionArtifacts.ts:328 · 跨 client 碰撞回滚整批** tool artifact**
    UPDATE 路径新增 denyCrossClientMutation('upsert',...) 抛错,catch(358) 对整批 restoreState(before) 并 rethrow,bridgeClient 仅 stderr action=dropped。于是一个 tool 批次里只要有一项 identity 撞上某 client 拥有的 artifact,整批 tool artifact 全被丢(main 上是 merge 那一项、批次照常记录)。→ 只跳过/merge 冲突项,别 nuke 整批。

🟠 新引入 / 其它

  1. sessionService.ts:1432 · forkSession 把 artifact side-records 线性化进 active chain → fork 后这些 record 变成会话链祖先,reconstructHistory 会把它们混进 messages,与源 session 结构分叉。
  2. sessionArtifacts.ts:1115 · 'changed' 时跳过 sizeBytes 更新(PLAUSIBLE)→ 报 changed 却带旧 size。

🟡 cleanup / 漂移风险

  1. sessionService.ts:1961 · 本地重复定义 isSessionArtifactRecord(模块已导出同名)→ 未来新增 artifact subtype 只改一处会让 leaf 选择与 snapshot rebuild 不一致,可能重新引入 如何自定义密钥文件 .env可能与其他文件冲突 #3。建议复用导出版本。
  2. acpAgent.ts:7393 · 每次 /rewind 都 new SessionService().loadSession() 全量重解析,只为取 artifactSnapshot;可从内存 store 派生。

非阻塞(按之前决定)

  • bridge.ts:4555 GET 校验 clientId(stale/rotated clientId 报 InvalidClientIdError)——属 v1 兼容,v1 未上线,不阻塞。

最终合入建议:还需修 3 条(restore 镜像 2 条 + 批回滚 1 条)后可合

必修:

建议同批:#4(fork 线性化)、#5(changed 的 size)、#6(去重 isSessionArtifactRecord 防 #3 复发)、#7(rewind 重解析),并补 workspacePath artifact 的 restore 测试(当前完全没有)。

根因观察:3🔴 修好了,但 2🟠 各自「只修 create、漏 restore」——create-path 与 restore-path 没有共享同一套 normalization(预算排除 + baseline 复用),导致每个修复要写两遍、restore 半边反复被漏。建议把两条路径收敛到同一个 normalize 函数,让修复落一次即可,避免下一轮又冒 restore 侧的边界。

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Handled the focused audit from #6259 (comment) in the pushed head d4d8691a5.

What changed:

  • Restore normalization now treats valid daemon-injected workspace metadata (qwen.workspace.sha256 / qwen.workspace.mtimeMs) as outside the 4096-byte user metadata budget, in both the core JSONL rebuild path and the ACP bridge restore path.
  • Workspace artifact restore now reuses the persisted size/mtime/sha baseline. Unchanged files can stay stat-only, size changes become changed without hashing, and same-size edits compare against the persisted SHA without replacing the baseline metadata.
  • Non-strict upsert batches now skip cross-client owner collisions instead of rolling back the whole batch. Strict client mutations still return SessionArtifactAuthorizationError.
  • Forked artifact side records remain side-channel records attached to the active parent, rather than becoming linear conversation ancestors. The session service now reuses the shared isSessionArtifactRecord predicate for this.
  • Workspace changed status now carries the current stat size when available.

Validation:

  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts
  • cd packages/core && mkdir -p coverage/.tmp && npx vitest run src/services/session-artifact-persistence.test.ts src/services/sessionService.test.ts
  • cd packages/acp-bridge && npm run typecheck
  • cd packages/core && npm run typecheck
  • npx prettier --check packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts packages/core/src/services/session-artifact-persistence.ts packages/core/src/services/session-artifact-persistence.test.ts packages/core/src/services/sessionService.ts packages/core/src/services/sessionService.test.ts
  • git diff --check

Pushed normally to both chiga0:feat/session-artifacts-persistence-v2-design and QwenLM:feat/session-artifacts-persistence-v2-design; no force-push.

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-Review at HEAD d4d8691a

12 new non-merge commits since last review at 44e2d3dd. Major changes: workspace content change detection (sha256 + mtime), pinned retention removed entirely, enhanced tombstone tracking, preserveLiveEphemeral restore option, fork-side artifact record inclusion, rewind artifact reconciliation.

Status of Previous Findings

Finding Status
Major: TOCTOU in pinWorkspaceFile N/A — content retention removed in 729dff74
Minor: stickyEphemeralIds unbounded Addressed — bounded by maxArtifacts (200) via snapshot filter

New Findings

Major: Rewind synthesizes empty snapshot on loadSession returning undefined (acpAgent.ts, rewind handler)

When loadSession returns undefined (session empty, not found, or transient I/O failure), the rewind handler falls through to a synthetic empty snapshot:

artifactSnapshot = sessionData?.artifactSnapshot ?? {
  v: SESSION_ARTIFACT_PERSISTENCE_VERSION,
  sessionId, sequence: 0, artifacts: [], tombstonedIds: [], stickyEphemeralIds: [], warnings: [],
};

This calls store.restore(emptySnapshot, { preserveLiveEphemeral: true }), which clears all durable artifacts. If undefined was caused by a transient issue (file I/O race during rewind), durable artifacts are silently lost. The adjacent catch block (artifactSnapshotUnavailable path) correctly avoids this by not calling restore().

Fix: Treat sessionData === undefined similarly to the catch path — set artifactSnapshotUnavailable rather than synthesizing an empty snapshot. Or at minimum, only synthesize when the caller can confirm the session is genuinely empty (not a load failure).


Minor: remove() no longer rolls back on persistence failure (sessionArtifacts.ts, ~line 454)

The old remove() captured before = this.cloneState() before deleting and called persistChanges(changes, strict=true) when persistence was available, rolling back on failure. The new code passes persistenceStrict = false and has no cloneState(). If persistence fails, the artifact is unconditionally removed from the in-memory store but the removal is not durable — creating a divergence between live state and the journal.

The design doc (§5.3) specifies durable-first for explicit DELETE. If remove() is only called for eviction (not explicit delete), this is acceptable since eviction is live-only by design. But if explicit delete flows through this path, the rollback safety net is lost.


Minor: mtime-based hash skip can miss content changes (sessionArtifacts.ts, commit 37fb6ccc)

The optimized getWorkspaceStatus short-circuits sha256 computation when size + mtimeMs are both unchanged. This heuristic is violated by git checkout/git restore (restores content while preserving mtime depending on config), cp -p/rsync -a, and coarse-resolution filesystems. A workspace artifact may report 'available' when the file content has actually changed.

This is a reasonable performance tradeoff for the common case (IDE saves, build outputs always change mtime). Consider documenting it as a best-effort heuristic and/or adding ctimeMs (inode change time) as an additional gate.


Minor: lastRestoreWarnings persist indefinitely in list() output (sessionArtifacts.ts, ~line 248)

lastRestoreWarnings is only cleared by another restore() call. Every list() invocation between restores includes the same stale warnings with no mechanism to acknowledge or dismiss them. Consider clearing warnings after the first list() that surfaces them, or adding a version/timestamp to the warnings.

Verified Non-Issues

  • Fork artifact parentUuid: Confirmed correct. Forked conversation records reuse source UUIDs, and artifact records' parentUuid references source-session parents that ARE on the active branch (already validated by includeActiveSideArtifactRecords). No orphaning risk.
  • rememberTombstone Set ordering: Confirmed correct. JS Set delete + add does move the element to the end of insertion order per ECMAScript spec. The LRU eviction via Set.values().next().value correctly evicts the oldest entry.
  • Sticky ephemeral override: Still correct. applyStickyEphemeralOverride properly checks retentionExplicit and sticky IDs are correctly loaded from snapshots during restore.
  • normalizePersistedArtifact allowlist: Still correct with proper prototype pollution guards (__proto__, constructor, prototype).
  • updateActiveTail: false for artifact records: Correctly decouples artifact writes from the conversation tail pointer.

Additional Audit Coverage

  • Workspace content change detection: Fast-path (size + mtime) → slow-path (sha256) is a sound tiered approach. The changed status correctly maps back to available in persistence (preventing daemon-local status leakage).
  • preserveLiveEphemeral restore: Correctly re-injects ephemeral artifacts after snapshot restore with tombstone/ID collision checks. Skip-re-validation is acceptable since ephemeral artifacts are short-lived.
  • remapSessionArtifactPayloadForFork: Correctly handles both snapshot and event payloads, drops tombstonedIds/stickyEphemeralIds, downgrades pinned→restorable, deletes contentRef.
  • ChatRecordingService updateActiveTail parameter: Clean addition that correctly prevents artifact system records from becoming conversation endpoints.

Verdict

COMMENT. One Major finding (rewind empty-snapshot synthesis) should be addressed to prevent potential data loss on transient load failures. Three Minor findings are noted for consideration. The overall implementation continues to improve — the workspace change detection, enhanced tombstone tracking, and fork-side artifact inclusion are all well-executed.


This review was generated by QoderWork AI

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

聚焦审计(head d4d8691a5):几乎可 merge —— 只剩 1 条 correctness(同一预算 bug 的第 3 条路径)

上一轮 3 条必修的核对结果:

🔴 唯一剩余 correctness 阻塞

packages/acp-bridge/src/sessionArtifacts.ts:1501 · mergeMetadataisMetadataWithinLimit 仍把注入 hash 键计入 4096
仓库已有 metadataBudgetBytes(metadata,'persisted')(排除 qwen.workspace.sha256/mtimeMs),create/restore 都用它修好了;但 mergeMetadata 走的是 isMetadataWithinLimit=裸 Buffer.byteLength(JSON.stringify(merged)) <= 4096没排除注入键
后果:一个 ~4KB 用户 metadata 的 workspace artifact(create 时被正确接受),当文件内容变化、同 identity 再 publish 时,merged = 用户 4KB + 注入 ~110B > 4096 → metadata_merge_dropped保留旧 hash;status 置 available 但 sha256 baseline 永不前进 → 之后每次 refresh 都判文件与陈旧 baseline 不符 → 永久 spurious changed,即便内容与最新 publish 一致
→ 修复:mergeMetadata 改用 metadataBudgetBytes(merged,'persisted') <= 4096(与 create/restore 同一判据)。一行级修改

非阻塞

  • REST client-id 收紧routes/session.ts:665 POST/DELETE 403、:644 GET 对 unbound id 400)—— v1 未上线、无消费者,按既定决定不阻塞
  • sessionArtifacts.ts:2270 mtime 启发式假阴性(size+mtime 都不变的 offline 改动,如 cp -p/rsync -t → 报 available 带旧 hash)—— 属 mtime-baseline 的固有取舍,可接受;建议文档注明。
  • cleanup / 漂移根因(强烈建议一并处理)metadataBudgetBytes/isWorkspaceContentMetadataEntry/WORKSPACE_CONTENT_* 在 core 与 acp-bridge 逐字重复两份sessionArtifacts.ts:2152 vs session-artifact-persistence.ts:552)。这正是预算修复一次次漏路径的根因——三条路径各自判预算、每次只修一两处。抽成单一导出 normalizer,让三条路径共用,才能根除这类反复(否则将来改注入键又会分叉)。
  • 漏测sessionArtifacts.test.ts:2954 restore 的「size+mtime 匹配→不重算」快路径无测试(现有 restore 测试用 mtime 0 强制重算);merge 路径预算、批回滚只丢冲突项也建议补测。

Merge 结论:还差 1 条即可 merge

  • 必修(1 条)#1501 mergeMetadata 改用 metadataBudgetBytes('persisted') —— 一行级,修完永久 spurious changed 消失。
  • 强烈建议同批(防复发):把重复的 metadata 预算/normalization 抽成单一导出函数,三条路径共用。
  • 其余(REST client-id、mtime 假阴性、漏测)按前述均为非阻塞

修掉这 1 条 correctness(+ 建议的去重)后,本 PR 的 correctness 阻塞即全部清除,可以 merge。整体已从多轮的密集缺陷收敛到「一个一行修复 + 一次去重收尾」。

@chiga0

chiga0 commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Handled #6259 (comment) in 95a54e354.

What changed:

  • mergeMetadata now uses the same persisted metadata budget check as restore, so valid daemon-injected workspace metadata (qwen.workspace.sha256 / qwen.workspace.mtimeMs) does not count against the 4096-byte user metadata budget during merge/update.
  • Added a regression test for a near-limit workspace artifact whose same-identity update refreshes the workspace SHA baseline instead of dropping merged metadata and leaving a stale hash.

I kept this fix intentionally narrow for #6259: no content-retention changes, no API contract changes, and no history rewrite.

Validation:

  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts -t "workspace hash metadata against the merge limit|merge would exceed"
  • cd packages/acp-bridge && npx vitest run src/sessionArtifacts.test.ts
  • cd packages/acp-bridge && npm run typecheck
  • npx prettier --check packages/acp-bridge/src/sessionArtifacts.ts packages/acp-bridge/src/sessionArtifacts.test.ts
  • git diff --check

Pushed normally to both chiga0:feat/session-artifacts-persistence-v2-design and QwenLM:feat/session-artifacts-persistence-v2-design.

type RebuiltSessionArtifactSnapshot,
type SessionArtifactEventRecordPayload,
type SessionArtifactSnapshotRecordPayload,
} from '@qwen-code/qwen-code-core';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Importing from @qwen-code/qwen-code-core barrel pulls in shellExecutionService.ts@xterm/headless, which resolves to undefined under vitest ESM transform from the acp-bridge package. This causes a suite collection error — 0 tests run across all 42+ existing and new tests in this file. This is a regression from main.

Suggested change
} from '@qwen-code/qwen-code-core';
// Option 1: Mock @xterm/headless in vitest setup
vi.mock('@xterm/headless', () => ({ default: { Terminal: class {} } }));

Or create a focused sub-entry in @qwen-code/qwen-code-core so acp-bridge can import artifact types without loading the full barrel.

— qwen3.7-max via Qwen Code /review

artifactSnapshot !== undefined &&
artifactSnapshotUnavailable === undefined;
const artifactRestoreWarnings =
artifactSnapshotUnavailable !== undefined

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] Two rewind edge cases cause silent data loss:

  1. Undefined snapshot wipes artifacts: When both artifactSnapshot and artifactSnapshotUnavailable are undefined (version skew), restore(undefined, { preserveLiveEphemeral: true }) clears this.artifacts and only re-adds ephemeral artifacts. All restorable/pinned artifacts are silently wiped.

  2. JSONL divergence: When artifactSnapshotUnavailable is set, restore() is skipped (in-memory retains pre-rewind state), but the JSONL has already been truncated by rewind. On daemon restart, the JSONL rebuild produces fewer artifacts → permanent data loss.

Suggested change
artifactSnapshotUnavailable !== undefined
const artifactRestoreWarnings =
artifactSnapshotUnavailable !== undefined
? [
`artifact snapshot rebuild unavailable during rewind: ${artifactSnapshotUnavailable}`,
]
: artifactSnapshot !== undefined
? await entry.artifacts.restore(artifactSnapshot, {
preserveLiveEphemeral: true,
})
: [];
// Force a snapshot when JSONL divergence risk exists
if (artifactSnapshotUnavailable !== undefined) {
await entry.artifacts.recordSnapshot();
}

— qwen3.7-max via Qwen Code /review

}
if (
(snapshot?.artifacts.length ?? 0) > 0 &&
restoredCount === 0 &&

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] restore() clears this.artifacts unconditionally (line 503), then re-adds artifacts one by one. The rollback guard only fires when restoredCount === 0 — i.e., all artifacts must fail. If 4 of 5 restore successfully and 1 fails, the rollback is skipped and the 1 artifact is permanently lost with only a stderr warning. Compounding this, the rewind handler unconditionally calls recordSnapshot() after partial restore, cementing the degraded state into the JSONL.

Suggested change
restoredCount === 0 &&
if (
(snapshot?.artifacts.length ?? 0) > 0 &&
restoredCount < (snapshot?.artifacts.length ?? 0) &&
warnings.length > warningCountBeforeRestore
) {
warnings.push(
`restored ${restoredCount}/${snapshot?.artifacts.length} artifacts; ${
(snapshot?.artifacts.length ?? 0) - restoredCount
} failed validation`,
);
if (restoredCount === 0) {
this.restoreState(previousState);
}
}

Also gate recordSnapshot in the rewind handler on restoreOk.

— qwen3.7-max via Qwen Code /review

}
if (kind === 'event') {
await recording.recordSessionArtifactEvent(
payload as SessionArtifactEventRecordPayload,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] The payload is cast to SessionArtifactEventRecordPayload / SessionArtifactSnapshotRecordPayload using as without validating required fields (v, sessionId, sequence, recordedAt, changes/artifacts). Malformed records are silently persisted to the JSONL and later skipped during rebuild, causing data loss on session restore.

Suggested change
payload as SessionArtifactEventRecordPayload,
// Add structural validation before persisting
if (
typeof (payload as Record<string, unknown>).v !== 'number' ||
typeof (payload as Record<string, unknown>).sessionId !== 'string' ||
typeof (payload as Record<string, unknown>).sequence !== 'number'
) {
throw new Error('sessionArtifactsPersist: payload missing required fields');
}

— qwen3.7-max via Qwen Code /review


const event = normalizeEventPayload(payload, []);
if (!event) return payload;
const remappedArtifactIds = new Map<string, string>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] remappedArtifactIds is scoped per-call to remapSessionArtifactPayloadForFork and doesn't survive across event records. When an artifact is created in event record N and removed in record N+1, the remove in N+1 can't find the original id in its empty map and falls back to a synthetic id. During rebuildSessionArtifactSnapshot, artifacts.delete(syntheticId) is a no-op (the artifact lives under the remapped id), so the artifact is resurrected in the forked session.

Suggested fix: Build the remappedArtifactIds mapping across all event records before remapping them. Either a two-pass approach (first pass collects all originalId → remappedId mappings, second pass remaps removals using the complete map), or accumulate the map in the caller (forkSession).

— qwen3.7-max via Qwen Code /review

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants