@vzn/vx 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -30,8 +30,6 @@ export const WATCH_PROBE = '.vx-watch-probe'
30
30
 
31
31
  /** How long a watcher gets to report its own probe before the loop goes on without proof. */
32
32
  export const WATCH_PROBE_TIMEOUT_MS = 2_000
33
- /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
34
- const WATCH_CLOCK_STAMP = '.vx-watch-clock'
35
33
 
36
34
  /** Anything the loop needs to shut down at exit. */
37
35
  export interface WatchHandle {
@@ -104,20 +102,19 @@ export function pollWatcher(
104
102
  if (e.name === WATCH_PROBE) continue
105
103
  const childRel = rel === '' ? e.name : `${rel}/${e.name}`
106
104
  if (e.isDirectory()) {
107
- if (recursive) {
108
- if (!skipDir(childRel)) walk(path.join(abs, e.name), childRel)
109
- } else {
110
- // A non-recursive arm is there for a name coming or going (a
111
- // project's parent hears the project made again), as the OS
112
- // watcher reports it: by the directory's identity, not its
113
- // times, which every write inside moves.
114
- try {
115
- const st = fs.statSync(path.join(abs, e.name))
116
- current.set(childRel, `dir:${st.dev}:${st.ino}:${st.birthtimeMs}`)
117
- } catch {
118
- // raced with a delete; the next scan settles it
119
- }
105
+ if (recursive && skipDir(childRel)) continue
106
+ // A directory is sampled for a name coming or going (a project's
107
+ // parent hears the project made again, one moved in names its
108
+ // files, whose times predate the arm), as the OS watcher reports
109
+ // it: by its identity, not its times, which every write inside
110
+ // moves.
111
+ try {
112
+ const st = fs.statSync(path.join(abs, e.name))
113
+ current.set(childRel, `dir:${st.dev}:${st.ino}:${st.birthtimeMs}`)
114
+ } catch {
115
+ continue // raced with a delete; the next scan settles it
120
116
  }
117
+ if (recursive) walk(path.join(abs, e.name), childRel)
121
118
  continue
122
119
  }
123
120
  // A link is an input as its target string (watch-judge.ts), so that
@@ -184,6 +181,8 @@ export function armWatcher(
184
181
  recursive: boolean,
185
182
  onEvent: (filename: string) => void,
186
183
  timeoutMs = WATCH_PROBE_TIMEOUT_MS,
184
+ /** The poller's `skipDir`, asked on Linux of a directory under `node_modules` only. */
185
+ skipDir: (rel: string) => boolean = (rel) => isIgnoredDir(rel.split('/')),
187
186
  ): ArmedWatcher {
188
187
  let markReady: (ok: boolean) => void = () => {}
189
188
  let arrived = false
@@ -211,7 +210,7 @@ export function armWatcher(
211
210
  }
212
211
  const watcher: WatchHandle =
213
212
  recursive && process.platform === 'linux'
214
- ? treeWatcher(dir, listener)
213
+ ? treeWatcher(dir, listener, skipDir)
215
214
  : fs.watch(dir, { recursive, persistent: true }, (_event, filename) => listener(filename))
216
215
  const probe = path.join(dir, WATCH_PROBE)
217
216
  const ready = (async (): Promise<boolean> => {
@@ -220,7 +219,7 @@ export function armWatcher(
220
219
  // measured 2026-09-03, with every delivered event under 60 ms). So it is
221
220
  // re-written on a short backoff until its event arrives — the first write
222
221
  // after the stream goes live is the one that proves it.
223
- const deadline = Date.now() + timeoutMs
222
+ const deadline = performance.now() + timeoutMs
224
223
  let ok = false
225
224
  let step = 50
226
225
  while (!ok) {
@@ -229,7 +228,7 @@ export function armWatcher(
229
228
  } catch {
230
229
  break // an unwritable dir gets no proof; the watcher is kept
231
230
  }
232
- const remaining = deadline - Date.now()
231
+ const remaining = deadline - performance.now()
233
232
  if (remaining <= 0) {
234
233
  // The write itself can outlast the budget (a loaded macOS runner:
235
234
  // 261 ms against 100, E-87), and the loop has not yielded yet: let
@@ -267,8 +266,19 @@ export function armWatcher(
267
266
  * inotify watch follows the inode and would report the old name. The
268
267
  * arming walk throws at the watch limit, so the pool's fallback still
269
268
  * applies; a subdirectory that vanished mid-walk is simply not watched.
269
+ * A `node_modules` is entered where `skipDir` keeps it: an input names a
270
+ * path there, or git tracks a file there (watch-filter.ts).
270
271
  */
271
- function treeWatcher(root: string, listener: (filename: string) => void): WatchHandle {
272
+ function treeWatcher(
273
+ root: string,
274
+ listener: (filename: string) => void,
275
+ skipDir: (rel: string) => boolean,
276
+ ): WatchHandle {
277
+ const skips = (rel: string): boolean => {
278
+ const segments = rel.split('/')
279
+ if (isIgnoredDir(segments) && segments.at(-1) !== 'node_modules') return true
280
+ return segments.includes('node_modules') && skipDir(rel)
281
+ }
272
282
  const watchers = new Map<string, fs.FSWatcher>()
273
283
  // Which directory each watch holds: inotify follows the inode, so a name
274
284
  // removed and made again before its event is handled stats as a
@@ -300,7 +310,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
300
310
  if (name == null || typeof name !== 'string' || name === '' || name === '.') return
301
311
  const child = rel === '' ? name : `${rel}/${name}`
302
312
  listener(child)
303
- if (isIgnoredDir(child.split('/'))) return
313
+ if (skips(child)) return
304
314
  let st: fs.Stats
305
315
  try {
306
316
  st = fs.statSync(path.join(abs, name))
@@ -347,7 +357,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
347
357
  for (const e of entries) {
348
358
  const child = rel === '' ? e.name : `${rel}/${e.name}`
349
359
  if (e.isDirectory()) {
350
- if (!isIgnoredDir(child.split('/'))) watchDir(child, report)
360
+ if (!skips(child)) watchDir(child, report)
351
361
  } else if (report) listener(child)
352
362
  }
353
363
  }
@@ -384,31 +394,6 @@ export function modifiedBefore(abs: string, t: number): boolean {
384
394
  }
385
395
  }
386
396
 
387
- /**
388
- * "Now" as the filesystem will stamp the next write, not as `Date.now()`
389
- * reads it. A file's mtime comes from the kernel's coarse clock, which
390
- * runs up to a tick behind the fine clock `Date.now()` reads — measured
391
- * on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
392
- * mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
393
- * more under CPU load, where the tick is skipped. An `armedAt` from the
394
- * fine clock then judged an edit made right after the ready line as
395
- * "modified before the arm" and dropped it (the watch e2e flake, three
396
- * shard runs that day; traced: raw event, trigger, `same=true`). A stamp
397
- * read off a file written here is on the mtime clock itself, and every
398
- * later write's mtime is at or after it.
399
- */
400
- export function fsClockNow(dir: string): number {
401
- const stamp = path.join(dir, WATCH_CLOCK_STAMP)
402
- try {
403
- fs.writeFileSync(stamp, '')
404
- const at = fs.statSync(stamp).mtimeMs
405
- fs.unlinkSync(stamp)
406
- return at
407
- } catch {
408
- return Date.now()
409
- }
410
- }
411
-
412
397
  /** A handle that watches nothing: a slot a drop emptied, an arm not made. */
413
398
  export const CLOSED: WatchHandle = { close() {} }
414
399
 
@@ -460,7 +445,9 @@ export class WatcherPool {
460
445
  }
461
446
  let armed: ArmedWatcher
462
447
  try {
463
- armed = armWatcher(dir, recursive, onEvent)
448
+ armed = armWatcher(dir, recursive, onEvent, WATCH_PROBE_TIMEOUT_MS, (rel) =>
449
+ this.skip(dir, rel),
450
+ )
464
451
  } catch (err) {
465
452
  // The OS's watch limit, not the directory: the loop said "watching"
466
453
  // and never fired. The poller needs no watch slot.
@@ -4,13 +4,16 @@
4
4
 
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
- import { filesBehind } from '../cache/index.js'
7
+ import { FILE_HASH_RACY_MS, filesBehind, racyWindowMs } from '../cache/index.js'
8
+ import { commandWindows, type CommandWindows } from '../orchestrator/index.js'
8
9
  import { printable, xxh3 } from '../util/index.js'
9
10
  import * as ui from '../ui/index.js'
10
11
  import { gitIgnored, gitSpeller } from './watch-filter.js'
11
12
  import { modifiedBefore } from './watch-fs.js'
12
13
 
13
14
  const ABSENT = -1n
15
+ /** A state no judgement saw (`unseen`): equal to none, since a digest is never negative. */
16
+ const UNSEEN = -2n
14
17
  /** `settledState` of a directory with no entries. */
15
18
  const EMPTY_DIR = xxh3(Buffer.from(''))
16
19
 
@@ -108,6 +111,8 @@ export interface JudgeContext {
108
111
  awaited?(abs: string): boolean
109
112
  /** The files git listed at the arm; absent when git could not answer. */
110
113
  existedAtArm?: ReadonlySet<string>
114
+ /** "Now" on the mtime clock (`fsClockNow`); `Date.now()` when absent. */
115
+ clock?(): number
111
116
  /** The files git tracked at the arm; absent when git could not answer. */
112
117
  trackedAtArm?: ReadonlySet<string>
113
118
  }
@@ -128,6 +133,12 @@ export class ChangeJudge {
128
133
  changed: readonly string[] = []
129
134
  /** The last cycle's window, its start on the mtime clock (`fsClockNow`); a write inside it is the run's own. */
130
135
  lastCycle: { start: number; end: number } | undefined
136
+ /**
137
+ * The cycle the last change-finding judgement started: from that
138
+ * judgement on the mtime clock, until the loop sets `to` when its run
139
+ * ends, with the run's command windows (`RunOptions.commandWindows`).
140
+ */
141
+ cycle: { from: number; to: number; windows: CommandWindows } | undefined
131
142
  // Declared outputs are ignored by PATH above. A task with no `cache`
132
143
  // block declares none and still writes into its project, and the
133
144
  // watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
@@ -169,6 +180,52 @@ export class ChangeJudge {
169
180
  if (!this.lastState.has(abs)) this.lastState.set(abs, settledState(abs))
170
181
  }
171
182
 
183
+ /**
184
+ * An event on `abs`. A write that lands after the judgement that started
185
+ * a cycle and before its run ends, where no command of the run may write
186
+ * (`CommandWindows.writerDuring`) and no server the loop holds may have,
187
+ * is the user's: the run may have keyed its bytes, so the path is a
188
+ * change at its next judgement whatever it settles to. Judged against
189
+ * the state that judgement recorded, an edit landing before the keys and
190
+ * put back while the cycle ran settled to "the same", and the outputs
191
+ * stood built from bytes the tree no longer held. A write inside a
192
+ * command's window is judged by state as before: a task rewriting a file
193
+ * with the same bytes every run is no change, and so is a user's write
194
+ * there, which the run cannot tell from its own. Read when the event
195
+ * comes, not at the judgement: the edit's own time, before a revert
196
+ * stamps over it. The stamp runs up to a tick behind `Date.now()`, which
197
+ * dates the windows: a task's write was dated before its window opened,
198
+ * and every such write re-ran the loop, so the window widens by the racy
199
+ * window, as the hash memo judges a stamp.
200
+ */
201
+ sawEvent(abs: string): void {
202
+ const c = this.cycle
203
+ if (c === undefined) return
204
+ let t: number
205
+ try {
206
+ t = fs.lstatSync(abs).ctimeMs
207
+ } catch {
208
+ return
209
+ }
210
+ if (t < c.from || t > c.to) return
211
+ if (c.windows.writerDuring('', abs, t, t + racyWindowMs(t, FILE_HASH_RACY_MS)) !== undefined)
212
+ return
213
+ if (this.ctx.held() && !this.trackedAtArm(abs)) return
214
+ this.lastState.set(abs, UNSEEN)
215
+ }
216
+
217
+ /**
218
+ * The inputs a run read in a state no judgement saw (`TaskOutcome.driftedInput`):
219
+ * edited and put back while the task ran, a file settles to the state this
220
+ * loop last saw, and judged against it, the output built from the other
221
+ * state stood with no cycle after it. Each is a change at its next
222
+ * judgement; null (the input unnamed) makes every pending path one.
223
+ */
224
+ unseen(moved: readonly (string | null)[]): void {
225
+ for (const abs of moved)
226
+ for (const p of abs === null ? [...this.pending.keys()] : [abs]) this.lastState.set(p, UNSEEN)
227
+ }
228
+
172
229
  // A path this loop has never judged is a change only if it moved since
173
230
  // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
174
231
  // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
@@ -178,17 +235,18 @@ export class ChangeJudge {
178
235
  // change: a deletion has no date to read.
179
236
  // One exception: a path git did not list at the arm, and gone now, was
180
237
  // born and removed since (vim's `4913` write probe, a tool's lock file),
181
- // unless a nested repository holds it (`inNestedRepo`); it started a cycle with nothing changed. The blind spot: one born in
182
- // the moment between a judgement and that cycle's keys, and gone by the
183
- // next judgement, was read and its deletion re-runs nothing. Git lists
184
- // no ignored path, so one of those is a deletion as before.
238
+ // unless a nested repository holds it (`inNestedRepo`); it started a cycle with nothing changed. One born
239
+ // during a cycle where no command of it may write is a change all the
240
+ // same (`sawEvent`): the run may have read it. The blind spot left: one
241
+ // gone again before its event is read, which has no time to judge by.
242
+ // Git lists no ignored path, so one of those is a deletion as before.
185
243
  private sameState(abs: string, ignored: boolean, spell: (p: string) => string): boolean {
186
244
  const state = settledState(abs)
187
245
  const prev = this.lastState.get(abs)
188
246
  this.lastState.set(abs, state)
189
247
  if (prev !== undefined) return prev === state
190
- // A new empty directory holds nothing a key reads, and the poller,
191
- // sampling files, never reported one: a file made in it is the edit.
248
+ // A new empty directory holds nothing a key reads: a file made in it
249
+ // is the edit.
192
250
  // One a watch waits on (a project directory made again) is a change:
193
251
  // its cycle re-arms the project.
194
252
  if (state === EMPTY_DIR && isDirectory(abs) && this.ctx.awaited?.(abs) !== true) return true
@@ -241,6 +299,8 @@ export class ChangeJudge {
241
299
  }
242
300
 
243
301
  judge(): string | undefined {
302
+ // Before any state is read: a write after this is one the judgement may not have seen.
303
+ const at = this.ctx.clock?.() ?? Date.now()
244
304
  const ignored = gitIgnored(this.ctx.workspaceRoot, [...this.pending.keys()])
245
305
  const spell = gitSpeller(this.ctx.workspaceRoot)
246
306
  let first: string | undefined
@@ -260,12 +320,15 @@ export class ChangeJudge {
260
320
  // task rewrites every run, the loop the filter exists for) stays out,
261
321
  // as does one in a project nested under it, which it does not own.
262
322
  // So is one a task's inputs name outright (`.env.local`, `dist/**`).
323
+ // A write `sawEvent` found no command of the cycle could have made is
324
+ // the user's though it lies in the cycle: taken for the run's own, an
325
+ // edit to `node_modules/.gen/**` after its task ran never re-ran.
263
326
  const editForUncached = (p: string): boolean =>
264
327
  ([...this.ctx.uncached()].some(
265
328
  (dir) => p.startsWith(dir + path.sep) && this.ctx.fenced?.(dir, p) !== true,
266
329
  ) ||
267
330
  (this.ctx.namedIgnored?.() ?? []).some((n) => p === n || p.startsWith(n + path.sep))) &&
268
- !this.writtenDuringLastCycle(p)
331
+ (this.lastState.get(p) === UNSEEN || !this.writtenDuringLastCycle(p))
269
332
  // A path still there names the cycle before a gone one: an editor or
270
333
  // `sed -i` saving through a temporary file fires that file first, and
271
334
  // the cycle was announced by a name already renamed away.
@@ -306,6 +369,7 @@ export class ChangeJudge {
306
369
  // read its write; resetting on either, the storm ran on unnamed (macOS
307
370
  // CI, 2026-10-29).
308
371
  if (firstAbs === undefined) return named
372
+ this.cycle = { from: at, to: Infinity, windows: commandWindows() }
309
373
  const byServer = this.ctx.held()
310
374
  if (!this.writtenDuringLastCycle(firstAbs, spell)) {
311
375
  this.streak.n = 0
@@ -319,7 +383,7 @@ export class ChangeJudge {
319
383
  'warn',
320
384
  byServer
321
385
  ? `vx watch: ${first} has started 3 cycles in a row, written while a server was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write starts a cycle.\n`
322
- : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
386
+ : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
323
387
  'out',
324
388
  )
325
389
  }
@@ -26,8 +26,10 @@ import { type CliLoadOptions, loadCliProjects } from './workspace-config.js'
26
26
  * per-project arm watched the filter's answer only, so
27
27
  * `vx watch build --filter app` never re-ran on a `lib` edit that
28
28
  * `vx run build --filter app` would have rebuilt. A whole-workspace
29
- * scope is every project already and walks nothing. A config the loader
30
- * rejects contributes no edges here; the run that just happened said so.
29
+ * scope is every project already and walks nothing. When the edges cannot
30
+ * be read (a config the loader rejects, a dependency's directory removed)
31
+ * every project is watched: the scope alone dropped the dependencies a
32
+ * cycle runs, and nothing heard them again until a `vx lock`.
31
33
  */
32
34
  export async function watchedProjects(
33
35
  workspaceRoot: string,
@@ -37,14 +39,14 @@ export async function watchedProjects(
37
39
  staged: ReadonlyMap<string, ProjectEntry> | null = null,
38
40
  ): Promise<ProjectMeta[]> {
39
41
  if (scope.length === allProjects.length) return [...allProjects]
40
- let edges: Map<string, string[]> | undefined
42
+ let edges: Map<string, string[]>
41
43
  try {
42
44
  edges =
43
45
  staged !== null
44
46
  ? taskEdgesFrom(staged)
45
47
  : (await taskEdges(workspaceRoot, allProjects, load)).edges
46
48
  } catch {
47
- edges = undefined
49
+ return [...allProjects]
48
50
  }
49
51
  const graph = buildPackageGraph([...allProjects], edges)
50
52
  const names = new Set(scope.map((p) => p.name))
@@ -132,8 +134,8 @@ export async function sweepConfigs(
132
134
  }
133
135
  const foldTasks = (dir: string, config: ProjectConfig, names: Set<string> | null): void => {
134
136
  for (const [name, task] of Object.entries(config.tasks ?? {})) {
135
- add(dir, task.cache?.outputs?.files)
136
- add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
137
+ add(dir, task.outputs?.files)
138
+ add(workspaceRoot, task.outputs?.workspaceFiles)
137
139
  if (names !== null && !names.has(name)) continue
138
140
  if (
139
141
  task.inputs === undefined &&
@@ -147,8 +149,8 @@ export async function sweepConfigs(
147
149
  for (const rel of named.workspaceFiles) namedIgnored.add(path.join(workspaceRoot, rel))
148
150
  }
149
151
  for (const g of task.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
150
- addInputs(dir, task.inputs?.files, task.cache?.outputs?.files)
151
- addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.cache?.outputs?.workspaceFiles)
152
+ addInputs(dir, task.inputs?.files, task.outputs?.files)
153
+ addInputs(workspaceRoot, task.inputs?.workspaceFiles, task.outputs?.workspaceFiles)
152
154
  }
153
155
  }
154
156
  const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({