Skip to content

Commit a114234

Browse files
anandgupta42claude
andcommitted
fix: eliminate stale file race condition and fix error misclassification
- Use file's actual `mtime` instead of `new Date()` in `FileTime.read()` to eliminate clock drift between Node.js and filesystem (WSL, networked drives) - Increase staleness tolerance from 50ms to 2s (p50 gap was 651ms, caused 782-retry loops on WSL) - Split `file_stale` out of `validation` error class for cleaner triage - Move `http_error` pattern before `validation` and add `HTTP 4xx` keywords to prevent WebFetch 404s from being misclassified as validation errors - Update tests to use `fs.utimes()` for deterministic mtime manipulation Closes #610 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5d0ada3 commit a114234

5 files changed

Lines changed: 61 additions & 28 deletions

File tree

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ export namespace Telemetry {
475475
session_id: string
476476
tool_name: string
477477
tool_category: string
478-
error_class: "parse_error" | "connection" | "timeout" | "validation" | "internal" | "permission" | "http_error" | "file_not_found" | "edit_mismatch" | "not_configured" | "resource_exhausted" | "unknown"
478+
error_class: "parse_error" | "connection" | "timeout" | "validation" | "internal" | "permission" | "http_error" | "file_not_found" | "file_stale" | "edit_mismatch" | "not_configured" | "resource_exhausted" | "unknown"
479479
error_message: string
480480
input_signature: string
481481
masked_args?: string
@@ -761,19 +761,31 @@ export namespace Telemetry {
761761
// altimate_change end
762762
{ class: "timeout", keywords: ["timeout", "etimedout", "bridge timeout", "timed out"] },
763763
{ class: "permission", keywords: ["permission", "access denied", "permission denied", "unauthorized", "forbidden", "authentication"] },
764+
// altimate_change start — http_error before validation to prevent "HTTP 404" matching "invalid"/"missing"
765+
{
766+
class: "http_error",
767+
keywords: ["status code: 4", "status code: 5", "request failed with status", "http 404", "http 410", "http 429", "http 451", "http 403"],
768+
},
769+
// altimate_change end
770+
// altimate_change start — split file_stale out of validation; remove "does not exist" (was catching HTTP 404s)
771+
{
772+
class: "file_stale",
773+
keywords: [
774+
"must read file",
775+
"has been modified since",
776+
"before overwriting",
777+
],
778+
},
764779
{
765780
class: "validation",
766781
keywords: [
767782
"invalid params",
768783
"invalid",
769784
"missing",
770785
"required",
771-
"must read file",
772-
"has been modified since",
773-
"does not exist",
774-
"before overwriting",
775786
],
776787
},
788+
// altimate_change end
777789
{ class: "internal", keywords: ["internal", "assertion"] },
778790
// altimate_change start — resource_exhausted class for OOM/quota errors
779791
{
@@ -788,10 +800,6 @@ export namespace Telemetry {
788800
],
789801
},
790802
// altimate_change end
791-
{
792-
class: "http_error",
793-
keywords: ["status code: 4", "status code: 5", "request failed with status"],
794-
},
795803
]
796804
// altimate_change end
797805

packages/opencode/src/file/time.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,12 @@ export namespace FileTime {
2626
log.info("read", { sessionID, file })
2727
const { read } = state()
2828
read[sessionID] = read[sessionID] || {}
29-
read[sessionID][file] = new Date()
29+
// Use the file's actual mtime instead of wall-clock time to avoid
30+
// clock drift between Node.js and the filesystem (especially on WSL,
31+
// networked drives, and macOS APFS). This eliminates the race condition
32+
// where mtime > new Date() due to filesystem clock skew.
33+
const mtime = Filesystem.stat(file)?.mtime
34+
read[sessionID][file] = mtime ?? new Date()
3035
}
3136

3237
export function get(sessionID: string, file: string) {
@@ -61,8 +66,11 @@ export namespace FileTime {
6166
const time = get(sessionID, filepath)
6267
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
6368
const mtime = Filesystem.stat(filepath)?.mtime
64-
// Allow a 50ms tolerance for Windows NTFS timestamp fuzziness / async flushing
65-
if (mtime && mtime.getTime() > time.getTime() + 50) {
69+
// Allow a 2s tolerance for filesystem clock drift.
70+
// WSL (NTFS-over-9P) and networked drives routinely show 400ms–1.2s gaps
71+
// between Node.js Date.now() and filesystem mtime. The previous 50ms
72+
// tolerance caused massive retry loops (782 retries in one session).
73+
if (mtime && mtime.getTime() > time.getTime() + 2000) {
6674
throw new Error(
6775
`File ${filepath} has been modified since it was last read.\nLast modification: ${mtime.toISOString()}\nLast read: ${time.toISOString()}\n\nPlease read the file again before modifying it.`,
6876
)

packages/opencode/test/file/time.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,10 @@ describe("file/time", () => {
112112
fn: async () => {
113113
FileTime.read(sessionID, filepath)
114114

115-
// Wait to ensure different timestamps
116-
await new Promise((resolve) => setTimeout(resolve, 100))
117-
118-
// Modify file after reading
115+
// Modify file and set mtime 5s in the future to exceed 2s tolerance
119116
await fs.writeFile(filepath, "modified content", "utf-8")
117+
const future = new Date(Date.now() + 5000)
118+
await fs.utimes(filepath, future, future)
120119

121120
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
122121
},
@@ -132,8 +131,10 @@ describe("file/time", () => {
132131
directory: tmp.path,
133132
fn: async () => {
134133
FileTime.read(sessionID, filepath)
135-
await new Promise((resolve) => setTimeout(resolve, 100))
136134
await fs.writeFile(filepath, "modified", "utf-8")
135+
// Set mtime 5 seconds in the future to exceed the 2s tolerance
136+
const future = new Date(Date.now() + 5000)
137+
await fs.utimes(filepath, future, future)
137138

138139
let error: Error | undefined
139140
try {
@@ -346,9 +347,10 @@ describe("file/time", () => {
346347

347348
const originalStat = Filesystem.stat(filepath)
348349

349-
// Wait and modify
350-
await new Promise((resolve) => setTimeout(resolve, 100))
351350
await fs.writeFile(filepath, "modified", "utf-8")
351+
// Set mtime 5 seconds in the future to exceed the 2s tolerance
352+
const future = new Date(Date.now() + 5000)
353+
await fs.utimes(filepath, future, future)
352354

353355
const newStat = Filesystem.stat(filepath)
354356
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())

packages/opencode/test/telemetry/telemetry.test.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1628,11 +1628,13 @@ describe("telemetry.classifyError", () => {
16281628
expect(Telemetry.classifyError("Invalid dialect specified")).toBe("validation")
16291629
expect(Telemetry.classifyError("Missing required field")).toBe("validation")
16301630
expect(Telemetry.classifyError("Required parameter 'query' not provided")).toBe("validation")
1631-
// altimate_change start — expanded validation patterns
1632-
expect(Telemetry.classifyError("You must read file /path/to/file before overwriting it")).toBe("validation")
1633-
expect(Telemetry.classifyError("File has been modified since it was last read")).toBe("validation")
1634-
expect(Telemetry.classifyError("error: column foo does not exist")).toBe("validation")
1635-
expect(Telemetry.classifyError("You must read file before overwriting it. Use the Read tool first")).toBe("validation")
1631+
// altimate_change start — file_stale split out from validation; "does not exist" moved to http_error
1632+
// These are now classified as file_stale, not validation
1633+
expect(Telemetry.classifyError("You must read file /path/to/file before overwriting it")).toBe("file_stale")
1634+
expect(Telemetry.classifyError("File has been modified since it was last read")).toBe("file_stale")
1635+
expect(Telemetry.classifyError("You must read file before overwriting it. Use the Read tool first")).toBe("file_stale")
1636+
// HTTP 404 "does not exist" is now http_error
1637+
expect(Telemetry.classifyError("HTTP 404: https://example.com/page does not exist")).toBe("http_error")
16361638
// altimate_change end
16371639
})
16381640

@@ -1651,12 +1653,25 @@ describe("telemetry.classifyError", () => {
16511653
expect(Telemetry.classifyError("Assertion failed: x > 0")).toBe("internal")
16521654
})
16531655

1656+
// altimate_change start — file_stale class tests
1657+
test("classifies file_stale errors", () => {
1658+
expect(Telemetry.classifyError("You must read file /path/to/file before overwriting it")).toBe("file_stale")
1659+
expect(Telemetry.classifyError("File /foo.ts has been modified since it was last read")).toBe("file_stale")
1660+
expect(Telemetry.classifyError("Read the file before overwriting it")).toBe("file_stale")
1661+
})
1662+
// altimate_change end
1663+
16541664
// altimate_change start — http_error class and priority ordering tests
16551665
test("classifies http errors", () => {
16561666
expect(Telemetry.classifyError("Request failed with status code: 404 (example.com)")).toBe("http_error")
16571667
expect(Telemetry.classifyError("Request failed with status code: 500")).toBe("http_error")
16581668
expect(Telemetry.classifyError("status code: 403")).toBe("http_error")
16591669
expect(Telemetry.classifyError("Request failed with status")).toBe("http_error")
1670+
// altimate_change start — HTTP status codes in webfetch error messages
1671+
expect(Telemetry.classifyError("HTTP 404: https://example.com does not exist. Do NOT retry")).toBe("http_error")
1672+
expect(Telemetry.classifyError("HTTP 410: https://example.com has been permanently removed")).toBe("http_error")
1673+
expect(Telemetry.classifyError("HTTP 429: Rate limited by example.com")).toBe("http_error")
1674+
// altimate_change end
16601675
})
16611676

16621677
test("priority ordering: earlier patterns win over later ones", () => {

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -238,11 +238,11 @@ describe("tool.edit", () => {
238238
// Read first
239239
FileTime.read(ctx.sessionID, filepath)
240240

241-
// Wait a bit to ensure different timestamps
242-
await new Promise((resolve) => setTimeout(resolve, 100))
243-
244-
// Simulate external modification
241+
// Simulate external modification with mtime 5s in the future
242+
// to exceed the 2s tolerance in FileTime.assert()
245243
await fs.writeFile(filepath, "modified externally", "utf-8")
244+
const future = new Date(Date.now() + 5000)
245+
await fs.utimes(filepath, future, future)
246246

247247
// Try to edit with the new content
248248
const edit = await EditTool.init()

0 commit comments

Comments
 (0)