Skip to content

fix(core): target microcompaction cache disarms - #5407

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/microcompact-targeted-cache-disarm
Jun 19, 2026
Merged

fix(core): target microcompaction cache disarms#5407
wenshao merged 1 commit into
QwenLM:mainfrom
tt-a1i:fix/microcompact-targeted-cache-disarm

Conversation

@tt-a1i

@tt-a1i tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

What this PR does

This avoids reporting evicted read paths when a kept same-path tool result remains quotable. It adds a path-level FileReadCache fallback for stat failures or inode mismatches, while keeping the blanket clear fallback for idless or unlinkable blanked reads.

Why it's needed

Issue #4259 showed that microcompaction could disarm quoting for a file path even when the conversation still retained a same-path read result that should remain quotable. That makes later file references look unavailable even though useful read context is still present. The fix lets the cache fall back by path when identity checks are unavailable, without trusting unrelated blanked reads.

Reviewer Test Plan

How to verify

Check that microcompaction keeps a path quotable when a retained same-path read result exists, and still clears broadly for idless or unlinkable blanked reads. The tests cover the FileReadCache fallback and the microcompaction/client behavior that consumes it.

Evidence (Before & After)

Before: a blanked read could cause microcompaction to report a path as evicted even when a kept same-path read remained in context. After: same-path retained reads keep that path quotable, and the broad clear fallback remains for ambiguous blanked reads.

Tested on

OS Status
macOS tested
Windows CI
Linux CI

Environment (optional)

Node 22 via npx -p node@22.

Commands run locally:

  • npx -p node@22 node node_modules/vitest/vitest.mjs run --coverage.enabled=false packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.test.ts
  • npx -p node@22 node node_modules/typescript/bin/tsc --noEmit --project packages/core/tsconfig.json
  • npx -p node@22 node node_modules/eslint/bin/eslint.js packages/core/src/services/microcompaction/microcompact.ts packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.ts packages/core/src/core/client.test.ts
  • npx -p node@22 node node_modules/prettier/bin/prettier.cjs --check packages/core/src/services/microcompaction/microcompact.ts packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.ts packages/core/src/core/client.test.ts
  • git diff --check

Risk & Scope

  • Main risk or tradeoff: path fallback is intentionally limited to retained same-path reads, so it improves stat-failure/inode-mismatch handling without making unrelated paths quotable.
  • Not validated / out of scope: no full interactive microcompaction session was recorded.
  • Breaking changes / migration notes: none.

Linked Issues

Fixes #4259

中文说明

What this PR does

这个 PR 避免在仍然保留同路径可引用 tool result 时,把对应 read path 报告成已驱逐。它为 stat 失败或 inode 不匹配增加了 path-level FileReadCache fallback,同时保留 idless 或无法关联路径的 blanked reads 的 blanket clear fallback。

Why it's needed

#4259 暴露出 microcompaction 可能会让一个文件路径失去引用能力,即使对话里还保留着同路径、仍应可引用的 read result。这样后续文件引用会看起来不可用,但实际上还有有用的 read context。这个修复在 identity check 不可用时按 path fallback,同时不会信任无关的 blanked reads。

Reviewer Test Plan

How to verify

确认当保留了同路径 read result 时,microcompaction 会让该 path 继续可引用;同时对于 idless 或无法关联路径的 blanked reads,仍然会走 broad clear。测试覆盖了 FileReadCache fallback,以及消费它的 microcompaction/client 行为。

Evidence (Before & After)

Before:blanked read 可能让 microcompaction 把某个 path 报告成已驱逐,即使 context 里还保留着同路径 read。After:同路径 retained reads 会让该 path 保持可引用,ambiguous blanked reads 仍然保留 broad clear fallback。

Tested on

OS Status
macOS tested
Windows CI
Linux CI

Environment (optional)

通过 npx -p node@22 使用 Node 22。

