Skip to content

feat(core): let grep results satisfy prior-read checks#5043

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
he-yufeng:fix/grep-satisfies-prior-read
Jun 12, 2026
Merged

feat(core): let grep results satisfy prior-read checks#5043
wenshao merged 1 commit into
QwenLM:mainfrom
he-yufeng:fix/grep-satisfies-prior-read

Conversation

@he-yufeng

Copy link
Copy Markdown
Contributor

What this PR does

This PR lets the structured grep tools stamp the session FileReadCache for files whose matching lines are actually returned to the model. Both the JS fallback GrepTool and the ripgrep-backed implementation now record visible result file paths as partial, cacheable text reads, so a follow-up Edit or WriteFile can use the existing prior-read enforcement path instead of requiring a separate read_file call.

Why it's needed

Issue #4939 points out that grep-to-edit is a common workflow: the model has already seen the matching line, but Edit currently rejects the file because only read_file populates FileReadCache. Reusing recordRead keeps the existing mtime/size drift check and non-text rejection behavior intact, while avoiding an unnecessary extra tool call for files that grep already exposed.

Reviewer Test Plan

How to verify

Run the focused tool tests and confirm grep/ripgrep results still return the same resultFilePaths while the matching files are marked fresh in FileReadCache. The change intentionally records grep output as full: false, cacheable: true, so it satisfies prior-read enforcement without enabling the read_file full-read fast path.

Commands I ran locally:

npm test --workspace=@qwen-code/qwen-code-core -- src/tools/grep.test.ts src/tools/ripGrep.test.ts
npm run lint --workspace=@qwen-code/qwen-code-core
npm run typecheck --workspace=@qwen-code/qwen-code-core
npx prettier --check packages/core/src/tools/grepReadTracking.ts packages/core/src/tools/grep.ts packages/core/src/tools/ripGrep.ts packages/core/src/tools/grep.test.ts packages/core/src/tools/ripGrep.test.ts
git diff --check

Evidence (Before & After)

N/A. This is core tool state tracking rather than a UI change.

Tested on

OS Status
macOS not tested
Windows tested
Linux not tested

Environment (optional)

Windows 11, Node/npm from the local Qwen Code workspace. npm ci completed first; it reported existing npm audit/deprecation warnings but no install failure.

Risk & Scope

  • Main risk or tradeoff: a grep hit only proves the model saw matching lines, not the full file. This matches the existing prior-read contract because checkPriorRead does not require full reads; the mtime/size drift check remains the safety gate.
  • Not validated / out of scope: this does not parse arbitrary shell grep/egrep/fgrep commands. The triage note on Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 recommended splitting that harder path into a separate design/PR.
  • Breaking changes / migration notes: none.

Linked Issues

Part of #4939.

中文说明

What this PR does

这个 PR 让结构化 grep 工具在把匹配行返回给模型时,同步把这些文件记录到当前会话的 FileReadCache。JS fallback 的 GrepTool 和 ripgrep 版本都会把可见结果文件记录为 partial、cacheable 的文本 read,因此后续 Edit / WriteFile 可以复用现有 prior-read enforcement,不再强制再调用一次 read_file。

Why it's needed

#4939 提到 grep 到 edit 是非常常见的路径:模型已经看到了匹配行,但当前只有 read_file 会写入 FileReadCache,所以 Edit 仍会拒绝。这里复用 recordRead,保留现有的 mtime/size 漂移检查和非文本文件保护,同时减少一次没有必要的工具调用。

Reviewer Test Plan

How to verify

运行 focused tool tests,确认 grep/ripgrep 仍返回相同的 resultFilePaths,并且匹配文件在 FileReadCache 里变成 fresh。这个改动刻意用 full: false, cacheable: true 记录 grep 输出,所以它只满足 prior-read enforcement,不会误触发 read_file 的 full-read fast path。

本地已运行:

npm test --workspace=@qwen-code/qwen-code-core -- src/tools/grep.test.ts src/tools/ripGrep.test.ts
npm run lint --workspace=@qwen-code/qwen-code-core
npm run typecheck --workspace=@qwen-code/qwen-code-core
npx prettier --check packages/core/src/tools/grepReadTracking.ts packages/core/src/tools/grep.ts packages/core/src/tools/ripGrep.ts packages/core/src/tools/grep.test.ts packages/core/src/tools/ripGrep.test.ts
git diff --check

