Repository navigation
fix(core): preserve read-before-write state across idle microcompaction #4243
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
0b3af20
ed0b668
f602477
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -104,6 +104,7 @@ import { | |
| } from '../confirmation-bus/types.js'; | ||
| import { partToString } from '../utils/partUtils.js'; | ||
| import { createHookOutput, SessionStartSource } from '../hooks/types.js'; | ||
| import fsPromises from 'node:fs/promises'; | ||
|
|
||
| // IDE integration | ||
| import { ideContextStore } from '../ide/ideContext.js'; | ||
|
|
@@ -1206,18 +1207,51 @@ export class GeminiClient { | |
| this.config.getClearContextOnIdle(), | ||
| ); | ||
| if (mcResult.meta) { | ||
| this.getChat().setHistory(mcResult.history); | ||
| // Microcompaction replaces old compactable tool outputs | ||
| // (including read_file) with a placeholder, but the | ||
| // FileReadCache still records the prior full Reads as "seen in | ||
| // this conversation". A follow-up Read of an unchanged file | ||
| // would then return the file_unchanged placeholder pointing at | ||
| // bytes the model can no longer retrieve from history. Drop the | ||
| // cache so post-microcompaction Reads re-emit the bytes, | ||
| // mirroring the post-compaction clear in tryCompressChat. | ||
| debugLogger.debug('[FILE_READ_CACHE] clear after microcompaction'); | ||
| this.config.getFileReadCache().clear(); | ||
| const m = mcResult.meta; | ||
| this.getChat().setHistory(mcResult.history); | ||
| // Disarm only the blanked files' fast-path, keeping | ||
| // read-before-write state intact (issue #4239; rationale on | ||
| // FileReadEntry.readResidentInHistory). Any blanked read we | ||
| // can't disarm surgically forces the old blanket wipe so a | ||
| // later Read can't get a dangling file_unchanged placeholder. | ||
| const fileReadCache = this.config.getFileReadCache(); | ||
| if (m.unresolvedEvictedReads > 0) { | ||
| debugLogger.debug( | ||
| `[FILE_READ_CACHE] clear after microcompaction ` + | ||
| `(${m.unresolvedEvictedReads} unresolved blanked read(s))`, | ||
| ); | ||
| fileReadCache.clear(); | ||
| } else { | ||
| // Concurrent stats — don't serialize N FS round-trips | ||
| // before the next turn. | ||
| const statResults = await Promise.all( | ||
| m.evictedReadPaths.map((p) => | ||
| fsPromises.stat(p).catch(() => undefined), | ||
| ), | ||
| ); | ||
| // A path is surgically disarmed only if it stats AND its | ||
| // inode matches the recorded entry. A failed stat or inode | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Suggestion: This log says "an evicted path was unresolvable" without naming which file(s) failed, how many were attempted, or the failure mode (stat error vs inode mismatch). When this fires in production it is very difficult to diagnose why the cache was wiped. The data is all in scope — consider including it: const failedPaths = m.evictedReadPaths.filter((_, i) => !statResults[i]);
debugLogger.debug(
"[FILE_READ_CACHE] clear after microcompaction " +
`(${failedPaths.length}/${m.evictedReadPaths.length} path(s) unresolvable: ${failedPaths.join(", ")})`,
); |
||
| // miss could leave a stale entry armed, so fall back to the | ||
| // blanket wipe if any path is unresolvable. | ||
| let fullyDisarmed = true; | ||
| for (const stats of statResults) { | ||
| if (!stats || !fileReadCache.markReadEvictedFromHistory(stats)) { | ||
| fullyDisarmed = false; | ||
| } | ||
| } | ||
| if (fullyDisarmed) { | ||
| debugLogger.debug( | ||
| `[FILE_READ_CACHE] disarmed fast-path for ` + | ||
| `${m.evictedReadPaths.length} file(s) after microcompaction`, | ||
| ); | ||
| } else { | ||
| debugLogger.debug( | ||
| '[FILE_READ_CACHE] clear after microcompaction ' + | ||
| '(an evicted path was unresolvable)', | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Nice to have: Consider a targeted clear instead of blanket // After the loop, selectively clear only entries that weren't disarmed:
const unresolvedPaths = m.evictedReadPaths.filter(
(_, i) => !statResults[i] || !fileReadCache.markReadEvictedFromHistory(statResults[i]!),
);
for (const p of unresolvedPaths) fileReadCache.invalidateByPath(p);This preserves the PR's core benefit (read-before-write state survives) even in the edge case where one evicted file was deleted during the idle gap. The current blanket wipe is safe but destroys state for non-evicted files and already-disarmed entries. |
||
| ); | ||
| fileReadCache.clear(); | ||
| } | ||
| } | ||
| debugLogger.debug( | ||
| `[TIME-BASED MC] gap ${m.gapMinutes}min > ${m.thresholdMinutes}min, ` + | ||
| `cleared ${m.toolsCleared} tool result(s) + ${m.mediaCleared} media (~${m.tokensSaved} tokens), ` + | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The
.catch(() => undefined)swallows all errors indiscriminately. ENOENT is expected (file deleted since read), but EMFILE, EACCES, ENOMEM, etc. are not — they silently produceundefined, triggering the blanket wipe with zero diagnostic info.Consider logging unexpected errors before falling through: