Skip to content

feat(serve): persist prompt terminal ledger for cold-load reconciliation - #9426

Merged
chiga0 merged 11 commits into
QwenLM:mainfrom
chiga0:feat/prompt-terminal-ledger
Aug 20, 2026
Merged

feat(serve): persist prompt terminal ledger for cold-load reconciliation#9426
chiga0 merged 11 commits into
QwenLM:mainfrom
chiga0:feat/prompt-terminal-ledger

Conversation

@chiga0

@chiga0 chiga0 commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Each session now keeps a small append-only ledger of prompt lifecycle outcomes, stored as a sidecar file next to the session transcript. When a prompt is accepted for execution, an in-flight record is written; when its formal terminal outcome publishes — normal completion, cancellation, error, or any of the shutdown/kill/channel-loss flushes — a terminal record is written. Records contain only a version, the prompt id, the state or terminal kind, a machine-readable cause code, the agent stop reason when present, and a timestamp; no prompt text, user content, tool input/output, or file paths are ever written, so the ledger inherits the transcript directory's privacy boundary. Writes are best-effort and synchronous: a ledger failure is logged and never blocks prompt execution or shutdown flushing.

On a cold session load (the path taken after a daemon restart, when no live entry owns the session and no prompt is currently active), the serve layer closes the loop for prompts the dead daemon left in flight: it classifies the tail of the session transcript with the existing turn-interruption detector and appends a reconstructed verdict — completed when the tail is clean, interrupted with a daemon-lost cause when the tail shows an interrupted prompt or turn. An attribution guard ensures the visible transcript tail is only ever mapped to the most recently admitted dangling prompt; anything that cannot be attributed with confidence stays unknown, and a wrong terminal is never synthesized (fail-closed). Queued-but-never-started prompts also stay unknown by design, since they produced no transcript content.

The load response gains an optional promptTerminals field carrying the trailing 64 terminal records (including freshly reconstructed verdicts). The field is omitted entirely when there is no terminal evidence, so old clients and pre-ledger sessions see the exact previous response shape. Archiving and unarchiving a session moves the sidecar alongside its transcript, keeping the evidence through the storage lifecycle.

Why it's needed

The daemon's turn terminal events (turn_complete / turn_error) are synthesized in memory and published over SSE; they were never persisted. After a daemon restart, a cold load replays the transcript through the agent subprocess, which only emits chunk-style updates — never terminal events. External orchestrators that mediate prompts by id therefore could never resolve a prompt that was in flight when the daemon died: the contract "a terminal event for exactly this promptId" was unsatisfiable, and the only safe answer was unknown. This PR makes terminal facts survive restarts and reconstructs a trustworthy verdict for the dangling prompt, without adding any new state machine: it reuses the existing shutdown terminal flush and the existing transcript-tail interruption classifier, and layers a tiny append-only sidecar on top.

Design doc: docs/design/2026-08-19-prompt-terminal-ledger-design.md

Reviewer Test Plan

How to verify

  • Start the daemon, send a prompt, and let it finish; a cold load of that session afterwards returns a promptTerminals list whose last entry reports the prompt as completed with the agent's stop reason.
  • Kill the daemon while a prompt is in flight, then cold-load the session: if the transcript tail is clean (the turn actually finished before the kill), the dangling prompt is reported as completed with the reconstructed-from-transcript stop reason; if the tail shows an interrupted prompt or turn, it is reported as interrupted with the daemon-lost cause. In both cases the verdict persists, so a second load returns it without redoing work.
  • Kill the daemon mid-turn and check the ledger sidecar next to the transcript: it holds the in-flight admission line and, after the cold load, exactly one terminal line for the reconciled prompt.
  • Stop the daemon gracefully while a prompt is in flight (normal shutdown, session close, or session kill): the flush persists an error terminal with the corresponding cause code, and a later load reports it.
  • Load a session that predates this change (no sidecar file): the response is byte-for-byte the previous shape — no promptTerminals field — and clients fall back to unknown exactly as before.
  • Attach to a session that already has a live owner with an active prompt: no reconciliation runs; the live owner still publishes the real terminal itself.
  • Archive a session with ledger evidence, then unarchive it: the sidecar travels with the transcript and the load still reports the terminal history.
  • Fill the session storage with an unwritable ledger location (or make appends fail): prompts still execute and settle normally, terminals still publish over SSE, and only a stderr warning records the ledger failure.

Evidence (Before & After)

N/A (daemon-side persistence and HTTP response shape; no user-visible TUI change. Unit and route-level tests cover the write points, reconciliation branches, fail-closed paths, and response field presence/omission.)

Tested on

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

Environment (optional)

N/A — package-level unit tests plus root build and typecheck only.

Risk & Scope

  • Main risk or tradeoff: every accepted prompt now performs two synchronous file appends (admission and terminal) beside the transcript; the records are single tiny id/state lines and the writes are best-effort, so the worst case is a stderr warning and a lost verdict, never a blocked prompt. Sessions whose prompts predate the ledger get no backfill: without ledger evidence there is no reconstruction (by design).
  • Not validated / out of scope: no ledger truncation or compaction in this PR; no new SSE events and no change to replay semantics; live-conversation workspaces are not wired to a ledger sink because their transcripts live outside the runtime storage layout. Windows/Linux verified via CI only.
  • Breaking changes / migration notes: none. The promptTerminals response field is additive and omitted when absent; old daemons and old clients behave exactly as before.

Linked Issues

N/A

中文说明

这个 PR 做了什么

每个会话现在在会话转录文件旁边维护一个小型只追加的 prompt 生命周期结果账本(sidecar 文件)。当一个 prompt 被接受执行时写入一条 in-flight 记录;当它的正式终态发布时——正常完成、取消、错误,以及关停/终止/通道断开等各种冲刷路径——写入一条终态记录。记录只包含版本号、prompt id、状态或终态类型、机器可读的原因码、存在时的 agent 停止原因、以及时间戳;绝不写入 prompt 文本、用户内容、工具输入输出或文件路径,因此账本完全继承转录目录的隐私边界。写入是尽力而为且同步的:账本失败只记录日志,绝不阻塞 prompt 执行或关停冲刷。

在会话冷加载时(守护进程重启后、没有存活入口持有会话且没有活跃 prompt 的路径),serve 层为被死亡守护进程遗留的 in-flight prompt 收口:它用既有的回合中断检测器对会话转录尾部分类,并追加一条重建的判定——尾部干净时判定为 completed,尾部显示中断的 prompt 或回合时判定为 interrupted 并携带 daemon-lost 原因。一个归因守卫确保可见的转录尾部只会映射到最近一次准入的悬空 prompt;任何无法确信归因的情况都保持 unknown,绝不合成错误的终态(fail-closed)。排队但从未启动的 prompt 按设计同样保持 unknown,因为它们没有产生任何转录内容。

加载响应新增可选的 promptTerminals 字段,携带尾部最多 64 条终态记录(包括刚重建的判定)。没有终态证据时该字段整体省略,因此旧客户端和账本存在之前的会话看到的是与之前完全一致的响应形状。归档与取消归档会随转录一起搬动 sidecar,证据在整个存储生命周期中保留。

为什么需要

守护进程的回合终态事件(turn_complete / turn_error)在内存中合成并经 SSE 发布,从未被持久化。守护进程重启后,冷加载通过 agent 子进程重放转录,只会发出分块类更新——不会有终态事件。因此按 id 介导 prompt 的外部编排者在守护进程死亡时永远无法解析当时正在执行的 prompt:「恰好属于这个 promptId 的终态事件」这一契约无法满足,唯一安全的答案是 unknown。本 PR 让终态事实在重启后存活,并为悬空 prompt 重建可信的判定,同时不引入任何新状态机:它复用既有的关停终态冲刷和既有的转录尾部中断分类器,只在其上叠加一个极小的只追加 sidecar。

设计文档:docs/design/2026-08-19-prompt-terminal-ledger-design.md

审阅者测试计划

如何验证

  • 启动守护进程,发送一个 prompt 并等待完成;之后冷加载该会话,返回的 promptTerminals 列表最后一条会报告该 prompt 为 completed 并带有 agent 的停止原因。
  • 在 prompt 执行期间杀死守护进程,然后冷加载该会话:如果转录尾部干净(回合在杀死前实际已完成),悬空 prompt 被报告为 completed 并带有 reconstructed-from-transcript 停止原因;如果尾部显示中断的 prompt 或回合,则被报告为 interrupted 并带有 daemon-lost 原因。两种情况下判定都会持久化,第二次加载直接返回而不会重做工作。
  • 在回合中途杀死守护进程并检查转录旁的账本 sidecar:它包含 in-flight 准入行,冷加载后恰好多出该 prompt 的一条终态行。
  • 在 prompt 执行期间优雅停止守护进程(正常关停、会话关闭或会话终止):冲刷会持久化一条带有对应原因码的错误终态,之后的加载会报告它。
  • 加载一个早于本变更的会话(没有 sidecar 文件):响应与之前的形状逐字节一致——没有 promptTerminals 字段——客户端按原样回退到 unknown。
  • 附着到一个已有存活持有者且持有活跃 prompt 的会话:不会运行归因收口;存活持有者仍会自行发布真实终态。
  • 归档一个有账本证据的会话,再取消归档:sidecar 随转录一起迁移,加载仍能报告终态历史。
  • 把账本所在位置变为不可写(或让追加失败):prompt 照常执行和结算,终态照常经 SSE 发布,只有一条 stderr 警告记录账本失败。

证据(前后对比)

N/A(守护进程侧持久化与 HTTP 响应形状变化;无用户可见的 TUI 变化。单测与路由级测试覆盖写点、归因分支、fail-closed 路径以及响应字段的存在/省略。)

测试平台

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

环境(可选)

N/A —— 仅包级单测加根目录 build 与 typecheck。

风险与范围

  • 主要风险或取舍:每个被接受的 prompt 现在会在转录旁执行两次同步文件追加(准入与终态);记录是单行微小的 id/state 行,写入是尽力而为的,最坏情况是一条 stderr 警告和丢失判定,绝不会阻塞 prompt。账本出现之前的会话不做回填:没有账本证据就没有重建(设计如此)。
  • 未验证 / 范围之外:本 PR 不做账本截断或压缩;不新增 SSE 事件、不改变重放语义;live-conversation 工作区不接入账本 sink,因为其转录在运行时存储布局之外。Windows/Linux 仅通过 CI 验证。
  • 破坏性变更 / 迁移说明:无。promptTerminals 响应字段是附加的且缺失时省略;旧守护进程与旧客户端行为与之前完全一致。

关联 Issue

N/A

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

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

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

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR — this is a well-framed one, design doc included.

Template: complete ✓