本地运行过:

  • npx -p node@22 node node_modules/vitest/vitest.mjs run --coverage.enabled=false packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.test.ts
  • npx -p node@22 node node_modules/typescript/bin/tsc --noEmit --project packages/core/tsconfig.json
  • npx -p node@22 node node_modules/eslint/bin/eslint.js packages/core/src/services/microcompaction/microcompact.ts packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.ts packages/core/src/core/client.test.ts
  • npx -p node@22 node node_modules/prettier/bin/prettier.cjs --check packages/core/src/services/microcompaction/microcompact.ts packages/core/src/services/microcompaction/microcompact.test.ts packages/core/src/services/fileReadCache.ts packages/core/src/services/fileReadCache.test.ts packages/core/src/core/client.ts packages/core/src/core/client.test.ts
  • git diff --check

Risk & Scope

  • Main risk or tradeoff:path fallback 被限制在 retained same-path reads 上,所以它改善 stat-failure/inode-mismatch 场景,但不会让无关路径变成可引用。
  • Not validated / out of scope:没有录制完整 interactive microcompaction session。
  • Breaking changes / migration notes:无。

Linked Issues

Fixes #4259

AI Assistance Disclosure

I used Codex to review the changes, sanity-check the implementation against existing patterns, and help spot potential edge cases.

entry.realPath === absPath ||
resolvePath(entry.realPath) === target
) {
this.byInode.delete(key);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] invalidateByPath deletes the entire cache entry (mtimeMs, sizeBytes, lastReadAt, lastReadCacheable, readResidentInHistory) while markReadEvictedFromHistory only sets entry.readResidentInHistory = false, deliberately preserving the fingerprint. This creates two issues:

  1. If a file was replaced then re-read, both the stale entry (old inode) and a fresh entry (new inode) share the same realPath. invalidateByPath deletes both — the fresh entry's prior-read enforcement data is lost.
  2. priorReadEnforcement.ts treats state: 'unknown' and state: 'stale' differently; full deletion causes 'unknown' where 'stale' would be more appropriate.

Consider setting readResidentInHistory = false instead of deleting, to match markReadEvictedFromHistory semantics:

invalidateByPath(absPath: string): boolean {
  const target = resolvePath(absPath);
  let disarmed = false;
  for (const entry of this.byInode.values()) {
    if (resolvePath(entry.realPath) === target) {
      entry.readResidentInHistory = false;
      disarmed = true;
    }
  }
  return disarmed;
}

— qwen3.7-max via Qwen Code /review

@@ -294,9 +295,10 @@ export class FileReadCache {
* Returns `true` if a matching entry was found and disarmed; `false`
* if there is no entry for `stats` (never tracked, or `stats`
* resolved to a different inode than recorded — file replaced /

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] The realPath field's JSDoc (lines 45–49) still states "Diagnostic only — it is *not* used for lookup", but invalidateByPath now uses it as a lookup key (entry.realPath === absPath || resolvePath(entry.realPath) === target). The field's contract has silently changed from "informational" to "load-bearing". Future refactors that treat realPath as diagnostic-only (e.g., dropping it or changing how it's populated) will break invalidateByPath with no compile-time signal.

Update the realPath doc to reflect the new usage:

/**
 * Last absolute path we observed pointing at this inode. Used by
 * {@link invalidateByPath} as a best-effort lookup fallback when
 * Stats are unavailable. Multiple paths can resolve to the same
 * inode (symlinks, case variants), so this match is not authoritative.
 */
realPath: string;

— qwen3.7-max via Qwen Code /review

}
const evictedPath = meta.evictedReadPaths[i];
if (evictedPath) {
fileReadCache.invalidateByPath(evictedPath);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] invalidateByPath(evictedPath) return value is silently discarded. When it returns false (no cache entry found for this path), there is no logging or fallback. The old code called clear() which was at least visible in debug logs. If a stale armed entry survives because path matching failed (e.g., symlink variant, CWD change between read and eviction), the failure is completely invisible.

Consider logging when invalidation misses:

if (evictedPath) {
  const invalidated = fileReadCache.invalidateByPath(evictedPath);
  if (!invalidated) {
    debugLogger.debug(
      `[FILE_READ_CACHE] invalidateByPath miss for ${evictedPath} after ${logTag}`,
    );
  }
  usedPathFallback = true;
}

— qwen3.7-max via Qwen Code /review

debugLogger.debug(
`[FILE_READ_CACHE] disarmed fast-path for ` +
`[FILE_READ_CACHE] disarmed fast-path by path for ` +
`${meta.evictedReadPaths.length} file(s) after ${logTag}`,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] Both branches log nearly identical messages — the only difference is "by path" in one. Both report meta.evictedReadPaths.length regardless of how many actually used the path fallback vs. succeeded via inode stats. An engineer debugging cache behavior from logs cannot easily distinguish the two paths or tell how many disarms went through each sub-path.

Consider differentiating more clearly or merging into a single log:

debugLogger.debug(
  `[FILE_READ_CACHE] disarmed fast-path for ` +
    `${meta.evictedReadPaths.length} file(s) after ${logTag}` +
    (usedPathFallback ? ' (path fallback used)' : ''),
);

— qwen3.7-max via Qwen Code /review

);
if (!sizePlan) {
return { history };
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] keptPathRefs is initialized to [] and only populated in each branch (tool for force/idle, sizePlan.toolRefs for size). The kept-path filter in the clearing loop suppresses eviction for all FILE_PATH_TOOLS (read_file, edit, write_file) when a kept same-path result exists. This is safe for read_file, but for EDIT/WRITE_FILE evictions, a kept READ result for the same path would suppress the write's cache disarm — the fast-path could retain post-read readResidentInHistory = true even though the file was modified.

