Edit tool: false "String not found in file" for multi-line old_string byte-identical to file content (file-state desync after successful edits)
Summary
The Edit tool intermittently returns "String not found in file. Failed to apply edit." for a multi-line old_string that is byte-identical to the file content on disk at call time. Verified two occurrences on the same day, on two different markdown files, with a distinctive pattern. Post-hoc transcript analysis proves the tool refused strings that were present in its own recorded file snapshots.
Environment
- Claude Code 2.1.183, Linux (kernel 6.17, Ubuntu), VS Code extension host
- Model: claude-fable-5
- Permission mode: acceptEdits; the target path is in an allowed additional working directory
Repro pattern (occurrence 1 — 8 Edit calls to one ~6KB markdown file)
- Outcomes alternated perfectly: success, FAIL, success, FAIL, success, FAIL, success, FAIL — within a single parallel batch and across subsequent single-call messages.
- Every failing
old_stringwas ≥3 lines (3, 5, and 22 lines); every succeeding one was ≤2 lines. - An identical
old_stringfailed (call 1), a different edit succeeded (call 2), then the identical string failed again (call 3). - At the time,
cmpbetween the file and a heredoc of the failing hunk reported identical bytes.
Post-hoc evidence from the session transcript JSONL
Extracting the actual tool_use inputs and toolUseResult records:
- All four failing
old_strings (as received by the tool — exact bytes from the transcript) are verbatim substrings of the file content at their call time (simulated by replaying the successful edits in order over the git-committed pre-edit state). - Decisive: successful Edit calls record
toolUseResult.originalFile(the content the tool saw before applying that edit). The failing strings are present inside theoriginalFilesnapshots recorded by the immediately-preceding and immediately-following successful calls. The tool refused strings its own snapshots contained. - Ruled out: PreToolUse hooks (none intercept/deny for this path; the only denying hook is scoped to code suffixes), concurrent writers (the only timers do pathspec-scoped git add/commit and never touch the file), unicode/whitespace mismatch (byte-compared), non-unique matches (strings occur exactly once).
Control tests
- The exact failing 3-line string succeeded via Edit on a byte-identical copy of the file at a different path (fresh file state) — content exonerated.
- After an out-of-band write to the file (external
pythonreplace, which triggered the "file was modified externally" state re-sync), the same class of multi-line Edit on the real file succeeded.
Occurrence 2 (same day, different file)
A 3-line edit to another markdown file failed the same way immediately after a successful edit to that file in the same message batch. A fresh targeted Read of the hunk's lines followed by an identical retry succeeded — no content change.
Working theory + workarounds
Looks like a file-state desync after successful edits: some multi-line match path consults a stale view of the file, while ≤2-line matches and the originalFile snapshot recorder see current content. Workarounds that clear it, in increasing weight: fresh targeted Read + retry → split into ≤2-line hunks → out-of-band write (resets tracked state).
Happy to provide the extraction script used against the transcript JSONL if useful.