@@ -2,9 +2,48 @@ import { Instance } from "../project/instance"
22import { Log } from "../util/log"
33import { Flag } from "../flag/flag"
44import { Filesystem } from "../util/filesystem"
5+ // altimate_change start — telemetry for FileTime drift measurement
6+ import { Telemetry } from "../altimate/telemetry"
7+ // altimate_change end
58
69export namespace FileTime {
710 const log = Log . create ( { service : "file.time" } )
11+
12+ // altimate_change start — FileTime clock source change documentation
13+ //
14+ // CHANGE: read() now records the file's filesystem mtime instead of Date.now().
15+ //
16+ // WHY: The original code used `new Date()` (wall-clock) as the "last read" timestamp,
17+ // then assert() compared it against `Filesystem.stat(file).mtime`. On WSL (NTFS-over-9P),
18+ // networked drives, and some macOS APFS mounts, the filesystem clock drifts 400ms–1.2s
19+ // behind Node.js's clock. This caused mtime > readTime even for the file's own write,
20+ // triggering false "modified since last read" errors. One user hit 782 consecutive retries.
21+ //
22+ // FIX: Both timestamps now come from the same clock (filesystem mtime), eliminating
23+ // cross-clock skew. The tolerance is kept at 50ms (upstream default) since same-clock
24+ // comparisons don't need a larger window.
25+ //
26+ // TRADE-OFF: On coarse-resolution filesystems (HFS+ = 1s, NFS with stale cache,
27+ // Docker overlayfs after copy-up), two writes within the same resolution window
28+ // produce identical mtimes — so we'd miss a real external modification. This is a
29+ // false-negative risk (missed edit) vs the false-positive risk (retry loop) we're
30+ // fixing. Acceptable because: (a) the file gets re-read on the next attempt anyway,
31+ // (b) HFS+ is rare (macOS defaulted to APFS since 2017), and (c) the wall-clock
32+ // approach was actively causing 782-retry production loops on WSL.
33+ //
34+ // MONITORING: filetime_drift telemetry event tracks the gap between wall-clock and mtime
35+ // at read time. If drift_ms is consistently 0, this change has no effect (good). If
36+ // drift_ms shows large values, this change is preventing false positives (also good).
37+ // If file_stale errors increase post-deploy, the tolerance may need adjustment.
38+ //
39+ // UPSTREAM: sst/opencode issues #19040, #14183, #20354 track the same problem.
40+ // Upstream is pursuing processor-level recovery (PR #19099) rather than fixing the clock
41+ // source. Both approaches are complementary.
42+ //
43+ // ROLLBACK: Set OPENCODE_DISABLE_FILETIME_CHECK=true to bypass all checks, or revert
44+ // this change to restore `new Date()` behavior with a wider tolerance.
45+ // altimate_change end
46+
847 // Per-session read times plus per-file write locks.
948 // All tools that overwrite existing files should run their
1049 // assert/read/write/update sequence inside withLock(filepath, ...)
@@ -26,12 +65,32 @@ export namespace FileTime {
2665 log . info ( "read" , { sessionID, file } )
2766 const { read } = state ( )
2867 read [ sessionID ] = read [ sessionID ] || { }
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.
68+ // altimate_change start — use filesystem mtime instead of wall-clock (see doc block above)
69+ const wallClock = new Date ( )
3370 const mtime = Filesystem . stat ( file ) ?. mtime
34- read [ sessionID ] [ file ] = mtime ?? new Date ( )
71+ read [ sessionID ] [ file ] = mtime ?? wallClock
72+
73+ // Track drift between wall-clock and filesystem mtime for monitoring.
74+ // This lets us measure the real-world impact of the clock source change
75+ // and detect environments where drift is significant.
76+ if ( mtime ) {
77+ const driftMs = Math . abs ( wallClock . getTime ( ) - mtime . getTime ( ) )
78+ if ( driftMs > 10 ) {
79+ // Only emit when drift is non-trivial (>10ms) to avoid noise
80+ try {
81+ Telemetry . track ( {
82+ type : "filetime_drift" ,
83+ timestamp : Date . now ( ) ,
84+ session_id : sessionID ,
85+ drift_ms : driftMs ,
86+ mtime_ahead : mtime . getTime ( ) > wallClock . getTime ( ) ,
87+ } )
88+ } catch {
89+ // Telemetry must never break file operations
90+ }
91+ }
92+ }
93+ // altimate_change end
3594 }
3695
3796 export function get ( sessionID : string , file : string ) {
@@ -66,14 +125,27 @@ export namespace FileTime {
66125 const time = get ( sessionID , filepath )
67126 if ( ! time ) throw new Error ( `You must read file ${ filepath } before overwriting it. Use the Read tool first` )
68127 const mtime = Filesystem . stat ( filepath ) ?. mtime
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 ) {
128+ // altimate_change start — keep upstream's 50ms tolerance (sufficient now that both
129+ // timestamps come from the same filesystem clock). Track assertion outcomes for monitoring.
130+ const toleranceMs = 50
131+ const deltaMs = mtime ? mtime . getTime ( ) - time . getTime ( ) : 0
132+ if ( mtime && deltaMs > toleranceMs ) {
133+ try {
134+ Telemetry . track ( {
135+ type : "filetime_assert" ,
136+ timestamp : Date . now ( ) ,
137+ session_id : sessionID ,
138+ outcome : "stale" ,
139+ delta_ms : deltaMs ,
140+ tolerance_ms : toleranceMs ,
141+ } )
142+ } catch {
143+ // Telemetry must never mask the stale-file error
144+ }
74145 throw new Error (
75- `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.` ,
146+ `File ${ filepath } has been modified since it was last read.\nLast modification: ${ mtime . toISOString ( ) } \nLast read: ${ time . toISOString ( ) } \nDelta: ${ deltaMs } ms (tolerance: ${ toleranceMs } ms)\ n\nPlease read the file again before modifying it.` ,
76147 )
77148 }
149+ // altimate_change end
78150 }
79151}
0 commit comments