Currently unreachable because keepRecent always keeps the most recent (post-write) result, but the invariant is fragile — any future change to the clearing strategy (priority-based, size-weighted) could expose it. Consider restricting the kept-path suppression to READ_FILE evictions only:

if (
  part.functionResponse.name !== ToolNames.READ_FILE ||
  !keptFilePaths.has(p)
) {
  evictedReadPaths.add(p);
}

— qwen3.7-max via Qwen Code /review

@tt-a1i
tt-a1i marked this pull request as ready for review June 19, 2026 14:23
@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hey @tt-a1i — thanks for the fix! The code changes look focused, but the PR body doesn't follow our PR template.

The template requires these sections:

  • What this PR does — prose description of the change
  • Why it's needed — motivation / problem being solved
  • Reviewer Test Plan — how a reviewer can verify this (with Before/After evidence)
  • Risk & Scope — main risk, what's out of scope, breaking changes
  • Linked Issues — closing keyword or reference

Right now the body has ## Summary, ## Tests, and ## AI Assistance Disclosure which don't map to the template. The Reviewer Test Plan section is especially important — maintainers use it to verify the fix without having to reverse-engineer the reproduction. Without it, review gets delayed.

Could you update the PR description to match the template? Once that's done I'll continue the review.

中文说明

@tt-a1i 感谢修复!代码改动很集中,但 PR 正文没有使用我们的 PR 模板

模板要求以下章节:

  • What this PR does — 用文字描述改动内容
  • Why it's needed — 动机 / 要解决的问题
  • Reviewer Test Plan — 审查者如何验证(含 Before/After 证据)
  • Risk & Scope — 主要风险、不在范围内的内容、破坏性变更
  • Linked Issues — 关闭关键词或引用

当前正文用的是 ## Summary## Tests## AI Assistance Disclosure,跟模板对不上。Reviewer Test Plan 尤其重要——维护者靠它来验证修复,缺了会导致 review 延迟。

请更新 PR 描述以匹配模板,更新后我会继续审查。

Qwen Code · qwen3.7-max

@tt-a1i

tt-a1i commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

updated the PR description to match the template. code is unchanged.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-reviewed at HEAD a7d33b3ano new issues. I re-verified the core correctness of the targeted cache disarm: an unrecoverable blanked read (unresolvedEvictedReads > 0) still falls back to a blanket clear() (client.ts:2714 — the safety net is intact), path-resolution failures are narrowed to invalidateByPath instead of wiping everything, and buildKeptFilePaths only protects a path it can prove is single-resident (conservative — errs toward disarming, so no stale-cache risk). core typecheck clean; changed-area tests pass (305); CI green (58 checks).

The 5 suggestions from my earlier review remain open (all non-blocking, already inline):

  • fileReadCache.ts:333invalidateByPath deletes the whole entry rather than just disarming (inconsistent with markReadEvictedFromHistory).
  • fileReadCache.ts:297realPath JSDoc still says "not used for lookup", but invalidateByPath now matches on it.
  • client.ts:2738invalidateByPath's return value is discarded (a no-op miss is indistinguishable from a real disarm).
  • client.ts:2745 — the two debug branches log near-identical messages.
  • microcompact.ts:523keptPathRefs/keptPathHistory init + per-branch population could be tightened.

No blockers from the code review. (The bot's CHANGES_REQUESTED is about the PR-template sections, not the code.)

— claude-opus-4-8 via Claude Code /qreview

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @tt-a1i!

Template looks good ✓ — all required sections present, bilingual, linked issue.

On direction: this is squarely in scope — a real bug (#4259) where microcompaction incorrectly disarms paths that still have quotable read results in context. Core engine caching behavior, exactly the kind of fix that matters for long-session reliability. No Claude Code CHANGELOG reference for this specific area, but microcompaction and context management are core mission.

On approach: the scope feels right — three focused source changes (invalidateByPath on the cache, path-level fallback in disarmFileReadCacheAfterEviction, kept-file-path filtering in microcompact.ts) plus matching tests. No scope creep, no drive-by refactors. The three-pronged approach (prevent false evictions at source + targeted cache fallback + path-level invalidation) is the minimal change set needed for the stated goal.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必要章节齐全,双语,关联了 issue。

方向:完全在范围内 — #4259 是一个真实的 bug,microcompaction 会错误地把仍然有可引用 read result 的路径解除武装。这是核心引擎缓存行为,对长会话可靠性很重要。Claude Code CHANGELOG 中没有直接相关的参考,但 microcompaction 和上下文管理是核心任务。

方案:范围合理 — 三处聚焦的源码改动(cache 上的 invalidateByPathdisarmFileReadCacheAfterEviction 中的 path-level fallback、microcompact.ts 中的 kept-file-path 过滤)加上配套测试。没有范围蔓延,没有顺手重构。三管齐下(源头防止误驱逐 + 针对性 cache fallback + path-level invalidation)是达成目标所需的最小改动集。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): to fix #4259, I'd (1) filter out paths from evictedReadPaths when a kept same-path result still exists in context, and (2) replace the blanket clear() fallback with a targeted path-based invalidation for stat failures / inode mismatches. Both changes are needed — one prevents false positives, the other prevents collateral cache wipes.

The PR matches this proposal closely. The implementation is clean and the three source changes are well-separated:

  • invalidateByPath on FileReadCache — straightforward O(n) scan with resolvePath() normalization. Correctly handles both absolute and relative paths.
  • disarmFileReadCacheAfterEviction — properly narrows the fallback from blanket clear() to per-path invalidation. The unresolvedEvictedReads > 0 early-return safety net is preserved.
  • buildKeptFilePaths — correctly refuses to protect paths when an id maps to multiple files (preserving Assistant is forced to re-read files it already read, after the session has been idle #4239 safety).

No critical blockers found. Two minor observations:

  1. JSDoc stalenessFileReadEntry.realPath (line 46) still says "Diagnostic only — it is not used for lookup", but invalidateByPath now uses it as a lookup key. Worth updating the doc to reflect reality.
  2. Log message near-duplication — the two branches in disarmFileReadCacheAfterEviction now log nearly identical messages (one says "by path", the other doesn't). Could be collapsed, but not a blocker.

These were also raised by @wenshao's earlier inline suggestions and remain unaddressed at commit a7d33b3a.

Tests

All 305 unit tests pass across the three affected test files:

  • fileReadCache.test.ts — 53 tests ✅ (new invalidateByPath coverage: basic, relative path resolution, missing path)
  • microcompact.test.ts — 47 tests ✅ (new coverage: same-path kept result, pending kept result, ambiguous reused id)
  • client.test.ts — 205 tests ✅ (updated tests now assert invalidateByPath instead of clear() for stat failure, inode mismatch, and mixed batch)

Typecheck (tsc --noEmit): clean ✅
Lint (eslint): clean ✅

Real-Scenario Testing

npm run dev is broken on both main and the PR branch due to a pre-existing ink/dom subpath export issue (ERR_PACKAGE_PATH_NOT_EXPORTED), unrelated to this PR. Verified the failure reproduces identically on main.

Installed build (v0.18.3) — smoke test

$ qwen -p 'Read /tmp/triage-test-file.ts and reply in one sentence.' --output-format=text
The file contains a single line: `test file content for triage 5407`.

Dev build (this PR) — blocked

$ npm run dev -- -p 'Read /tmp/triage-test-file.ts ...' --output-format=text
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: Package subpath './dom' is not defined
by "exports" in .../node_modules/ink/package.json

This change is internal (caching behavior during microcompaction) — no user-visible CLI output difference. The behavioral improvement is fully validated by the unit test suite: before this PR, clear() was called on stat failure / inode mismatch / mixed batch; after, only invalidateByPath() is called, preserving unrelated cache entries.

中文说明

代码审查

独立方案(在阅读 diff 之前):要修复 #4259,我会 (1) 在 context 中仍有同路径 kept result 时,从 evictedReadPaths 中过滤掉该路径;(2) 用针对性的 path-based invalidation 替代 blanket clear() fallback。两个改动都需要 — 一个防止误报,一个防止 cache 连带清除。

PR 与这个方案高度吻合。 实现干净,三处源码改动分离得当:

  • FileReadCache 上的 invalidateByPath — 简单的 O(n) 扫描加 resolvePath() 归一化。正确处理绝对和相对路径。
  • disarmFileReadCacheAfterEviction — 正确将 fallback 从 blanket clear() 缩小到 per-path invalidation。unresolvedEvictedReads > 0 的 early-return 安全网保留完好。
  • buildKeptFilePaths — 当一个 id 映射到多个文件时,正确拒绝保护路径(保留 Assistant is forced to re-read files it already read, after the session has been idle #4239 安全性)。

无关键阻塞问题。 两个小观察:

  1. JSDoc 过时FileReadEntry.realPath(第 46 行)仍说 "Diagnostic only — it is not used for lookup",但 invalidateByPath 现在用它做查找键。值得更新文档以反映实际用法。
  2. 日志信息近乎重复disarmFileReadCacheAfterEviction 的两个分支现在输出几乎一样的日志(一个说 "by path",另一个没说)。可以合并,但不是阻塞项。

这些也是 @wenshao 之前 inline suggestions 提出的,在 commit a7d33b3a 时尚未处理。

测试

305 个单元测试全部通过:

  • fileReadCache.test.ts — 53 tests ✅
  • microcompact.test.ts — 47 tests ✅
  • client.test.ts — 205 tests ✅

Typecheck、Lint 均通过 ✅

真实场景测试

npm run devmain 和 PR 分支上均因预存的 ink/dom subpath export 问题报错(ERR_PACKAGE_PATH_NOT_EXPORTED),与本 PR 无关。

已用安装版 qwen v0.18.3 做了基础 smoke test,文件读取功能正常。本 PR 的改动是内部缓存行为,无 CLI 可见输出差异,行为改进由单元测试充分验证。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Final Reflection

Stepping back: this PR solves a genuine problem cleanly. The motivation is clear (microcompaction shouldn't disarm paths that are still quotable), the implementation is minimal and well-separated, and the test coverage is thorough — 305 tests including edge cases for reused ids, pending content, and ghost files.

Going back to my independent proposal: the PR matches it closely, and in fact the buildKeptFilePaths guard against ambiguous reused ids is a subtlety I might have missed. The decision to not let a kept result protect paths when its id maps to multiple files is the right call — it preserves the #4239 safety invariant.

The two minor items (stale realPath JSDoc, near-duplicate log messages) are polish, not correctness issues. The invalidateByPath return value being discarded in client.ts is a deliberate design choice — the caller doesn't need to know whether the path-level invalidation found an entry, since the old blanket-clear fallback is gone and the path is now always targeted.

The npm run dev build is broken on main due to an unrelated ink/dom export issue — this is an environment problem, not a PR problem. The behavioral change is internal and fully validated by unit tests.

Verdict: approve. The code is correct, focused, well-tested, and solves the stated problem. The two minor suggestions from the prior review are worth addressing but don't block the merge.

中文说明

最终评审

回顾全局:这个 PR 干净地解决了一个真实问题。动机清晰(microcompaction 不应解除仍然可引用的路径的武装),实现最小且分离得当,测试覆盖充分 — 305 个测试涵盖了复用 id、pending content、ghost file 等边界情况。

回到我的独立方案:PR 与之高度吻合,而且 buildKeptFilePaths 对模糊复用 id 的防护是我可能忽略的细节。当 id 映射到多个文件时,不让 kept result 保护任何路径的决定是正确的 — 它保留了 #4239 安全不变量。

两个小问题(过时的 realPath JSDoc、近乎重复的日志)是打磨级别,不是正确性问题。client.ts 中丢弃 invalidateByPath 返回值是有意的设计选择 — 调用方不需要知道 path-level invalidation 是否找到了条目,因为旧的 blanket-clear fallback 已经移除,路径处理现在总是针对性的。

npm run devmain 上因无关的 ink/dom export 问题而无法构建 — 这是环境问题,不是 PR 问题。行为改动是内部的,由单元测试充分验证。

结论:approve。 代码正确、聚焦、测试充分,解决了所述问题。前次 review 中的两个小建议值得处理,但不阻塞合并。

Qwen Code · qwen3.7-max

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, looks ready to ship. ✅ Code is correct, focused, and well-tested. Minor polish items (stale JSDoc, log dedup) from prior review are non-blocking.

@wenshao

wenshao commented Jun 19, 2026

Copy link
Copy Markdown
Collaborator

Maintainer verification — local merge build + before/after (both halves)

Verified the 3-way merge result (origin/main 30bfe63528 + this PR a7d33b3a3a) on macOS (Darwin arm64, Node v22.22.2). The merge auto-resolved cleanly and the diff is exactly the PR's +237/-47 across the 6 files. This fix has two coordinated halves, so I drove a separate before/after for each.

1. What the fix does (#4259)

Microcompaction "disarms" FileReadCache entries for the file-read results it blanks. Two over-aggressive behaviors are fixed:

  • (microcompaction) A path was reported as evicted even when a kept same-path read result was still retained and quotable in history → later references looked unavailable despite valid context. The PR adds buildKeptFilePaths / getFilePathsForResponse: a path is not reported as evicted if a kept, non-error, not-already-cleared result for the same single path remains (incl. a pendingContent tail). Reused-id ambiguity (one id → multiple candidate paths) is deliberately not protected, preserving the Assistant is forced to re-read files it already read, after the session has been idle #4239-safe behavior.
  • (client cache fallback) When an evicted path couldn't be stat'd (ghost file) or stat'd to a different inode, the client fell back to a blanket clear(), wiping the entire cache including unrelated entries. The PR adds FileReadCache.invalidateByPath() and targets just that one path; the blanket clear() now only applies to genuinely unlinkable (id-less / unresolved) reads.

2. Build / tests / typecheck / lint (merge result)

Check Result
microcompact.test.ts / fileReadCache.test.ts / client.test.ts 47 / 53 / 205 = 305 passed
tsc --noEmit core ✅ exit 0, 0 errors
eslint (all 6 files) ✅ clean (exit 0)
prettier --check (all 6 files) ✅ clean
CI green on macOS / Windows / Linux, Lint + CodeQL pass; ci-bot approved

3. Before/after A — microcompaction keeps same-path reads quotable

Running the PR's new tests against the old microcompact.ts (keeping the new tests):

× does not report a path when a kept read_file result for the same file remains   → FAIL (old reports the path; expected [])
× does not report a path when a pending kept result for the same file remains       → FAIL (old reports the path; expected [])
✓ does not let a kept reused id protect ambiguous candidate paths                    → PASS on old code too (correctly NOT protected)
✓ disarms ALL paths sharing a reused functionCall.id / unresolved fallback / …       → existing #4239 tests still pass

So the two new protections genuinely change behavior, while the ambiguity safety and all existing #4239 tests are preserved. With the PR source → all pass.

4. Before/after B — client targets the path instead of wiping the cache

Running the PR's tests against the old client.ts (keeping the PR's fileReadCache.ts + tests):

× invalidates only the path when an evicted path cannot be stat’d
× keeps a mixed batch targeted when one path is on disk and one is a ghost
× invalidates only the path when an evicted path stats to a different inode
× uses targeted path fallback when fast compression sees an inode miss

All 4 fail on old code because it calls clear() where the PR expects invalidateByPath(<path>) with clear not called. The "blanket clear for id-less / unresolvedEvictedReads > 0" tests remain and pass → the safe wipe is kept exactly for the truly-unresolvable case, only removed for path-level resolution failures. With the PR source → all pass.

5. Consistency / safety audit

  • invalidateByPath matches entry.realPath === absPath || resolvePath(entry.realPath) === target (exact + normalized), returns true on any removal — covered by 3 new fileReadCache tests (resident, relative-path, no-match→false).
  • byInode is FileReadCache's only index, so invalidateByPath leaves nothing stale; its delete-during-iteration loop is the same established pattern already used elsewhere in the file (idiomatic, safe in JS).
  • The client's per-path loop removes the blanket clear() from this method only; the unresolved-reads clear() lives in a separate gated path and is untouched.
  • buildKeptFilePaths protects a path only for a single-path, kept, non-error, not-already-cleared result → it can never make a non-quotable path look quotable.

Note on merge state

BLOCKED is the needs-human-maintainer gate (REVIEW_REQUIRED): ci-bot first filed a stale CHANGES_REQUESTED (template) at 17:11, then APPROVED at 18:20 after the body was fixed, but the bot's approval doesn't satisfy branch protection. A maintainer review unblocks it.

Verdict

Correct, well-scoped, and thoroughly tested two-part fix: it stops microcompaction from evicting paths that are still quotable, and makes the cache fallback surgical (one ghost file no longer wipes the whole cache) — all while preserving the #4239-safe behavior for ambiguous ids and the blanket wipe for truly-unresolvable reads. Merge is clean, typecheck/lint clean, 305/305 local, CI green on all three platforms. ✅ Safe to merge.

Verified by maintainer @wenshao: 3-way merge build (origin/main 30bfe63528 + PR a7d33b3a3a) + microcompact/fileReadCache/client suites (305/305) + two before/afters (old microcompact.ts → 2 kept-path protections fail, ambiguity test passes both ways; old client.ts → 4 targeted-fallback tests fail on blanket clear) + core tsc exit 0 + eslint/prettier clean. Env: Darwin arm64, Node v22.22.2.

中文版(点击展开)

维护者验证 —— 本地合并构建 + 前后对比(两个半部分)

在 macOS(Darwin arm64,Node v22.22.2)上验证了三方合并结果origin/main 30bfe63528 + 本 PR a7d33b3a3a)。合并自动干净解决,diff 正好是 PR 的 +237/-47(6 个文件)。本修复有两个协同的半部分,所以我对每一半各做了一次前后对比。

1. 修复做了什么(#4259

microcompaction 会为它清空(blank)的文件读取结果"解除"(disarm)FileReadCache 条目。本 PR 修了两个过激行为:

  • (microcompaction 侧) 即使历史里还保留着同路径、仍可引用的 read 结果,某个 path 也会被报告成已驱逐 → 后续引用看起来不可用,但其实 context 还在。PR 新增 buildKeptFilePaths / getFilePathsForResponse:如果同一单一路径还有一个被保留的、非 error、未被清空的结果(含 pendingContent 尾部),则该 path 报告为已驱逐。复用 id 的歧义情况(一个 id → 多个候选路径)故意不保护,从而保留 Assistant is forced to re-read files it already read, after the session has been idle #4239 的安全行为。
  • (client 缓存回退侧) 当某个被驱逐的 path 无法 stat(ghost 文件)或 stat 到不同 inode 时,client 会回退到全量 clear(),把整个缓存(含无关条目)一起清掉。PR 新增 FileReadCache.invalidateByPath(),只针对那一个 path;全量 clear() 现在只用于真正无法关联(idless / unresolved)的读取。

2. 构建 / 测试 / 类型检查 / lint(合并结果)

检查 结果
microcompact.test.ts / fileReadCache.test.ts / client.test.ts 47 / 53 / 205 = 305 通过
tsc --noEmit core ✅ exit 0,0 错误
eslint(全部 6 个文件) ✅ 干净(exit 0)
prettier --check(全部 6 个文件) ✅ 干净
CI macOS / Windows / Linux 全绿,Lint + CodeQL 通过;ci-bot 已 approve

3. 前后对比 A —— microcompaction 让同路径读取保持可引用

用 PR 的新测试去打旧版 microcompact.ts(保留新测试):

× does not report a path when a kept read_file result for the same file remains   → 失败(旧代码报告了该 path;期望 [])
× does not report a path when a pending kept result for the same file remains       → 失败(旧代码报告了该 path;期望 [])
✓ does not let a kept reused id protect ambiguous candidate paths                    → 旧代码上也通过(正确地"不"保护)
✓ disarms ALL paths sharing a reused functionCall.id / unresolved fallback / …       → 既有 #4239 测试仍通过

即:两个新保护确实改变了行为,而歧义安全性和全部既有 #4239 测试都被保留。换上 PR 的源后 → 全部通过。

4. 前后对比 B —— client 只针对该 path,而非清空整个缓存

用 PR 的测试去打旧版 client.ts(保留 PR 的 fileReadCache.ts 和测试):

× invalidates only the path when an evicted path cannot be stat’d
× keeps a mixed batch targeted when one path is on disk and one is a ghost
× invalidates only the path when an evicted path stats to a different inode
× uses targeted path fallback when fast compression sees an inode miss

这 4 个在旧代码上都失败,因为旧代码在 PR 期望 invalidateByPath(<path>)(且 clear 被调用)的地方调用了 clear()。而"idless / unresolvedEvictedReads > 0 时全量 clear"的测试仍然保留并通过 → 安全清空恰好为真正无法解析的情况保留,只在 path 级解析失败时被去掉。换上 PR 的源后 → 全部通过。

5. 一致性 / 安全审计

  • invalidateByPath 匹配 entry.realPath === absPath || resolvePath(entry.realPath) === target(精确 + 归一化),任意删除即返回 true——由 3 个新增 fileReadCache 测试覆盖(resident、相对路径、未命中→false)。
  • byInode 是 FileReadCache 的唯一索引,因此 invalidateByPath 不会留下陈旧索引;它的"边遍历边删除"循环与文件里别处已有的相同模式一致(地道、在 JS 里安全)。
  • client 的逐 path 循环只在这个方法里去掉了全量 clear();unresolved-reads 的 clear() 在另一条独立 gated 路径上,未被触动。
  • buildKeptFilePaths 仅对单路径、被保留、非 error、未被清空的结果做保护 → 它绝不会把一个不可引用的 path 变成看似可引用。

关于合并状态

BLOCKED需要真人维护者的门(REVIEW_REQUIRED):ci-bot 先在 17:11 留了一条过时的 CHANGES_REQUESTED(模板问题),在正文修正后于 18:20 APPROVED,但 bot 的 approve 不满足分支保护。真人维护者 review 一下即可解锁。

结论

正确、范围克制、测试充分的两段式修复:它阻止 microcompaction 驱逐仍可引用的 path,并让缓存回退变成外科手术式(一个 ghost 文件不再清空整个缓存)——同时保留了对歧义 id 的 #4239 安全行为,以及对真正无法解析读取的全量清空。合并干净,类型检查/lint 干净,本地 305/305,CI 三平台全绿。✅ 可以合并。

维护者 @wenshao 验证:三方合并构建(origin/main 30bfe63528 + PR a7d33b3a3a)+ microcompact/fileReadCache/client 套件(305/305)+ 两次前后对比(旧 microcompact.ts → 2 个同路径保护失败,歧义测试前后都通过;旧 client.ts → 4 个针对性回退测试在全量 clear 下失败)+ core tsc exit 0 + eslint/prettier 干净。环境:Darwin arm64,Node v22.22.2。

@wenshao
wenshao merged commit c8e8c5b into QwenLM:main Jun 19, 2026
73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Follow-up: tighten microcompaction fast-path eviction (token efficiency)

3 participants