Conversation
Implements the Phase 2 client data layer of the Web Shell global turn navigation design (docs/design/web-shell/web-shell-global-turn-navigation-phase2.md, part of QwenLM#10750), on top of the Phase 1 daemon/SDK protocol: - Provider-owned transcript page ledger with explicit gap tracking over the flat block store, so eviction leaves re-fetchable locators instead of untracked holes; trim/rewind reconciliation preserves gap alignment. - Session-wide turn-index store: newest-first seed, clamped older paging, two-step tail merge with divergent-reset, LRU eviction with pinned newest page, transcript_too_large unsupported latch, snapshot invalidation recovery, and promptId/record-id-only reconciliation. - Persisted identity plumbing: messages carry sourceRecordIds/promptId, enabling the canonical turn locator and record/prompt-id dedup. - Anchored admission actions openTranscriptAtTurn plus older/newer continuations with distinct failure reasons (page_too_large, window_full, window_impossible, snapshot_gone, invalid_anchor), admitted without disturbing the live tail. The rail UI, App wiring, and gap sentinel rendering remain Phase 3.
|
E2E / 单元测试报告(Phase 2 数据层,无真实浏览器 E2E —— rail UI 属 Phase 3) 验证环境:Linux,Node 22,分支
基线说明:worktree 初次 dry-run 的 4 个失败为环境性(共享主检出 node_modules 导致 SDK dist 过旧),worktree 内 自审计:5 轮 presume-wrong diff 审计,修复 5 个实际问题(markUnsupported 在途响应、insertEntry 挤位 gap 保留、非 ready → unavailable、legacy 对账多 record 链接、trim/rewind gap 对齐与尾部 gap 丢失),R4/R5 连续清洁收敛。测试计划与完整结果存档于 |
|
✅ Qwen Triage finished — CI landed green on ✅ Qwen Triage 已完成 —— |
|
Thanks for the PR! Template looks good ✓ — every required heading is present and filled in with real content, including an honest Risk & Scope section that names its own weak spots. Problem: documented, not theoretical. Tracking issue #10750 is open with Direction: aligned. This is Phase 2 of a three-phase plan whose design was reviewed and merged before any code was written, Phase 1 already shipped the daemon-side protocol, and the work sits squarely inside the Web Shell's own surface. The daemon already advertises Size: no core paths — all 12 files are under Approach: the scope tracks the merged design closely, and the diff is disciplined for its size — 15 deletions across 3568 additions, no drive-by refactors, no formatting churn, no unrelated files. Two things I would like your read on before the code review goes further:
One substantive question on the design's own terms: the doc's Open Question 1 says the window budgets "need a measurement pass against today's 50,000-block behavior before freezing", and Open Question 3 leaves the 500-block quiet-period reload in place pending measurement. The description confirms the reload stays and calls the chunked tail-fill loop "defensive and unreachable in production conditions". Since neither open question is resolved here, what is the plan for closing them — does Phase 3 carry the measurement pass, or should that be its own follow-up? Unreachable defensive code plus unfrozen budgets is the kind of thing that quietly ossifies. Risk: no elevated risk signals — none of the changed files match the revert-correlated high-risk path set (Stage 1e clean), and this PR does not touch auth, sandbox, model selection, telemetry, release, or a public contract. The one thing a reviewer should focus on regardless: Moving on to code review. 🔍 中文说明感谢贡献! 模板完整 ✓ —— 所有必需小节都存在且填写了真实内容,Risk & Scope 还主动指出了自身的薄弱点。 问题: 有据可查,不是理论性加固。跟踪 issue #10750 处于 open 状态,带 方向: 对齐。这是三阶段计划中的 Phase 2,设计先经评审并合入才开始写代码,Phase 1 已交付 daemon 侧协议,工作范围完全落在 Web Shell 自身界面内。daemon 已经声明 规模: 未触及核心路径——12 个文件全部位于 方案: 范围与已合入的设计贴合,且以这个体量而言 diff 很克制——3568 行新增里只有 15 行删除,没有顺手重构、没有格式化噪音、没有无关文件。在进入代码审查前,有两点想听你的判断:
另有一个基于设计文档自身条款的实质问题:文档 Open Question 1 说窗口预算"需要针对当前 50,000 block 行为做一次测量后才能冻结",Open Question 3 把 500-block 静默期整页重载留待测量后再决定。描述确认该重载保留,并称分块尾部填充循环"是防御性的,在生产条件下不可达"。既然这两个开放问题在本 PR 中都没有结论,打算怎么收尾——由 Phase 3 承担这次测量,还是单独开一个后续项?不可达的防御代码加上未冻结的预算,正是容易悄悄僵化的组合。 风险: 无升级风险信号——改动文件均未命中与回滚相关的高风险路径集合(Stage 1e 干净),本 PR 也没有触及 auth、sandbox、模型选择、telemetry、发布或对外契约。评审者仍应重点关注一处: 进入代码审查 🔍 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
🖼️ web-shell visual previewRendered against a mock daemon (no real backend): the PR base vs this PR head Screenshots · before / afterFull-resolution recordings (.webm) are attached to the workflow run. — Qwen Code · web-shell visuals |
Code reviewI read the whole diff against the merged Phase 2 design doc and against the current client code on 1. The capability gate is much narrower than the "byte-identical" claim.
Nothing reads those fields or that ledger yet, so no user would see a difference and I'm not calling this a regression. But "Daemons without 2. 3. The invalidation branch of 4. 5. Unconsumed surface, counted rather than estimated. Production references vs. test-only references, verified across the diff:
This is deliberate phase staging against a merged design and the doc comments say so, which is the right way to land it. I'm naming it only so the merge is a conscious decision: roughly 1,965 production lines arrive with no user-visible effect and nothing exercised outside mocks, and a future What I checked and believe is correctWorth stating, because it's where the risk actually was:
One nit I'm not asking you to change: Anchored open — the new request → admit → re-inject pathsequenceDiagram
participant P1 as Caller (Phase 3, absent here)
participant P2 as DaemonSessionProvider
participant P3 as SessionTurnIndexStore
participant P4 as DaemonSessionClient
participant P5 as TranscriptPageLedger
participant P6 as SDK transcript store
P1->>P2: openTranscriptAtTurn(turnId)
P2->>P3: findTurn(turnId)
P3-->>P2: entry plus its own page snapshot
P2->>P4: getTranscriptPage(atRecordId, snapshot, limit)
P4-->>P2: page with events, hasMore, hasOlder, targetRecordId
P2->>P2: normalize, then materialize with record-id and prompt-id dedup
P2->>P5: computeLedgerInsertIndex by ordinal
P5-->>P2: insertIndex
P2->>P6: reset with the page spliced at insertBlockIndex
P2->>P5: insertEntry(page, insertIndex, gapBefore)
P2-->>P1: ok with targetRecordId, or a distinct failure reason
The live tail is never touched: the splice lands at a ledger-derived block index, and the page is admitted atomically — a rejected admission leaves the window unchanged. Files changed (12)
TestingThis is an unattended CI run. Per the gate's rules I did not build, run, or execute anything from this PR — no Not verified, and why:
Sandboxed verification would settle the part static review cannot: Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 No check had failed at the time of this read, so there is no failing-job log excerpt to quote. If 中文说明代码审查我把整个 diff 对照已合入的 Phase 2 设计文档、以及 1. 能力门控的范围远窄于"逐字节一致"这个论断。 整个 diff 里
目前没有任何代码读取这些字段或这个台账,所以用户看不出差别,我也不把它称为回归。但"没有 2. 3. 4. 5. 未被消费的表面——是数出来的,不是估的。 在 diff 全范围内核对生产引用与仅测试引用:
这是对照已合入设计的有意分阶段,且文档注释写明了,这是正确的落地方式。我点出来只是为了让合并成为一个有意识的决定:大约 1965 行生产代码落地后没有任何用户可见效果,也没有在 mock 之外被行使过;除非 Phase 3 的意图继续写在它们旁边,否则未来的 我核对过并认为正确的部分值得说明,因为风险恰恰在那里:
一个我并不要求你修改的小点: 测试这是一次无人值守的 CI 运行。按照门禁规则,我没有构建、运行或执行本 PR 的任何内容——没有 未验证项及原因:
沙箱验证可以解决静态审查解决不了的部分: CI 表格与英文部分相同,此处不重复。读取时刻没有检查失败,因此没有失败任务日志可引用。如果 — Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
|
Confidence: 4/5 — solid, and the parts I went in expecting to be wrong were already defended; what's left is an overstated claim in the description, a latent trap for Phase 3, and a lot of deliberately inert surface. Before reading the diff I wrote down what I'd do from the title and the "Why it's needed" section alone: keep the SDK store flat rather than teaching a shared reducer about non-contiguous ranges, hang a provider-owned page index off it, keep turn metadata in a separate store because it's two orders of magnitude cheaper than transcript bytes, plumb persisted identity onto messages first since everything else keys on it, and gate the whole thing on the advertised capability. That is what this PR does. I don't think that's a coincidence or a sign I anchored — the design doc was reviewed and merged before any code existed, so the structural calls were already made and argued. I did not find a materially simpler path it missed. The only thing I'd have done differently is shipped it as two PRs, which I raised in Stage 1 and am not repeating as a blocker. What raised my confidence is where the defenses were. I expected So my reservations are all about things that don't bite today. The The inert surface is the honest reservation, and I want to be clear that I don't think it's a mistake. Seven exported methods and hooks have no production caller, three wired actions have no component calling them, and roughly 1,965 production lines land with nothing user-visible. That is what a middle phase of a phased plan looks like, the doc comments name Phase 3 as the consumer, and deferring it because it isn't independently shippable would be second-guessing a plan that was already approved on its own merits. The six-months-from-now test comes down entirely to whether Phase 3 lands: if it does, the comments and invariants here make it a good foundation to build on; if it stalls, this becomes the largest block of dead code in the package and a Am I approving because I ran out of reasons to say no? No — I have five findings and I'd hold the same verdict with all five stated, because none of them is a defect in code that executes today, and the code that does execute today produces the same output it did before. Approval is deferred until CI lands green on
中文说明信心度:4/5 —— 扎实;我原本预判会出错的地方都已有防护,剩下的是描述中一处过强的论断、一个留给 Phase 3 的潜在陷阱,以及大量有意为之的惰性表面。 在读 diff 之前,我仅凭标题和"Why it's needed"写下了自己的做法:保持 SDK store 扁平,不去教一个共享 reducer 理解非连续区间,而是在其上挂一个 provider 持有的页索引;把 turn 元数据放在独立 store 里,因为它比 transcript 字节便宜两个数量级;先把持久身份打通到消息上,因为其余一切都以它为键;整体由已声明的能力门控。这正是本 PR 所做的。我不认为这是巧合,也不说明我被锚定了——设计文档在写任何代码之前就已评审并合入,所以结构性决策早已做出并论证过。我没有找到它遗漏的、明显更简的路径。我唯一会做得不同的是拆成两个 PR,这一点我在 Stage 1 已提出,不再作为阻塞项重复。 真正提升我信心的是防护所在的位置。我原以为 所以我的保留意见都集中在今天不会发作的事情上。 惰性表面是我最真实的保留意见,而我想说清楚:我不认为它是错误。七个导出的方法与 hook 没有生产调用方,三个已接线的动作没有组件调用,大约 1965 行生产代码落地后没有任何用户可见效果。这就是分阶段计划中间阶段的样子,文档注释指明了 Phase 3 是消费者,而因为它不能独立发布就推迟它,等于去质疑一个已凭自身价值获批的计划。"六个月后"的检验完全取决于 Phase 3 是否落地:如果落地,这里的注释与不变量会成为一个好的基础;如果停滞,它就会成为整个 package 里最大的一块死代码,而 我批准是因为我说不出反对理由了吗?不是——我有五项发现,且把这五项都讲明之后我仍会持同样结论,因为它们没有一项是今天会执行的代码中的缺陷,而今天确实会执行的代码产出与之前相同。 批准已推迟,直到 CI 在
— Qwen Code · qwen3.8-max-2026-09-02 Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
LGTM, looks ready to ship — CI landed green after the review. ✅
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not reviewed: reverse audit finding R1-59 — the verifier never ruled on it.
Not explored to full depth (tool budget reached): "agent reverse-audit (round 1)": executing the new describe('turn navigation (Phase 2)') block under vitest to confirm which refreshTail / admission branch each test actually takes — both f…; "agent 1c": I did not locate the legacy /session/:id/transcript bridge implementation's event-envelope construction ( AcpSessionBridge.getSessionTranscriptPage resolves ….
Not reviewed: reverse audit — stopped before round 3 by the review time budget.
Test Plan (not a blocker): client/daemon/session/DaemonSessionProvider.test.tsx — no such file or directory.
[Critical] R1-14 [certifies-falsely] [new-surface] packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2858 (with the sweep at :1093-1108 and the flush feed at :1503-1512) — shell: live-overlay entries are reconciled only by the onTruncation microtask sweep, so the wholesale store.reset() of a live-journal repair rebuild — which keeps the same turn-index store — leaves overlays pointing at blocks that no longer exist and never registers the rebuilt ones. A session restored mid-turn carries user_shell blocks whose shell:<blockId> overlays the batched flush registered via addLiveShell; a repair rebuild then calls store.reset({...replayState}) (:2673-2676), replacing the whole block set with freshly-minted block ids. store.reset() does NOT fire onTruncation (sdk-typescript/src/daemon/ui/store.ts), so the sweep never runs: removeLiveShell is never called for the dead block ids and addLiveShell is never called for the rebuilt ones. SessionTurnIndexState.liveEntries then lists shell overlays whose block ids resolve to nothing — Phase 3's rail renders running-command indicators for shells that finished or vanished and omits the ones actually running. The ring-eviction resync reset (:3547-3549) and the epoch reset (:3221-3223) have the same shape. witness: not run — no successful-repair fixture exists in the suite to drive this against; 2 of the 3 named reset sites were refuted as reachable with overlays live, and the confirmed site is the repair rebuild, traced through store.reset()'s non-emission of onTruncation. Fix: reconcile shell overlays wherever the store is wholesale-replaced, not only on truncation — after each store.reset() that keeps the same turn-index store, re-derive the overlay set from the new snapshot's user_shell blocks (drop entries whose blockId is absent, add entries for new ones), or route those resets through the same reconciliation helper the microtask sweep uses. The test that must pin the fix: a new DaemonSessionProvider.test.tsx case that emits a user_shell block so a shell: overlay is registered, drives a live-journal repair rebuild, and asserts getTurnIndex()?.liveEntries contains no entry whose id references a block absent from useDaemonTranscriptBlocks() — it must go red with the reconciliation removed.
中文说明
仅完成部分审查,审查缺口已披露。
未审查(原文为英文):reverse audit finding R1-59 — the verifier never ruled on it.
未探索到全部深度(达到工具调用预算):"agent reverse-audit (round 1)":executing the new describe('turn navigation (Phase 2)') block under vitest to confirm which refreshTail / admission branch each test actually takes — both f…;"agent 1c":I did not locate the legacy /session/:id/transcript bridge implementation's event-envelope construction ( AcpSessionBridge.getSessionTranscriptPage resolves …。
未审查:反向审计——评审时间预算不足,未能开始第 3 轮。
Test Plan(非阻断):client/daemon/session/DaemonSessionProvider.test.tsx — no such file or directory。
[Critical] R1-14 [certifies-falsely] [new-surface] packages/web-shell/client/daemon/session/DaemonSessionProvider.tsx:2858 (with the sweep at :1093-1108 and the flush feed at :1503-1512) — shell: live-overlay entries are reconciled only by the onTruncation microtask sweep, so the wholesale store.reset() of a live-journal repair rebuild — which keeps the same turn-index store — leaves overlays pointing at blocks that no longer exist and never registers the rebuilt ones. A session restored mid-turn carries user_shell blocks whose shell:<blockId> overlays the batched flush registered via addLiveShell; a repair rebuild then calls store.reset({...replayState}) (:2673-2676), replacing the whole block set with freshly-minted block ids. store.reset() does NOT fire onTruncation (sdk-typescript/src/daemon/ui/store.ts), so the sweep never runs: removeLiveShell is never called for the dead block ids and addLiveShell is never called for the rebuilt ones. SessionTurnIndexState.liveEntries then lists shell overlays whose block ids resolve to nothing — Phase 3's rail renders running-command indicators for shells that finished or vanished and omits the ones actually running. The ring-eviction resync reset (:3547-3549) and the epoch reset (:3221-3223) have the same shape. witness: not run — no successful-repair fixture exists in the suite to drive this against; 2 of the 3 named reset sites were refuted as reachable with overlays live, and the confirmed site is the repair rebuild, traced through store.reset()'s non-emission of onTruncation. Fix: reconcile shell overlays wherever the store is wholesale-replaced, not only on truncation — after each store.reset() that keeps the same turn-index store, re-derive the overlay set from the new snapshot's user_shell blocks (drop entries whose blockId is absent, add entries for new ones), or route those resets through the same reconciliation helper the microtask sweep uses. The test that must pin the fix: a new DaemonSessionProvider.test.tsx case that emits a user_shell block so a shell: overlay is registered, drives a live-journal repair rebuild, and asserts getTurnIndex()?.liveEntries contains no entry whose id references a block absent from useDaemonTranscriptBlocks() — it must go red with the reconciliation removed.
— qwen3.8-max via Qwen Code /review (v0.23.0)
| minOrdinal = | ||
| minOrdinal === undefined ? ordinal : Math.min(minOrdinal, ordinal); | ||
| } | ||
| if (minOrdinal === undefined || minOrdinal > targetOrdinal) return index; |
There was a problem hiding this comment.
[Critical] R1-1: [certifies-falsely] [new-surface] computeLedgerInsertIndex reads "this entry has no index-known turn id" as "newer than everything" and returns its index immediately, so an anchored page is spliced at the wrong end of the transcript.
ordinalByTurnId is rebuilt on every open from the retained index pages, which are bounded, and nothing in production calls loadOlder() / ensurePage(). Three routine states make an entry resolve to nothing: a load-older prepend entry older than the retained index window; any page of limit records falling entirely inside one long tool-heavy turn, so it holds no turn head (this includes the initial-load entry); and an anchored entry whose ordinals a 409-triggered reseed discarded.
Failure scenario: in a 500-turn session retaining ordinals 300-499, "load earlier history" prepends turns ~290-295, giving [E0(prepend, unknown ordinals), E1(load, known)]. A jump to turn 400 hits E0 first, minOrdinal stays undefined, and the function returns 0 instead of 1. computeInsertBlockIndex then resolves blockIndexById[E0.firstBlockId] === 0, so turn 400's blocks are spliced at the very top, above the older turn-290 page: newer content renders above older content, insertEntry records the same wrong order, and every later continuation compounds the scramble. When the unknown entry is the initial-load entry, every anchored open returns 0 regardless of target.
Witness:
not run — traced through computeLedgerInsertIndex / computeInsertBlockIndex and the bounded index
coverage at ebb7c62c. No provider fixture can reach the ordinal branch: the test fixture mints index
turn ids (turn-N) in a namespace disjoint from every block's record ids (record-*), so minOrdinal is
always undefined and replacing the whole ordinal branch with `return 0` changes no assertion.
Stop inferring recency from absence: record the ordinal range on the entry at admission (the anchored path already holds located.entry.ordinal) and position against the recorded ordinals. Minimal variant if the field is not added — continue past an entry with no resolvable ordinal instead of returning its index, returning entries.length only when no entry resolves.
Any fix that keeps resolving ordinals from retained pages must tolerate entries outside that window: const DEFAULT_PAGE_SIZE = 200; / const DEFAULT_MAX_PAGES = 8; (turnIndexStore.ts:64-65), with evictLru pinning only the newest page.
Please add the test that pins this — seed an index page that does not cover a prepended page's turn ids, prepend via load-older, then openTranscriptAtTurn for a newer turn and assert the anchored entry lands after the prepend entry and the block texts are chronological — and confirm it goes red with the fix removed (today the entry lands at index 0).
中文说明
computeLedgerInsertIndex 把「该 entry 没有任何索引已知的 turn id」当成「比一切都新」并立即返回其下标,导致锚定页被拼接到 transcript 的错误一端。
ordinalByTurnId 每次打开时都从当前保留的索引页重建,而保留范围是有限的,且生产代码中没有任何地方调用 loadOlder() / ensurePage()。三种常见状态都会让 entry 解析不到 ordinal:早于保留索引窗口的 load-older prepend entry;完全落在某个超长 tool 密集 turn 内部、因此不含 turn head 的页(初始加载 entry 就属于这种);以及被 409 触发的 reseed 丢弃了 ordinal 的锚定 entry。
触发场景: 在一个 500 turn、索引仅保留 ordinal 300-499 的会话中,「加载更早历史」prepend 了 turn ~290-295,台账变为 [E0(prepend, ordinal 未知), E1(load, 已知)]。此时跳转到 turn 400:循环先命中 E0,minOrdinal 保持 undefined,函数返回 0 而不是 1。computeInsertBlockIndex 随后解析为 blockIndexById[E0.firstBlockId] === 0,于是 turn 400 的 block 被拼接到最顶部,位于更旧的 turn-290 页之上:较新内容渲染在较旧内容之上,insertEntry 记录了同样的错误顺序,后续每次 continuation 都会加剧错乱。当 ordinal 未知的 entry 是初始加载 entry 时,无论目标是什么,每次锚定打开都返回 0。
证据:
not run — 在 ebb7c62c 上沿 computeLedgerInsertIndex / computeInsertBlockIndex 与有限索引覆盖范围逐行追踪。
现有 provider 测试无法进入 ordinal 分支:测试 fixture 生成的索引 turn id(turn-N)与所有 block 的
record id(record-*)处于不同命名空间,因此 minOrdinal 恒为 undefined,把整个 ordinal 分支替换成
`return 0` 不会让任何断言失败。
修复方向:不要从「缺失」推断新旧——在入账时把 ordinal 区间记录到 entry 上(锚定路径已持有 located.entry.ordinal),并依据记录的 ordinal 定位。若不新增字段,最小改法是:遇到无法解析 ordinal 的 entry 时 continue 而不是返回其下标,仅在所有 entry 都无法解析时才返回 entries.length。
约束:turnIndexStore.ts:64-65 的 const DEFAULT_PAGE_SIZE = 200; / const DEFAULT_MAX_PAGES = 8;(且 evictLru 只固定最新页)意味着保留的索引覆盖范围有限,任何仍从保留页解析 ordinal 的修复都必须容忍落在该窗口之外的 entry。
请补上能锁定该行为的测试:先 seed 一个不覆盖 prepend 页 turn id 的索引页,通过 load-older 执行 prepend,再对更新的 turn 调用 openTranscriptAtTurn,断言锚定 entry 落在 prepend entry 之后且 block 文本按时间顺序排列——并确认移除修复后该测试变红(当前 entry 会落在下标 0)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| displayedPromptIds !== undefined && | ||
| event.promptId !== undefined && | ||
| displayedPromptIds.has(event.promptId) |
There was a problem hiding this comment.
[Critical] R1-9: [certifies-falsely] [new-surface] The new promptIdDedup filter can never fire on a fetched transcript-window page, so the anchored path — which explicitly turns boundaryEchoDedup off — has no echo dedup at all and renders the user's own prompt twice.
Neither side of the match carries a promptId. Page events are built daemon-side as {v:1, type:'session_update', data:update} with no envelope-level promptId (acpAgent.ts:9068-9072, forwarded unchanged by routes/session.ts:4864-4872), createBase reads only event.promptId, and the user_message_chunk case adds nothing — so every normalized page event has promptId === undefined. The optimistic block for this client's own prompt is created by createTextBlock with no sourceRecordIds and no promptId, and the daemon's recordId-stamped echo is suppressed for the originating client.
Failure scenario: the user sends a prompt, the turn is persisted, and they click that turn (or any turn within limit records of it — atRecordId pages run forward from the anchor). admitTranscriptWindowPage calls materializeTranscriptHistory(..., {boundaryEchoDedup:false, promptIdDedup:true}); the record-id filter misses (the displayed block has no record ids) and the prompt-id filter misses (event.promptId === undefined), so the page's copy of that prompt is materialized and spliced in just before the live tail: the same user message appears twice, and the ledger records the range as loaded so no gap affordance can repair it. boundaryEchoKey would not have caught it either — it only compares against current.blocks[0].
Witness:
probe (real provider, A/B): anchored open over a retained optimistic prompt
-> allTexts ["my prompt", "live reply", "my prompt"] # the duplicate
sweep over the 4 page-event construction paths: 0 of 4 stamp an envelope promptId
Dedup on an identity both sides actually carry: the provider knows which promptIds this client submitted (onPromptAdmitted) and the index maps turnId -> promptId, so resolve own-prompt ids to their persisted turn record ids via the retained index pages and pass them as an extra record-id set into materializeTranscriptHistory. If no such identity is available, do not claim the echo case is covered — keep boundaryEchoDedup semantics for live-tail-adjacent inserts instead of disabling them.
The fix must not widen to content/text matching across the window: DaemonSessionProvider.tsx:373-375 — "Keying on text window-wide would instead drop DISTINCT older prompts the user happened to send twice ("yes", a retry), permanently orphaning their assistant replies."
Please add the test that pins this — submit a prompt through the optimistic path, mock getSessionTranscriptPage to return a user_message_chunk carrying that prompt's persisted record id, call openTranscriptAtTurn, and assert exactly one user block with that text — and confirm it goes red with the fix removed (the existing dedupe test only covers an assistant event with a record id).
中文说明
新增的 promptIdDedup 过滤在抓取到的 transcript window 页上永远不可能命中,因此显式关闭了 boundaryEchoDedup 的锚定路径完全没有任何回显去重,会把用户自己的 prompt 渲染两次。
匹配两侧都不携带 promptId。页事件在 daemon 侧构造为 {v:1, type:'session_update', data:update},没有 envelope 级 promptId(acpAgent.ts:9068-9072,并由 routes/session.ts:4864-4872 原样转发);createBase 只读 event.promptId,而 user_message_chunk 分支不追加任何字段——所以每个归一化后的页事件都是 promptId === undefined。本客户端为自己 prompt 创建的乐观 block 由 createTextBlock 生成,既无 sourceRecordIds 也无 promptId,而 daemon 带 recordId 的回显对发起方客户端是被抑制的。
触发场景: 用户发送 prompt、该 turn 被持久化,然后点击这个 turn(或 limit 条记录范围内的任意 turn——atRecordId 页从锚点向后展开)。admitTranscriptWindowPage 以 {boundaryEchoDedup:false, promptIdDedup:true} 调用 materializeTranscriptHistory;record-id 过滤未命中(已显示的 block 没有 record id),prompt-id 过滤也未命中(event.promptId === undefined),于是该 prompt 在页中的副本被物化并拼接到实时尾部之前:同一条用户消息出现两次,且台账把该区间记为已加载,没有任何 gap 提示可以修复。boundaryEchoKey 同样拦不住——它只与 current.blocks[0] 比较。
证据:
probe(真实 provider,A/B):对已保留的乐观 prompt 执行锚定打开
-> allTexts ["my prompt", "live reply", "my prompt"] # 出现重复
对 4 条页事件构造路径的扫描:0 / 4 会写入 envelope promptId
修复方向:改用两侧都真正携带的身份去重——provider 知道本客户端提交过哪些 promptId(onPromptAdmitted),索引也维护 turnId -> promptId 映射,因此可通过保留的索引页把自己 prompt 的 id 解析为其持久化 turn record id,并作为额外的 record-id 集合传入 materializeTranscriptHistory。若拿不到这样的身份,就不要声称已覆盖回显场景——对紧邻实时尾部的插入保留 boundaryEchoDedup 语义,而不是关闭它。
约束:修复不得扩大到全窗口文本/内容匹配——DaemonSessionProvider.tsx:373-375:「按文本在全窗口去重会误删用户恰好发送过两次的不同旧 prompt(例如「yes」、重试),并使其 assistant 回复永久成为孤儿。」
请补上能锁定该行为的测试:通过乐观路径提交一个 prompt,mock getSessionTranscriptPage 返回携带该 prompt 持久化 record id 的 user_message_chunk,调用 openTranscriptAtTurn,断言只存在一个该文本的 user block——并确认移除修复后该测试变红(现有去重测试只覆盖了带 record id 的 assistant 事件)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| const turnNavigationSupported = | ||
| Array.isArray(capabilities?.features) && | ||
| capabilities.features.includes(SESSION_TURN_NAVIGATION_FEATURE); |
There was a problem hiding this comment.
[Critical] R1-8: [fails-closed] [new-surface] The capability gate reads the run-loop-local capabilities, which is assigned only inside the if (!session) load block — so on the session-reuse path the store is constructed with enabled: false for a daemon that does advertise the feature.
Failure scenario: fresh Web Shell, provider mounted with no sessionId; the user sends the first prompt. createAndAttachSessionForPrompt -> actions.createSession() sets sessionRef.current, then attachSession() bumps attachSessionNonce without ever setting restoreSessionId. The connect effect re-runs, the reuse branch takes session = sessionRef.current and skips the whole if (!session) block — the only assignment site of capabilities (:1725) — so it stays undefined. The creation condition at :2805 is satisfied twice over (ledgerSessionReinit is true because transcriptHistoryRef.current.sessionId is still undefined, and turnIndexStoreRef.current === undefined), so the store is built with enabled: false. Result: seed() no-ops, getSessionTurnIndexPage is never requested, addLivePrompt / addLiveShell / refreshTail all no-op, useDaemonTurnIndex() reports 'disabled' and openTranscriptAtTurn() returns {ok:false, reason:'unsupported'} — for the whole life of that page session, because recreation needs a PATH B reload carrying replay data. Meanwhile sessionCapabilitiesRef.current already held the real feature list.
Witness:
PROBE R1-8 ARM A (load path) : status = ready , indexPage calls = 1
PROBE R1-8 ARM B (create+attach) : status = disabled , indexPage calls = 0
PROBE R1-8 ARM B openTranscriptAtTurn = {"ok":false,"reason":"unsupported"}
fix flip (capabilities?.features ?? sessionCapabilitiesRef.current?.features)
-> ARM B: status = ready, indexPage calls = 1 ; ARM A unchanged
| const turnNavigationSupported = | |
| Array.isArray(capabilities?.features) && | |
| capabilities.features.includes(SESSION_TURN_NAVIGATION_FEATURE); | |
| const turnNavFeatures = | |
| capabilities?.features ?? sessionCapabilitiesRef.current?.features; | |
| const turnNavigationSupported = | |
| Array.isArray(turnNavFeatures) && | |
| turnNavFeatures.includes(SESSION_TURN_NAVIGATION_FEATURE); |
sessionCapabilitiesRef is already the authoritative source for the sibling gates — const features = sessionCapabilitiesRef.current?.features; at :1144 and :1195 — and knownCapabilities at :1333-1336 fixes the precedence as workspace?.capabilities ?? sessionCapabilitiesRef.current ?? connection.capabilities; the fix must not introduce a fourth precedence order.
Please add the test that pins this — render with sessionId: undefined explicitly, advertise ['session_turn_navigation'], then createSession() + attachSession() and assert getSessionTurnIndexPage was called and getTurnIndex()?.status === 'ready' — and confirm it goes red with the fix reverted (every current turn-nav test goes through the load path and stays green either way).
中文说明
能力门控读取的是运行循环内的局部变量 capabilities,而它只在 if (!session) 加载分支内被赋值——因此在会话复用路径上,即使 daemon 确实声明了该能力,store 仍会以 enabled: false 构造。
触发场景: 全新 Web Shell,provider 挂载时没有 sessionId,用户发送第一个 prompt。createAndAttachSessionForPrompt -> actions.createSession() 设置 sessionRef.current,随后 attachSession() 递增 attachSessionNonce,但从未设置 restoreSessionId。connect effect 重新执行,复用分支取 session = sessionRef.current 并跳过整个 if (!session) 块——也就是 capabilities 唯一的赋值点(:1725)——于是它保持 undefined。:2805 的创建条件被双重满足(ledgerSessionReinit 为真,因为 transcriptHistoryRef.current.sessionId 仍是 undefined;且 turnIndexStoreRef.current === undefined),store 以 enabled: false 构建。结果:seed() 空转,getSessionTurnIndexPage 从不被请求,addLivePrompt / addLiveShell / refreshTail 全部空转,useDaemonTurnIndex() 报告 'disabled',openTranscriptAtTurn() 返回 {ok:false, reason:'unsupported'}——并且在该页面会话的整个生命周期内都是如此,因为重建需要一次携带 replay 数据的 PATH B 重新加载。而此时 sessionCapabilitiesRef.current 早已持有真实的 feature 列表。
证据:
PROBE R1-8 ARM A(加载路径) : status = ready , indexPage calls = 1
PROBE R1-8 ARM B(create+attach) : status = disabled , indexPage calls = 0
PROBE R1-8 ARM B openTranscriptAtTurn = {"ok":false,"reason":"unsupported"}
修复翻转(capabilities?.features ?? sessionCapabilitiesRef.current?.features)
-> ARM B:status = ready,indexPage calls = 1;ARM A 不变
修复方向:改用同类门控已经在用的 ref 快照(见上方 suggestion 块)。
约束:sessionCapabilitiesRef 已是同类门控的权威来源——:1144 与 :1195 的 const features = sessionCapabilitiesRef.current?.features;——且 :1333-1336 的 knownCapabilities 已把优先级固定为 workspace?.capabilities ?? sessionCapabilitiesRef.current ?? connection.capabilities;修复不得引入第四种优先级顺序。
请补上能锁定该行为的测试:显式以 sessionId: undefined 渲染,声明 ['session_turn_navigation'],然后执行 createSession() + attachSession(),断言 getSessionTurnIndexPage 被调用且 getTurnIndex()?.status === 'ready'——并确认回退修复后该测试变红(当前所有 turn-nav 测试都走加载路径,两种情况下都是绿的)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| return { | ||
| ok: false, | ||
| reason: admission.impossible ? 'window_impossible' : 'window_full', |
There was a problem hiding this comment.
[Critical] R1-11: [fails-closed] [new-surface] window_full never evicts a page to make room and never latches the rejected footprint, so random access permanently fails in a saturated window — the eviction half of issue #10750's incident is unimplemented.
Issue #10750: "retained blocks are capped by count and bytes. Consequently, the rail cannot represent all turns in a long session"; this PR's own narrative: "once the window evicts old content there is no way back."
Failure scenario: in a long session pinned at maxBlocks / maxRetainedBytes, oldest-first trim keeps the store at the cap, so pageBlocks + current.blocks.length > maxBlocks (:466) is true for every anchored page. admitTranscriptWindowPage calls materializeTranscriptHistory once, gets admitted: false and returns window_full — it never evicts a ledger entry ("evicting whole pages farthest from the target first", design flow step 4), and TranscriptPageLedger exposes no interior-eviction primitive at all. It also never sets the rejectedPage footprint the design says window_full carries over ("the rejected page's footprint is remembered and the same page is re-offered only once enough capacity exists"), so no later trim re-offers it either. Net: Phase 3's rail gets a permanent window_full for every unloaded turn in exactly the long-session regime #10750 exists to fix.
Witness:
probe + latch-half flip: the saturated-window admission returns window_full with no eviction
attempted and no rejectedPage footprint recorded; design flow step 4 and the re-offer latch have
no implementation anywhere in the diff (TranscriptPageLedger's mutator set is clear/recordInitialLoad/
recordPrepend/setOlderGap/insertEntry/updateEntry/applyPrefixTrim/applyRewind — no interior eviction).
When !admission.admitted && !admission.impossible, evict whole ledger entries farthest from the target (a new TranscriptPageLedger.removeEntry(id) plus a provider-directed store.reset over the retained pages), then re-run materializeTranscriptHistory once. If it still does not fit, return window_full and latch the footprint next to history.rejectedPage so the existing trim-time re-open gate (:1190-1240) re-offers it.
Two premises the fix must respect: the live tail must never be the eviction victim (design ledger invariant — "Streaming keeps writing through the existing batcher; historical admission never touches tail blocks"), and a count trim leaves no slack to absorb a page — :1206-1207: "A count trim restores the window to exactly maxBlocks (zero headroom), so without this check every live block during streaming would re-open the latch into an immediate fetch/reject cycle."
Please add the test that pins this — fill the window with an admitted historical page far from the target plus a small live tail under a tight maxBlocks, then assert openTranscriptAtTurn(target) resolves {ok:true, targetRecordId}, the far page's blocks are gone and the live-tail text survives — and confirm it goes red today (it returns {ok:false, reason:'window_full'}). The existing window_full / window_impossible case must stay green.
中文说明
window_full 既不会为腾出空间而驱逐任何页,也不会记录被拒页的 footprint,因此在窗口饱和时随机访问会永久失败——issue #10750 所述事故中「驱逐后无法回头」这一半并未实现。
issue #10750:「保留的 block 受数量与字节双重上限约束。因此导航条无法表示长会话中的全部 turn」;本 PR 自述:「窗口一旦驱逐旧内容就无法回头」。
触发场景: 在已被 maxBlocks / maxRetainedBytes 顶满的长会话中,oldest-first 裁剪使 store 始终贴着上限,因此 pageBlocks + current.blocks.length > maxBlocks(:466)对每一个锚定页都成立。admitTranscriptWindowPage 只调用一次 materializeTranscriptHistory,得到 admitted: false 后返回 window_full——它从不驱逐台账 entry(设计流程第 4 步要求「优先驱逐离目标最远的整页」),而 TranscriptPageLedger 根本没有暴露任何内部驱逐原语。它也不会设置设计文档所说 window_full 应当承接的 rejectedPage footprint(「被拒页的 footprint 会被记住,只有在容量足够时才重新提供同一页」),因此后续裁剪也不会重新提供它。最终结果:Phase 3 的导航条在 #10750 正是为解决其而存在的长会话场景下,对每一个未加载的 turn 都只能得到永久的 window_full。
证据:
probe + latch 半边翻转:饱和窗口下的入账返回 window_full,既未尝试驱逐,也未记录 rejectedPage
footprint;设计流程第 4 步与重新提供闩锁在本 diff 中完全没有实现(TranscriptPageLedger 的变更方法集为
clear/recordInitialLoad/recordPrepend/setOlderGap/insertEntry/updateEntry/applyPrefixTrim/applyRewind
——没有内部驱逐)。
修复方向:当 !admission.admitted && !admission.impossible 时,驱逐离目标最远的整条台账 entry(新增 TranscriptPageLedger.removeEntry(id),并由 provider 在保留页上执行定向 store.reset),然后重跑一次 materializeTranscriptHistory。若仍放不下,返回 window_full 并把 footprint 记录到 history.rejectedPage 旁,使既有的裁剪期重开闸门(:1190-1240)能够重新提供该页。
约束:修复必须尊重两个前提——实时尾部绝不能成为驱逐对象(设计台账不变量:「流式写入继续经由既有 batcher;历史入账绝不触碰尾部 block」);且按数量裁剪不留任何余量——:1206-1207:「按数量裁剪会把窗口恢复到恰好 maxBlocks(零余量),因此没有这个检查的话,流式期间每一个实时 block 都会重新打开闩锁,陷入立即抓取/立即拒绝的循环。」
请补上能锁定该行为的测试:在紧凑的 maxBlocks 下,用一个远离目标的已入账历史页加一小段实时尾部填满窗口,然后断言 openTranscriptAtTurn(target) 返回 {ok:true, targetRecordId}、远端页的 block 已消失、实时尾部文本仍在——并确认该测试今天是红的(当前返回 {ok:false, reason:'window_full'})。现有的 window_full / window_impossible 用例必须保持绿色。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| const ledger = transcriptPageLedgerRef.current; | ||
| const state = store.getSnapshot(); | ||
| store.reset( |
There was a problem hiding this comment.
[Critical] R1-12: [fails-closed] [new-surface] Anchored and continuation admissions are merged into the visible store but not into the open live-journal repair episode's checkpoint, so a repair reload silently discards the historical page the user jumped to.
#10750 acceptance criterion: "A live session remains connected and continues receiving output while the user inspects historical pages."
Failure scenario: attaching to a session restored mid-turn creates a repair episode with checkpoint = replayStore.getSnapshot() (:2647, :2695-2698). While the turn streams, openTranscriptAtTurn(turnId) splices the page into the visible store and records the ledger entry but never touches repair.checkpoint — contrast loadMoreTranscript at :4617-4621, which does repair.checkpoint = applyTranscriptHistory(repair.checkpoint, historyMaterialization) for exactly this reason. The turn then completes, the session goes idle, tryLiveJournalRepair fires reloadSession(..., {replaySource:'memory'}); markerStillVisible is true (an insert does not remove the marker block, :2373-2379), so replayStore is seeded from the stale checkpoint and store.reset({...replayState}) replaces the visible store. The anchored page's blocks are gone and the ledger is rebuilt by recordInitialLoad, so the loss is silent — the turn the user was reading disappears with no notice, while prepended history in the same window survives.
Witness:
not run — no successful-repair fixture exists in the suite to drive this against. The asymmetry with
the prepend path's checkpoint merge at DaemonSessionProvider.tsx:4617-4621 was traced at ebb7c62c:
that path merges into repair.checkpoint, the anchored path does not, and markerStillVisible (:2373-2379)
is the gate that makes the stale checkpoint authoritative on the rebuild.
Apply the same checkpoint merge on the anchored path — after the store.reset, when liveJournalRepairRef.current?.sessionId === activeSession.sessionId, rebase repair.checkpoint with applyTranscriptPageInsert at the page's position within the checkpoint state — or, if that position is not derivable, invalidate the open repair episode (liveJournalRepairRef.current = undefined) whenever a non-prepend page is admitted, so the rebuild cannot drop admitted content.
The prepend path's merge is the shape to match (:4617-4621), and the checkpoint is only used when the marker survives (markerStillVisible, :2373-2379).
Please add the test that pins this — attach with a live-journal repair marker in the replay snapshot, admit an anchored page via openTranscriptAtTurn, drive the repair (terminal + idle) and assert the anchored block's text is still in useDaemonTranscriptBlocks() — and confirm it goes red today.
中文说明
锚定与 continuation 入账只合并进可见 store,没有合并进当前打开的 live-journal 修复回合的 checkpoint,因此一次修复重载会静默丢弃用户刚刚跳转到的历史页。
#10750 验收标准:「用户查看历史页时,实时会话保持连接并继续接收输出。」
触发场景: 附加到一个在 turn 中途恢复的会话时,会创建修复回合,其 checkpoint = replayStore.getSnapshot()(:2647、:2695-2698)。在该 turn 流式进行期间,openTranscriptAtTurn(turnId) 把页拼接进可见 store 并记录台账 entry,但从不触碰 repair.checkpoint——对比 :4617-4621 的 loadMoreTranscript,它正是为此执行 repair.checkpoint = applyTranscriptHistory(repair.checkpoint, historyMaterialization)。随后该 turn 完成、会话进入 idle,tryLiveJournalRepair 触发 reloadSession(..., {replaySource:'memory'});此时 markerStillVisible 为真(插入不会移除 marker block,:2373-2379),于是 replayStore 从过期的 checkpoint 播种,store.reset({...replayState}) 替换可见 store。锚定页的 block 消失,台账被 recordInitialLoad 重建,因此丢失是无声的——用户正在阅读的那个 turn 毫无提示地消失,而同一窗口内 prepend 进来的历史却得以保留。
证据:
not run — 测试套件中不存在可用于驱动该路径的「修复成功」fixture。已在 ebb7c62c 上追踪其与 prepend
路径 checkpoint 合并(DaemonSessionProvider.tsx:4617-4621)的不对称:该路径会合并进 repair.checkpoint,
锚定路径不会,而 markerStillVisible(:2373-2379)正是在重建时让过期 checkpoint 成为权威来源的闸门。
修复方向:在锚定路径上做同样的 checkpoint 合并——store.reset 之后,当 liveJournalRepairRef.current?.sessionId === activeSession.sessionId 时,用 applyTranscriptPageInsert 在 checkpoint state 内部的对应位置重建 repair.checkpoint;若该位置无法推导,则在入账任何非 prepend 页时作废当前修复回合(liveJournalRepairRef.current = undefined),使重建不可能丢弃已入账内容。
约束:应以 prepend 路径的合并为范式(:4617-4621),且 checkpoint 只在 marker 存活时才被使用(markerStillVisible,:2373-2379)。
请补上能锁定该行为的测试:在 replay 快照中带有 live-journal 修复 marker 的情况下附加会话,通过 openTranscriptAtTurn 入账一个锚定页,驱动修复(terminal + idle),断言该锚定 block 的文本仍存在于 useDaemonTranscriptBlocks() 中——并确认该测试今天是红的。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| mintForwardCursor: true, | ||
| buttAfter: true, |
There was a problem hiding this comment.
[Critical] R1-3: [certifies-falsely] [new-surface] continueTranscriptNewer passes buttAfter: true unconditionally, so insertEntry overwrites the displaced older-gap locator with {} even when the page reported hasMore — the range becomes an untracked hole.
A forward continuation butts against its predecessor (gaps[index]), not its successor, yet buttAfter writes gaps[index+1]. This is the same call that mints a forward nextCursor precisely because page.hasMore was true.
Failure scenario: entries [A(anchored, records 1..5, nextCursor 'cursor-1'), L(load, records 50..60)], gaps [{older:record-1},{older:record-50},{}] — the range 10..49 between A and L is unloaded but has a locator. continueTranscriptNewer(A) returns records 6..9 with hasMore:true, nextCursor:'cursor-2'. The gaps become [{older:record-1},{},{},{}]: the {older:{beforeRecordId:'record-50'}} locator for records 10..49 is destroyed, and the published useDaemonTranscriptLedger() view now asserts N and L are contiguous while the same call records that more content remains ahead. The only surviving handle is N's nextCursor, which applyPrefixTrim and applyRewind both clear (transcriptPageLedger.ts:270, :334); after either, records 10..49 are an untracked hole with no gap sentinel — precisely what the ledger exists to prevent (:1035-1037, "eviction leaves re-fetchable locators instead of untracked holes"). The same overwrite closes the trailing gap toward the live tail when A is the newest entry and the continuation did not reach the live blocks.
Witness:
executed against the real TranscriptPageLedger:
before: gaps [{older:record-1},{older:{beforeRecordId:'record-50'}},{}]
after continueTranscriptNewer (hasMore:true): gaps [{older:record-1},{},{},{}]
fix flip (buttAfter: page.hasMore !== true) -> the displaced {older:{beforeRecordId:'record-50'}} survives
| mintForwardCursor: true, | |
| buttAfter: true, | |
| mintForwardCursor: true, | |
| buttAfter: page.hasMore !== true, |
A preserved-but-redundant locator is benign (a re-fetch at that beforeRecordId is dropped by materializeTranscriptHistory's record-id dedup); a destroyed one is unrecoverable. Leave continueTranscriptOlder's buttAfter: true alone — a backward page always butts against the entry it continued from.
Two facts the fix must respect: insertEntry's contract (transcriptPageLedger.ts:197-204) is that the displaced gap becomes the gap after the new page unless an explicit gapAfter is passed, and the gaps array must stay entries.length + 1 long; and hasMore is journal-relative, not window-relative (session-transcript-reader.ts:3477-3480), so hasMore === false is sufficient but not necessary for adjacency — do not read hasMore === true as proof the next entry is unreachable.
Please extend continues newer with the stored cursor sent alone with a continuation page carrying hasMore:true, nextCursor:'cursor-2' and a following entry whose older gap is populated, asserting getLedger()?.gaps[2] still equals the displaced {older:{beforeRecordId:...}} — and confirm it goes red without the fix. The current test uses hasMore:false, where closing is correct, so it cannot distinguish the two.
中文说明
continueTranscriptNewer 无条件传入 buttAfter: true,因此即使页报告了 hasMore,insertEntry 也会用 {} 覆盖被挤位的 older gap 定位器——该区间由此变成无法追踪的空洞。
向前方向的 continuation 紧贴的是它的前驱(gaps[index]),而不是后继,但 buttAfter 写的是 gaps[index+1]。而正是同一次调用,因为 page.hasMore 为真才生成了向前的 nextCursor。
触发场景: entries 为 [A(anchored, 记录 1..5, nextCursor 'cursor-1'), L(load, 记录 50..60)],gaps 为 [{older:record-1},{older:record-50},{}]——A 与 L 之间的 10..49 区间尚未加载但有定位器。continueTranscriptNewer(A) 返回记录 6..9,hasMore:true, nextCursor:'cursor-2'。gaps 变为 [{older:record-1},{},{},{}]:记录 10..49 的 {older:{beforeRecordId:'record-50'}} 定位器被销毁,而对外发布的 useDaemonTranscriptLedger() 视图此时断言 N 与 L 连续,同一次调用却又记录了前方仍有内容。唯一残存的抓手是 N 的 nextCursor,而 applyPrefixTrim 与 applyRewind 都会清除它(transcriptPageLedger.ts:270、:334);两者之一发生后,记录 10..49 就成为没有 gap 哨兵的不可追踪空洞——这正是台账存在的目的所要避免的(:1035-1037:「驱逐留下的是可重新抓取的定位器,而不是无法追踪的空洞」)。当 A 是最新 entry 且 continuation 未抵达实时 block 时,同样的覆盖会关闭通向实时尾部的末尾 gap。
证据:
在真实 TranscriptPageLedger 上执行:
之前:gaps [{older:record-1},{older:{beforeRecordId:'record-50'}},{}]
continueTranscriptNewer(hasMore:true)之后:gaps [{older:record-1},{},{},{}]
修复翻转(buttAfter: page.hasMore !== true)-> 被挤位的 {older:{beforeRecordId:'record-50'}} 得以保留
保留一个冗余定位器是无害的(在该 beforeRecordId 上的重新抓取会被 materializeTranscriptHistory 的 record-id 去重丢弃);销毁一个则是不可恢复的。continueTranscriptOlder 的 buttAfter: true 应保持不动——向后方向的页总是紧贴它所延续的那个 entry。
约束:修复必须尊重两点——insertEntry 的契约(transcriptPageLedger.ts:197-204)是「被挤位的 gap 成为新页之后的 gap,除非显式传入 gapAfter」,且 gaps 数组长度必须保持 entries.length + 1;以及 hasMore 是相对于 journal 而非窗口的(session-transcript-reader.ts:3477-3480),因此 hasMore === false 对「紧贴」是充分条件但非必要条件——不要把 hasMore === true 读作「下一个 entry 不可达」的证明。
请把 continues newer with the stored cursor sent alone 扩展为使用携带 hasMore:true, nextCursor:'cursor-2' 的 continuation 页、且其后继 entry 的 older gap 非空,断言 getLedger()?.gaps[2] 仍等于被挤位的 {older:{beforeRecordId:...}}——并确认没有修复时该测试变红。当前测试用的是 hasMore:false,此时关闭 gap 是正确的,因此它无法区分这两种情形。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| firstBlockId: newFirst.id, | ||
| firstRecordId: | ||
| detail.oldestRetainedRecordId ?? firstRecordIdOf(retainedSlice), |
There was a problem hiding this comment.
[Critical] R1-6: [certifies-falsely] [new-surface] applyPrefixTrim (and applyRewind at :359-364) shrink a straddling entry's block range, record ids and byteSize, but leave turnIds describing the pre-trim range — so the entry keeps advertising record ids whose blocks were just evicted.
Failure scenario: the initial-load entry E covers blocks b-1..b-100 with turnIds = [r-1..r-50]. Streaming overflows maxBlocks / maxRetainedBytes; the SDK fires onTruncation with blockCount = 60, so cut = 40 straddles E. The reconciler rewrites E.firstBlockId / firstRecordId / byteSize but E.turnIds still contains r-1..r-20, whose blocks are gone. The user then jumps back to evicted turn r-10 through the action this same PR ships: indexStore.findTurn('r-10') succeeds (index metadata survives eviction), and computeLedgerInsertIndex walks for (const turnId of entry.turnIds) — E's minOrdinal is 1 from the stale r-1, 1 > 10 is false, so it returns entries.length = 1 instead of 0. computeInsertBlockIndex then splices the fetched older page at blockIndexById[E.lastBlockId] + 1, i.e. after turns 21-50: the flat render store shows turn ~5-15 content below newer history. This breaks the ledger's documented invariant (entries "cover exactly the block ranges present in the store") and poisons every later insert-index computation plus the useDaemonTranscriptLedger() read model. With turnIds recomputed over retainedSlice, minOrdinal would be 21 > 10 -> index 0 -> correct order.
Witness:
not run — traced through both reconcilers and computeLedgerInsertIndex at ebb7c62c. The existing
straddle test builds its entry via pageInput, which defaults turnIds: [], so nothing pins the
recompute: giving it turnIds ['r-1','r-2','r-3'] and asserting ['r-2','r-3'] after
applyPrefixTrim(preTrim, {blockCount:3, oldestRetainedRecordId:'r-2'}) is RED today.
Extract the collection loop from ledgerEntryFromBlocks into a turnIdsOf(blocks) helper and recompute it in both shrink branches: add turnIds: turnIdsOf(retainedSlice) to the straddled entry literal in applyPrefixTrim, and next to lastRecordId: lastRecordIdOf(retainedSlice) in applyRewind. Fully dropped entries already disappear with their ids, and entries kept untouched (firstIndex >= cut) keep valid ids.
The recompute must keep all retained record ids, not only index-known ones: computeLedgerInsertIndex treats an entry with no index-known turn ids as newest (DaemonSessionProvider.tsx:613-615, documented at :598-600 as "Entries without index-known turn ids are live-tail-adjacent and count as newer than everything"), and this file's :52-54 states "turnIds collects every persisted record id present on the page (a superset of navigation turn ids until the turn-index store narrows it)" — narrowing to index-known ids would flip retained-but-unindexed entries to "newer than everything".
Please add that assertion to the existing "shrinks a straddling entry to the oldest retained block" case (and mirror it in the rewind straddle case), plus a provider case that trims first and then asserts openTranscriptAtTurn on an evicted turn yields entries[0] as the anchored page — and confirm each goes red with the recompute removed.
中文说明
applyPrefixTrim(以及 :359-364 的 applyRewind)会收缩跨切 entry 的 block 区间、record id 与 byteSize,却把 turnIds 留成裁剪前的区间——于是该 entry 继续宣称拥有那些 block 已被驱逐的 record id。
触发场景: 初始加载 entry E 覆盖 block b-1..b-100,turnIds = [r-1..r-50]。流式输出使 maxBlocks / maxRetainedBytes 溢出,SDK 以 blockCount = 60 触发 onTruncation,于是 cut = 40 跨切 E。对账逻辑改写了 E.firstBlockId / firstRecordId / byteSize,但 E.turnIds 仍包含 r-1..r-20,而这些 block 已经消失。用户随后通过本 PR 自身提供的动作跳回被驱逐的 turn r-10:indexStore.findTurn('r-10') 成功(索引元数据不受驱逐影响),computeLedgerInsertIndex 遍历 for (const turnId of entry.turnIds)——E 的 minOrdinal 因过期的 r-1 而为 1,1 > 10 为假,于是返回 entries.length = 1 而不是 0。computeInsertBlockIndex 随后把抓取到的更早页拼接在 blockIndexById[E.lastBlockId] + 1,即 turn 21-50 之后:扁平渲染 store 把 turn ~5-15 的内容显示在更新的历史下方。这破坏了台账文档化的不变量(entry「恰好覆盖 store 中存在的 block 区间」),并污染之后每一次插入位置计算以及 useDaemonTranscriptLedger() 读模型。若基于 retainedSlice 重算 turnIds,minOrdinal 将是 21 > 10 -> 下标 0 -> 顺序正确。
证据:
not run — 在 ebb7c62c 上沿两个对账器与 computeLedgerInsertIndex 逐行追踪。现有的跨切测试通过 pageInput
构造 entry,而它默认 turnIds: [],因此没有任何断言锁定这次重算:给它 turnIds ['r-1','r-2','r-3'] 并在
applyPrefixTrim(preTrim, {blockCount:3, oldestRetainedRecordId:'r-2'}) 之后断言得到 ['r-2','r-3'],
今天是红的。
修复方向:把 ledgerEntryFromBlocks 中的收集循环抽成 turnIdsOf(blocks) 辅助函数,并在两个收缩分支中重算——在 applyPrefixTrim 的跨切 entry 字面量中加入 turnIds: turnIdsOf(retainedSlice),在 applyRewind 中放在 lastRecordId: lastRecordIdOf(retainedSlice) 旁边。被完全丢弃的 entry 本就随其 id 一起消失,未被触碰的 entry(firstIndex >= cut)其 id 仍然有效。
约束:重算必须保留全部存活的 record id,而不只是索引已知的那些——computeLedgerInsertIndex 把没有任何索引已知 turn id 的 entry 视为最新(DaemonSessionProvider.tsx:613-615,其 :598-600 注释为「没有索引已知 turn id 的 entry 紧邻实时尾部,视为比一切都新」),且本文件 :52-54 写明「turnIds 收集页上存在的每一个持久化 record id(在 turn-index store 收窄之前,它是导航 turn id 的超集)」——收窄为索引已知 id 会把「已保留但未被索引」的 entry 翻转成「比一切都新」。
请把上述断言加入现有的「shrinks a straddling entry to the oldest retained block」用例(并在 rewind 跨切用例中镜像一份),另加一个 provider 用例:先裁剪,再断言对被驱逐 turn 执行 openTranscriptAtTurn 时锚定页落在 entries[0]——并确认移除重算后每个用例都变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| const next = new Map(this.pages); | ||
| next.set(page.start, this.retain(page)); | ||
| this.pages = next; |
There was a problem hiding this comment.
[Critical] R1-7: [fails-closed] [new-surface] status: 'error' is one-way — only seed() assigns 'ready' (:235) — so one transient failure disables turn navigation for the rest of the session even while the store keeps serving fresh pages.
Failure scenario: the session seeds fine ('ready'). A prompt terminal triggers refreshTail() (provider :3397); its validation fetch fails once with a transient 500 / network blip, so handleFetchError sets status = 'error' (:626) and schedules a bounded retry. The retry — or the next turn_complete's refreshTail — succeeds: snapshot, totalTurns and pages are repopulated and retryAttempts is reset to 0. But none of refreshTail's three success paths (:392-397, :400-405, :428-431) touches status, and refreshTail cannot reach seed() because this.snapshot !== undefined (:356). From then on getState().status reads 'error' forever, so openTranscriptAtTurn short-circuits at if (indexStatus !== 'ready') (provider :4866-4868) and returns {ok:false, reason:'unavailable'} for every turn, and useDaemonTurnIndex() hands Phase 3 a permanently-'error' rail over a fully populated, working index. Only a rewind or a 409 routes through invalidateAndReseed() -> seed(). The same omission in fetchOlderSlice also skips this.retryAttempts = 0, so older-page failures consume the MAX_RETRY_ATTEMPTS = 3 budget cumulatively across a session even when each one recovered; after the third recovered blip, handleFetchError returns at :628 and later older-page failures are never retried at all. The asymmetry is the evidence: all three refreshTail success paths do the retryAttempts half of the recovery bookkeeping, seed() does both halves, and fetchOlderSlice does neither.
Witness:
AFTER_SEED status=ready
AFTER_LIFECYCLE_REJECT status=error scheduled=[1000]
AFTER_RETRY_SUCCESS status=error totalTurns=4 snapshot=snap-b pages=[0,3] # retry SUCCEEDED
CONTRAST (seed path) SEED_FAIL status=error -> SEED_RETRY_OK status=ready
Do the recovery bookkeeping once, in the post-admission hook every path already calls, instead of duplicating it per path:
| const next = new Map(this.pages); | |
| next.set(page.start, this.retain(page)); | |
| this.pages = next; | |
| private afterAdmission(): void { | |
| this.status = 'ready'; | |
| this.retryAttempts = 0; | |
| this.reconcileLiveEntries(); | |
| this.evictLru(); | |
| this.notify(); | |
| } |
…then drop the now-redundant this.status = 'ready'; this.retryAttempts = 0; from seed() and the three retryAttempts = 0 lines in refreshTail().
'unsupported' and 'disabled' must not be resurrected by an unconditional assignment: markUnsupported() latches with this.status = 'unsupported'; (:263) and bumps generation, so no admission path can reach afterAdmission() afterwards (each checks if (generation !== this.generation) return; right after its await), and the constructor's this.status = this.enabled ? 'idle' : 'disabled'; (:133) is unreachable there because every entry point starts with if (!this.enabled || this.status === 'unsupported') return;. If you would rather not make that generation/latch reasoning load-bearing, guard the assignment as if (this.status === 'error' || this.status === 'loading') this.status = 'ready';.
Please add the test that pins this — seed OK, make fetchPage reject once so refreshTail() lands in 'error', run the scheduled retry so it succeeds, then expect(store.getState().status).toBe('ready') — and confirm it goes red with the restore removed (today it reads 'error'). A provider-level companion (after a transient rejection and a successful retry, openTranscriptAtTurn('turn-1') must still return {ok:true}) pins the user-visible half; neither exists today.
中文说明
status: 'error' 是单向的——只有 seed() 会赋 'ready'(:235)——因此一次瞬时失败就会在本次会话余下的时间里禁用 turn 导航,即使 store 之后一直在正常提供新页。
触发场景: 会话正常 seed('ready')。某个 prompt 终态触发 refreshTail()(provider :3397),其校验抓取因一次瞬时 500 / 网络抖动失败,于是 handleFetchError 设置 status = 'error'(:626)并安排有限次重试。重试——或下一次 turn_complete 的 refreshTail——成功了:snapshot、totalTurns、pages 都被重新填充,retryAttempts 也归零。但 refreshTail 的三条成功路径(:392-397、:400-405、:428-431)都不触碰 status,而 refreshTail 又因为 this.snapshot !== undefined(:356)无法走到 seed()。从此 getState().status 永远是 'error',于是 openTranscriptAtTurn 在 if (indexStatus !== 'ready')(provider :4866-4868)处短路,对每一个 turn 都返回 {ok:false, reason:'unavailable'},而 useDaemonTurnIndex() 交给 Phase 3 的是一条永远 'error' 的导航条,底下的索引其实完整可用。只有 rewind 或 409 才会经由 invalidateAndReseed() -> seed() 清除。fetchOlderSlice 中同样的遗漏还跳过了 this.retryAttempts = 0,因此即便每次都恢复成功,更早页的失败也会在整个会话中累计消耗 MAX_RETRY_ATTEMPTS = 3 预算;第三次恢复之后,handleFetchError 会在 :628 直接返回,之后的更早页失败将完全不再重试。这种不对称正是证据:refreshTail 的三条成功路径都做了恢复记账中 retryAttempts 的那一半,seed() 两半都做,而 fetchOlderSlice 两半都不做。
证据:
AFTER_SEED status=ready
AFTER_LIFECYCLE_REJECT status=error scheduled=[1000]
AFTER_RETRY_SUCCESS status=error totalTurns=4 snapshot=snap-b pages=[0,3] # 重试已成功
对照(seed 路径) SEED_FAIL status=error -> SEED_RETRY_OK status=ready
修复方向:把恢复记账只做一次,放在每条路径都已经调用的入账后钩子里(见上方 suggestion 块),然后删除 seed() 中已冗余的 this.status = 'ready'; this.retryAttempts = 0; 与 refreshTail() 中的三处 retryAttempts = 0。
约束:无条件赋值不得复活 'unsupported' 与 'disabled'——markUnsupported() 以 this.status = 'unsupported';(:263)闩锁并递增 generation,因此之后没有任何入账路径能抵达 afterAdmission()(每条路径都在 await 之后立刻检查 if (generation !== this.generation) return;);构造函数的 this.status = this.enabled ? 'idle' : 'disabled';(:133)在那里也不可达,因为每个入口都以 if (!this.enabled || this.status === 'unsupported') return; 开头。如果不希望让上述 generation/闩锁推理成为承重前提,可把赋值加守卫写成 if (this.status === 'error' || this.status === 'loading') this.status = 'ready';。
请补上能锁定该行为的测试:先成功 seed,让 fetchPage 拒绝一次使 refreshTail() 落入 'error',再执行已安排的重试使其成功,然后断言 expect(store.getState().status).toBe('ready')——并确认移除该恢复赋值后测试变红(今天读到的是 'error')。另加一个 provider 层的配套用例(瞬时拒绝并成功重试之后,openTranscriptAtTurn('turn-1') 仍须返回 {ok:true})以锁定用户可见的那一半;目前两者都不存在。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| if (provisional !== undefined) { | ||
| this.linkedRecordIds.set(provisional.id, recordIds); |
There was a problem hiding this comment.
[Critical] R1-54: [certifies-falsely] [new-surface] observeAdmittedBlocks overwrites rather than unions the provisional's linked record ids, so the assistant/thought blocks of the same turn replace the user echo's link — and assistant record uuids are never navigation turn ids, so reconcileLiveEntries' record-UUID fallback can never match.
Failure scenario: prompt P is accepted -> addLivePrompt({promptId:'P'}) (provider :4421, fed from actions.ts:993). The flush feed (:1504) first admits the user echo block (promptId:'P', sourceRecordIds:['<user-record-uuid>']), linking live:P to that uuid; then it admits the assistant/thought blocks of the same turn — same promptId, their own record uuids — and each set() overwrites the link. Assistant blocks really do carry the turn's promptId and their own record uuids (ui/transcript.ts:856-864; envelope field promptId?: string, "Admitted prompt identifier for events belonging to a specific turn", daemon/types.ts:4466-4467; the SDK's own test asserts an assistant block ends with promptId:'prompt-1', sourceRecordIds:['assistant-record']). Index turnIds are always user record uuids (session-transcript-reader.ts:1016-1018 if (record.type !== 'user') return undefined; -> :2160-2166 turnId: uuid). So for a legacy index whose entries carry no promptId, the fallback linked.some((id) => turnIds.has(id)) (:522) can never match: the provisional survives forever, the rail shows a completed turn as still in-flight, and the some(kind === 'prompt') fast paths at :500 and :578 never short-circuit again.
Witness:
PROBE R1-54 CONTROL liveEntries: [] # only the user echo block fed
PROBE R1-54 liveEntries: [{"id":"live:prompt-P","kind":"prompt","promptId":"prompt-P","label":"hello"}]
PROBE R1-54 totalTurns/pages: 3 [0,2] # the turn's index page WAS admitted
fix flip (union instead of overwrite at :587) -> liveEntries: [] , all 26 store tests still pass
| if (provisional !== undefined) { | |
| this.linkedRecordIds.set(provisional.id, recordIds); | |
| if (provisional !== undefined) { | |
| this.linkedRecordIds.set(provisional.id, [ | |
| ...new Set([...(this.linkedRecordIds.get(provisional.id) ?? []), ...recordIds]), | |
| ]); |
Scope, so the fix is sized correctly: where a later turn_result record supplies promptId, a subsequent refreshTail retires the provisional by promptId, so the permanent phantom is confined to index entries with no promptId — legacy transcripts, the path this diff deliberately supports (JSDoc :571-575 and the test reconciles a legacy no-prompt-id provisional by record UUID) — plus any turn that never persists a turn_result promptId.
Please add the test that pins this — addLivePrompt, then observeAdmittedBlocks with a user-echo block followed by an assistant block for the same promptId, then admit an index page whose turnId is the user record uuid, and assert liveEntries is empty — and confirm it goes red with the union removed (today the provisional survives).
中文说明
observeAdmittedBlocks 对临时项已关联的 record id 是覆盖而非并集,因此同一 turn 的 assistant/thought block 会替换掉 user 回显 block 的关联——而 assistant 的 record uuid 永远不是导航 turn id,于是 reconcileLiveEntries 的 record-UUID 回退分支永远无法匹配。
触发场景: prompt P 被接受 -> addLivePrompt({promptId:'P'})(provider :4421,由 actions.ts:993 触发)。flush 馈送(:1504)先入账 user 回显 block(promptId:'P',sourceRecordIds:['<user-record-uuid>']),把 live:P 关联到该 uuid;随后入账同一 turn 的 assistant/thought block——promptId 相同、record uuid 各不相同——每次 set() 都覆盖前一次的关联。assistant block 确实携带该 turn 的 promptId 和自己的 record uuid(ui/transcript.ts:856-864;envelope 字段 promptId?: string,「属于特定 turn 的事件所对应的已接受 prompt 标识」,daemon/types.ts:4466-4467;SDK 自身的测试断言 assistant block 最终带有 promptId:'prompt-1'、sourceRecordIds:['assistant-record'])。而索引的 turnId 永远是 user 记录的 uuid(session-transcript-reader.ts:1016-1018 的 if (record.type !== 'user') return undefined; -> :2160-2166 的 turnId: uuid)。因此对于条目不带 promptId 的旧式索引,回退分支 linked.some((id) => turnIds.has(id))(:522)永远无法匹配:临时项永久存活,导航条把一个已完成的 turn 显示为仍在进行,且 :500 与 :578 的 some(kind === 'prompt') 快速路径再也无法短路。
证据:
PROBE R1-54 CONTROL liveEntries: [] # 只馈送 user 回显 block
PROBE R1-54 liveEntries: [{"id":"live:prompt-P","kind":"prompt","promptId":"prompt-P","label":"hello"}]
PROBE R1-54 totalTurns/pages: 3 [0,2] # 该 turn 的索引页确实已入账
修复翻转(在 :587 改为并集而非覆盖)-> liveEntries: [],且全部 26 个 store 测试仍通过
修复方向见上方 suggestion 块:改为并集。
范围界定(便于把修复做准):当后续的 turn_result 记录提供了 promptId 时,之后的 refreshTail 会按 promptId 清除该临时项,因此永久性幽灵仅局限于不带 promptId 的索引条目——即本 diff 刻意支持的旧式 transcript(JSDoc :571-575 与测试 reconciles a legacy no-prompt-id provisional by record UUID)——外加任何从未持久化 turn_result promptId 的 turn。
请补上能锁定该行为的测试:addLivePrompt,然后用同一 promptId 的 user 回显 block 与 assistant block 依次调用 observeAdmittedBlocks,再入账一个 turnId 为 user record uuid 的索引页,断言 liveEntries 为空——并确认移除并集后测试变红(当前临时项会存活)。
— qwen3.8-max via Qwen Code /review (v0.23.0)
| options?.onAdmitted?.(); | ||
| onPromptAdmitted?.({ promptId: accepted.promptId, label: text }); |
There was a problem hiding this comment.
[Critical] R1-53: [certifies-falsely] [new-surface] onPromptAdmitted carries no session identity and fires with no session guard, so a prompt admitted to session A is written into session B's turn-index store as a phantom live: entry that can never be reconciled away.
The payload type is {promptId, label} with no sessionId (:220), and both fires are post-await with no identity re-check — while adjacent lines do re-check (:989 if (activePromptsRef.current.get(sessionId)?.controller === ctrl), :1000 if (sessionRef.current?.sessionId === sessionId)). The provider handler writes through a ref (turnIndexStoreRef.current?.addLivePrompt(...), provider :4420-4425).
Failure scenario: the user sends a prompt in session A, then clicks another session in the sidebar while session.submitPrompt(...) is in flight (loadSession has no prompt-status gate). startSessionSwitch only rejects the waiter and deletes the map entry (:742-752), never aborting the controller — the sole aborter clearActiveSessionState (:585-592) runs only from clearSession (:1790) and newSession (:1818) — so A's admission still resolves. The provider's run loop has by then disposed A's store and installed B's, so addLivePrompt writes live:prompt-A, labelled with A's prompt text, into B's liveEntries. addLivePrompt has no session gate and reconcileLiveEntries (turnIndexStore.ts:499-527) matches only promptIds / record ids from that session's pages, so B can never retire it. This also defeats the invariant this diff states at provider :5109-5111 — "a store created for a previous session must never leak its snapshot-bound pages into the new session's consumers".
Witness:
PROBE R1-53 [connected A] session-a []
PROBE R1-53 [switched to B] session-b []
PROBE R1-53 [A admission resolved] session-b [{"id":"live:prompt-A","kind":"prompt",
"promptId":"prompt-A","label":"hello from A"}]
-> AssertionError: expected [ { id: 'live:prompt-A', ...(3) } ] to deeply equal []
fix flip (sessionId on the payload + guard on indexStore.getState().sessionId)
-> session-b [] , and the PR's own 'appends a live provisional when a prompt is admitted' still passes
Add sessionId to the onPromptAdmitted payload and have the provider handler drop the signal when it does not match the store's session. Compare against turnIndexStoreRef.current.getState().sessionId, not only sessionRef.current.
The store-replacement gate already compares turnIndexStoreRef.current.getState().sessionId !== activeSession.sessionId (provider :2806-2808), so the guard must key on the store's sessionId — the same-session reconnect path leaves sessionRef.current unchanged while the store instance is swapped.
Please add the test that pins this — admit a prompt with a deferred submitPrompt, switch sessions, resolve the submit, and assert the new session's getTurnIndex()?.liveEntries is empty — and confirm it goes red without the guard.
中文说明
onPromptAdmitted 不携带会话身份、触发时也没有会话守卫,因此提交给会话 A 的 prompt 会被写入会话 B 的 turn-index store,成为一个永远无法被对账清除的幽灵 live: 条目。
其载荷类型是 {promptId, label},没有 sessionId(:220),且两处触发都位于 await 之后而没有身份复核——而相邻的行确实做了复核(:989 的 if (activePromptsRef.current.get(sessionId)?.controller === ctrl)、:1000 的 if (sessionRef.current?.sessionId === sessionId))。provider 侧的处理函数通过 ref 写入(turnIndexStoreRef.current?.addLivePrompt(...),provider :4420-4425)。
触发场景: 用户在会话 A 中发送 prompt,随后在 session.submitPrompt(...) 仍在进行时于侧边栏点击另一个会话(loadSession 没有 prompt 状态闸门)。startSessionSwitch 只拒绝等待者并删除 map 条目(:742-752),从不 abort 该 controller——唯一的 abort 者 clearActiveSessionState(:585-592)只在 clearSession(:1790)与 newSession(:1818)中被调用——因此 A 的入账仍会 resolve。此时 provider 的运行循环已经销毁 A 的 store 并装入 B 的 store,于是 addLivePrompt 把带着 A 的 prompt 文本的 live:prompt-A 写进了 B 的 liveEntries。addLivePrompt 没有会话闸门,而 reconcileLiveEntries(turnIndexStore.ts:499-527)只按本会话索引页中的 promptId / record id 匹配,因此 B 永远无法清除它。这也违背了本 diff 在 provider :5109-5111 自己声明的不变量——「为上一个会话创建的 store 绝不能把它绑定快照的页泄漏给新会话的消费者」。
证据:
PROBE R1-53 [已连接 A] session-a []
PROBE R1-53 [切换到 B] session-b []
PROBE R1-53 [A 的入账 resolve] session-b [{"id":"live:prompt-A","kind":"prompt",
"promptId":"prompt-A","label":"hello from A"}]
-> AssertionError: expected [ { id: 'live:prompt-A', ...(3) } ] to deeply equal []
修复翻转(载荷带上 sessionId + 以 indexStore.getState().sessionId 做守卫)
-> session-b [],且 PR 自身的 'appends a live provisional when a prompt is admitted' 仍然通过
修复方向:为 onPromptAdmitted 载荷加上 sessionId,并让 provider 处理函数在与 store 的会话不匹配时丢弃该信号。要与 turnIndexStoreRef.current.getState().sessionId 比较,而不只是 sessionRef.current。
约束:store 替换闸门本身比较的就是 turnIndexStoreRef.current.getState().sessionId !== activeSession.sessionId(provider :2806-2808),因此守卫必须以 store 的 sessionId 为键——同会话重连路径会在 store 实例被替换的同时保持 sessionRef.current 不变。
请补上能锁定该行为的测试:以挂起的 submitPrompt 入账一个 prompt,切换会话,再让 submit 返回,断言新会话的 getTurnIndex()?.liveEntries 为空——并确认没有守卫时测试变红。
— qwen3.8-max via Qwen Code /review (v0.23.0)
|
Closing for the same reason as #11053: #11054 merged as To be clear about what this PR was: of the three, it was the leanest and the closest to the merged Phase 2 design document — 3,568 added lines against 5,941, the module boundaries and file names the design named, and the sequential-pagination migration kept in the order the design prescribed rather than deferred. What did not survive is the structural decision underneath it, not the execution. Why the other route was kept. Sharing the live window's admission budget means a jump competes with live output: at capacity a valid anchor is refused, and a streaming session offers no bounded moment at which space frees again. The design's own open question already conceded that fixing that through interior page eviction may require a new range-delete method in the SDK store. A separate historical budget gets the property structurally, which is what #10750's acceptance criteria ask for. Exposures recorded before closing, so they are not rediscovered later. An anchored page's surviving records are spliced at a single computed offset, so a dedup hole inside the page can place newer records ahead of a retained page while the open still reports success — the same shape as R2-22 on #11053. The ledger insert search treats an entry whose turn ids do not resolve against currently retained index pages as newer than everything, which lands blocks in the wrong order rather than merely misplacing a rail highlight; the risk note in the description covered the incoming page having no navigation turns, which is the narrower case. And rewind reaches the index only through the store's truncation callback, which is emitted only when a block is actually dropped. All three were latent — nothing called the new surface — and none applies to the implementation on main. One correction to the description: it cited #10968 as part of the Phase 1 protocol. That is an unrelated CI fix; the protocol landed in #10751, with the routing follow-up in #11047. Worth lifting: the persisted-identity plumbing onto the message layer, with its adapter tests, if Phase 3 decides it needs a message-to-turn mapping in the render path. Main locates turns inside the navigation store instead, so that is an open decision on #10750 rather than a gap. Conflicts with main in four files today. Tracking stays on #10750. 中文说明与 #11053 同因关闭:#11054 已于 2026-09-06 合并为 需要说清的是:三者之中本 PR 最精简、也最贴合已合并的 Phase 2 设计文档——3568 行对 5941 行,模块边界与文件命名都与设计一致,并按设计要求先做顺序分页迁移而非推迟。落选的是其下的结构决策,不是实现质量。 与实时窗口共用准入预算意味着跳转与实时输出争抢容量:满载时合法锚点被拒,而持续产出的会话没有确定的腾位时刻。设计自身的 open question 已承认,用内部页淘汰来修可能需要在 SDK store 新增 range-delete。独立历史预算从结构上满足 #10750 的验收标准。 关闭前登记三处暴露,避免以后重新发现:一是锚定页的存活记录按单一偏移整段拼接,页内被去重打洞时较新记录可能落到保留页之前,而调用仍返回成功(与 #11053 的 R2-22 同形);二是账本插入位置查找把"turn id 无法在当前保留索引页解析"的条目视为最新,导致 block 落错顺序而不只是高亮错位——描述里的风险提示说的是"来页不含导航 turn",是更窄的情形;三是 rewind 只经 store 的 truncation 回调抵达索引,而该信号仅在确有 block 被丢弃时发出。三者今天都是潜伏的(新面无调用方),也都不适用于 main 上的实现。 描述里有一处需更正:把 #10968 当作 Phase 1 协议的一部分,实为无关的 CI 修复;协议落在 #10751,路由后续在 #11047。 值得摘出的部分:消息层持久身份透传及其适配器测试——前提是 Phase 3 确认需要"渲染消息 → durable turn"的映射。main 是在导航 store 内定位的,因此这是 #10750 上的待定项,而非缺口。 与 main 已在 4 个文件上冲突。追踪继续留在 #10750。 |

What this PR does
Implements the Phase 2 client data layer of the Web Shell global turn navigation design (
docs/design/web-shell/web-shell-global-turn-navigation-phase2.md, merged in #11020) on top of the Phase 1 daemon/SDK protocol (#10968, #11047).The change introduces three cooperating pieces, all gated behind the
session_turn_navigationcapability and invisible when the daemon does not advertise it. First, a provider-owned page ledger over the flat transcript store records which block ranges arrived as which fetched page, plus the explicit gaps between them, so that trimming or rewinding the window leaves re-fetchable locators instead of untracked holes. Second, a session-wide turn-index store loads the newest metadata page first, pages older metadata independently of transcript blocks, keeps the tail fresh with a two-step merge on prompt terminal (append-only fast path, conservative reset on any divergence), bounds memory with an LRU that never shrinks the advertised turn count, and latches an explicitunsupportedstate when the daemon reports its indexing ceiling. Third, rendered messages now carry their persisted identity (sourceRecordIds/promptId), which feeds a canonical turn locator and record-id/prompt-id deduplication.On top of those, the session actions gain an anchored open (
openTranscriptAtTurn) and two continuations (older viabeforeRecordId+ snapshot, newer via the stored signed cursor sent alone). An anchored page is materialized, deduplicated, and admitted atomically at its ledger position without disturbing the connected live tail, with deliberately distinct failure reasons — daemon-side whole-response refusal, retryable client-window rejection, terminal window overflow, snapshot invalidation, and missing anchor — so the Phase 3 UI can react to each appropriately.Existing behavior is unchanged: the load-older path, pagination anchoring, capacity handling, and rendering all work exactly as before, which the full provider test suite confirms.
Why it's needed
Long-running daemon sessions are the norm in the Web Shell, but today the transcript window can only grow sequentially from the newest edge: reaching an early turn means paging through everything in between, and once the window evicts old content there is no way back. Issue #10750 tracks making turns randomly addressable. Phase 1 shipped the daemon-side turn-index and anchored-read protocol; this PR lands the client-side data layer that consumes it — the page ledger, the turn-index store, and the anchored-admission actions that Phase 3's navigation rail will be built on.
Reviewer Test Plan
How to verify
This is a data-layer change with no UI surface of its own; verification is unit tests plus confirming existing transcript behavior is untouched.
cd packages/web-shell && npx vitest run— the full client suite passes (6072 tests, including 56 new ones covering the ledger, the turn-index store, and the anchored-open/continuation actions).npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx— the 295-test provider suite passes unmodified apart from the newturn navigation (Phase 2)describe block, demonstrating the load-older path, capacity handling, trimming, and session lifecycle are behavior-preserving.Key behaviors a reviewer can spot-check in the new tests: older-page requests clamp to a butted limit and never fire when the boundary is 0; the tail validation response is never admitted while clamped fill pages land on the grid; a shrunk or rewritten chain resets conservatively; the
unsupportedlatch sticks for the session; anchored opens leave the live tail untouched and dedupe by record id then prompt id; continuation requests send exactly the locator the protocol allows (snapshot pair vs. standalone cursor).Evidence (Before & After)
N/A — no user-visible UI change in this phase (the navigation rail lands in Phase 3).
Tested on
Environment (optional)
Unit tests only (
vitestunderpackages/web-shell), plusnpm run buildandnpm run typecheckfrom the repository root.Risk & Scope
session_turn_navigationget byte-identical behavior; the new session actions returnunsupportedwithout issuing requests.Linked Issues
Part of #10750 (Phase 2 of 3; does not close the tracking issue). Design docs: #11020, #10968. Protocol: #11047.
中文说明
本 PR 做了什么
按照已合并的 Phase 2 设计文档(
docs/design/web-shell/web-shell-global-turn-navigation-phase2.md,#11020 并入),在 Phase 1 的 daemon/SDK 协议(#10968、#11047)之上实现 Web Shell 全局 turn 导航的客户端数据层。改动由三部分协作完成,全部由
session_turn_navigation能力门控,daemon 未声明该能力时完全不可见。其一,provider 持有的页台账记录在扁平 transcript store 上哪些 block 区间来自哪一次抓取,以及区间之间的显式 gap,使窗口裁剪或 rewind 后留下的是可重新抓取的定位器而不是无法追踪的空洞。其二,会话级 turn-index store 优先加载最新元数据页,独立于 transcript block 向前翻页,在 prompt 终态用两步合并保持尾部新鲜(仅追加走快速路径,任何分歧都保守重置),用永不缩减通告 turn 总数的 LRU 限制内存,并在 daemon 报告索引上限时闩锁为显式unsupported状态。其三,渲染消息现在携带其持久身份(sourceRecordIds/promptId),支撑规范的 turn 定位器和按 record id / prompt id 的去重。在此之上,会话动作新增锚定打开(
openTranscriptAtTurn)和两个方向的 continuation(older 用beforeRecordId+ 快照,newer 用单独发送的存储签名游标)。锚定页经物化、去重后按台账位置原子入账,不扰动已连接的实时尾部;失败原因刻意区分——daemon 整页拒绝、可重试的客户端窗口拒绝、终态窗口溢出、快照失效、锚点缺失——便于 Phase 3 的 UI 分别处理。现有行为不变:load-older 路径、分页锚定、容量处理与渲染均与之前完全一致,完整的 provider 测试套件证实了这一点。
为什么需要
长时间运行的 daemon 会话在 Web Shell 中是常态,但目前 transcript 窗口只能从最新边缘顺序增长:要到达早期 turn 必须逐页翻完中间所有内容,而窗口一旦驱逐旧内容就无法回头。issue #10750 跟踪让 turn 可随机寻址的目标。Phase 1 交付了 daemon 侧 turn-index 与锚定读协议;本 PR 落地消费该协议的客户端数据层——页台账、turn-index store,以及 Phase 3 导航条将构建其上的锚定入账动作。
评审者测试计划
如何验证
这是没有自身 UI 的数据层改动;验证手段为单元测试加上确认现有 transcript 行为未受影响。
cd packages/web-shell && npx vitest run— 客户端全套测试通过(6072 项,含覆盖台账、turn-index store 与锚定打开/continuation 动作的 56 项新增)。npx vitest run client/daemon/session/DaemonSessionProvider.test.tsx— 295 项 provider 套件除新增turn navigation (Phase 2)分组外未经修改即通过,证明 load-older 路径、容量处理、裁剪与会话生命周期保持行为不变。评审者可在新测试中抽查的关键行为:older 页请求会 clamp 到贴合的 limit,边界为 0 时不发请求;尾部 validate 响应永不被入账,而 clamped fill 页落在网格上;链缩短或被改写时保守重置;
unsupported闩锁在会话内持续生效;锚定打开不扰动实时尾部,并按 record id 然后 prompt id 去重;continuation 请求只发送协议允许的定位器(快照配对 vs 独立游标)。证据(前后对比)
N/A —— 本阶段没有用户可见的 UI 变化(导航条在 Phase 3 落地)。
测试平台
Linux 已测试;macOS、Windows 未测试。
环境(可选)
仅单元测试(
packages/web-shell下的vitest),外加仓库根目录的npm run build与npm run typecheck。风险与范围
session_turn_navigation的 daemon 获得逐字节一致的行为;新会话动作返回unsupported且不发出请求。关联 Issue
属于 #10750 的一部分(三阶段中的 Phase 2,不关闭该跟踪 issue)。设计文档:#11020、#10968。协议:#11047。