fix: merge sub-agent readFileState to prevent cross-agent write conflicts

子代理完成后将其 readFileState 合并回主代理缓存,防止主代理写文件时因
缓存过期而误报 FILE_UNEXPECTEDLY_MODIFIED_ERROR。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
IronRookieCoder 2026-05-13 16:36:46 +08:00
parent 5c2e1918ca
commit 233971c6ce
3 changed files with 91 additions and 0 deletions

View File

@ -854,6 +854,11 @@ export async function* runAgent({
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
cleanupAgentTracking(agentId)
}
if (agentToolUseContext.readFileState !== toolUseContext.readFileState) {
toolUseContext.readFileState.mergeFrom(
agentToolUseContext.readFileState,
)
}
// Release cloned file state cache memory
agentToolUseContext.readFileState.clear()
// Release the cloned fork context messages

View File

@ -146,3 +146,80 @@ describe('coerceToolContentToString', () => {
expect(coerceToolContentToString(nested)).toBe('{"a":{"b":[1,2,3]}}')
})
})
describe('FileStateCache.mergeFrom', () => {
test('merges new entries from other cache into target', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
target.set('/a.txt', makeEntry('old-a', { timestamp: 100 }))
other.set('/b.txt', makeEntry('new-b', { timestamp: 200 }))
target.mergeFrom(other)
expect(target.get('/a.txt')?.content).toBe('old-a')
expect(target.get('/b.txt')?.content).toBe('new-b')
expect(target.size).toBe(2)
})
test('overrides entries with newer timestamp from other', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
target.set('/a.txt', makeEntry('old-content', { timestamp: 100 }))
other.set('/a.txt', makeEntry('new-content', { timestamp: 200 }))
target.mergeFrom(other)
expect(target.get('/a.txt')?.content).toBe('new-content')
})
test('does not override entries with older timestamp', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
target.set('/a.txt', makeEntry('newer-content', { timestamp: 200 }))
other.set('/a.txt', makeEntry('older-content', { timestamp: 100 }))
target.mergeFrom(other)
expect(target.get('/a.txt')?.content).toBe('newer-content')
})
test('handles empty other cache (no-op)', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
target.set('/a.txt', makeEntry('content'))
target.mergeFrom(other)
expect(target.size).toBe(1)
expect(target.get('/a.txt')?.content).toBe('content')
})
test('merges into empty target cache', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
other.set('/a.txt', makeEntry('content-a'))
other.set('/b.txt', makeEntry('content-b'))
target.mergeFrom(other)
expect(target.size).toBe(2)
expect(target.get('/a.txt')?.content).toBe('content-a')
expect(target.get('/b.txt')?.content).toBe('content-b')
})
test('preserves existing entries when merging new ones', () => {
const target = createFileStateCacheWithSizeLimit(100)
const other = createFileStateCacheWithSizeLimit(100)
target.set('/keep.txt', makeEntry('keep-me'))
other.set('/new.txt', makeEntry('new-file'))
target.mergeFrom(other)
expect(target.has('/keep.txt')).toBe(true)
expect(target.has('/new.txt')).toBe(true)
})
})

View File

@ -101,6 +101,15 @@ export class FileStateCache {
load(entries: ReturnType<LRUCache<string, FileState>['dump']>): void {
this.cache.load(entries)
}
mergeFrom(other: FileStateCache): void {
for (const [filePath, fileState] of other.entries()) {
const existing = this.get(filePath)
if (!existing || fileState.timestamp > existing.timestamp) {
this.set(filePath, fileState)
}
}
}
}
/**