Problem: the gap is real and verifiable in code rather than theoretical: turn terminals are synthesized in memory and published over SSE only, cold-load replay re-emits chunk-class events through the agent subprocess and never a terminal, so an orchestrator mediating prompts by id genuinely cannot resolve a prompt that was in flight when the daemon died. No linked issue or field report (the PR says N/A) — this reads as serve-contract completion rather than an observed user bug, and the load-bearing assumption is that external orchestrators actually rely on the "terminal for exactly this promptId" contract across daemon restarts.

Direction: aligned — daemon reliability/observability, additive and backward-compatible response shape, privacy-conscious record format. Flagging per policy: this touches the serve HTTP response contract, the session storage layout, and packages/core, so it needs maintainer awareness (see Size).

Size: ~529 production-logic lines (bridge 112, serve ledger module 157, serve routes 37, run-qwen-serve 23, core sessionService.ts 31, acp-bridge ledger module 169) vs ~1005 test lines and a 116-line design doc. A feat touching core at 500+ production lines is not blocked, but per policy it is flagged for maintainer awareness.

Approach: the reuse ladder is solid — the existing turn-interruption classifier, the existing shutdown terminal flush, the existing sidecar convention (moveOptionalFile, archive/unarchive lifecycle), and a dependency-free injected sink so the bridge learns nothing about storage layout. Scope feels right for the stated goal. One real question I'll dig into during code review: the attribution logic when multiple prompts are left dangling (a queued backlog at crash time) — the FIFO reasoning in the design doc deserves a second look there.

Risk: no high-risk-path matches from the revert-history signal. Review depth: full Stage 2 with CI evidence, given the core-path touch.

Moving on to code review. 🔍

中文说明

感谢贡献——这个 PR 写得很清晰,还附带了设计文档。

模板: 完整 ✓

问题: 这个缺口是真实存在且可以从代码中验证的,不是理论性的:turn 终态事件只在内存中合成并通过 SSE 发布;冷加载回放只通过 agent 子进程重新发出 chunk 类事件,从不发出终态事件。因此按 promptId 中介 prompt 的外部编排器,确实无法解析 daemon 挂掉时仍在执行中的 prompt。没有关联 issue 或实际使用中的报告(PR 中写 N/A)——这更像是 serve 契约的补全,而不是观测到的用户 bug;其关键假设是:外部编排器确实依赖"恰好这个 promptId 的终态事件"这一契约跨越 daemon 重启。

方向: 对齐——daemon 可靠性/可观测性方向,响应结构向后兼容(纯增量、可选字段),记录格式注意了隐私边界。按策略提示:此 PR 触及 serve HTTP 响应契约、会话存储布局以及 packages/core,需要维护者关注(见"规模")。

规模: 约 529 行生产逻辑代码(bridge 112、serve ledger 模块 157、serve 路由 37、run-qwen-serve 23、core sessionService.ts 31、acp-bridge ledger 模块 169),对比约 1005 行测试代码和 116 行设计文档。触及 core 的 feat 类 PR 达到 500+ 生产行不会被阻断,但按策略需要提请维护者关注。

方案: 复用做得扎实——复用了现有的 turn-interruption 分类器、现有的 shutdown 终态 flush、现有的 sidecar 约定(moveOptionalFile、归档/解归档生命周期),以及零依赖的注入式 sink,使 bridge 不需要了解存储布局。就目标而言范围合适。有一个真实的问题我会在代码审查中深入看:当多个 prompt 处于悬空状态(崩溃时有排队积压)时的归属逻辑——设计文档中关于 FIFO 的推理在那里值得再推敲。

风险: revert 历史信号的高风险路径无命中。鉴于触及 core 路径,审查深度:完整 Stage 2 + CI 证据。

进入代码审查。🔍

Qwen Code · qwen3.8-max

Reviewed at 5d64fd94d1244b31c93638d11a19cd49f3948d21 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

Code review

Reviewed against 5d64fd94d1244b31c93638d11a19cd49f3948d21. I verified the diff's claims against the base tree (export chains, bridge plumbing, route context, the interruption classifier) rather than taking the design doc at its word.

What holds up well. The layering is right: the bridge only writes, through an injected appendSync seam that knows nothing about storage layout, and path resolution, reads, and reconciliation live in the serve layer. I confirmed there is exactly one session-entry creation site and one pendingPromptList.push site in the bridge, so the single admission write point covers every admitted prompt, and the single terminal write point inside publishPromptTerminal covers all four flush scenarios because they all funnel through the terminalPublished latch. The reader is genuinely defensive — strict per-line coercion, torn-tail tolerance, ENOENT-as-empty — and the response extension is backward-compatible: no ledger evidence, no promptTerminals field, byte-identical shape. Test coverage is broad (bridge write points, reconciliation branches, route-level presence/omission), and the reuse ladder is exactly what one wants: existing classifier, existing flush, existing sidecar/archive conventions.

One real correctness question — attribution with a queued backlog. The in_flight record is appended at admission for queued prompts as well as the running one (verified at the push site). Now take: p1 admitted and running, p2 admitted and queued, daemon hard-killed mid p1-turn. The ledger is [if p1, if p2]; dangling is [p1, p2]; the target is the last dangling id (p2), and the attribution guard compares the last in_flight record (p2) against it — so it passes, and p1's transcript-tail verdict is written against p2. If the tail is interrupted, p2 gets interrupted/daemon_lost (arguably fair) while p1 stays unknown forever. But if the tail is clean, p2 is reported completed despite never having run, and an orchestrator would take p2's work as done. The design doc's claim that "under FIFO prompt settlement the newest admitted prompt is the one whose transcript tail is visible" is the wrong way around in the backlog case: the earliest unsettled prompt ran last. Note the unit test's commented scenario ("p1 never ran, p2 was running") is unreachable under FIFO with an intact ledger, which is why the suite doesn't catch this. The two worlds that produce [if p1, if p2] — lost terminal record for p1 vs p2 still queued — are indistinguishable from ledger evidence alone, so my suggestion would be to fail closed when more than one dangling id exists (or attribute to the earliest dangling and document the tradeoff).

One inherited limitation worth surfacing in the design doc. detectTurnInterruption documents that a model-text tail truncated mid-stream is indistinguishable from a clean finish and classifies as none. If a hard kill ever leaves a partially flushed assistant record at the transcript tail, reconciliation would mark the dangling prompt completed — a gap in the doc's "never a wrong terminal" invariant. Whether transcript flush semantics actually expose a partial assistant tail after SIGKILL is not settleable from the diff; I've named it in the verification lane below.

Minor notes, non-blocking. (1) The design doc credits inFlightRestores with single-flight reconciliation, but that map is bridge-level — two concurrent HTTP cold loads still both run the route-level reconcile after the shared restore resolves; worst case is a duplicated identical terminal record in the response, harmless but the doc claim is imprecise. (2) A cold load with a dangling prompt parses the transcript a second time via sessionService.loadSession — bounded by the dangling gate, fine. (3) promptId is client-supplied and lands in the ledger unbounded; JSON escaping keeps the JSONL intact and the reader coerces strictly, so this is hygiene rather than a hole — a length cap would be cheap if wanted.

sequenceDiagram
    participant P1 as Orchestrator
    participant P2 as Serve load route
    participant P3 as Bridge
    participant P4 as Ledger sidecar
    participant P5 as Transcript
    P1->>P2: send prompt, daemon alive
    P2->>P3: sendPrompt
    P3->>P4: append in_flight at admission
    P3->>P4: append terminal at settle or flush
    Note over P3: daemon dies with a prompt in flight
    P1->>P2: POST session load, cold
    P2->>P3: loadSession
    P3-->>P2: restored, not attached, no active prompt
    P2->>P4: read records, find dangling ids
    P2->>P5: classify transcript tail
    P5-->>P2: clean or interrupted
    P2->>P4: append reconstructed verdict
    P2-->>P1: response with promptTerminals
Loading
Files changed (15)
File What changed
docs/design/2026-08-19-prompt-terminal-ledger-design.md Design doc: format, write points, reconciliation algorithm, compatibility matrix
packages/acp-bridge/package.json New promptLedger subpath export
packages/acp-bridge/src/prompt-ledger.ts Dependency-free ledger module: append, tolerant read, dangling reduction, 64-record window
packages/acp-bridge/src/bridge.ts Best-effort admission and terminal writes around the existing latch
packages/acp-bridge/src/bridgeOptions.ts Optional PromptLedgerSink seam on BridgeOptions
packages/acp-bridge/src/index.ts Re-export of the ledger module
packages/acp-bridge/src/bridge-prompt-ledger.test.ts Bridge write-point tests incl. sink-failure containment
packages/acp-bridge/src/prompt-ledger.test.ts Ledger round-trip, torn tail, reduction, windowing tests
packages/cli/src/serve/prompt-terminal-ledger.ts Serve layer: sink factory, cold-load reconciliation, response extension
packages/cli/src/serve/prompt-terminal-ledger.test.ts Reconciliation branch tests against real SessionService fixtures
packages/cli/src/serve/routes/session.ts Cold-path reconcile hook plus promptTerminals on the load response
packages/cli/src/serve/routes/session-prompt-terminals.test.ts Route-level supertest coverage of field presence and omission
packages/cli/src/serve/run-qwen-serve.ts Sink injection at all three bridge construction sites, skipped for live-conversation
packages/cli/vitest.config.ts Test alias for the new subpath export
packages/core/src/services/sessionService.ts getPromptLedgerPath plus sidecar moves on archive/unarchive

Testing evidence — the PR's own CI

This is an unattended CI run, so per policy nothing was built or executed here; the evidence below is the PR's own CI on the reviewed commit, fetched through the API. No red checks at review time, but the primary suites had not landed yet — the Qwen Triage Finalize job updates the table below once CI settles. The macOS/Windows test matrix and the CLI integration tests report skipped on this commit, so cross-platform evidence does not exist for it yet; that is stated as fact, not attributed to a cause.

Final CI results for 5d64fd9 (auto-updated by the triage finalize job after CI completed):

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

One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。

Not verified: the live daemon kill/restart flow itself — this pass is static review plus the PR's own CI. The unit suite pins the designed attribution behavior, including the multi-dangling case questioned above, so a green suite cannot settle whether that behavior is the right one.

Sandboxed verification would settle this: @qwen-code /verify — specifically (a) which prompt a reconstructed verdict lands on when the daemon dies with a queued backlog, (b) whether a mid-stream kill yields completed or interrupted for the dangling prompt, and (c) that the verdict persists across a second cold load. The author has write access and can trigger it directly on this head.

中文说明

代码审查针对提交 5d64fd94d1244b31c93638d11a19cd49f3948d21,并对照基线代码核实了 diff 的各项声明(导出链、bridge 内部结构、路由上下文、中断分类器),而不是仅凭设计文档。

