fix(core): isolate fork cache readers by session - #9471
Conversation
Verification reportEvidence level: function-level dynamic integration test against production readers; this was not a full ACP daemon or live-model E2E run. Baseline reproduction
Post-fix verification
Environment: macOS, Node.js 22.22.3. No provider credentials or model network calls were used. |
|
|
|
Thanks for the PR!
Moving on to code review. 🔍 中文说明感谢贡献!
进入代码审查。🔍 — Qwen Code · qwen3.8-max Reviewed at |
Code reviewThe approach is the one I'd have picked independently: enforce ownership in the getter, fail closed, and pass every production reader's own session id. Comparing against the base:
No blockers, no convention violations. CI test evidenceThe unit suite for this commit is still running; this table reflects one fetch and is updated in place by the finalize job once CI settles. No failures so far — security checks, desktop shell builds, and the PR precheck are green, with the ubuntu unit test job still in flight and the macOS/Windows/integration legs skipped by CI classification. Final CI results for
One row per check name (latest run); skipped checks omitted; failures sort first. / 每个检查名一行(取最新一次运行),省略 skipped,失败项排在最前。 What the suite can and cannot settle: the new unit tests pin the mechanism (getter guard + per-reader wiring), but multi-session daemon behavior — an ACP process actually interleaving two sessions — is covered only by the author's self-reported function-level probe (author's claim, not independently re-run). Sandboxed verification would settle this: 中文说明代码审查方案与我独立想到的做法一致:把所有权校验下沉到 getter,不匹配即 fail closed,每个生产读取方传入自己的 session id。对照基础代码:
无阻塞问题,无规范违反。 CI 测试证据该提交的单元测试仍在运行;上表来自一次抓取,CI 结束后由 finalize 任务原地更新。目前无失败——安全扫描、桌面壳构建与 PR 预检均通过,ubuntu 单元测试仍在进行,macOS/Windows/集成测试腿被 CI 分类跳过。 单元测试能钉住机制(getter 守卫 + 读取方接线),但多 session 守护进程层面的行为目前只有作者自述的函数级探针(作者声明,未独立复跑)。如需沙箱验证: — Qwen Code · qwen3.8-max Reviewed at |
|
Confidence: 4/5 — a clean, minimal fix for a reproduced cross-session leak; the one open item is daemon-level evidence, which the unit suite pins only indirectly. Stepping back: the bug is real (verified in the base code, not just taken from the PR's framing), the fix is exactly the shape I'd have chosen — ownership enforced once in the getter, every production reader passing its own session id, fail closed on mismatch — and it goes slightly further than required by collapsing the suggestion generator's now-redundant double-check. All 42 production lines serve the stated goal; the two comment updates keep documentation truthful. The failure mode is the right one: optional background work gets skipped instead of consuming a foreign transcript. Why not 5/5: the multi-session ACP scenario end-to-end rests on unit tests plus the author's probe — nothing I can re-run here. That's a reason for the Approval deferred until CI lands green on 中文说明置信度:4/5 —— 针对一个已复现的跨 session 泄漏,修复干净且最小化;唯一未决项是守护进程层面的证据,单元测试只能间接钉住。 退一步看:bug 真实存在(已在基础代码中核实,而非照单全收 PR 的描述),修复形态与我独立选择的一致——所有权校验集中在 getter 一处,每个生产读取方传入自身 session id,不匹配即 fail closed——并且还顺手收敛了 suggestion generator 中冗余的双重校验。全部 42 行生产代码都服务于既定目标;两处注释更新保持了文档的准确性。失败模式也是正确的那种:可选后台任务被跳过,而不是消费外来对话记录。 为何不是 5/5:多 session ACP 场景的端到端证据依赖单元测试加作者的探针——我在这里无法复跑。这是建议走 审批暂缓,等待 CI 在该提交上变绿——单元测试仍在运行,此刻批准等于为一个尚不存在的结果背书。若该提交的测试全绿,审批将自动跟进;若有失败或提交变动,则不予批准。 — Qwen Code · qwen3.8-max Reviewed at |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed. Suggestions are inline.
中文说明
已审查。 建议见行内评论。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| options?: { model?: string }, | ||
| ): Promise<SpeculationState> { | ||
| const cacheSafe = getCacheSafeParams(); | ||
| const cacheSafe = getCacheSafeParams(config.getSessionId()); |
There was a problem hiding this comment.
[Suggestion] The fail-closed outcome of the session-scoped lookup is untested for both speculation readers. Every test in this file uses a mock that returns params unconditionally, and the new assertions only pin that the session id is passed — there is no case where getCacheSafeParams returns null, so neither the throw in startSpeculation nor the return null in generatePipelinedSuggestion is exercised. The sibling readers pin the equivalent path (extractionAgentPlanner.test.ts "throws when getCacheSafeParams returns null", suggestionGenerator.test.ts foreign-slot fallback), but speculation does not.
This matters in exactly the multi-session scenario this PR fixes: a future edit relaxing the null guard — for example falling back to an unscoped read to "fix" a speculation-not-starting report — would pass every test in this file green and silently reintroduce the cross-session fork. A mutation probe confirmed it: applying getCacheSafeParams(config.getSessionId()) ?? getCacheSafeParams() here kept the suite 18/18 green while a contention probe showed the mutated code consuming the foreign snapshot past the guard; the same probe passes on the unmodified source.
Add a test setting the mock to return null and asserting startSpeculation rejects with 'CacheSafeParams not available for speculation' and neither createForkedChat nor runForkedAgent is called, plus the analogous null case for the pipelined suggestion path:
it('does not start speculation when the session-scoped lookup returns null', async () => {
forkedAgentMocks.getCacheSafeParams.mockReturnValue(null);
await expect(startSpeculation(config, 'read a.ts')).rejects.toThrow(
'CacheSafeParams not available for speculation',
);
expect(createForkedChat).not.toHaveBeenCalled();
expect(forkedAgentMocks.runForkedAgent).not.toHaveBeenCalled();
});中文说明
两个推测(speculation)读取方的 fail-closed(会话校验失败返回 null)结果都没有测试覆盖。本文件中所有测试都使用无条件返回 params 的 mock,新增断言也只钉住了「传入了 session id」这一行为——没有任何用例让 getCacheSafeParams 返回 null,因此 startSpeculation 的 throw 和 generatePipelinedSuggestion 的 return null 都不会被执行到。相邻的读取方钉住了等价路径(extractionAgentPlanner.test.ts 的 "throws when getCacheSafeParams returns null"、suggestionGenerator.test.ts 的外部 slot 回退),唯独 speculation 没有。
这正是本 PR 修复的多 session 场景下的风险所在:未来若有人放宽 null 守卫——例如为了「修复」推测不启动的问题而回退到无 session 限定的读取——本文件的所有测试仍会全绿,跨 session fork 会被悄悄重新引入。变异探针证实了这一点:在此处应用 getCacheSafeParams(config.getSessionId()) ?? getCacheSafeParams() 后,测试套件仍 18/18 全绿,而竞争探针显示变异后的代码越过守卫消费了外来快照;同一探针在未修改的源码上通过。
建议新增一个测试:将 mock 设为返回 null,断言 startSpeculation 以 'CacheSafeParams not available for speculation' 拒绝,且 createForkedChat 与 runForkedAgent 均未被调用;流水线推测路径也加类似的 null 用例。
— qwen3.8-max via Qwen Code /review (v0.21.14)
There was a problem hiding this comment.
Addressed in bd1babe. Added fail-closed coverage for both speculation readers: the initial null lookup now asserts rejection with zero forked-chat/agent calls, and a null second lookup completes speculation without producing a pipelined suggestion or calling the forked agent.
| projectRoot: string, | ||
| ): Promise<AutoMemoryExtractionExecutionResult> { | ||
| const cacheSafe = getCacheSafeParams(); | ||
| const cacheSafe = getCacheSafeParams(config.getSessionId()); |
There was a problem hiding this comment.
[Suggestion] With this change, a session-mismatched extraction now fails closed by throwing — but the throw is recorded downstream as a failure, not the benign skip the PR description ("that work is skipped") and the linked issue's fix direction ("extraction: skip the run") describe. The error message also names only the empty-slot cause, so it misdiagnoses the mismatch case.
Concrete shape: in the issue's own multi-session scenario, session B's completed turn overwrites the single slot before session A's queued extraction reads it (extraction is serialized per projectRoot in memory/manager.ts, so A's run can lag well behind A's turn). The planner throws, and runExtract in manager.ts catches and unconditionally records status: 'failed' plus MemoryExtractEvent({status:'failed'}) — for an expected, benign race that recurs whenever two sessions interleave turns, over-counting failure telemetry and pointing oncall at the turn-completion path instead of slot contention. A probe driving the real planner gate showed mismatch-slot and empty-slot errors are byte-identical:
PROBE mismatch-slot: runAutoMemoryExtractionByAgent: no cache-safe params available; extraction must run after a completed main turn.
PROBE empty-slot: runAutoMemoryExtractionByAgent: no cache-safe params available; extraction must run after a completed main turn.
PROBE matching-slot: resolved, touchedTopics=[]
The codebase already models benign no-runs as status: 'skipped' with a skippedReason — the memory-pressure gate in the very same runExtract — and MemoryExtractEvent already carries a skipped_reason union. Distinguish the foreign-slot case from the empty-slot case (e.g. when getCacheSafeParamsSessionId() differs from config.getSessionId(), surface a skip with skippedReason: 'session_mismatch'), or at minimum extend the message to name both causes.
中文说明
本次改动后,session 不匹配的记忆提取会以抛错的方式 fail closed——但该抛错在下游被记录为失败(failed),而不是 PR 描述("该后台任务会被跳过")和关联 issue 修复方向("extraction: skip the run")所说的良性跳过。错误信息也只描述了空槽这一种原因,会误导不匹配场景的排查。
具体场景:在 issue 本身的多 session 场景下,session B 完成的回合可能在 session A 排队的提取读取之前覆盖唯一槽位(提取在 memory/manager.ts 中按 projectRoot 串行化,A 的执行可能远滞后于 A 的回合)。planner 抛错后,manager.ts 的 runExtract 捕获并无条件记录 status: 'failed' 并发出 MemoryExtractEvent({status:'failed'})——而这其实是两个 session 交替回合时必然反复出现的良性竞争:失败遥测被抬高,值班排查也会被引向回合完成/保存路径,而非真正的槽位竞争。驱动真实 planner 守卫的探针显示,不匹配槽与空槽的报错逐字节相同(见上方代码块)。
代码库已经把良性的「不执行」建模为 status: 'skipped' 加 skippedReason——就在同一个 runExtract 里的内存压力守卫——且 MemoryExtractEvent 已有 skipped_reason 联合类型。建议把外来槽场景与空槽场景区分开(例如当 getCacheSafeParamsSessionId() 与 config.getSessionId() 不一致时,以 skippedReason: 'session_mismatch' 记为跳过),至少也应扩展错误信息,把两种原因都写明。
— qwen3.8-max via Qwen Code /review (v0.21.14)
There was a problem hiding this comment.
Addressed in bd1babe. Auto-memory extraction now recognizes a foreign cache owner immediately before entering the planner and returns skippedReason=session_mismatch without advancing the cursor. The manager records the task and telemetry event as skipped rather than failed, including the structured skipped reason. Added extract and manager tests for cursor preservation, zero planner calls, task state, and telemetry.
| if ( | ||
| expectedSessionId !== undefined && | ||
| currentCacheSafeParams.sessionId !== expectedSessionId | ||
| ) { | ||
| return null; | ||
| } |
There was a problem hiding this comment.
[Suggestion] The mismatch path returns null identically to the never-captured path with no log, so cross-session slot contention — the exact condition this PR is about — is invisible: a repo-wide grep finds session_mismatch logged in exactly one place (the suggestion generator's debug line). In a multi-session daemon, session A's readers intermittently get null whenever session B owns the slot — startSpeculation throws into AppContainer's no-op .catch, the pipelined suggestion disappears bare, and the planner throws with a message that does not name mismatch — leaving an oncall debugging "background speculation/extraction randomly stops working" nothing to grep for to tell contention apart from "no turn completed yet".
Emit a debug log before returning null (debugLogger already exists in this module):
| if ( | |
| expectedSessionId !== undefined && | |
| currentCacheSafeParams.sessionId !== expectedSessionId | |
| ) { | |
| return null; | |
| } | |
| if ( | |
| expectedSessionId !== undefined && | |
| currentCacheSafeParams.sessionId !== expectedSessionId | |
| ) { | |
| debugLogger.debug( | |
| `CacheSafeParams session mismatch: slot owned by ${currentCacheSafeParams.sessionId}, requested ${expectedSessionId}`, | |
| ); | |
| return null; | |
| } |
中文说明
不匹配分支返回 null 的方式与「尚未捕获」路径完全相同,且没有任何日志,因此跨 session 槽位竞争——正是本 PR 针对的状况——是不可见的:全仓 grep session_mismatch 只有一处命中(suggestionGenerator 的 debug 日志)。在多 session 守护进程中,只要槽位属于 session B,session A 的读取方就会间歇性拿到 null——startSpeculation 抛错后被 AppContainer 的空 .catch 吞掉,流水线推测无声消失,planner 抛出的信息又不提不匹配——值班排查「后台推测/提取随机罢工」时,没有任何可 grep 的线索来区分槽位竞争与「尚未完成回合」。
建议在返回 null 前输出一条 debug 日志(本模块已有 debugLogger)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
There was a problem hiding this comment.
Addressed in bd1babe. The scoped getter now emits a DEBUG session_mismatch diagnostic with requested and cached session IDs before returning null. Auto-memory extraction also exposes the condition structurally through skipped_reason=session_mismatch.
|
@qwen-code /triage |
doudouOUC
left a comment
There was a problem hiding this comment.
Partially reviewed — gaps disclosed.
Not explored to full depth (tool budget reached): "agent 6b": NO Budget gap: line — all checks completed within budget.; "agent 4": NO; "agent 1a": NO; "agent 3a": 无.
Not reviewed: reverse audit — no auditor was launched with a prompt this skill builds — the pass that hunts what the rest of the review missed ran, if at all, without the method its brief carries.
中文说明
仅完成部分审查,审查缺口已披露。
未探索到全部深度(达到工具调用预算):"agent 6b":NO Budget gap: line — all checks completed within budget.;"agent 4":NO;"agent 1a":NO;"agent 3a":无。
未审查:反向审计——没有审计 agent 是用本 skill 构建的 prompt 启动的——负责搜寻评审其余部分遗漏问题的这道工序,即便运行过,也缺失了 brief 承载的方法。
— Qwen3-235B-A22B via Qwen Code /review (v0.21.13)
doudouOUC
left a comment
There was a problem hiding this comment.
PR head 已漂移:审查锁定的 head 为 7055fc38ddcf68b0225a43a04f37e5f2d007ea31,当前 head 为 bd1babe52661d474aba7fb19ef6372ff3b022d5a。原 inline 评论可能因行号失效,此处仅保留汇总。
结论
ISSUES_FOUND(一轮 deepseek-v4-flash 审查发现建议项)
要点
- R1-1 / R1-2:
speculation.ts:145与extractionAgentPlanner.ts:251的错误信息把「没有保存 params」和「params 属于另一 session」混为一谈,多 session daemon 调试时易误导。建议:调用getCacheSafeParamsSessionId()区分并在日志中带上 owning session ID。 - R1-3:
generatePipelinedSuggestion(speculation.ts:721)在 session 不匹配时静默返回null,缺少诊断信息。建议:增加debugLogger.debug日志。
验证结果
packages/core构建成功(exit 0)- 4 个相关测试文件共 81/81 通过
- 根本原因定位为客户端 scope gap,修复方案与 issue triage 建议一致
原始审查输出摘要
Review complete: pr-9471 — COMMENT posted (0 Critical, 0 Suggestion inline)
Posted: #9471 (review)
audio-capture 构建失败(缺少 Python/node-gyp)为预先存在的环境问题,与本次 PR 无关。
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
Reviewed — no blockers. Suggestions are inline.
Deferred under the convergence posture (round 2, not a blocker) — recorded, not requested in this round:
packages/core/src/utils/forkedAgent.ts:147 — [review] the session-ownership gate is opt-in: getCacheSafeParams() without an id returns whatever the process-global slot holds, so a future reader can silently reopen the #9470 leak
中文说明
已审查——无阻断问题。 建议见行内评论。
收敛姿态下延后(第 2 轮,非阻断)——已记录,本轮不要求修改:共 1 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
| const cachedSessionId = getCacheSafeParamsSessionId(); | ||
| if ( | ||
| cachedSessionId !== undefined && | ||
| cachedSessionId !== params.config.getSessionId() | ||
| ) { |
There was a problem hiding this comment.
[Suggestion] This O(1) session-mismatch gate runs only after ensureAutoMemoryScaffold (mkdir + three writeFileIfMissing round-trips), ensureUserAutoMemoryScaffold, readExtractCursor (readFile + JSON.parse), and the params.history.slice(startOffset).some(...) scan that allocates a copy of the entire unprocessed tail. Because the skip deliberately does not advance the cursor, every subsequent user turn of a mismatched session repeats all of that IO over a slice that grew since the last skip — cumulative wasted work is quadratic in the number of consecutive mismatched turns. This is not a rare path: in the multi-session daemons this PR targets, a probe racing saves into the pre-gate window resolved 15/15 iterations as session_mismatch, so the ordering is paid routinely under contention.
Hoist the gate to the top of runAutoMemoryExtract, immediately after the !params.config guard — it needs only the slot id and params.config.getSessionId(), so moving it is semantics-preserving (cachedSessionId === undefined still falls through to the existing empty-cache path) — and delete this block from its current position:
const cachedSessionId = getCacheSafeParamsSessionId();
if (
cachedSessionId !== undefined &&
cachedSessionId !== params.config.getSessionId()
) {
debugLogger.debug('Skipping auto-memory extract: session_mismatch.');
return {
touchedTopics: [],
skippedReason: 'session_mismatch',
cursor: {
sessionId: params.sessionId,
updatedAt: now.toISOString(),
},
};
}中文说明
这个 O(1) 的 session 不匹配检查在 ensureAutoMemoryScaffold(mkdir + 三次 writeFileIfMissing 往返)、ensureUserAutoMemoryScaffold、readExtractCursor(readFile + JSON.parse)以及 params.history.slice(startOffset).some(...) 扫描(会为整个未处理的尾部历史分配一份拷贝)之后才执行。由于 mismatch 跳过故意不推进游标,处于 mismatch 状态的 session 每个后续 user turn 都会在比上次更大的切片上重复上述全部 IO —— 累计浪费的工作量随连续 mismatch turn 数呈二次方增长。这并非罕见路径:在本 PR 面向的多 session daemon 场景中,一个向 gate 前窗口注入并发保存的探针实验 15/15 次迭代都得到 session_mismatch,说明该顺序开销在槽竞争下是常规代价。
建议把该检查上移到 runAutoMemoryExtract 开头、!params.config 守卫之后 —— 它只需要槽的 session id 和 params.config.getSessionId(),移动后语义不变(cachedSessionId === undefined 仍会落入既有的空缓存路径),并删除当前位置的这段代码块。
— qwen3.8-max via Qwen Code /review (v0.21.14)
There was a problem hiding this comment.
Addressed in 20bd886 with an early fast-path plus the existing pre-planner check. The early check avoids scaffold, cursor IO, and history scanning when the slot is already foreign. The second check is intentionally retained because those operations await: the slot can be replaced after the early check, and deleting the late check would turn that benign race back into a failed extraction. Tests cover both cases: an existing mismatch leaves an absent project root uncreated, while an owner sequence of session-1 then session-2 is caught before the planner without advancing the cursor. The focused suite passes 152/152.
|
@qwen-code /triage |
qwen-code-ci-bot
left a comment
There was a problem hiding this comment.
No blocking issues. LGTM! ✅
Deferred under the convergence posture (round 3, not a blocker) — recorded, not requested in this round:
packages/core/src/utils/forkedAgent.ts:147 — [review] the session-ownership gate is opt-in: getCacheSafeParams() without an id returns the raw process-global slot, so a future reader can silently reopen the #9470 leakpackages/core/src/followup/speculation.ts:144 — [review] speculation's mismatch failure is indistinguishable from an empty cache and is swallowed by its only caller — no telemetry, unlike the extract boundary
中文说明
无阻断问题。LGTM!✅
收敛姿态下延后(第 3 轮,非阻断)——已记录,本轮不要求修改:共 2 条(原文未翻译,列表见上方英文部分)。
— qwen3.8-max via Qwen Code /review (v0.21.14)
Maintainer verification — real-stack A/B on macOSI rebuilt this PR locally and ran it against a real CLI bundle and a real (local, recording) model endpoint rather than re-running the author's unit probe. Verdict: the fix does what it claims, with no regression on the same-session path, and every guard it adds is covered by a test. One reachability caveat and two small follow-ups are noted at the end. Environment
Sanity check on the two bundles: How the two sessions were produced (please read this part)The host matters here. Both are one-session-per-process hosts, so I used the headless one ( Proof the probe is inert: I re-ran the control scenario on the unpatched HEAD bundle and compared the extraction request that hit the wire. Byte-identical, Result — 2×2 matrix
The BASE leak is not inferred from a mock — it is the literal body of On HEAD the same run produces 1 model request instead of 2 (the main turn only), and two independent signals confirm the intended path was taken:
Do the tests actually gate the fix? — 8/8I reverted one guard at a time on the PR branch and re-ran the 153-test focused suite. Every mutation was caught, so no guard in this PR is untested: Static checks
Review notes1. Reachability today is narrower than #9470's "Impact" section implies. The slot is only written by 2. The race window still exists, but it fails closed noisily rather than benignly. (Code reading — not reproduced.) The late check in 3. An unowned slot now hard-fails where it previously worked. ReproducingHarness, machine-readable per-arm summaries, the captured wire requests and the mutation results are on 中文版维护者验证 —— macOS 上的真实链路 A/B我在本地重新构建了这个 PR,用真实的 CLI 打包产物 + 真实(本地、记账型)模型端点跑了一遍,而不是复跑作者的单元探针。结论:修复确实达成了它声称的效果,同一 session 的正常路径没有回归,而且它新增的每一处防护都有测试兜住。 末尾有一条可达性说明和两个小的后续建议。 环境
两个产物的对照检查: 两个 session 是怎么造出来的(这段请一定看)宿主进程在这里很关键。 两者都是「一个进程一个 session」的宿主,所以我用了 headless 那条( 探针无副作用的证明:我用未打补丁的 HEAD 产物重跑了对照场景,并比对打到线上的那条提取请求 —— 逐字节相同,两者 结果 —— 2×2 矩阵
BASE 的泄漏不是从 mock 推断出来的,而是 同样的场景下 HEAD 只产生 1 条模型请求(只有主回合),并且有两条独立信号佐证走的是预期路径:
测试真的兜住了修复吗 —— 8/8我在 PR 分支上逐个还原每一处防护,再复跑 153 个测试的聚焦套件。8 个变异全部被抓住,说明这个 PR 里没有任何一处防护是没测试覆盖的。 静态检查
评审意见1. 目前的可达性比 #9470「Impact」一节描述的要窄。 缓存槽只由 2. 竞态窗口仍然存在,只是失败得比较吵,而不是良性跳过。(源码阅读得出,未实测复现。) 3. 「无主」缓存槽现在会硬失败,而以前是可用的。 复现方式脚手架、每条腿的机器可读汇总、抓到的线上报文以及变异测试结果都在 |
|
Released in v0.22.2. |



What this PR does
This change makes cached fork snapshots session-owned at every background read boundary. A reader now receives a snapshot only when its session ID matches the session that last saved it; otherwise the optional background operation fails closed without creating a forked chat or invoking an agent. Same-session suggestion, speculative generation, pipelined speculation, and automatic memory extraction keep their existing behavior.
Why it's needed
An ACP channel can multiplex multiple sessions in one process, while the cached fork snapshot is held in one process-global slot. If session B saves after session A, an unscoped background reader for A can consume B's history. This can generate follow-up work from the wrong conversation and can send another session's transcript to a model. Fixes #9470.
Reviewer Test Plan
How to verify
Save a cached snapshot for session B, then invoke speculative generation or automatic memory extraction using a configuration for session A. Confirm that the lookup returns no snapshot and that neither a forked chat nor a forked agent is invoked. Repeat with session B as the reader and confirm that initial speculation, pipelined speculation, and memory extraction still receive B's history. The focused unit tests also assert that every production background reader supplies its active session ID.
Evidence (Before & After)
Before: a function-level dynamic probe saved B's history and then invoked readers configured for A. Initial speculation created a forked chat from B's history, pipelined speculation reused B's history, and automatic memory extraction passed B's history to its agent.
After: the same probe reports no snapshot for B-to-A reads and zero downstream forked-chat or agent calls. B-to-B reads continue successfully for initial speculation, pipelined speculation, and memory extraction. The post-fix probe passed 3/3 scenarios, and the focused unit suite passed 153/153 tests. Review follow-up coverage also confirms both speculation readers fail closed, records a foreign auto-memory cache owner as a benign skip without advancing the extraction cursor, and short-circuits an existing mismatch before scaffold and cursor I/O while retaining a second check for ownership changes during that asynchronous work.
Tested on
Environment (optional)
Node.js 22.22.3 on macOS. Verified with a function-level dynamic integration probe, focused unit tests, the core lint task, the repository build, and TypeScript type checking. No provider credentials or model network calls were used.
Risk & Scope
Linked Issues
Fixes #9470
中文说明
本 PR 的改动
本改动让所有后台读取边界都按 session 校验缓存的 fork 快照。只有读取方的 session ID 与最近一次保存快照的 session 一致时,读取方才能获得快照;否则可选的后台操作会安全终止,不会创建 fork chat,也不会调用 agent。同一 session 内的后续建议、推测生成、流水线推测和自动记忆提取保持原有行为。
为什么需要
一个 ACP channel 进程可以复用来承载多个 session,但缓存的 fork 快照只保存在一个进程级全局槽中。如果 session B 在 session A 之后保存,A 的未限定后台读取就可能消费 B 的历史。这会基于错误会话生成后续任务,也可能把另一 session 的对话记录发送给模型。修复 #9470。
Reviewer 测试计划
如何验证
先为 session B 保存缓存快照,再用 session A 的配置调用推测生成或自动记忆提取。确认读取不到快照,而且 fork chat 和 fork agent 都没有被调用。然后改用 session B 读取,确认初次推测、流水线推测和记忆提取仍能正常收到 B 的历史。聚焦单元测试还会断言每个生产后台读取方都传入其当前 session ID。
证据(修复前后)
修复前:函数级动态探针保存 B 的历史后,调用配置为 A 的读取方。初次推测使用 B 的历史创建了 fork chat,流水线推测复用了 B 的历史,自动记忆提取也把 B 的历史传给了 agent。
修复后:同一个探针显示 B 到 A 的读取无法获得快照,下游 fork chat 和 agent 调用数均为零。B 到 B 的读取在初次推测、流水线推测和记忆提取中继续成功。修复后探针的 3/3 个场景通过,聚焦单元测试 153/153 通过。审查跟进测试还确认两条推测读取路径都会安全失败,将外来 auto-memory 缓存所有者记录为良性跳过且不推进提取游标,并在脚手架和游标 IO 前快速跳过已存在的不匹配,同时保留第二次检查以捕获异步工作期间的所有权变化。
已测试平台
环境(可选)
macOS 上的 Node.js 22.22.3。已通过函数级动态集成探针、聚焦单元测试、core lint、仓库构建和 TypeScript 类型检查验证。未使用 provider 凭据或模型网络调用。
风险与范围
关联 Issue
修复 #9470