Evidence (Before & After)

N/A。这里改的是 core tool 状态记录,不是 UI 行为。

Tested on

OS Status
macOS not tested
Windows tested
Linux not tested

Environment (optional)

Windows 11,本地 Qwen Code workspace 的 Node/npm。先运行了 npm ci,安装成功;只出现已有 npm audit / deprecated package 警告。

Risk & Scope

  • 主要风险 / tradeoff:grep 命中只能证明模型看到了匹配行,不代表完整文件。但这与现有 prior-read contract 一致,因为 checkPriorRead 本来不要求 full read;mtime/size 漂移检查仍然是安全边界。
  • 未验证 / 不在范围内:本 PR 不解析任意 shell grep/egrep/fgrep 命令。Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 的 triage 也建议把这个更复杂的路径拆成单独设计 / PR。
  • Breaking changes / migration notes:无。

Linked Issues

#4939 的一部分。

try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
return;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Critical] cacheable: true unconditionally overrides a prior cacheable: false for non-text files (e.g., .ipynb) via the sticky-on-true logic in FileReadCache.recordRead.

Exploit chain:

  1. read_file(notebook.ipynb) → sets cacheable: false (notebooks return structured payload, originalLineCount is undefined)
  2. grep matches the same file → recordRead({full: false, cacheable: true}) → same fingerprint → sticky-on-true upgrades lastReadCacheable to true
  3. checkPriorRead now approves Edit/WriteFile against a notebook the model never saw as editable text, risking silent JSON corruption

Ripgrep does NOT skip .ipynb (it's a text file). The .ipynb scenario is the most concrete, but any file type that read_file returns as non-cacheable is affected.

Suggested change
return;
const ext = path.extname(filePath).toLowerCase();
const cacheable = ext !== '.ipynb';
cache.recordRead(filePath, stats, { full: false, cacheable });

— qwen3.7-max via Qwen Code /review

const fileAStats = await fs.stat(path.join(tempRootDir, 'fileA.txt'));
const fileCStats = await fs.stat(
path.join(tempRootDir, 'sub', 'fileC.txt'),
);

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] These assertions only verify state === 'fresh' but do not check lastReadWasFull === false or lastReadCacheable === true. The {full: false, cacheable: true} contract — which is the entire semantic point of this PR — is unverified. If someone accidentally flipped full to true, these tests would still pass but the Read tool would incorrectly return file_unchanged placeholders.

Suggested change
);
const fileACheck = fileReadCache.check(fileAStats);
expect(fileACheck.state).toBe('fresh');
if (fileACheck.state === 'fresh') {
expect(fileACheck.entry.lastReadWasFull).toBe(false);
expect(fileACheck.entry.lastReadCacheable).toBe(true);
}
const fileCCheck = fileReadCache.check(fileCStats);
expect(fileCCheck.state).toBe('fresh');
if (fileCCheck.state === 'fresh') {
expect(fileCCheck.entry.lastReadWasFull).toBe(false);
expect(fileCCheck.entry.lastReadCacheable).toBe(true);
}

— qwen3.7-max via Qwen Code /review


import fs from 'node:fs/promises';
import type { Config } from '../config/config.js';

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] No dedicated grepReadTracking.test.ts for this new module. The function has 5 code paths (cache disabled, no cache, happy path, stat failure, non-file result) but only the happy path is exercised indirectly through grep/ripGrep tests. The early-return guards (getFileReadCacheDisabled, getFileReadCache?.() returning undefined) and the error catch-all have zero test coverage.

— qwen3.7-max via Qwen Code /review

}
cache.recordRead(filePath, stats, { full: false, cacheable: true });
} catch {
// Grep should not fail because a matched file disappeared before

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 catch block swallows ALL errors, not just ENOENT. The comment justifies only the file-disappeared case, but EACCES, EIO, EBUSY, and NFS errors are also silently discarded with zero logging. If all grep result files fail stat on a network filesystem, the entire optimization becomes a silent no-op with no diagnostic path.

Consider narrowing the catch to ENOENT and adding a debug log for other failures:

Suggested change
// Grep should not fail because a matched file disappeared before
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
// Log non-ENOENT failures for debugging
console.debug(`grepReadTracking: stat failed for ${filePath}: ${(err as Error).message}`);
}
}