扎实的部分。 分层正确:bridge 只负责写入,通过注入的 appendSync 接口(不了解存储布局);路径解析、读取与对账都在 serve 层。已核实 bridge 中只有一个会话条目创建点、一个 pendingPromptList.push 点,因此唯一的准入写入点覆盖所有被接受的 prompt;publishPromptTerminal 内唯一的终态写入点覆盖全部四种 flush 场景(都经过 terminalPublished 闩锁)。读取端防御性到位——逐行严格校验、容忍撕裂尾行、ENOENT 视为空;响应扩展向后兼容:无账本证据则无 promptTerminals 字段,响应结构逐字节不变。测试覆盖面广,复用梯度也正是期望的样子:现有分类器、现有 flush、现有 sidecar/归档约定。

一个真实的正确性问题——排队积压时的归属。 in_flight 记录在准入时写入,排队中的 prompt 也一样(已在 push 处核实)。考虑:p1 被接受并运行中,p2 被接受并排队,daemon 在 p1 的 turn 中被硬杀。账本为 [if p1, if p2];悬空列表 [p1, p2];目标是最后一个悬空 id(p2),归属守卫比较最后一条 in_flight 记录(p2)——通过,于是 p1 的转录尾判定被写到 p2 名下。若尾部显示中断,p2 得到 interrupted/daemon_lost(勉强说得通),而 p1 永远保持 unknown;但若尾部是干净的,p2 会被报告为 completed——尽管它从未运行——编排器会认为 p2 的工作已完成。设计文档中"FIFO 结算下最新接受的 prompt 才是转录尾可见的那个"这一论断,在积压场景下恰好说反了:最早未结算的 prompt 才是最后运行的。注意单测注释的场景("p1 从未运行,p2 正在运行")在账本完好的 FIFO 下不可达,所以测试套件发现不了这一点。产生 [if p1, if p2] 的两种世界——p1 的终态记录丢失 vs p2 仍在排队——仅凭账本证据无法区分,因此建议:存在多个悬空 id 时直接 fail-closed(或归属给最早的悬空 id 并写明取舍)。

一个值得在设计文档中披露的继承性限制。 detectTurnInterruption 明确记载:流式中途被截断的模型文本尾与正常结束无法区分,会被分类为 none。如果硬杀后转录尾部残留了部分刷写的 assistant 记录,对账会把悬空 prompt 标记为 completed——这是文档"绝不合成错误终态"不变量的一个缺口。SIGKILL 后转录刷写语义是否真的会暴露部分 assistant 尾部,无法从 diff 判定;已将其列入下方验证通道。

次要备注(不阻塞)。(1)设计文档把单飞对账归功于 inFlightRestores,但该映射在 bridge 层——两个并发 HTTP 冷加载在共享 restore 解决后仍会各自执行路由层对账;最坏情况是响应中出现重复的相同终态记录,无害,但文档表述不精确。(2)有悬空 prompt 的冷加载会通过 sessionService.loadSession 二次解析转录——受悬空门控约束,可接受。(3)promptId 由客户端提供且不限长地落入账本;JSON 转义保证了 JSONL 的完整性,读取端也严格校验,属于卫生问题而非漏洞——如需要可低成本加长度上限。

测试证据。 本次为无人值守 CI 运行,按策略未构建或执行任何 PR 代码;证据来自 API 拉取的该提交自身 CI。审查时无红色检查,但主要套件尚未出结果——Qwen Triage Finalize 会在 CI 结束后更新上方表格。macOS/Windows 测试矩阵与 CLI 集成测试在该提交上报告 skipped,因此尚无跨平台证据;此处仅陈述事实。未验证:daemon 实杀/重启流程本身——本审查为静态审查 + PR 自身 CI。单测固化的是设计中的归属行为(包括上文质疑的多悬空场景),因此全绿并不能证明该行为是正确的。沙箱验证可以定论:@qwen-code /verify——具体验证 (a) daemon 带排队积压挂掉时重构判定落在哪个 prompt 上;(b) 流式中途被杀时悬空 prompt 得到 completed 还是 interrupted;(c) 判定是否在第二次冷加载时持久化。作者有 write 权限,可直接在该提交上触发。

Qwen Code · qwen3.8-max

Reviewed at 5d64fd94d1244b31c93638d11a19cd49f3948d21 · re-run with @qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Confidence: 3/5 — a well-engineered feature whose main path I trust, but two unresolved edge-case correctness questions and the core-path size escalation mean a maintainer should make the call, not the gate.

Stepping back: my independent proposal for this problem was "reuse the turn-interruption classifier on the transcript tail at cold load and persist the verdict in a sidecar" — the PR matches it and goes one step further in a way that earns its cost: the live admission/terminal ledger is what preserves the cause (shutdown vs channel loss vs kill) and the admission evidence that makes any attribution defensible at all; transcript-only reconstruction could not do that. The discipline is what I want to see in this area: fail-closed intent everywhere, no prompt content in records, byte-identical response shape for pre-ledger sessions, synchronous appends so the shutdown flush lands before process exit, and roughly two lines of test for every line of production code. If I had to maintain this in six months, the module boundaries (bridge writes through an injected seam, serve layer owns layout and reads) would make it easy to reason about.

My reservations are the two from the review, and I don't want to paper over them: the queued-backlog attribution can produce a completed verdict for a prompt that never ran, which breaks the PR's own headline invariant in a reachable scenario, and the mid-stream-truncation behavior of the reused classifier is inherited without acknowledgment in the design doc. Neither touches the common single-dangling-prompt path, which looks sound, but "a wrong terminal is never synthesized" is the contract this PR sells, so its edge cases are exactly what needs to be settled. Also noting plainly: 529 production lines across bridge, serve, and core in one PR is above the maintainer-awareness bar for core-touching features even when the code is clean.

CI was still in flight at review time (primary ubuntu suite and Serve A/B pending; macOS/Windows matrix skipped on this commit), which alone would defer an approval — but the verdict here is escalation, not deferred approval, so no approve-on-green marker is attached.

⏸️ Deferring to the core code owners@wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC (per CODEOWNERS for packages/core). Needs a human call on: (1) the multi-dangling attribution design — fail closed on backlog vs attribute-to-earliest, the two worlds are indistinguishable from ledger evidence; (2) whether the mid-stream-truncation classification gap needs a design-doc acknowledgment or a transcript-flush check; (3) the standard sign-off for a 500+-line core-touching feature. The author has write access and @qwen-code /verify on this head would settle (1) and (2) with A/B evidence if that helps the decision.

中文说明

置信度:3/5 —— 主路径可信、工程质量扎实的功能,但存在两个未解决的边界场景正确性问题,且触及 core 的规模已触发升级策略,应由维护者拍板,而不是由门禁决定。

回顾一下:我对这个问题的独立方案是"冷加载时复用 turn-interruption 分类器判断转录尾部,并把判定持久化到 sidecar"——这个 PR 与之一致,并且多走了一步且这一步物有所值:实时的准入/终态账本保留了原因(shutdown、通道丢失还是 kill)以及使归属判断站得住脚的准入证据;仅靠转录重构做不到这一点。这个领域该有的纪律都在:处处 fail-closed 的意图、记录中不含任何 prompt 内容、旧会话响应结构逐字节不变、同步追加保证 shutdown flush 在进程退出前落盘、测试代码约为生产代码的两倍。如果六个月后由我维护,模块边界(bridge 通过注入接口写入、serve 层拥有布局与读取)会让它易于推理。

我的保留意见就是审查中的那两点,不想掩饰:排队积压时的归属可能给一个从未运行的 prompt 产生 completed 判定,在一个可达的场景下打破了 PR 自己的核心不变量;复用的分类器在流式中途截断时的行为被继承下来,但设计文档未予说明。两者都不影响常见的单悬空 prompt 路径(该路径看起来是可靠的),但"绝不合成错误终态"是这个 PR 对外承诺的契约,所以它的边界场景恰恰是需要被解决的。另外直说:一个 PR 里 529 行生产代码横跨 bridge、serve 和 core,即使代码干净,也超过了触及 core 的功能类 PR 的维护者关注线。

审查时 CI 仍在进行(ubuntu 主套件与 Serve A/B 待定;macOS/Windows 矩阵在该提交上为 skipped)——仅此一点就足以推迟批准;但这里的结论是升级人工处理,而非推迟批准,因此不附带 approve-on-green 标记。

⏸️ 转交 core 代码负责人 —— @wenshao @tanzhenxin @yiliang114 @LaZzyMan @doudouOUC(依据 packages/core 的 CODEOWNERS)。需要人工决策:(1)多悬空归属设计——积压时 fail-closed 还是归属给最早的悬空 id,这两种世界仅凭账本证据无法区分;(2)流式中途截断的分类缺口是需要写入设计文档,还是需要核查转录刷写语义;(3)500+ 行触及 core 的功能类 PR 的例行签核。作者有 write 权限,在该提交上运行 @qwen-code /verify 可以用 A/B 证据帮助决策(1)和(2)。

Qwen Code · qwen3.8-max

Reviewed at 5d64fd94d1244b31c93638d11a19cd49f3948d21 · re-run with @qwen-code /triage

@qwen-code-ci-bot

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

Copy link
Copy Markdown
Collaborator

🩺 serve daemon A/B

Built the PR base vs this PR head f11760d, drove a fixed endpoint set against each, and diffed the JSON responses. Only fields that changed are shown.

No response changes against the PR base across 4 scenario(s).

Qwen Code · serve A/B

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

中文说明

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

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

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

Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts Outdated
Comment thread packages/core/src/services/sessionService.ts
Comment thread packages/acp-bridge/src/prompt-ledger.test.ts
Comment thread packages/acp-bridge/src/bridge-prompt-ledger.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts
Comment thread packages/cli/src/serve/routes/session.ts
Comment thread packages/cli/src/serve/routes/session.ts

@doudouOUC doudouOUC 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 #9426 — feat(serve): persist prompt terminal ledger for cold-load reconciliation

Reviewed commit: 943eaf5bbfa021d8f39b61dd7227da0dc7b4b16a

Overall assessment

No new blockers found. The PR is well-engineered: the design doc is thorough, the fail-closed invariants are correctly implemented, the test coverage is comprehensive, and the cross-file integration is sound.

This is a re-review after the previous round found 6 Criticals and 11 Suggestions. All 6 Criticals were addressed by the author in commit 943eaf5bbf with the described fixes, and the review agents have verified the fixes against the current code. The 11 Suggestions were also addressed.

Previous blockers re-check

