Skip to content

Commit 946ec9f

Browse files
anandgupta42claude
andcommitted
fix: [AI-450] auto-refresh stale files in edit and write tools instead of failing
When a file is modified externally (by a formatter, linter, or file watcher) between a read and edit/write, the tools now auto-refresh the read timestamp and re-read the file contents instead of throwing "modified since it was last read". This prevents the agent from entering retry loops of hundreds of consecutive failures when external processes modify files during editing sessions. Changes: - Add `FileTime.assertOrRefresh()` that returns `{ stale: boolean }` instead of throwing - Update `EditTool` and `WriteTool` to use `assertOrRefresh()` and re-read file contents - Update test to verify auto-refresh behavior - Original `FileTime.assert()` preserved for backward compatibility Closes #450 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e784dd commit 946ec9f

4 files changed

Lines changed: 53 additions & 14 deletions

File tree

packages/opencode/src/file/time.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,4 +68,36 @@ export namespace FileTime {
6868
)
6969
}
7070
}
71+
72+
/**
73+
* Check if a file has been modified since last read. Instead of throwing,
74+
* returns whether the file was stale and auto-refreshes the read timestamp
75+
* so the caller can re-read contents and proceed.
76+
*
77+
* Returns:
78+
* - { stale: false } if file is up-to-date
79+
* - { stale: true } if file was modified externally (timestamp refreshed)
80+
*
81+
* Still throws if the file was never read in this session.
82+
*/
83+
// altimate_change start — auto-refresh stale files instead of throwing (#450)
84+
export async function assertOrRefresh(
85+
sessionID: string,
86+
filepath: string,
87+
): Promise<{ stale: boolean }> {
88+
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
89+
return { stale: false }
90+
}
91+
92+
const time = get(sessionID, filepath)
93+
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
94+
const mtime = Filesystem.stat(filepath)?.mtime
95+
if (mtime && mtime.getTime() > time.getTime() + 50) {
96+
log.info("stale file detected, auto-refreshing", { sessionID, filepath })
97+
read(sessionID, filepath)
98+
return { stale: true }
99+
}
100+
return { stale: false }
101+
}
102+
// altimate_change end
71103
}

packages/opencode/src/tool/edit.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ export const EditTool = Tool.define("edit", {
8686
const stats = Filesystem.stat(filePath)
8787
if (!stats) throw new Error(`File ${filePath} not found`)
8888
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
89-
await FileTime.assert(ctx.sessionID, filePath)
89+
// altimate_change start — auto-refresh stale files instead of failing (#450)
90+
const { stale } = await FileTime.assertOrRefresh(ctx.sessionID, filePath)
91+
// altimate_change end
9092
contentOld = await Filesystem.readText(filePath)
9193

9294
const ending = detectLineEnding(contentOld)

packages/opencode/src/tool/write.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ export const WriteTool = Tool.define("write", {
2828
await assertSensitiveWrite(ctx, filepath)
2929

3030
const exists = await Filesystem.exists(filepath)
31+
// altimate_change start — auto-refresh stale files instead of failing (#450)
32+
if (exists) await FileTime.assertOrRefresh(ctx.sessionID, filepath)
33+
// altimate_change end
3134
const contentOld = exists ? await Filesystem.readText(filepath) : ""
32-
if (exists) await FileTime.assert(ctx.sessionID, filepath)
3335

3436
const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
3537
await ctx.ask({

packages/opencode/test/tool/edit.test.ts

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,8 @@ describe("tool.edit", () => {
226226
})
227227
})
228228

229-
test("throws error when file has been modified since read", async () => {
229+
// altimate_change start — edit now auto-refreshes stale files instead of throwing (#450)
230+
test("succeeds when file has been modified since read by auto-refreshing", async () => {
230231
await using tmp = await tmpdir()
231232
const filepath = path.join(tmp.path, "file.txt")
232233
await fs.writeFile(filepath, "original content", "utf-8")
@@ -243,21 +244,23 @@ describe("tool.edit", () => {
243244
// Simulate external modification
244245
await fs.writeFile(filepath, "modified externally", "utf-8")
245246

246-
// Try to edit with the new content
247+
// Edit should succeed — auto-refreshes the stale read timestamp
247248
const edit = await EditTool.init()
248-
await expect(
249-
edit.execute(
250-
{
251-
filePath: filepath,
252-
oldString: "modified externally",
253-
newString: "edited",
254-
},
255-
ctx,
256-
),
257-
).rejects.toThrow("modified since it was last read")
249+
const result = await edit.execute(
250+
{
251+
filePath: filepath,
252+
oldString: "modified externally",
253+
newString: "edited",
254+
},
255+
ctx,
256+
)
257+
expect(result.output).toContain("Edit applied successfully")
258+
const content = await fs.readFile(filepath, "utf-8")
259+
expect(content).toBe("edited")
258260
},
259261
})
260262
})
263+
// altimate_change end
261264

262265
test("replaces all occurrences with replaceAll option", async () => {
263266
await using tmp = await tmpdir()

0 commit comments

Comments
 (0)