— qwen3.7-max via Qwen Code /review

return;
}

await Promise.all(

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] Promise.all fires N concurrent fs.stat calls with no concurrency limit. For a grep returning hundreds of unique file paths, this creates a burst of simultaneous syscalls on libuv's thread pool. More importantly, the await at both call sites (grep.ts:282, ripGrep.ts:370) blocks the grep tool's return on the stat burst completing.

Consider either batching:

const BATCH = 50;
for (let i = 0; i < unique.length; i += BATCH) {
  await Promise.all(unique.slice(i, i + BATCH).map(async (fp) => { ... }));
}

Or fire-and-forget (since cache stamping is a side-effect that doesn't affect the grep return value):

void recordGrepResultFileReads(this.config, resultFilePaths);

— qwen3.7-max via Qwen Code /review

@he-yufeng he-yufeng force-pushed the fix/grep-satisfies-prior-read branch from 40eb598 to 192f78d Compare June 12, 2026 12:32
@he-yufeng

Copy link
Copy Markdown
Contributor Author

Thanks for the review. I updated the patch to address the cache-safety concerns:

  • grep result tracking now keeps notebook results non-cacheable instead of upgrading .ipynb prior reads through the sticky-on-true cache behavior.
  • stat work is batched instead of using one unbounded Promise.all over every result path.
  • disappeared files are still ignored, while non-ENOENT stat failures are logged for debug visibility.
  • added dedicated grepReadTracking coverage for disabled cache, missing cache, text file tracking, notebook tracking, non-file paths, vanished paths, and a stat failure continuing to later paths.
  • tightened both grep and ripgrep integration tests to assert the recorded reads are partial and cacheable, not just fresh.

Validated locally:

  • npm run test --workspace=packages/core -- src/tools/grepReadTracking.test.ts src/tools/grep.test.ts src/tools/ripGrep.test.ts
  • npx prettier --check packages/core/src/tools/grepReadTracking.ts packages/core/src/tools/grepReadTracking.test.ts packages/core/src/tools/grep.test.ts packages/core/src/tools/ripGrep.test.ts
  • npx eslint packages/core/src/tools/grepReadTracking.ts packages/core/src/tools/grepReadTracking.test.ts packages/core/src/tools/grep.test.ts packages/core/src/tools/ripGrep.test.ts --max-warnings 0
  • npm run typecheck --workspace=packages/core
  • git diff --check HEAD

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

The prior critical notebook cacheability issue appears resolved on head 192f78d; I did not find any new critical blocker in this pass.

@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Runtime verification — real TUI, tmux-driven (maintainer merge reference)

Verdict: PASS — built PR head 192f78dd locally and drove the actual TUI through the grep→edit workflow. A grep hit now satisfies prior-read enforcement so the follow-up Edit no longer needs a separate read_file, with a clean before/after against the PR's merge-base, and three probes confirm the clearance is precise (notebook, drift, and never-grepped files are all still rejected).

Claim (my read of the diff)

grep/ripgrep now call recordGrepResultFileReads, which stats each visible result file and stamps the session FileReadCache via recordRead(stats, {full: false, cacheable: ext !== '.ipynb'}). A later Edit/WriteFile runs checkPriorRead, which clears on fresh + cacheable — so a grep-hit file passes without a read_file, while the mtime/size drift check stays the safety gate. The diff matches this.

Method

  • PR worktree + a baseline worktree at the PR's merge-base 78f06351 (so the only delta is this PR), both npm install && npm run build.
  • Real TUI (packages/cli/dist/index.js --approval-mode yolo) in tmux, isolated $HOME.
  • A local mock OpenAI server orchestrates a fixed tool-call sequence (grep_searchedit the same file → done). The grep runs real ripgrep against a real file in the workspace, so recordGrepResultFileReads executes for real; only the model's tool-call choice is mocked.

Steps

  1. Baseline (78f06351), grep then edit — Edit rejected:

    ✓  Grep 'EDIT_TARGET_MARKER'  — Found 1 match
    x  Edit target.txt: ORIGINAL_CONTENT => EDITED_BY_GREP_FLOW
       File .../target.txt has not been read in this session. Use the read_file tool first
       to load the current content ... before editing it.
    

    File unchanged. This is exactly the Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 friction: the model saw the matching line but Edit still demands a separate read.

  2. PR (192f78dd), same sequence — Edit succeeds:

    ✓  Grep 'EDIT_TARGET_MARKER'  — Found 1 match
    ✓  Edit target.txt: ORIGINAL_CONTENT => EDITED_BY_GREP_FLOW
       1   EDIT_TARGET_MARKER
       2 - ORIGINAL_CONTENT
       2 + EDITED_BY_GREP_FLOW
    

    File written. The grep hit alone cleared prior-read enforcement — no read_file in between.

  3. 🔍 Probe — notebook (PR build). grep a .ipynb, then edit it:

    ✓  Grep 'NOTEBOOK_MARKER'
    x  Edit notebook.ipynb
       File .../notebook.ipynb is a binary / image / audio / video / PDF / notebook payload ...
       use a different mechanism ...
    

    Still rejected, file unchanged. The PR's NON_CACHEABLE_GREP_EXTENSIONS = {.ipynb} means a notebook grep hit records cacheable: false, so Edit does not get a free pass on notebooks.

  4. 🔍 Probe — mtime/size drift (PR build). grep → mutate the file via shell → edit:

    ✓  Grep 'EDIT_TARGET_MARKER'
    ✓  Shell  printf 'DRIFT_APPENDED_LINE\n' >> target.txt
    x  Edit target.txt
       File .../target.txt has been modified since you last read it (mtime or size changed).
       Re-read it with the read_file tool before editing it ...
    

    Edit rejected; the file shows the shell append but not the edit. The grep-stamped cache entry goes stale exactly like a read_file-stamped one — the safety net the PR description calls out is intact.

  5. 🔍 Probe — never-grepped file (PR build). Edit a file with no prior grep/read:

    x  Edit direct.txt
       File .../direct.txt has not been read in this session. Use the read_file tool first ...
    

    Still rejected (unknown). The PR clears only files a grep actually surfaced, not enforcement globally.

Scenario (PR build unless noted) Edit outcome Why
Baseline: grep → edit ❌ rejected (unknown) grep didn't stamp the cache pre-PR
PR: grep → edit ✅ allowed grep stamps fresh+cacheable
PR: grep .ipynb → edit ❌ rejected (non-text) notebooks recorded non-cacheable
PR: grep → mutate → edit ❌ rejected (drift) mtime/size check still fires
PR: edit, no grep ❌ rejected (unknown) only grep-hit files cleared

Findings

  • The main fix does exactly what Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 asked: grep-to-edit no longer wastes a read_file round-trip. The three probes are the more interesting result — they show the clearance is scoped, not a blanket bypass: notebooks stay blocked, a file mutated after the grep goes stale, and a file never surfaced by grep is still rejected. That directly exercises the risk the PR description flags ("a grep hit only proves the model saw matching lines") and shows the drift check absorbs it.
  • Only the ripgrep path was exercised at the TUI (the box has rg 15.1.0, so RipGrepTool is the active tool). The JS-fallback GrepTool gets the identical recordGrepResultFileReads(this.config, resultFilePaths) call and is covered by the PR's unit tests, but I did not separately drive it through the TUI (would require forcing ripgrep off).
  • Scaffolding note (not about the PR): on my first drift/direct attempt the mock server was still the pre-edit process, so both probes silently ran the normal grep→edit path; after restarting the mock they behaved correctly. The results above are all from the current mock; I re-ran the notebook probe under it too for a consistent capture.

Build: npm install && npm run build on both worktrees, clean. Tests not re-run locally (CI covers them); this is runtime evidence only.

中文版(验证报告)

运行时验证 — tmux 驱动真实 TUI(合并参考)

结论:PASS — 本地构建 PR head 192f78dd,在真实 TUI 里走完 grep→edit 流程。grep 命中现在能满足 prior-read enforcement,后续 Edit 不再需要单独的 read_file,与 PR 的 merge-base 形成干净的前后对照;三个探针证明放行是精确的(notebook、文件漂移、从未 grep 过的文件都仍被拒)。

对 diff 的理解

grep/ripgrep 现在调用 recordGrepResultFileReads,对每个可见结果文件 stat 后通过 recordRead(stats, {full:false, cacheable: 扩展名 !== '.ipynb'}) 写入会话级 FileReadCache。后续 Edit/WriteFilecheckPriorRead,在 fresh + cacheable 时放行 —— 所以 grep 命中的文件无需 read_file 即可通过,而 mtime/size 漂移检查仍是安全闸门。diff 与此一致。

方法

  • PR worktree + 一个位于 PR merge-base 78f06351 的基线 worktree(确保唯一差异就是本 PR),都 npm install && npm run build
  • 真实 TUI(--approval-mode yolo)在 tmux 中运行,$HOME 隔离。
  • 本地 mock OpenAI 服务器编排固定的 tool-call 序列(grep_search → 对同一文件 edit → done)。grep 跑的是真实 ripgrep、匹配工作区真实文件,所以 recordGrepResultFileReads 真实执行;只有模型的 tool-call 选择是 mock 的。

步骤

  1. 基线(78f06351),grep 后 edit —— Edit 被拒:"File ... has not been read in this session. Use the read_file tool first ..." 文件未变。正是 Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 的痛点:模型已看到匹配行,但 Edit 仍要求单独 read。
  2. PR(192f78dd),同序列 —— Edit 成功:ORIGINAL_CONTENT => EDITED_BY_GREP_FLOW,diff 显示替换,文件写入。仅凭 grep 命中就清除了 prior-read enforcement,中间没有 read_file
  3. 🔍 探针 — notebook(PR 构建)。 grep 一个 .ipynb 再 edit:仍被拒("binary / ... / notebook payload ... use a different mechanism"),文件未变。PR 的 NON_CACHEABLE_GREP_EXTENSIONS = {.ipynb} 让 notebook 命中记为 cacheable: false,所以 Edit 不会对 notebook 放行。
  4. 🔍 探针 — mtime/size 漂移(PR 构建)。 grep → 用 shell 改文件 → edit:Edit 被拒("has been modified since you last read it"),文件只有 shell 追加、没有 edit 替换。grep 写的 cache 条目和 read_file 写的一样会因漂移失效 —— PR 描述提到的安全网完好。
  5. 🔍 探针 — 从未 grep 的文件(PR 构建)。 直接 edit 一个没 grep/read 过的文件:仍被拒(unknown)。PR 清除 grep 实际暴露过的文件,不是全局关闭 enforcement。
场景(除注明外均 PR 构建) Edit 结果 原因
基线:grep → edit ❌ 拒(unknown) PR 前 grep 不写 cache
PR:grep → edit ✅ 放行 grep 写入 fresh+cacheable
PR:grep .ipynb → edit ❌ 拒(非文本) notebook 记为非 cacheable
PR:grep → 改文件 → edit ❌ 拒(漂移) mtime/size 检查仍触发
PR:无 grep 直接 edit ❌ 拒(unknown) 只放行 grep 命中的文件

观察

  • 主修复正是 Let grep/egrep/fgrep satisfy the read-before-edit check so a separate Read call is not required #4939 要的:grep 到 edit 不再浪费一次 read_file 往返。三个探针是更有价值的结果 —— 它们证明放行是有范围的、不是一刀切绕过:notebook 仍被挡、grep 之后被改的文件会失效、从未被 grep 暴露的文件仍被拒。这直接验证了 PR 描述标注的风险("grep 命中只证明模型看到了匹配行"),并显示漂移检查吸收了这个风险。
  • 只在 TUI 上验证了 ripgrep 路径(机器有 rg 15.1.0,所以 RipGrepTool 是激活的工具)。JS fallback 的 GrepTool 得到完全相同的 recordGrepResultFileReads(this.config, resultFilePaths) 调用,并被 PR 单测覆盖,但我没单独在 TUI 上驱动它(需强制关掉 ripgrep)。
  • 脚手架说明(与 PR 无关):我第一次跑 drift/direct 探针时 mock 服务器还是改代码前的旧进程,导致两个探针静默走了 normal 的 grep→edit 路径;重启 mock 后行为正确。以上结果全部来自当前 mock;为保持一致,notebook 探针也在重启后的 mock 下重跑过。

构建:两个 worktree 上 npm install && npm run build 干净通过。本地未重跑测试(CI 已覆盖);本报告只提供运行时证据。

@wenshao wenshao merged commit 233e8e0 into QwenLM:main Jun 12, 2026
23 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.

3 participants