All 6 previous Criticals have been verified as fixed:

  1. Cold-load reconciliation / false completed (comment 3807690282) — FIXED. The attribution guard now skips settled in_flight records, and the temporal evidence check (lastWriteMs >= targetAdmission.at) prevents attributing an earlier turn's tail to a queued-but-never-started prompt.

  2. id-less functionCall tail (comment 3807690287) — FIXED. tailHoldsAnyFunctionCall inspects the last apiHistory entry for ANY functionCall part (id or not), and upgrades the verdict to interrupted/daemon_lost.

  3. Settled queued prompt guards (comment 3807690303) — FIXED. The guard now skips the in_flight records of prompts that later settled (a terminal exists for their id) and requires the last unsettled in_flight to be the target's own admission.

  4. Test asserts wrong fail-closed invariant (comment 3807690308) — FIXED. The test now expects the fail-closed outcome (nothing appended) for the ambiguous pair, and 'newest gets the verdict' coverage moved to the attribution-valid interleaving.

  5. Ledger not deleted on session removal (comment 3807690318) — FIXED. removeSessionFiles now deletes both ledger states via removePromptLedgers.

  6. Ledger ingested as transcript by insight scanner (comment 3807690327) — FIXED. Both DataProcessor.scanChatFiles and usageHistoryService.rebuildFromSessionJsonl now exclude *.ledger.jsonl.

New finding

Suggestion: The usageHistoryService.ts filter change (.ledger.jsonl exclusion) has no test coverage. The existing rebuildFromSessionJsonl tests would pass even if the filter were removed. A future refactor could silently remove it, causing the ledger sidecar to be parsed as a transcript. Consider adding a test in usageHistoryService.test.ts where a .ledger.jsonl file is placed alongside a real session .jsonl and asserting only the session file is included.

Build & Test Status

Build: packages/core (✅) and packages/acp-bridge (✅) compiled successfully. packages/audio-capture failed due to a pre-existing environment issue (missing Python for node-gyp on Windows), unrelated to this PR. Test suite could not run due to the build chain being blocked by the audio-capture dependency.

Conclusion

No critical or blocking issues found. The feature is well-structured, the fail-closed invariants are correct, and the test coverage is thorough. One minor suggestion regarding test coverage for the usageHistoryService.ts filter.

@chiga0

chiga0 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator Author

Self-audit finding — fixed in 7a10362: hot-path synchronous full-ledger read

While auditing this PR I found a performance issue on the per-request hot path.

Problem. readRecentPromptTerminals runs on every POST /session/:id/load — including attached hot loads — and readPromptLedgerRecords would readFileSync the entire ledger and JSON-parse it line by line. The sidecar grows without bound (two lines per prompt, forever), so for a long session a multi-megabyte ledger turns each load into a synchronous event-loop stall on a request that is supposed to be cheap.

Fix (7a10362):

  • readPromptLedgerRecords gains an optional tailBytes window: files larger than the window are read from the tail only. The first line inside the window is always dropped — the window start can tear a line in half (a torn UTF-8 sequence even decodes to U+FFFD), so the first window line is untrustworthy by construction even when the window happens to start exactly on a line boundary. That contract is documented on the option and pinned by tests.
  • The load path reads a 256 KiB window (RECENT_TERMINALS_TAIL_BYTES). Terminal records are ~150 bytes and the response caps at 64 terminals, so even with in_flight lines interleaved the window holds the full trailing set with ~25× headroom — for any realistic session the response is byte-identical to a full read.
  • Sessions whose ledger does outgrow the window get a best-effort trailing subset, which the load-response contract already allows: the field is advisory evidence. Cold-load reconciliation — the one path that genuinely needs the whole file — still reads it in full; it runs once per cold load, off the hot path.

Tests: 3 new unit tests for the tail-window contract (fits-in-window, torn-first-line drop, aligned-boundary drop) plus an end-to-end serve test driving a ~290 KiB ledger through readRecentPromptTerminals and asserting the trailing 64 terminals come back.

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

Not explored to full depth (tool budget reached): "agent reverse-audit (round 3)": verify the transcript persistence path for addHistory 'd reminder entries ( drainPendingAddedMcpToolsReminder / skill & agent reminder drains) — the ceiling s…; chunk 8: none — though note I could not execute the new unit tests (no node_modules / dist in this worktree); verification of them is by static walkthrough only..

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

  • packages/cli/src/serve/run-qwen-serve.ts:4354 — [review] Production sink wiring has no test at any level
  • packages/acp-bridge/src/bridge.ts:2023 — [probe] kind: 'cancelled' projection branch is untested (mutation survives)
  • packages/cli/src/serve/prompt-terminal-ledger.ts:72 — [probe] Reconcile's unbounded synchronous full-ledger read on every eligible cold load
  • packages/cli/src/serve/routes/session.ts:3221 — [probe] Coalesced load waiter reads the ledger before the owner's reconcile appends
  • packages/acp-bridge/src/bridge.ts:2012 — [probe] Unbounded agent-supplied stopReason persisted into the ledger
中文说明

未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 3)"verify the transcript persistence path for addHistory 'd reminder entries ( drainPendingAddedMcpToolsReminder / skill & agent reminder drains) — the ceiling s…;chunk 8:none — though note I could not execute the new unit tests (no node_modules / dist in this worktree); verification of them is by static walkthrough only.

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

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

Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/acp-bridge/src/prompt-ledger.test.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts 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.

Review — cold-load prompt terminal ledger

Note on form: prose instead of inline comments. GitHub rejected the inline
anchors with 422 Line could not be resolved. All seven lines validate as in-hunk
against this PR's own files patch, and the identical method posted cleanly to
#9391 minutes earlier — the difference is that this PR is mergeable: CONFLICTING,
so there is no computable merge diff to anchor against. Rebasing should restore
inline review.
File and line references below are exact.

Tier: Deep — new on-disk format, writer/reader split across packages, and records already written cannot be un-written by reverting the commit.

Blocking, before anything else

This branch does not typecheck, and CI never noticed. readRecentPromptTerminals returns PromptLedgerTerminalRecord[] | undefined (prompt-terminal-ledger.ts:190-193), and prompt-terminal-ledger.test.ts:508 indexes it unguarded — expect(terminals[63]). :514's terminals[0]?.at optional-chains the element, so it still requires terminals to be defined, and toHaveLength(64) does not narrow for TS.

Why CI is silent: mergeable: CONFLICTING, so no merge commit can be built. Seventeen checks that ran on #9391 are simply absent hereTest (ubuntu-latest), Integration Tests, Secret scan, Dependency CVE audit, the whole matrix. The six green checks are all review orchestration (authorize, label, review-pr, precheck-pr, delay-automatic-review, Remind on force-push). A green rollup here is an absence of evidence, not a pass.

Findings

2 blockers · 3 major · 2 minor. Both blockers are in reconcileDanglingPromptTerminals but have different root causes, and each was settled by execution.


Blocker 1 · prompt-terminal-ledger.ts:163

[Blocker] The temporal fence uses queue-admission time, so a prompt that never ran can be attributed another prompt's tail
targetAdmission.at is queuedAt = Date.now() taken at the top of sendPrompt (bridge.ts:8118) — that is enqueue time, not start-of-execution time. Any prompt queued behind a running one therefore carries an at that predates the running prompt's transcript writes, so this guard passes on a tail the queued prompt never produced.

Failure scenario: t0 p1 admitted and runs · t0+1s p2 admitted → queued · p1 writes its turn (last record t0+4s) · t0+5s p1 completes · t0+6s daemon killed. p2 never executed. Cold load → dangling=[p2]; attribution guard passes (p1 is settled); this check passes (t0+4s ≥ t0+1s); p1's clean tail is classified → {promptId:"p2", terminal:"completed", stopReason:"reconstructed_from_transcript"} is appended durably. The orchestrator this feature exists for is told a prompt that never ran completed successfully, and the verdict is idempotent, so every later load re-serves it.

Witness (vitest, this head, real SessionService fixture; probe removed and tree verified clean):

PROBE ledger after reconcile: [ …p1 in_flight, p2 in_flight, p1 completed,
 {"v":1,"promptId":"p2","terminal":"completed",
  "stopReason":"reconstructed_from_transcript","at":1787130027111} ]

A second shape yields {"terminal":"interrupted","code":"daemon_lost"} for the same non-executed prompt.

This contradicts the design doc's Non-Goal "No reconstruction for queued-but-never-started prompts" and the fail-closed invariant "a wrong terminal is never synthesized". The existing test stays fail-closed when the last transcript write predates the admission only covers the case where the dangling prompt is the sole ledger entry — one preceding settled prompt defeats it.

Direction: fence on a start-of-execution timestamp (persist at from the pending_prompt_started transition, or add startedAt to the in_flight record); or require that no prior settled prompt's terminal at postdates the target's admission.


Blocker 2 · prompt-terminal-ledger.ts:168

[Blocker] The attribution guard and the verdict read two different views of the same transcript
The guard above reads resumed.conversation.messages — the raw ChatRecord list. The verdict reads buildApiHistoryFromConversation(...), which (session-api-history.ts:57,75-84) drops records with no message, drops type:'system' records, and — on a subtype:'chat_compression' record — replaces the entire accumulated history with payload.compressedHistory. So "the transcript's last write postdates the admission" is asserted about a record the verdict is not computed from.

Failure scenario: prompt P admitted and running; model emits an id'd functionCall, tool returns. Mid-turn auto-compaction writes a system/chat_compression record. Daemon is OOM-killed before the post-compaction round produces a message-bearing record. Cold load: dangling=[P], attribution passes, temporal guard passes (raw tail is the compaction record) — but the api-history tail is now compressedHistory, whose last entry is {role:'model',parts:[{text:'Got it. Thanks for the additional context!'}]} (postCompactAttachments.ts:849-851). detectTurnInterruption{kind:'none'} → a durable completed for a prompt that got no model token after admission.

Witness (real session-api-history.ts + turn-interruption.ts at this head, node --experimental-strip-types):

temporal guard  : lastWriteMs >= admission.at -> true  (raw tail = system/chat_compression)
api history len : 2  (4 raw records in)
detectTurnInterruption -> {"kind":"none"}
=> ledger record : {terminal:'completed', stopReason:'reconstructed_from_transcript'}

Corroborating the code's own intent: turn-interruption.ts:33-36 states "A model text tail that was truncated mid-stream is indistinguishable from a clean finish … so it classifies as none here." The classifier's only other consumer (session-recovery.ts:168-181) maps none{kind:'clean', canContinue:false} — "nothing to continue", not "the turn succeeded". This is the first caller to promote none into a positive success terminal.

The route test's fixture uses at: 1 (session-prompt-terminals.test.ts:174), so the temporal guard passes for any transcript timestamp and no test distinguishes the two views.


Major 1 · prompt-terminal-ledger.ts:270

[Major] promptTerminals is emitted by the daemon but never declared on the published SDK type, so the feature's stated consumer cannot read it
DaemonClient.loadSession returns (await res.json()) as DaemonRestoredSession (DaemonClient.ts:3073), and DaemonRestoredSession (sdk-typescript/src/daemon/types.ts:987) has no promptTerminals member. DaemonSession (types.ts:926-959) ends at branch? with no index signature. Every other daemon-added load-response field was declared there — replayDegraded (types.ts:1028), historyAnchorRecordId, eventEpoch — and each also got plumbing in DaemonSessionClient and an entry in docs/developers/qwen-serve-protocol.md. This field got none of the three.

Failure scenario: the PR's rationale is "external orchestrators that mediate prompts by id". Such an orchestrator on @qwen-code/sdk-typescript writes restored.promptTerminals and the build fails; it must as any or hand-redeclare, and there is no exported type for the record shape either (PromptLedgerTerminalRecord lives in @qwen-code/acp-bridge/promptLedger, outside the SDK surface). The value survives at runtime — DaemonSessionClient.load rest-spreads and omitSkillDetailsFromReplayArrays is spread-based — so this is a typed-surface gap, not data loss.

Witness: tsc 5.9.2 --strict against the real types.ts at this head → error TS2339: Property 'promptTerminals' does not exist on type 'DaemonRestoredSession'. Also git grep promptTerminals over the head tree: 12 hits, all under packages/cli/src/serve/** plus the design doc; zero in packages/sdk-typescript/, packages/webui/, or the protocol doc.

(Two agents reviewing this PR independently reached this finding.)


Major 2 · prompt-terminal-ledger.ts:183

[Major] The reconciled verdict is invisible to the pre-existing per-prompt terminal API, which answers 404 for the same promptId
The premise that terminal facts "were never persisted" holds for the SSE event, not the fact: chatRecordingService.ts:2670 already appends a system/turn_result record carrying promptId, state, stopReason, error{code,message}, startedAt, endedAt; acpAgent.ts:424 findSettledTurnResult scans it back after restart; and GET /session/:id/turns/:promptId (routes/session.ts:6288-6326bridge.ts:10653) already answers per-promptId queries from it across restarts.

Failure scenario: daemon dies mid-prompt P → restart → cold POST /session/:id/load reports promptTerminals:[{promptId:P, terminal:"interrupted", code:"daemon_lost"}]. Same daemon, same moment, GET /session/:id/turns/P: findLiveTurnStatus empty, enrichedTerminalPromptIds empty, transcript scan finds no turn_result for PgetSessionTurnStatus returns undefined404 prompt_not_found. Two endpoints, one promptId, contradictory answers; an orchestrator polling the documented per-prompt route gets no benefit from this PR.

Secondary cost: terminal:'interrupted' has no counterpart in TurnResultRecordPayload.state, and coercePromptLedgerRecord applies none of the length bounds isTurnResultRecordPayload applies to the same fields — two validators for one concept.

Witness: not run (needs a live daemon plus a real ACP child). Mechanism traced end-to-end: chatRecordingService.ts:2670acpAgent.ts:424bridge.ts:10653-10745routes/session.ts:6288-6326 (404 branch) vs prompt-terminal-ledger.ts:141-160.

Worth deciding explicitly whether the sidecar should be the second source of truth, or whether the reconstructed verdict belongs in turn_result.


Major 3 · routes/session.ts:3229

[Major] All seven reconciliation bail-out paths are silent, including the fail-closed veto — while the same PR's other two failure sinks both log
reconcileDanglingPromptTerminals has five bare returns (unreadable ledger · >1 dangling · attribution veto · loadSession threw · temporal veto) and two catch {} (read, append); this route wraps the call in a third. None logs.

Sibling inconsistency inside this diff: the bridge's ledger-append failure logs (bridge.ts:1984-1990, writeStderrLine), and the archive/unarchive ledger-move failures log (sessionService.ts:1746-1751, 1826-1831, this.warn). This route already has daemonLog in scope and uses it two hunks earlier (daemonLog?.warn('worktree sidecar path failed containment', …), session.ts:3346).

Failure scenario: an orchestrator reports a prompt permanently unknown. The operator has no way to tell which of five causes applied — unreadable ledger, two dangling prompts, attribution veto, loadSession threw, or nothing was dangling. The single most interesting state the design describes, the fail-closed veto, is the one with no diagnostic. The test plan says "only a stderr warning records the ledger failure", which is true of the write path and false of the whole reconciliation path.

Witness: not run (needs a live daemon); established by reading the three sinks in this diff side by side. A single daemonLog?.debug per veto reason would close it.


Minor 1 · prompt-terminal-ledger.ts:79

[Minor] Reconcile reads and parses the entire ledger on the same request whose response read this PR just windowed to 256 KiB
The head commit is "perf(serve): read only the ledger tail for load-response promptTerminals", and readRecentPromptTerminals passes tailBytes: RECENT_TERMINALS_TAIL_BYTES (256 KiB). This read passes no window, so every cold POST /session/:id/load does a synchronous readFileSync + per-line JSON.parse of the whole file on the event loop. The PR states there is no truncation or compaction, and the writer appends 2 records per prompt forever, so it is unbounded.

Concrete cost (probe, since removed): 25 000 prompts → 50 001 records, 5 525 068 bytes; full read 19.6 ms blocking vs tail read 1.0 ms. Linear and unbounded. moveLedgerSidecar also reads the whole file on an archive collision.

Note the full read is correct for dangling detection — a tail window could hide an in_flight record and make a dangling prompt look absent, which is the fail-closed direction — so the fix belongs on the writer (a cap, compaction, or checkpoint record), not a window here. Two open questions the design doc could answer: what bounds this file, and what happens when it stops fitting the 256 KiB response window.


Minor 2 · prompt-terminal-ledger.ts:92

[Minor] This fail-closed bail-out has no test that can fail — deleting the line leaves the suite green
Mutation: delete this line. prompt-terminal-ledger.test.ts + routes/session-prompt-terminals.test.ts24 passed (24).

The test named appends nothing when several prompts are dangling passes for a different reason: with 2+ unsettled ids, targetAdmission is always the newest in_flight record while target is dangling[0] (the oldest), so the attribution guard returns first. The listed invariant "Multiple dangling prompts never receive a synthesized terminal" is therefore asserted by a test that cannot distinguish the two guards.

For contrast, the other three guards are genuinely pinned — same suite, same matrix:

mutation result
drop this dangling.length > 1 bail-out survived (24/24 green)
drop temporal-evidence check killed
drop attribution guard killed
drop id-less functionCall upgrade killed

Actionable form: a fixture with two dangling ids where the oldest is also the last unsettled in_flight record — or accept the line is redundant and drop it, rather than documenting it as a load-bearing invariant.


Verification performed

npm ci clean · new reconciliation suite 19 passed, route suite 24 passed · 3 behavioural probes (both blockers) · 4-mutant matrix on the reconciler guards — 3 killed, 1 survived · 1 perf probe (25k-prompt ledger) · tsc --strict against the real SDK types. Working tree verified byte-identical to head afterwards.

Checked and found clean — so you can tell coverage from silence

  • Sidecar filename collision: enumerated every chats/ reader. sessionService (5 sites), core/memory/manager.ts:345, vscode-ide-companion/qwenSessionReader.ts:111 all gate on SESSION_FILE_PATTERN, which <uuid>.ledger.jsonl fails. The only two loose .jsonl filters — DataProcessor.ts:1004, usageHistoryService.ts:377 — are exactly the two this PR patched. No gap found; the drive-by fixes are correct and complete.
  • Writer ↔ reader symmetry: field sets, the four terminal values, torn-tail seal, dropFirstLine window — symmetric. Caps internally consistent (PROMPT_TERMINALS_RESPONSE_LIMIT=64 ↔ 256 KiB tail).
  • code forwarding chain flushPromptTerminalserr:{code}publishPromptTerminalpromptLedgerTerminalRecordnormalizeTurnResultError().code → ledger: intact.
  • Lifecycle: delete (removePromptLedgers, both archive states) mirrors removeWorktreeSidecars at both call sites; branch/copy deliberately copies the transcript only. No resurrection path found there.
  • Privacy: the ledger writes only v/promptId/state|terminal/code/stopReason/at — no prompt text, no tool payloads, no paths. The claim holds. (appendFileSync creates at 0o666 & ~umask while transcripts are explicitly 0o600, so "inherits the directory's permissions" is loose — not filed, since the content is ids and enums.)
  • Write-point completeness: one SessionEntry site, one publishPromptTerminal funnel with six callers — no path admits or settles a prompt without hitting the ledger. promptLedger is in the exports map, the barrel, and the vitest alias.

Recorded, not filed as defects

  • v: 1 has no forward-compat path. coercePromptLedgerRecord rejects record['v'] !== 1 outright, so a future v: 2 record is silently dropped rather than preserved-as-unknown. A mixed-version situation where a prompt's in_flight is v1 and its terminal v2 makes that prompt look dangling to the older reader — feeding it straight into blocker 1. No v2 writer exists yet, so this is a question: is v a hard gate or a compat hint?
  • Unarchive can invert ledger order. moveLedgerSidecar appends source after destination; in the unarchive direction that puts the older segment after the newer one, and both danglingInFlightPromptIds and recentPromptTerminalRecords decide supersession positionally while every record carries at. Reachability needs a prior warn-only move failure — a state the method's own docblock anticipates.

Not covered

mergeable: CONFLICTING — not reviewed against a merged tree. No live-daemon run (blocker-2's 404 divergence and the silent-veto finding are traced, not observed). No mutation matrix against the bridge write points. Windows/Linux path behaviour untested by anyone, and there is no CI to cover it.


Reviewed with AI assistance.

@chiga0
chiga0 force-pushed the feat/prompt-terminal-ledger branch from cd42cc8 to 13ac019 Compare August 19, 2026 10:19
@github-actions

Copy link
Copy Markdown
Contributor

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

中文

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

yiliang114
yiliang114 previously approved these changes Aug 19, 2026

@yiliang114 yiliang114 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the core (prompt-ledger.ts, prompt-terminal-ledger.ts, sessionService reconciliation).

Approve. The reconciliation is fail-closed where it matters: unreadable ledger → no-op; only a SINGLE dangling in-flight prompt is reconciled (multiple → no-op); degraded transcript → no-op. The ledger sink appends via the session service's ledger path.

0 unresolved threads.

@chiga0
chiga0 dismissed qwen-code-ci-bot’s stale review August 19, 2026 12:22

request review again

ytahdn
ytahdn previously approved these changes Aug 19, 2026

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

审查总结 / Review Summary

变更概览 / Overview: 19 files, +2724/−3. Adds an append-only prompt terminal ledger sidecar (<sessionId>.ledger.jsonl) per session, recording prompt admission (in_flight) and terminal outcome (completed / cancelled / error / interrupted). On cold-load reconciliation, classifies the transcript tail of a dangling in-flight prompt and persists a verdict so external orchestrators can resolve prompts that were in flight when the daemon died.

🎉 做得好 / Highlights

  1. 严密的 fail-closed 设计 / Rigorous fail-closed design: Every step that cannot attribute the tail with confidence returns without appending — the prompt stays unknown. Multi-dangling guard, attribution guard, compression fence, temporal evidence (projection-consistent with SessionApiHistoryAccumulator), FIFO evidence, and the id-less functionCall guard each close a concrete wrong-terminal class. No wrong terminal is ever synthesized.
  2. 分层清晰 / Clean layering: acp-bridge gains no new core coupling — the ledger sink is injected (PromptLedgerSink), the bridge module stays dependency-free beyond node:fs, and all writes are best-effort (never throw). Serve-layer reconciliation may import core.
  3. torn-write 处理 / Torn-write handling: sealTornTailSync detects a missing trailing \n (crash mid-append) and seals it before the next record, preventing two records from fusing into one unreadable line.
  4. 设计文档 / Design doc: The design document thoroughly explains each guard with concrete failure scenarios and examples ([A if, B if, B cancelled], [if p1, if p2, term p1]), making the algorithm auditable without reading the code.

交叉验证 ci-bot Critical 发现 / Cross-validation of ci-bot Critical findings

All 5 Critical findings from the ci-bot review (R1-1 through R1-6) were verified at head 13ac0194e1 and found to be false positives or compensated:

  • R1-1 (clean-tail verdict attributed to wrong prompt): Not a bug — dangling.length > 1 bails out, so exactly one target exists when the verdict fires. No oldest/newest ambiguity.
  • R1-2 (detectTurnInterruption returns none for mid-tool-run kill): Compensated at the call site — tailHoldsAnyFunctionCall upgrades the verdict to interrupted for any functionCall tail (with or without id). Test-pinned: 'marks a dangling prompt interrupted on an id-less functionCall tail'.
  • R1-3 (attribution guard misclassifies settled QUEUED prompt): Not reproduced — the guard correctly skips settled prompts' in_flight records.
  • R1-4 (test asserts opposite of fail-closed): Not reproduced — no test treats a corrupt ledger as valid; all tests reinforce fail-closed.
  • R1-5 (ledger not deleted on session delete): Not reproduced — removePromptLedgers is called at both deletion paths (lines 1654, 1669).
  • R1-6 (.ledger.jsonl confused with transcripts): Not reproduced — SESSION_FILE_PATTERN (/^[0-9a-fA-F-]{32,36}\.jsonl$/) does not match .ledger.jsonl.

🟡 重要问题 / Important (non-blocking)

  1. I1 — Reconciled at 使用 wall-clock 而非证据时间 / Reconciled at uses reconciliation time instead of evidence time (prompt-terminal-ledger.ts:184,191): Both the interrupted and completed reconciled records use at: Date.now() — the time of reconciliation, not the time the daemon actually died. lastVisibleWriteMs (lines 132–148, already in scope) is a much closer approximation of the actual interruption time. Failure scenario: daemon dies at 10:00, session loaded at 11:00 → reconciled record says at: 11:00. If a new prompt was admitted at 10:05 and terminated at 10:10, the reconciled at: 11:00 sorts after it, breaking temporal ordering. Suggested fix: at: Math.max(lastVisibleWriteMs, targetAdmission.at).

💡 建议 / Suggestions

  1. S1moveLedgerSidecar merge path (sessionService.ts:832-839): If the process crashes between appendFileSync (line 837) and unlinkSync (line 839), the next archive/unarchive re-appends the same source records, creating duplicates. The window is narrow (two sync calls) and the reader tolerates duplicates (last-write-wins for dangling detection, 64-record cap for terminals), but an idempotent merge (e.g., compare last record before appending) would be cleaner.
  2. S2 — Reconcile gate (routes/session.ts:3217-3222): The condition !restored.attached && !restored.hasActivePrompt skips reconciliation when a client is attached but has no active prompt. A reconnecting client that doesn't know about the previous prompt would miss its terminal. Consider whether hasActivePrompt alone should be the gate.

结论 / Verdict

批准 / Approve — A carefully designed, well-layered addition with correct fail-closed invariants throughout. The ci-bot's Critical findings are all resolved at the current head. The Important item (reconciled timestamp) is a correctness polish that improves temporal ordering but does not produce wrong verdicts. The five suggestion-level items from ci-bot (R1-7 through R1-16) are non-blocking observations.

doudouOUC
doudouOUC previously approved these changes Aug 19, 2026

@doudouOUC doudouOUC 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 — prompt terminal ledger (cold-load reconciliation)

Reviewed commit: 13ac0194e1adb057547196d11437f513e566da12 (verified as current HEAD at review time). Critical: 0 — APPROVE.

I checked out this exact head in an isolated worktree, installed, built, typechecked, and ran the affected suites rather than reading the diff alone. Every claim below was verified against the code at this commit.

First: clearing the standing blocker

The round-4 review posted a hard blocker — "This branch does not typecheck, and CI never noticed" — citing prompt-terminal-ledger.test.ts:508 indexing readRecentPromptTerminals()' possibly-undefined return as terminals[63]. That no longer stands at this head, on both halves of the claim:

  • There is no terminals[ indexing anywhere in prompt-terminal-ledger.test.ts any more.
  • npm run build → exit 0, npm run typecheck → exit 0, 0 TS errors across all workspaces.

The blocker was also written while the PR was CONFLICTING, which is why 17 checks were absent. The branch is now MERGEABLE and the full matrix ran green against this head, including Test (ubuntu-latest, Node 22.x), Serve A/B (no response drift vs. base), and Real daemon E2E.

One thing that claim got right and is worth recording: npm run typecheck is genuinely not part of the CI Test job (only preflight runs it). Type-only regressions in test files can land undetected. That is a CI gap, not a defect in this PR — this branch typechecks clean.

Local verification

Suite Result
cli prompt-terminal-ledger + session-prompt-terminals + DataProcessor 79 passed
acp-bridge prompt-ledger + bridge-prompt-ledger 20 passed
core sessionService 159 passed
core usageHistoryService 30 passed
npm run build / npm run typecheck exit 0 / exit 0, 0 errors

288 tests green. (First attempt failed on unrelated Ink type errors — my own --ignore-scripts install skipping patch-package, not the branch.)

What I verified, and why it holds

1. The sidecar cannot leak into transcript enumeration. This was my main concern: <id>.ledger.jsonl lives in the same chats/ dir as transcripts and ends in .jsonl, and the PR only patched two enumerators. I grepped every .jsonl reader repo-wide and adjudicated all of them:

  • usageHistoryService.rebuildFromSessionJsonl and DataProcessor.scanChatFiles — the two patched here; both now exclude .ledger.jsonl, and DataProcessor.test.ts pins it with a ledger file present in the fixture.
  • sessionService readdirSync(chatsDir) × 5 (lines 719, 1195, 1357, 2359, 2456) — four gate on SESSION_FILE_PATTERN, one on exact ${sessionId}.jsonl equality. None can match.
  • memory/manager.ts:345 defaultSessionScanner — gates on its own SESSION_FILE_PATTERN. Safe.

No enumerator was missed. And no collision is constructible: SESSION_FILE_PATTERN is /^[0-9a-fA-F-]{32,36}\.jsonl$/, so a session id can contain neither . nor l/g/r, and SessionService throws SessionWriterUnavailableError for ids that fail it — a session whose transcript is named <x>.ledger.jsonl cannot exist.

2. The fail-closed attribution invariant genuinely holds. I walked each guard in reconcileDanglingPromptTerminals and confirmed two properties the guards silently depend on:

  • Clock domain. queuedAt (bridge.ts:8258) and the terminal at are both Date.now() wall-clock epoch ms, so comparing them against Date.parse(record.timestamp) from the transcript is sound. A monotonic-clock source here would have broken every temporal guard at once.
  • Projection parity. The evidence loop's filters (type === 'system' skip, compression-reset detection, !record.message || subtype === 'realtime_message' skip) mirror SessionApiHistoryAccumulator / appendApiHistoryRecord exactly, so lastVisibleWriteMs is measured on the same projection the verdict runs on. isCompressionResetRecord correctly mirrors isApiHistoryCompressionCandidate.

I could not construct a reachable path that synthesizes completed for a prompt that never ran. The queued-backlog case flagged in triage is now closed by the FIFO guard: for [A in_flight, B in_flight, A completed] the tail predates A's terminal, so lastVisibleWriteMs < lastOtherTerminalAt vetoes attribution to B — and the test asserts the ledger stays at 3 records rather than merely asserting "no crash".

3. Reconciliation runs at most once per cold restore — verified, not assumed. archiveCoordinator.runSharedMany is a shared lock, so I expected concurrent cold loads to double-reconcile. It cannot happen: a coalesced waiter takes the inFlightRestores branch and returns attached: true (bridge.ts ~6790), so the route gate !restored.attached excludes it, and the owner alone reconciles. The does not reconcile an attached load test pins that gate. Worth knowing this invariant rests on the waiter's attached: true, since a future change there would break it silently.

4. No dead switches. Every added field has a real producer and consumer: tailBytes (set by readRecentPromptTerminals), promptLedger sink (injected at all three bridge construction sites, live-conversation deliberately excluded), getPromptLedgerPath, promptTerminals (attached only for action === 'load').

5. Lifecycle and format details. removePromptLedgers is wired at exactly the two sites as the pre-existing removeWorktreeSidecars, so deletion is symmetric with the established sidecar convention. moveLedgerSidecar's append-merge on an existing destination is correct and newline-sealed. The terminal append sits immediately after the terminalPublished latch inside publishPromptTerminal, the single funnel for all four flush paths, so it is exactly-once per prompt. The tailBytes window offset is always positive (guarded by size > tailBytes), and a file shrinking between statSync and readSync degrades to fewer records — i.e. fail-closed, never a throw.

6. In-scope and house style. The DataProcessor / usageHistoryService / vitest.config.ts edits are not drive-bys — they are required by the sidecar's placement. prompt-ledger.ts / prompt-terminal-ledger.ts are kebab-case, ESM, no any, tests collocated. The @qwen-code/acp-bridge/promptLedger subpath follows the existing processRegistryprocess-registry.ts precedent (export map + vitest alias; typecheck resolves via exports after build), which I confirmed by the clean typecheck.

Non-blocking note

The design doc says concurrent loads coalesce "so reconciliation runs at most once per cold restore." The conclusion is correct, but the mechanism is the waiter's attached: true gating the route check, not the reconcile call itself being coalesced. Worth a one-line precision fix whenever this file is next touched — not worth a round on its own.

Ledger growth is unbounded by design (two lines per prompt, no compaction), which the PR states as an explicit non-goal. Fine as scoped.

中文说明

审查提交:13ac0194e1ad(已确认为当前 HEAD)。Critical: 0 — 批准。

我在隔离 worktree 中检出该提交,完成安装、构建、类型检查并实际运行了相关测试,而非仅阅读 diff。

先处理遗留阻断项。 第 4 轮 review 提出的硬阻断「该分支无法通过类型检查」在当前 HEAD 已不成立:测试文件中已不存在被引用的 terminals[ 无保护索引;npm run buildnpm run typecheck 均退出码 0、0 个 TS 错误。该结论是在 PR 处于 CONFLICTING(导致 17 个检查缺失)时写下的;分支现为 MERGEABLE,完整矩阵在本 HEAD 全绿。不过该结论有一点是对的并值得记录:CI 的 Test job 确实不运行 npm run typecheck(仅 preflight 会跑),这是 CI 缺口而非本 PR 缺陷。

本地验证: cli 79 + acp-bridge 20 + core sessionService 159 + usageHistoryService 30 = 288 测试全绿;build / typecheck 均 0 错误。

逐项核验结论:

  1. sidecar 不会污染 transcript 枚举(我的首要关注点)。我全仓 grep 了所有 .jsonl 读取点并逐一判定:本 PR 修补的 2 处、sessionService 的 5 处 readdirSync(chatsDir)(4 处走 SESSION_FILE_PATTERN、1 处走精确等值比较)、以及 memory/manager.ts:345没有遗漏的枚举点。 且文件名冲突不可构造:session id 既不能含 . 也不能含 l/g/r,不合规 id 会抛 SessionWriterUnavailableError
  2. fail-closed 归因不变式成立。 我额外验证了两个守卫隐含依赖的前提:queuedAt 与终态 at 均为 Date.now() 墙钟毫秒,与 Date.parse(transcript.timestamp) 同一时钟域;证据循环的过滤条件与 SessionApiHistoryAccumulator 完全一致(投影一致性)。triage 提出的排队积压场景已被 FIFO 守卫关闭,且测试断言账本记录数保持不变,而非仅断言「不崩」。
  3. 冷加载收口每次至多执行一次——经验证而非假定。 runSharedMany 是共享锁,本应允许并发;但被合并的等待者走 inFlightRestores 分支并返回 attached: true,被路由的 !restored.attached 排除,故仅 owner 执行收口。
  4. 无死开关: tailBytespromptLedger(3 个注入点,live-conversation 有意排除)、getPromptLedgerPathpromptTerminals 均有真实生产读写点。
  5. 生命周期与格式: removePromptLedgers 与既有 removeWorktreeSidecars 调用点 1:1 对称;moveLedgerSidecar 追加合并与换行封边正确;终态写入紧随 terminalPublished 闩锁,覆盖全部四条冲刷路径且恰好一次;tail window 偏移恒为正,文件缩小时退化为 fail-closed 而非抛错。
  6. 范围与风格: DataProcessor / usageHistoryService / vitest.config.ts 的改动是 sidecar 落位的必要配套,非顺手改动;命名、ESM、无 any、测试同置均合规;子路径导出沿用既有 processRegistry 先例。

非阻塞备注: 设计文档称并发加载「收口至多执行一次」,结论正确,但真实机制是等待者的 attached: true 触发路由守卫,而非收口调用本身被合并——建议下次触碰该文件时顺手修正一行措辞,不值得单独一轮。账本无界增长已在文档中明确列为非目标,按当前范围可接受。

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

Not explored to full depth (tool budget reached): chunk 9: execute session-prompt-terminals.test.ts to confirm it passes (worktree has no node_modules and no built workspace dist/ ; npm ci + full build exceeded thi….

中文说明

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

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

未探索到全部深度(达到工具调用预算):chunk 9:execute session-prompt-terminals.test.ts to confirm it passes (worktree has no node_modules and no built workspace dist/ ; npm ci + full build exceeded thi…

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

Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts
Comment thread packages/acp-bridge/src/bridge-prompt-ledger.test.ts
Comment thread packages/core/src/services/sessionService.test.ts
Comment thread packages/acp-bridge/src/prompt-ledger.test.ts
Comment thread packages/cli/src/serve/routes/session-prompt-terminals.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts
@chiga0
chiga0 dismissed stale reviews from doudouOUC, ytahdn, and yiliang114 via f8e1a58 August 19, 2026 14:24
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Qwen Code review did not complete successfully. The review pipeline failed before a review could be posted. A transient error is retried automatically; if you are seeing this, retry with @qwen-code /review. See workflow logs.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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

  • packages/core/src/services/sessionService.ts:1825 — [review] The unarchive-direction ledger-move failure has no test, while the archive direction has one ('
  • packages/acp-bridge/src/bridgeOptions.ts:47 — [review] PromptLedgerSink.appendSync's void return type does not enforce the synchronous contract its JS
  • packages/cli/src/serve/routes/session.ts:3228 — [review] The route-level invariant 'reconciliation failure must never fail the load' is load-bearing and
  • packages/core/src/services/sessionService.ts:606 — [review] All ledger reads (reconcile, readRecentPromptTerminals, the sink) resolve only the ACTIVE chats
  • packages/acp-bridge/src/prompt-ledger.test.ts:185 — [review] The size <= tailBytes equality boundary in readPromptLedgerRecords has no test pinning it — the
中文说明

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

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

Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts
Comment thread packages/acp-bridge/src/bridge-prompt-ledger.test.ts
Comment thread packages/cli/src/serve/routes/session-prompt-terminals.test.ts
Comment thread packages/core/src/services/sessionService.test.ts
Comment thread packages/acp-bridge/src/prompt-ledger.test.ts
Comment thread packages/cli/src/serve/routes/session-prompt-terminals.test.ts
Comment thread packages/cli/src/serve/prompt-terminal-ledger.test.ts

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

Not explored to full depth (tool budget reached): chunk 12: none — no checks were cut short..

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

  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:724 — [probe] system-record exclusion test fixture predates admission; the mutant counting system records survives
  • packages/core/src/config/config.ts:5050 — [review] /cd artifact migration omits the .ledger.jsonl sidecar (evidence permanently split from transcript)
  • packages/acp-bridge/src/bridge.ts:2067 — [probe] cancelled-kind projection branch untested; mutant survives 1613+38 tests
  • packages/cli/src/serve/routes/session.ts:3223 — [probe] 'reconciliation failure must never fail the load' contract untested; catch removal ships green
  • docs/design/2026-08-19-prompt-terminal-ledger-design.md:111 — [review] privacy field enumeration omits the persisted tailUuid
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:160 — [probe] realtime_message exclusion unpinned; removing both exclusions ships green and synthesizes a wrong terminal
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:704 — [probe] compression guard >= equality boundary unpinned; flip to > ships green
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:1016 — [probe] readTranscriptTailUuid >64 KiB windowed branch untested (impl currently correct)
  • docs/design/2026-08-19-prompt-terminal-ledger-design.md:94 — [review] 'trailing 64 terminal records' promise vs 256 KiB window truncation undocumented
  • packages/acp-bridge/src/prompt-ledger.test.ts:206 — [probe] size === tailBytes boundary unpinned; <= to < mutant ships green
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:669 — [probe] TOCTOU race test's ordering premise rests on module-global recordSeq; isolated run inverts it
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:908 — [probe] RECENT_TERMINALS_TAIL_BYTES has no pinned lower bound; 256x shrink ships green
  • packages/cli/src/serve/prompt-terminal-ledger.ts:192 — [probe] session-artifact record captured as dispatch marker permanently vetoes reconciliation
  • packages/core/src/services/sessionService.ts:592 — [probe] /dream + auto-consolidation grep --include=*.jsonl ingests ledger sidecars as transcripts
  • docs/design/2026-08-19-prompt-terminal-ledger-design.md:78 — [probe] spec's visible-write predicate omits the realtime_message exclusion both scans apply
  • packages/acp-bridge/src/prompt-ledger.test.ts:113 — [probe] no test reads a ledger ending in a torn fragment without appending first
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:688 — [probe] compression gate pinned only in the veto direction; positive probes missing
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:446 — [probe] passing-marker + downstream-veto combination untested; short-circuit mutant ships green
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:864 — [probe] readRecentPromptTerminals catch-all (sole barrier vs failed load) unpinned
  • packages/core/src/services/sessionService.test.ts:1985 — [probe] transcript-first archive ordering unpinned; hoisted ledger move survives all tests
  • …and 5 more (see the run report)
中文说明

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

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

未探索到全部深度(达到工具调用预算):chunk 12:none — no checks were cut short.

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

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

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Partially reviewed — gaps disclosed.

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

Not explored to full depth (tool budget reached): chunk 2: live execution of bridge-prompt-ledger.test.ts (no node_modules in worktree or main checkout; monorepo-wide npm ci + build declined as disproportionate fo…; "agent 1c": none — no check was cut short..

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

  • packages/core/src/services/sessionService.test.ts:1878 — [probe] Ledger-removal test covers only the archived branch; the active-branch removePromptLedgers call site is unpinned
  • packages/acp-bridge/src/prompt-ledger.test.ts:29 — [probe] The documented throw-on-I/O-failure contract of appendPromptLedgerRecord is pinned by no test
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:1016 — [probe] The 64 KiB transcript-tail window in readTranscriptTailUuid is pinned by no test
  • packages/core/src/services/sessionService.ts:1672 — [probe] A write-ahead ledger with no transcript head survives session deletion permanently
  • packages/cli/src/serve/prompt-terminal-ledger.test.ts:313 — [probe] The multi-dangling fail-closed guard is unpinned; a re-admission shape then synthesizes a wrong terminal
  • packages/acp-bridge/src/prompt-ledger.test.ts:279 — [probe] danglingInFlightPromptIds' latest-wins supersession contract is pinned by no test
  • packages/acp-bridge/src/prompt-ledger.test.ts:127 — [probe] The version-strictness guard of coercePromptLedgerRecord is pinned by no test
中文说明

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

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

未探索到全部深度(达到工具调用预算):chunk 2:live execution of bridge-prompt-ledger.test.ts (no node_modules in worktree or main checkout; monorepo-wide npm ci + build declined as disproportionate fo…"agent 1c"none — no check was cut short.

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

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

Comment thread packages/acp-bridge/src/prompt-ledger.ts Outdated
Comment thread packages/cli/src/serve/prompt-terminal-ledger.ts

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Round 7 — Approve with a documented known risk

Reviewed head 7598852f (incremental over the round-6 ledger; the ledger feature code is unchanged from the round-7-reviewed tree — the new head is a rebase onto current main).

Verified fixed this round:

  • R6-1 (ledger sidecar permissions): appendPromptLedgerRecord now creates the sidecar with mode: 0o600 (owner-only, matching the transcript's convention) instead of the umask default; pinned by 'creates the ledger owner-only, not umask-default'.
  • All previously raised blocker classes (rounds 1–6) are closed in code and pinned by tests: multi-dangling fail-closed reconciliation, attribution guard for settled queued prompts, dispatch marker, projection-consistent temporal evidence with <= clock-equality veto, compression checkpoint, unconditional deadline fence, id-less tool-call interrupted upgrade, TOCTOU fence before append, .ledger.jsonl exclusion in both transcript scanners, and ledger removal on session deletion.

Known residual risk, accepted and tracked:

  • R2-2 (wrong-terminal attribution surface): the dispatch marker binds the transcript tail by ordering, not ownership — transcript records carry no writer identity, so the two documented entrances remain fail-open: (1) a recordless predecessor whose best-effort ledger appends were swallowed keeps writing past its queued successor's marker; (2) a ledger-less cross-client writer (interactive CLI resuming a serve-created session) writes transcript records invisible to every guard, which a cold load can then attribute to the dangling prompt. Entrance (2) needs no compound failure. This is a known limitation of the current design, documented in the design doc's "Residual attribution risk" section; the structural fix (writer identity on transcript records) is tracked in #9483 and this PR does not claim to close it.

Approve on the understanding that the residual attribution risk above is a documented, tracked limitation rather than an open defect in the guarded single-writer path, and that #9483 is the committed follow-up.

中文说明

第 7 轮 — 批准(附已记录的已知风险)

已审查 head 7598852f(相对第 6 轮 ledger 的增量;ledger 功能代码与第 7 轮审查的树一致——新 head 是对当前 main 的 rebase)。

本轮核实已修复:

  • R6-1(ledger sidecar 权限): appendPromptLedgerRecord 现在以 mode: 0o600 创建(仅属主,与转录约定一致),不再使用 umask 默认值;由 'creates the ledger owner-only, not umask-default' 测试固化。
  • 此前各轮(1–6)提出的所有阻断类问题均已修复并被测试固化:多悬空 fail-closed 对账、已结算排队 prompt 的归因守卫、dispatch marker、与投影一致的时序证据(含 <= 时钟相等否决)、压缩检查点、无条件 deadline 围栏、无 id 工具调用的 interrupted 升级、追加前 TOCTOU 围栏、两处转录扫描器排除 .ledger.jsonl、会话删除时清理 ledger。

已知残余风险,已接受并跟踪:

  • R2-2(错误终态归因面): dispatch marker 绑定的是转录尾部的顺序而非所有权——转录记录不携带写者身份,因此两个已记录的入口仍然 fail-open:(1) 无记录前驱(其 best-effort 账本追加被吞)在其排队后继的 marker 之后持续写入;(2) 无账本写者(交互式 CLI resume serve 创建的会话)写入对全部守卫不可见的转录记录,冷加载可将其归因给悬空 prompt。入口 (2) 无需复合故障即可触发。这是当前设计的已知局限,已在设计文档 "Residual attribution risk" 一节记录;结构性修复(转录记录携带写者身份)由 #9483 跟踪,本 PR 不声称关闭它。

在"上述残余归因风险是已记录、已跟踪的局限,而非受守卫的单写者路径上的未修复缺陷,且 #9483 是承诺的跟进项"这一前提下予以批准。

秦奇 added 11 commits August 20, 2026 15:08
Turn terminal events (turn_complete / turn_error) were synthesized by the ACP bridge and published over SSE only, so a prompt that was in flight when the daemon died could never be resolved after a restart: the cold load replay emits transcript chunks and carries no terminal evidence, leaving promptId-keyed orchestrators stuck on "unknown".

Each session now owns an append-only sidecar ledger next to its transcript. The bridge appends one in_flight record at prompt admission and one terminal record at the single publishPromptTerminal exit (covering the close/kill/channel-crash/daemon-shutdown flushes) through an injected synchronous sink. Ledger writes are best-effort and never block prompt execution or teardown, and records carry only ids, states, and timestamps — no prompt text, user content, or paths.

On a cold session load the serve layer reconciles prompts left dangling by a dead daemon: it classifies the transcript tail with the existing turn-interruption detector and appends a completed (stop reason reconstructed_from_transcript) or interrupted (code daemon_lost) verdict, guarded by an attribution check so an unattributable tail stays unknown (fail-closed). The load response gains an optional promptTerminals field with the trailing 64 terminal records, omitted entirely when the ledger holds no terminal evidence, and archive/unarchive move the sidecar alongside the transcript so evidence survives storage lifecycle.

Design: docs/design/2026-08-19-prompt-terminal-ledger-design.md
…omplete sidecar lifecycle

Address review findings on the prompt terminal ledger:

- reconcile: fail closed on multiple dangling prompts (no synthesized
  terminal for the newest either); attribute the oldest dangling prompt
  only when the attribution guard skips settled admissions (fixes the
  [A if, B if, B cancelled] misattribution veto), the transcript's last
  write postdates the admission (temporal evidence), and a clean
  verdict is upgraded to interrupted when the model tail holds any
  functionCall part, id or not (id-less tool-call guard covering the
  detectTurnInterruption wire-pairing blind spot)
- lifecycle: removeSessionFiles deletes the ledger in both archive
  states; archive/unarchive move it through a single
  getPromptLedgerPathForState helper with merge semantics when the
  destination already exists (append-and-unlink instead of a permanent
  split); move warnings carry full source and destination paths in
  both directions
- scans: DataProcessor.scanChatFiles and
  usageHistoryService.rebuildFromSessionJsonl exclude .ledger.jsonl
  sidecars (the ledger is not a transcript)
- writer: appendPromptLedgerRecord seals a torn tail before appending
  so a torn fragment cannot fuse with (and destroy) the next record
- tests: pin the new behavior across multi-dangling fail-closed,
  settled-then-queued attribution, valid interleave migration,
  temporal veto, id-less tool-call guard, sidecar lifecycle
  (move/merge/warn-only delete), torn-tail sealing, queued-admission
  flush on shutdown, active-prompt and resume load contracts, and
  ledger exclusion from insight scans
- docs: sync the design doc's reconciliation algorithm, lifecycle, and
  fail-closed invariants
readRecentPromptTerminals ran on every POST /session/:id/load (including
attached hot loads) and synchronously read and JSON-parsed the entire
ledger — a multi-megabyte event-loop stall for long sessions on the
per-request hot path.

Add a tailBytes option to readPromptLedgerRecords that reads a trailing
byte window (the first window line is always dropped: the window start
can tear a line in half). The load path now reads a 256 KiB window,
which holds hundreds of ~150-byte records against the 64-terminal
response cap; sessions whose ledger outgrows the window return a
best-effort trailing subset, which the response contract already allows.
…onciliation

Strengthen the reconcile attribution evidence per review round 2:
measure the temporal evidence on the same api-history projection the
verdict uses, fail closed on a compression checkpoint written after the
target's admission, and require the visible tail to postdate every other
prompt's settled terminal (FIFO evidence). Also fix a TS18048 narrowing
gap in the window test, make the seal test assert the raw file layout,
and restructure the window test so the call-site tailBytes wiring is
actually observable.
…pression by position

Round-7 review Criticals:
- appendPromptLedgerRecord created the sidecar with umask-default
  permissions (0o644) while the adjacent transcript is owner-only; the
  ledger now follows the 0o600 convention at creation time.
- Marker-bearing admissions fence post-admission compression by marker
  position instead of wall clock, so a backward clock step cannot hide
  a compression reset that voids the evidence chain.
- Design doc: the residual-risk claim is corrected — the dispatch
  marker binds ordering, not ownership; the two ownership classes that
  survive it (recordless predecessor with continued writes, ledger-less
  cross-client writer) are documented, pending writer identity on
  transcript records (QwenLM#9483).
@chiga0
chiga0 force-pushed the feat/prompt-terminal-ledger branch from 7598852 to 75cd5bc Compare August 20, 2026 07:08

@ytahdn ytahdn left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-approve after merging main

Head advanced 7598852f75cd5bcd by merging current main; the PR's own code is unchanged — the ledger feature files (prompt-ledger.ts, prompt-terminal-ledger.ts, sessionService.ts, routes/session.ts) are byte-identical to the round-7-approved tree, and the only delta is a main-side test fix in session-archive.test.ts.

Round-7 conclusion carries over unchanged: R6-1 (ledger sidecar created 0o600) and all rounds 1–6 blocker classes are fixed and pinned by tests; the R2-2 wrong-terminal attribution residual (cross-client ledger-less writer; ordering-bound marker, not ownership) remains a documented, tracked limitation — accepted on this approval, follow-up tracked in #9483.

中文说明

合并 main 后重新批准

head 由 7598852f 前进到 75cd5bcd(合并了当前 main);PR 自身代码未变——ledger 功能文件(prompt-ledger.tsprompt-terminal-ledger.tssessionService.tsroutes/session.ts)与第 7 轮批准的树逐字节一致,唯一差异是 main 侧的 session-archive.test.ts 测试修正。

第 7 轮结论原样沿用:R6-1(ledger 以 0o600 创建)及第 1–6 轮全部阻断类均已修复并被测试固化;R2-2 错误终态归因残余(跨客户端无账本写者;marker 绑定顺序而非所有权)仍是已记录、已跟踪的局限——本次批准基于此接受,跟进项见 #9483

@chiga0
chiga0 added this pull request to the merge queue Aug 20, 2026
Merged via the queue into QwenLM:main with commit 02d303f Aug 20, 2026
61 of 62 checks passed

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

⚠️ Downgraded from Approve to Comment: CI failing: review-pr, web-shell E2E Smoke (ubuntu-latest, Node 22.x), Test (ubuntu-latest, Node 22.x), Serve A/B (ubuntu-latest, Node 22.x), Real daemon E2E / Java 11; PR head advanced during review: reviewed 7598852, PR is now at 75cd5bc (history rewritten — the reviewed commit is no longer on the PR). Reviewed.

Not explored to full depth (tool budget reached): "You are review agent test-matrix — Test coverage matrix…": run-qwen-serve.ts wiring is not unit-tested. The createPromptLedgerSink function itself is tested (path layout, append, readTranscriptTailUuid), but the spe….

中文说明

⚠️ 已从批准降级为评论:CI failing: review-pr, web-shell E2E Smoke (ubuntu-latest, Node 22.x), Test (ubuntu-latest, Node 22.x), Serve A/B (ubuntu-latest, Node 22.x), Real daemon E2E / Java 11; PR head advanced during review: reviewed 7598852, PR is now at 75cd5bc (history rewritten — the reviewed commit is no longer on the PR)。 已审查。

未探索到全部深度(达到工具调用预算):"You are review agent test-matrix — Test coverage matrix…"run-qwen-serve.ts wiring is not unit-tested. The createPromptLedgerSink function itself is tested (path layout, append, readTranscriptTailUuid), but the spe…

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

wenshao added a commit to wenshao/qwen-code that referenced this pull request Aug 20, 2026
Conflict in sessionService.ts: main's prompt-ledger lifecycle
(QwenLM#9426) landed in the same spots as the PR-sidecar lifecycle —
both are kept: removeSessionFiles removes both, archive/unarchive
move both.
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Released in v0.21.15.

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.

5 participants