@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/watch.ts CHANGED
@@ -14,18 +14,42 @@
14
14
 
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
+ import {
18
+ executablePath,
19
+ isLiteralPattern,
20
+ normalizeGlob,
21
+ staticPrefix,
22
+ taskGlob,
23
+ xxh3,
24
+ } from '../util/index.js'
25
+ import { asTrees } from '../cache/index.js'
17
26
  import { parseRunArgs, resolveRunOptions } from './run.js'
18
- import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
19
27
  import {
28
+ fingerprintClaims,
29
+ forwardedSignal,
30
+ run as runOrchestrator,
31
+ type HeldPersistent,
32
+ type RunOptions,
33
+ } from '../orchestrator/index.js'
34
+ import {
35
+ buildPackageGraph,
20
36
  findWorkspaceRoot,
37
+ type LoadReads,
21
38
  listProjects,
22
39
  loadProjectConfig,
23
40
  loadWorkspace,
24
- loadWorkspaceConfig,
25
- resolveCacheDir,
41
+ LOCKFILE_NAME,
42
+ memberBaseDirs,
43
+ PROJECT_CONFIG_FILENAMES,
44
+ WORKSPACE_CONFIG_FILENAMES,
45
+ configImports,
26
46
  WORKSPACE_FINGERPRINT_FILES,
47
+ type ProjectEntry,
27
48
  type ProjectMeta,
28
49
  } from '../workspace/index.js'
50
+ import type { ProjectConfig } from '../config.js'
51
+ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
52
+ import { taskEdges, taskEdgesFrom } from './select.js'
29
53
 
30
54
  /** Wait this long after the last filesystem event before re-running. */
31
55
  const DEBOUNCE_MS = 150
@@ -65,31 +89,84 @@ export function isIgnoredWatchPath(rel: string): boolean {
65
89
  export function makeWatchIgnore(
66
90
  cacheDir: string,
67
91
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
92
+ inputs: ReadonlyMap<string, readonly string[]> = new Map(),
68
93
  ): (base: string, filename: string) => boolean {
69
94
  const cacheAbs = path.resolve(cacheDir)
70
95
  // A task's own outputs are not edits: without this every cycle that
71
96
  // writes `dist/` (or `out.txt`) re-runs once more, reporting
72
97
  // "up-to-date" for the trouble. Matched under the directory the globs
73
98
  // are relative to, whichever watcher delivered the event.
99
+ // The directory that HOLDS an output tree is the task's too: `dist/**`
100
+ // does not match `dist`, and since the clean before a miss prunes an
101
+ // emptied `dist` (2026-09-10) the task re-creates it, which the watcher
102
+ // reports as a change to `dist` itself — a second cycle per edit,
103
+ // reporting "up-to-date". The literal prefix of each glob (`dist` for
104
+ // `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
105
+ // every ancestor of it under the dir are output containers.
106
+ // A literal entry means the file or its whole tree — the resolver's own
107
+ // rule (`asTrees`), so the tree's files never count as edits.
74
108
  const declared = [...outputs].map(
75
- ([dir, globs]) => [path.resolve(dir), globs.map((g) => new Bun.Glob(g))] as const,
109
+ ([dir, globs]) =>
110
+ [
111
+ path.resolve(dir),
112
+ asTrees(globs).map((g) => taskGlob(g)),
113
+ globs.map(outputContainer).filter((c) => c !== ''),
114
+ ] as const,
115
+ )
116
+ // A path some task takes as an INPUT is never an output to ignore, even
117
+ // when another task declares it one: an in-place formatter declaring
118
+ // `src/**` hid every `src` edit from a `build` watched beside it, and no
119
+ // cycle ran (item 946). Negations are not consulted — they only narrow,
120
+ // and a path they would exclude costs one cache-hit cycle.
121
+ const read = [...inputs].map(
122
+ ([dir, globs]) =>
123
+ [
124
+ path.resolve(dir),
125
+ asTrees(globs.filter((g) => !g.startsWith('!'))).map((g) => taskGlob(g)),
126
+ ] as const,
76
127
  )
128
+ const isInput = (abs: string): boolean =>
129
+ read.some(([dir, globs]) => {
130
+ if (!abs.startsWith(dir + path.sep)) return false
131
+ const rel = abs
132
+ .slice(dir.length + 1)
133
+ .split(path.sep)
134
+ .join('/')
135
+ return globs.some((g) => g.match(rel))
136
+ })
77
137
  return (base, filename) => {
78
138
  if (isIgnoredWatchPath(filename)) return true
79
139
  const abs = path.resolve(base, filename)
80
140
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
81
- for (const [dir, globs] of declared) {
141
+ if (isInput(abs)) return false
142
+ for (const [dir, globs, containers] of declared) {
82
143
  if (!abs.startsWith(dir + path.sep)) continue
83
144
  const rel = abs
84
145
  .slice(dir.length + 1)
85
146
  .split(path.sep)
86
147
  .join('/')
87
148
  if (globs.some((g) => g.match(rel))) return true
149
+ if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
88
150
  }
89
151
  return false
90
152
  }
91
153
  }
92
154
 
155
+ /**
156
+ * The literal directory a glob's matches live under (`''` when the glob
157
+ * starts with a pattern, or negates). A literal entry is a file or its
158
+ * whole tree (schema: literal → tree), so the entry itself is the
159
+ * container; a pattern's is `staticPrefix` — the same rule the sandbox
160
+ * baseline and the deferral gate read.
161
+ */
162
+ function outputContainer(raw: string): string {
163
+ const glob = normalizeGlob(raw)
164
+ if (glob.startsWith('!')) return ''
165
+ if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
166
+ const prefix = staticPrefix(glob)
167
+ return prefix === '.' || prefix === '/' ? '' : prefix
168
+ }
169
+
93
170
  /**
94
171
  * The file `armWatcher` writes under a watched directory to prove the
95
172
  * watcher delivers. Intercepted by name before any other handling, so it
@@ -97,8 +174,51 @@ export function makeWatchIgnore(
97
174
  */
98
175
  export const WATCH_PROBE = '.vx-watch-probe'
99
176
 
177
+ /**
178
+ * The subset of `paths` git ignores, asked once per judgement (one
179
+ * `git check-ignore` per debounce window that has candidates, never per
180
+ * event). A git-ignored path is invisible to every cache key — inputs
181
+ * are tracked + untracked-not-ignored — so a cycle it starts can change
182
+ * nothing, and a task that writes one on every run (a pid file, a
183
+ * timestamped log, `.next/trace`) made the loop re-run itself forever
184
+ * (2026-09-16: 29 cycles in 8 s from one edit). A TRACKED file that
185
+ * matches a pattern is not reported, by git's own rule, so it stays an
186
+ * edit. Outside a repository (exit 128) nothing is ignored, as before.
187
+ */
188
+ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
189
+ const ignored = new Set<string>()
190
+ if (paths.length === 0) return ignored
191
+ let proc: ReturnType<typeof Bun.spawnSync>
192
+ try {
193
+ proc = Bun.spawnSync({
194
+ cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
195
+ cwd: workspaceRoot,
196
+ stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
197
+ stdout: 'pipe',
198
+ stderr: 'ignore',
199
+ })
200
+ } catch {
201
+ // No git on PATH: nothing is ignored, and the loop keeps judging —
202
+ // the initial run already said what vx requires.
203
+ return ignored
204
+ }
205
+ // 0: some ignored; 1: none. Anything else is git refusing (not a
206
+ // repository, a path inside a nested one): no path is ignored.
207
+ if (proc.exitCode !== 0) return ignored
208
+ for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
209
+ if (p.length > 0) ignored.add(p)
210
+ return ignored
211
+ }
212
+
100
213
  /** How long a watcher gets to report its own probe before the loop goes on without proof. */
101
214
  const WATCH_PROBE_TIMEOUT_MS = 2_000
215
+ /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
216
+ const WATCH_CLOCK_STAMP = '.vx-watch-clock'
217
+
218
+ /** Anything the loop needs to shut down at exit. */
219
+ export interface WatchHandle {
220
+ close(): void
221
+ }
102
222
 
103
223
  export interface ArmedWatcher {
104
224
  watcher: fs.FSWatcher
@@ -106,6 +226,101 @@ export interface ArmedWatcher {
106
226
  ready: Promise<boolean>
107
227
  }
108
228
 
229
+ /** How often the fallback re-walks a watched tree. */
230
+ const POLL_INTERVAL_MS = 250
231
+
232
+ /**
233
+ * Directory names the fallback never descends into WHEN no better filter is
234
+ * supplied. The justification is that `makeWatchIgnore` already drops their
235
+ * EVENTS, so the walk buys nothing — a poller pays for it either way, and
236
+ * `node_modules` is the difference between a cheap fallback and one that
237
+ * re-stats 40 000 files four times a second.
238
+ *
239
+ * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
240
+ * It used to carry a fourth name, `dist`, which the justification does NOT
241
+ * cover: `dist` is dropped only when a project DECLARES it as an output, and
242
+ * a project that does not declare it had its `dist/` sources silently
243
+ * invisible to `vx watch` on every host the poller exists for — a macOS
244
+ * sandbox, a network mount, a container bind — while the native watcher
245
+ * delivered them. Two watchers disagreeing about what an edit is (item 482).
246
+ * The real per-project answer is the caller's `skipDir`, below.
247
+ */
248
+ const POLL_SKIP = new Set(IGNORED_SEGMENTS)
249
+
250
+ /**
251
+ * A watcher built from `stat`, for when the OS one cannot deliver.
252
+ *
253
+ * `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
254
+ * `com.apple.FSEvents`; inside a sandbox that does not grant it the call
255
+ * SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
256
+ * 0 non-recursive, against 3 and 2 for the same writes outside — while
257
+ * `fs.watchFile` polling delivered in both). A network filesystem or a
258
+ * container bind mount fails the same way. Polling is slower and coarser,
259
+ * and it is the difference between `vx watch` working there and silently
260
+ * doing nothing.
261
+ */
262
+ export function pollWatcher(
263
+ dir: string,
264
+ recursive: boolean,
265
+ onEvent: (filename: string) => void,
266
+ intervalMs = POLL_INTERVAL_MS,
267
+ /**
268
+ * Directories not worth descending into, by their path relative to `dir`.
269
+ * The watch loop passes its own event filter, so the poller skips exactly
270
+ * what the filter would drop anyway — every declared output container, not
271
+ * a hard-coded name. The default covers the unconditional segments alone.
272
+ */
273
+ skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
274
+ ): WatchHandle {
275
+ let previous = new Map<string, number>()
276
+ let first = true
277
+ const scan = (): void => {
278
+ const current = new Map<string, number>()
279
+ const walk = (abs: string, rel: string): void => {
280
+ let entries: fs.Dirent[]
281
+ try {
282
+ entries = fs.readdirSync(abs, { withFileTypes: true })
283
+ } catch {
284
+ return // vanished or unreadable: its files simply stop appearing
285
+ }
286
+ for (const e of entries) {
287
+ if (e.name === WATCH_PROBE) continue
288
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
289
+ if (e.isDirectory()) {
290
+ if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
291
+ continue
292
+ }
293
+ if (!e.isFile()) continue
294
+ try {
295
+ current.set(childRel, fs.statSync(path.join(abs, e.name)).mtimeMs)
296
+ } catch {
297
+ // raced with a delete; the next scan settles it
298
+ }
299
+ }
300
+ }
301
+ walk(dir, '')
302
+ if (!first) {
303
+ for (const [rel, mtime] of current) {
304
+ if (previous.get(rel) !== mtime) onEvent(rel)
305
+ }
306
+ for (const rel of previous.keys()) {
307
+ if (!current.has(rel)) onEvent(rel)
308
+ }
309
+ }
310
+ previous = current
311
+ first = false
312
+ }
313
+ scan()
314
+ // Deliberately NOT unref'd: once the native watcher is closed this timer
315
+ // is the only thing keeping `vx watch` alive.
316
+ const timer = setInterval(scan, intervalMs)
317
+ return {
318
+ close(): void {
319
+ clearInterval(timer)
320
+ },
321
+ }
322
+ }
323
+
109
324
  /**
110
325
  * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
111
326
  * FSEvents stream that another thread schedules AFTER the call returns, and
@@ -132,10 +347,20 @@ export function armWatcher(
132
347
  })
133
348
  const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
134
349
  if (filename == null || typeof filename !== 'string') return
350
+ // An event naming the watched directory itself (macOS reports the
351
+ // directory a write landed in as its own item) carries nothing a key
352
+ // can see; the write's own event names the file.
353
+ if (filename === '' || filename === '.') return
135
354
  if (filename === WATCH_PROBE) {
136
355
  markReady(true)
137
356
  return
138
357
  }
358
+ // Another arm's probe, seen by this recursive watcher under a nested
359
+ // directory (a nested project's, a member base inside a project): no
360
+ // key sees it, and passing it on ran a cycle, and restarted a dev
361
+ // server, with no edit made (item 1016). Only the watcher's own probe
362
+ // proves this stream live.
363
+ if (path.basename(filename) === WATCH_PROBE) return
139
364
  onEvent(filename)
140
365
  })
141
366
  const probe = path.join(dir, WATCH_PROBE)
@@ -213,23 +438,54 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
213
438
  process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
214
439
  return 0
215
440
  }
216
- // The watch loop owns SIGINT/SIGTERM for its whole lifetime (the
217
- // process.once handlers below close watchers and resolve 0). A
218
- // cycle's run() must not install its exit-the-process handlers —
219
- // Ctrl-C mid-cycle would kill the loop with 130 instead of the
220
- // loop's own clean shutdown.
221
- const opts: RunOptions = { ...resolved, handleSignals: false }
441
+ // The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
442
+ // run() must not install its exit-the-process handlers — Ctrl-C
443
+ // mid-cycle would kill the loop with 130 instead of the loop's own
444
+ // clean shutdown — so it gets `signal` instead: on SIGINT/SIGTERM the
445
+ // controller aborts with the signal's name as the reason, the in-flight
446
+ // cycle forwards it to its children (a SIGINT as SIGINT, the rest as
447
+ // SIGTERM; grace, SIGKILL) and returns, and the loop resolves 0.
448
+ // Installed BEFORE the initial run: until 2026-09-10 the handlers went
449
+ // in with the loop, so a SIGTERM during the initial run took Bun's
450
+ // default (exit 143) and left the cycle's children running under init.
451
+ const stop = new AbortController()
452
+ const opts: RunOptions = {
453
+ ...resolved,
454
+ handleSignals: false,
455
+ signal: stop.signal,
456
+ holdPersistent: true,
457
+ }
458
+ // Every cycle's `invocations` row names the verb, as `vx run`'s does;
459
+ // run()'s process.argv fallback put the bin's absolute path there, so
460
+ // `vx last --list` showed `$ /…/bin.ts watch build` beside `$ vx run build`.
461
+ opts.command = ['vx', 'watch', ...args].join(' ')
462
+ // A staged load from the selection pass is one run's worth of configs;
463
+ // every cycle after an edit must evaluate live.
464
+ delete opts.staged
465
+ process.once('SIGINT', () => {
466
+ process.stdout.write('\nvx watch: stopped\n')
467
+ stop.abort('SIGINT')
468
+ })
469
+ process.once('SIGTERM', () => stop.abort('SIGTERM'))
470
+ // A task runs in its own session (exec/kill-tree.ts): the terminal
471
+ // closing reaches the loop alone, and the loop passes it on.
472
+ process.once('SIGHUP', () => stop.abort('SIGHUP'))
222
473
 
223
474
  // Enumerate projects-in-scope so we know what dirs to watch.
224
475
  // `opts.projects` is the resolved scope; undefined means "every
225
- // project". We watch only those (plus the workspace root, for
226
- // lockfile changes).
227
- const workspaceRoot = await findWorkspaceRoot(cwd)
228
- const allProjects = await listProjects(await loadWorkspace(workspaceRoot))
229
- const scope =
230
- opts.projects === undefined
231
- ? allProjects
232
- : allProjects.filter((p) => opts.projects!.includes(p.name))
476
+ // project". The watched set is what a cycle can RUN: the scope plus
477
+ // its transitive dependencies (a cycle runs `lib#build` for
478
+ // `app#build`'s `^build`, so a `lib` edit is an edit) — the same
479
+ // closure `--filter 'app...'` walks, computed below once the initial
480
+ // run has staged the configs. Plus the workspace root, for lockfile
481
+ // changes.
482
+ const reads: LoadReads = new Map()
483
+ const workspaceRoot = await findWorkspaceRoot(cwd, reads)
484
+ const workspace = await loadWorkspace(workspaceRoot, reads)
485
+ const allProjects = await listProjects(workspace)
486
+ const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
487
+ opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
488
+ const scope = inScope(allProjects)
233
489
 
234
490
  if (scope.length === 0) {
235
491
  process.stderr.write(`vx watch: no projects in scope\n`)
@@ -248,107 +504,540 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
248
504
  // closing it properly needs the workspaceWide decision made without loading
249
505
  // configs.
250
506
  process.stdout.write('vx watch: initial run...\n\n')
251
- await runOrchestrator(opts)
507
+ const initial = await runOrchestrator(opts)
508
+ if (stop.signal.aborted) {
509
+ await initial.persistent?.stop(forwardedSignal(stop.signal.reason))
510
+ return 0
511
+ }
252
512
 
253
- const swept = await sweepConfigs(allProjects, workspaceRoot)
513
+ const load: CliLoadOptions = {
514
+ ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
515
+ ...(opts.frozen === true ? { frozen: true } : {}),
516
+ }
517
+ const swept = await sweepConfigs(allProjects, workspaceRoot, load)
518
+ const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
519
+ const ws = await loadCliWorkspace(workspaceRoot)
254
520
  return await runWatchLoop({
255
521
  opts,
522
+ held: initial.persistent,
523
+ stop: stop.signal,
256
524
  workspaceRoot,
257
- projects: scope,
525
+ projects: watched,
258
526
  workspaceWide: swept.workspaceWide,
527
+ projectDirs: watched.map((p) => p.dir),
528
+ workspaceInputs: swept.workspaceInputs,
259
529
  outputs: swept.outputs,
530
+ inputs: swept.inputs,
531
+ uncached: swept.uncached,
532
+ configImports: swept.configImports,
533
+ workspaceConfigImports: swept.workspaceConfigImports,
534
+ memberBases: memberBaseDirs(workspace),
535
+ packageDirs: new Set(allProjects.map((p) => p.dir)),
536
+ // The workspace as the cycle that just ran saw it: a package added or
537
+ // removed since the loop armed joins or leaves the watched set. The
538
+ // scope is the one resolved at start; a new package joins it only as a
539
+ // dependency of it.
540
+ rediscover: async () => {
541
+ const all = await listProjects(await loadWorkspace(workspaceRoot))
542
+ const sweep = await sweepConfigs(all, workspaceRoot, load)
543
+ const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
544
+ return {
545
+ projects: now,
546
+ workspaceWide: sweep.workspaceWide,
547
+ workspaceInputs: sweep.workspaceInputs,
548
+ outputs: sweep.outputs,
549
+ inputs: sweep.inputs,
550
+ uncached: sweep.uncached,
551
+ configImports: sweep.configImports,
552
+ workspaceConfigImports: sweep.workspaceConfigImports,
553
+ packageDirs: new Set(all.map((p) => p.dir)),
554
+ }
555
+ },
556
+ // Under --frozen every cycle's configs are the lock's, so a re-lock is
557
+ // the one edit that changes what a cycle runs; unheard, the loop ran
558
+ // the old lock until a restart (item 971).
559
+ claimedRootFiles: new Set([
560
+ ...fingerprintClaims(ws.plugins).keys(),
561
+ ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
562
+ ]),
260
563
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
261
- cacheDir:
262
- opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
564
+ cacheDir: opts.cacheDir ?? ws.cacheDir,
263
565
  })
264
566
  }
265
567
 
568
+ /**
569
+ * The projects a cycle can run, so both watcher arms cover the same dirs:
570
+ * the scope plus its transitive dependencies through the package graph
571
+ * and the cross-project `dependsOn` edges the task graph adds
572
+ * (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
573
+ * per-project arm watched the filter's answer only, so
574
+ * `vx watch build --filter app` never re-ran on a `lib` edit that
575
+ * `vx run build --filter app` would have rebuilt. A whole-workspace
576
+ * scope is every project already and walks nothing. A config the loader
577
+ * rejects contributes no edges here; the run that just happened said so.
578
+ */
579
+ export async function watchedProjects(
580
+ workspaceRoot: string,
581
+ allProjects: readonly ProjectMeta[],
582
+ scope: readonly ProjectMeta[],
583
+ load: CliLoadOptions = {},
584
+ staged: ReadonlyMap<string, ProjectEntry> | null = null,
585
+ ): Promise<ProjectMeta[]> {
586
+ if (scope.length === allProjects.length) return [...allProjects]
587
+ let edges: Map<string, string[]> | undefined
588
+ try {
589
+ edges =
590
+ staged !== null
591
+ ? taskEdgesFrom(staged)
592
+ : (await taskEdges(workspaceRoot, allProjects, load)).edges
593
+ } catch {
594
+ edges = undefined
595
+ }
596
+ const graph = buildPackageGraph([...allProjects], edges)
597
+ const names = new Set(scope.map((p) => p.name))
598
+ for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
599
+ return allProjects.filter((p) => names.has(p.name))
600
+ }
601
+
266
602
  /**
267
603
  * One sweep over every config for the two things the loop needs: whether
268
604
  * any task declares `inputs.workspaceFiles` — globs with no project
269
- * boundary, so the per-project watchers can't see all triggering paths
270
- * and the loop switches to one recursive root watcher — and each
271
- * project's declared outputs, so their writes are not taken for edits.
272
- * Checked across ALL projects (not just the scope) because dependsOn can
273
- * pull tasks from anywhere; a broken out-of-scope config is skipped,
274
- * matching scoped-run semantics (it surfaces when that project enters
275
- * scope).
605
+ * prefix, so the whole tree has to be watched — and every project's
606
+ * declared outputs, so their writes are not taken for edits. The run
607
+ * path's load (`loadProjects`): the plugin `project` stage applies, so an
608
+ * output a plugin gave a config-less package is ignored like a declared
609
+ * one, and a pure config is served from its cached evaluation rather than
610
+ * re-evaluated in a worker. A config that fails to load is out of this
611
+ * concern's scope — the run that just happened already said so, or will —
612
+ * and the sweep falls back to the files that do load, one by one.
276
613
  */
277
- async function sweepConfigs(
614
+ export async function sweepConfigs(
278
615
  projects: readonly ProjectMeta[],
279
616
  workspaceRoot: string,
280
- ): Promise<{ workspaceWide: boolean; outputs: Map<string, string[]> }> {
617
+ load: CliLoadOptions = {},
618
+ ): Promise<{
619
+ workspaceWide: boolean
620
+ workspaceInputs: string[]
621
+ outputs: Map<string, string[]>
622
+ /** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
623
+ inputs: Map<string, string[]>
624
+ /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
625
+ uncached: Set<string>
626
+ /** Files the project configs import by relative path (item 949). */
627
+ configImports: string[]
628
+ /** Files the workspace config imports by relative path: loaded once per process (item 949). */
629
+ workspaceConfigImports: string[]
630
+ /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
631
+ staged: Map<string, ProjectEntry> | null
632
+ }> {
281
633
  const outputs = new Map<string, string[]>()
282
- const add = (dir: string, globs: readonly string[] | undefined): void => {
634
+ const inputs = new Map<string, string[]>()
635
+ const addTo = (
636
+ into: Map<string, string[]>,
637
+ dir: string,
638
+ globs: readonly string[] | undefined,
639
+ ): void => {
283
640
  if (globs === undefined || globs.length === 0) return
284
- outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
285
- }
286
- // Concurrent, not sequential: the run that just happened already
287
- // loaded the in-scope configs, so these are REPEAT loads that
288
- // re-evaluate in a worker. Issuing them together lets one worker
289
- // serve the whole sweep instead of one per project.
290
- const uses = await Promise.all(
291
- projects.map(async (p): Promise<boolean> => {
292
- if (p.configPath === null) return false
641
+ into.set(dir, [...(into.get(dir) ?? []), ...globs])
642
+ }
643
+ const add = (dir: string, globs: readonly string[] | undefined): void =>
644
+ addTo(outputs, dir, globs)
645
+ const workspaceInputs = new Set<string>()
646
+ const uncached = new Set<string>()
647
+ const fold = (dir: string, config: ProjectConfig): void => {
648
+ for (const task of Object.values(config.tasks ?? {})) {
649
+ if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
650
+ uncached.add(dir)
651
+ for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
652
+ addTo(inputs, dir, task.cache?.inputs?.files)
653
+ addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
654
+ add(dir, task.cache?.outputs?.files)
655
+ add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
656
+ }
657
+ }
658
+ const result = (
659
+ staged: Map<string, ProjectEntry> | null,
660
+ ): {
661
+ workspaceWide: boolean
662
+ workspaceInputs: string[]
663
+ outputs: Map<string, string[]>
664
+ inputs: Map<string, string[]>
665
+ uncached: Set<string>
666
+ configImports: string[]
667
+ workspaceConfigImports: string[]
668
+ staged: Map<string, ProjectEntry> | null
669
+ } => ({
670
+ workspaceWide: workspaceInputs.size > 0,
671
+ workspaceInputs: [...workspaceInputs],
672
+ outputs,
673
+ inputs,
674
+ uncached,
675
+ configImports: [...projectImports],
676
+ workspaceConfigImports: [...wsImports],
677
+ staged,
678
+ })
679
+ const projectImports = new Set<string>()
680
+ const wsImports = new Set<string>()
681
+ await Promise.all([
682
+ ...projects.map(async (p) => {
683
+ if (p.configPath === null) return
684
+ for (const f of await configImports(p.configPath)) projectImports.add(f)
685
+ }),
686
+ ...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
687
+ for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
688
+ }),
689
+ ])
690
+ let staged: Map<string, ProjectEntry> | null = null
691
+ try {
692
+ staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
693
+ } catch {
694
+ staged = null
695
+ }
696
+ if (staged !== null) {
697
+ for (const p of staged.values()) fold(p.dir, p.config)
698
+ return result(staged)
699
+ }
700
+ await Promise.all(
701
+ projects.map(async (p) => {
702
+ if (p.configPath === null) return
293
703
  try {
294
- const config = await loadProjectConfig(p.configPath)
295
- let wide = false
296
- for (const task of Object.values(config.tasks ?? {})) {
297
- if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) wide = true
298
- add(p.dir, task.cache?.outputs?.files)
299
- add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
300
- }
301
- return wide
704
+ fold(p.dir, await loadProjectConfig(p.configPath))
302
705
  } catch {
303
706
  // broken config — out of this concern's scope
304
- return false
305
707
  }
306
708
  }),
307
709
  )
308
- return { workspaceWide: uses.includes(true), outputs }
710
+ return result(null)
711
+ }
712
+
713
+ /**
714
+ * Under the recursive root watcher, the events that can move a key. The
715
+ * watcher hears every write in the workspace; a key can see three kinds
716
+ * of path and no other: a file inside a project's directory (its own
717
+ * `inputs.files`, or its outputs, which the ignore filter drops next), a
718
+ * workspace fingerprint file at the root, and a match of a declared
719
+ * `inputs.workspaceFiles` glob. Everything else — a log written at the
720
+ * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
721
+ * dropped before the trigger, so it costs no cycle. Before this rule the
722
+ * one Turbo idiom that puts every repo on this watcher
723
+ * (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
724
+ * inside the repo a loop that never settled: each cycle's output grew the
725
+ * log, the log was an event, the event was a cycle. Negated globs are not
726
+ * consulted: a `!` only narrows, and an event it would have excluded costs
727
+ * one cache-hit cycle, which is the wrong direction to be clever in.
728
+ */
729
+ export function makeRootEventFilter(
730
+ workspaceRoot: string,
731
+ projectDirs: readonly string[],
732
+ workspaceInputs: readonly string[],
733
+ claimedRootFiles: ReadonlySet<string> = new Set(),
734
+ ): (filename: string) => boolean {
735
+ const dirs = projectDirs.map((d) => path.resolve(d))
736
+ const globs = workspaceInputs
737
+ .map(normalizeGlob)
738
+ .filter((g) => !g.startsWith('!'))
739
+ .map((g) => taskGlob(g))
740
+ return (filename: string): boolean => {
741
+ const rel = filename.split(path.sep).join('/')
742
+ // The depth test is a READING AID, not a guard: both predicates below
743
+ // are exact membership in a set of BARE names, so a `rel` carrying a
744
+ // slash can never be in one. Measured with it removed (item 502) over
745
+ // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
746
+ // eight more: every answer identical. The two table rows that look
747
+ // like they pin it — `nested/pnpm-lock.yaml → false` and
748
+ // `nested/vx.workspace.ts → false` — pass either way; what they
749
+ // actually pin is that the predicates stay exact rather than becoming
750
+ // a basename or suffix match, which is the change that WOULD make
751
+ // this line load-bearing.
752
+ if (
753
+ !rel.includes('/') &&
754
+ (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
755
+ ) {
756
+ return true
757
+ }
758
+ const abs = path.resolve(workspaceRoot, filename)
759
+ for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
760
+ for (const g of globs) if (g.match(rel)) return true
761
+ return false
762
+ }
309
763
  }
310
764
 
311
765
  interface WatchLoopArgs {
312
766
  opts: RunOptions
767
+ /** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
768
+ held: HeldPersistent | undefined
769
+ /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
770
+ stop: AbortSignal
313
771
  workspaceRoot: string
314
772
  projects: readonly ProjectMeta[]
315
773
  workspaceWide: boolean
774
+ /** Every project's directory, in scope or not — under the root watcher an edit there is an edit. */
775
+ projectDirs: readonly string[]
776
+ /** Every declared `inputs.workspaceFiles` glob, root-relative. */
777
+ workspaceInputs: readonly string[]
316
778
  /** Absolute, already-resolved — the loop must never re-derive it. */
317
779
  cacheDir: string
318
780
  /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
319
781
  outputs: ReadonlyMap<string, readonly string[]>
782
+ /** Declared input globs per directory: never ignored as another task's output. */
783
+ inputs: ReadonlyMap<string, readonly string[]>
784
+ /** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
785
+ uncached: ReadonlySet<string>
786
+ /** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
787
+ configImports: readonly string[]
788
+ /** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
789
+ workspaceConfigImports: readonly string[]
790
+ /**
791
+ * Root files a plugin claims (`VxPlugin.fingerprint`): a lockfile, or a
792
+ * file its stages read (`turbo.json`, item 961). The workspace config is
793
+ * loaded once per process, so the set is fixed for the loop.
794
+ */
795
+ claimedRootFiles: ReadonlySet<string>
796
+ /** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
797
+ memberBases: readonly string[]
798
+ /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
799
+ packageDirs: ReadonlySet<string>
800
+ /** The watched set again, after a cycle that followed an event which can change it. */
801
+ rediscover: () => Promise<Rediscovered>
802
+ }
803
+
804
+ interface Rediscovered {
805
+ projects: readonly ProjectMeta[]
806
+ workspaceWide: boolean
807
+ workspaceInputs: readonly string[]
808
+ outputs: ReadonlyMap<string, readonly string[]>
809
+ inputs: ReadonlyMap<string, readonly string[]>
810
+ uncached: ReadonlySet<string>
811
+ configImports: readonly string[]
812
+ workspaceConfigImports: readonly string[]
813
+ packageDirs: ReadonlySet<string>
320
814
  }
321
815
 
816
+ const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
817
+
818
+ /**
819
+ * A file whose edit can change what the loop watches: a `package.json`
820
+ * (a dependency added under `--filter` widens the closure), a project
821
+ * config (a task that starts declaring `workspaceFiles` needs the root
822
+ * watcher, a new output must stop being an event) or the workspace config
823
+ * (a plugin's `project` stage). Until item 891 only a member directory
824
+ * coming or going re-read the set, so each of these waited for a restart
825
+ * while the loop looked alive.
826
+ */
827
+ function shapesWatchedSet(filename: string): boolean {
828
+ const base = path.basename(filename)
829
+ return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
830
+ }
831
+
832
+ const CLOSED: WatchHandle = { close() {} }
833
+
322
834
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
323
- const { opts, workspaceRoot, projects, workspaceWide, cacheDir, outputs } = args
835
+ const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
836
+ // The watched set as of the last cycle: `rearm` replaces these after an
837
+ // event that can change it, and every filter below reads the current one.
838
+ let workspaceWide = args.workspaceWide
839
+ let packageDirs = args.packageDirs
840
+ let projectDirs = args.projectDirs
841
+ let workspaceInputs = args.workspaceInputs
842
+ let outputs = args.outputs
843
+ let inputs = args.inputs
844
+ let uncached = args.uncached
845
+ let configImportFiles = args.configImports
846
+ let wsConfigImportFiles = args.workspaceConfigImports
847
+ // A dev server stays up while the loop idles; the cycle that replaces it
848
+ // stops it first, so the new one never meets the old one's port.
849
+ let held = args.held
324
850
 
325
- // Reentrancy guard — never two orchestrator runs in flight. While
326
- // one is running, any further events set `pending = true` and the
327
- // loop drains it after the current run finishes.
851
+ // Reentrancy guard — never two orchestrator runs in flight. Events that
852
+ // land while one is running wait in `pendingPaths` and are judged, on
853
+ // settled bytes, one debounce window after it ends.
328
854
  let running = false
329
- let pending = false
855
+ /** The cycle in flight, so the stop path can wait for its teardown before resolving. */
856
+ let inFlight: Promise<void> = Promise.resolve()
330
857
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
331
858
 
332
- const trigger = (label: string): void => {
859
+ // Declared outputs are ignored by PATH above. A task with no `cache`
860
+ // block declares none and still writes into its project, and the
861
+ // watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
862
+ // writes the same bytes, the event re-runs — forever (the init
863
+ // walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
864
+ // cache block). An undeclared write is caught by STATE, judged on what
865
+ // has settled (see `trigger`): a path whose settled state equals what
866
+ // this loop last saw for it is not a change. A file's state is its
867
+ // bytes; a directory's is its entries' names and sizes (a nested edit
868
+ // arrives as that path's own event); a path that is gone is one more
869
+ // state. A real edit changes the state; a first sighting passes through.
870
+ // So `rm -rf dist && tsc` — the shape of most build scripts — settles
871
+ // to the same `dist` it left and is one redundant cycle, not a loop
872
+ // (2026-09-10: 780 executions in two minutes from one edit, when a
873
+ // deletion and a directory each passed the gate unconditionally).
874
+ const ABSENT = -1n
875
+ const lastState = new Map<string, bigint>()
876
+ const settledState = (abs: string): bigint => {
877
+ let st: fs.Stats
878
+ try {
879
+ st = fs.statSync(abs)
880
+ } catch {
881
+ return ABSENT
882
+ }
883
+ if (!st.isDirectory()) {
884
+ try {
885
+ return xxh3(fs.readFileSync(abs))
886
+ } catch {
887
+ return ABSENT
888
+ }
889
+ }
890
+ const entries: string[] = []
891
+ try {
892
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
893
+ let size = 0
894
+ if (e.isFile()) {
895
+ try {
896
+ size = fs.statSync(path.join(abs, e.name)).size
897
+ } catch {
898
+ size = -1
899
+ }
900
+ }
901
+ entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
902
+ }
903
+ } catch {
904
+ return ABSENT
905
+ }
906
+ entries.sort()
907
+ return xxh3(Buffer.from(entries.join('\n')))
908
+ }
909
+ // A path this loop has never judged is a change only if it moved since
910
+ // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
911
+ // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
912
+ // made — FSEvents hands a stream what landed just before it started),
913
+ // and a first sighting used to pass unconditionally; the path's mtime
914
+ // says which side of the arm it belongs to. A path already gone is a
915
+ // change: a deletion has no date to read.
916
+ const sameState = (abs: string): boolean => {
917
+ const state = settledState(abs)
918
+ const prev = lastState.get(abs)
919
+ lastState.set(abs, state)
920
+ if (prev !== undefined) return prev === state
921
+ return state !== ABSENT && modifiedBefore(abs, armedAt)
922
+ }
923
+
924
+ // Paths that fired since the last judgement, first label wins. The state
925
+ // check runs when the timer fires, on SETTLED state: per event it is
926
+ // wrong on Linux, where a shell redirect truncates the file (one event,
927
+ // empty) and then writes it (another, full), so consecutive events never
928
+ // agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
929
+ // macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
930
+ // judged: the run's own writes are mid-flight (a `dist` deleted and not
931
+ // yet rebuilt is a state the tree will not keep), so the paths wait and
932
+ // are judged one window after the run ends, all together — an edit made
933
+ // meanwhile still differs from what the loop last saw and re-runs.
934
+ const pendingPaths = new Map<string, string>()
935
+ // A path that starts cycle after cycle from the run's own writes is a
936
+ // task rewriting a file with different bytes every run (a pid file, a
937
+ // timestamped log): the state gate cannot settle it, and nothing here
938
+ // can tell the third such write from a user's third save mid-run — so
939
+ // watch names it once, with the remedy, and keeps going. "The run's
940
+ // own write" is read off the path itself: its mtime falls inside the
941
+ // previous cycle's window. Not off which judgement started the cycle:
942
+ // macOS delivers a run's writes late, after the loop's own post-run
943
+ // judgement found nothing and broke out, so there every such cycle
944
+ // starts from the idle timer (CI, 2026-09-16: the storm ran, the
945
+ // notice never came).
946
+ const streak = { abs: '', n: 0 }
947
+ const noticed = new Set<string>()
948
+ let lastCycle: { start: number; end: number } | undefined
949
+ const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
950
+ // A server the last cycle left running is still that cycle's: its
951
+ // writes land after the cycle ended, and with a closed window a dev
952
+ // server that rewrites a log in its project restarted itself forever
953
+ // with no word of it, 12 restarts in 8 s (item 948). The initial run's
954
+ // server is one too, from the arm on.
955
+ const open = openWhileHeld && held !== undefined
956
+ if (lastCycle === undefined && !open) return false
957
+ try {
958
+ const m = fs.statSync(abs).mtimeMs
959
+ const start = lastCycle?.start ?? armedAt
960
+ return m >= start && (open || m <= lastCycle!.end)
961
+ } catch {
962
+ return false
963
+ }
964
+ }
965
+ const judge = (): string | undefined => {
966
+ const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
967
+ let first: string | undefined
968
+ let firstAbs: string | undefined
969
+ // `sameState` before the `first` test, never after it: it is what
970
+ // RECORDS a path's settled state, and every path this judgement saw
971
+ // must be recorded even though only the first change names the cycle.
972
+ // Short-circuiting after the winner leaves the rest unjudged, and
973
+ // their next event is a first sighting stamped after the arm — so a
974
+ // batch edit (a `git checkout`) makes the same bytes written to any
975
+ // of the others a change.
976
+ // A git-ignored path keys nothing, but a task with no cache has no key:
977
+ // it reads what it likes, and its `.env.local` edit re-ran nothing
978
+ // (item 947). Under such a project an ignored path is judged when the
979
+ // user wrote it; one the last cycle wrote (the pid file an uncached
980
+ // task rewrites every run, the loop the filter exists for) stays out.
981
+ const editForUncached = (p: string): boolean =>
982
+ [...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
983
+ for (const [p, l] of pendingPaths) {
984
+ if (ignored.has(p) && !editForUncached(p)) continue
985
+ if (!sameState(p) && first === undefined) {
986
+ first = l
987
+ firstAbs = p
988
+ }
989
+ }
990
+ pendingPaths.clear()
991
+ const byServer = firstAbs !== undefined && held !== undefined
992
+ if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
993
+ streak.n = 0
994
+ return first
995
+ }
996
+ streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
997
+ streak.abs = firstAbs
998
+ if (streak.n >= 3 && !noticed.has(firstAbs)) {
999
+ noticed.add(firstAbs)
1000
+ process.stdout.write(
1001
+ byServer
1002
+ ? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
1003
+ : `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`,
1004
+ )
1005
+ }
1006
+ return first
1007
+ }
1008
+ const trigger = (label: string, abs: string): void => {
1009
+ if (!pendingPaths.has(abs)) pendingPaths.set(abs, label)
1010
+ if (running) return
333
1011
  if (debounceTimer) clearTimeout(debounceTimer)
334
1012
  debounceTimer = setTimeout(() => {
335
1013
  debounceTimer = null
336
- void cycle(label)
1014
+ if (running) return
1015
+ const first = judge()
1016
+ if (first !== undefined) void cycle(first)
337
1017
  }, DEBOUNCE_MS)
338
1018
  }
339
1019
 
340
1020
  const cycle = async (label: string): Promise<void> => {
341
- if (running) {
342
- pending = true
343
- return
344
- }
1021
+ if (stop.aborted || running) return
345
1022
  running = true
1023
+ const done = (inFlight = runCycle(label))
1024
+ await done
1025
+ }
1026
+ const runCycle = async (first: string): Promise<void> => {
346
1027
  try {
347
- do {
348
- pending = false
1028
+ let label: string | undefined = first
1029
+ while (label !== undefined && !stop.aborted) {
349
1030
  process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
350
1031
  try {
351
- await runOrchestrator(opts)
1032
+ await held?.stop()
1033
+ held = undefined
1034
+ const start = Date.now()
1035
+ held = (await runOrchestrator(opts)).persistent
1036
+ lastCycle = { start, end: Date.now() }
1037
+ if (reread && !stop.aborted) {
1038
+ reread = false
1039
+ await rearm()
1040
+ }
352
1041
  } catch (err) {
353
1042
  // A re-run can fail catastrophically when the workspace
354
1043
  // itself moved out from under us — e.g. the user deleted
@@ -360,10 +1049,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
360
1049
  const message = err instanceof Error ? err.message : String(err)
361
1050
  process.stderr.write(`vx watch: cycle failed: ${message}\n`)
362
1051
  }
363
- // If a change arrived mid-run, loop again immediately.
364
- } while (pending)
1052
+ // What landed mid-run is judged on settled state, one window
1053
+ // after the run, under the label of what actually arrived.
1054
+ if (pendingPaths.size === 0 || stop.aborted) break
1055
+ await Bun.sleep(DEBOUNCE_MS)
1056
+ label = judge()
1057
+ }
365
1058
  } finally {
366
1059
  running = false
1060
+ // Anything that landed after the last judgement waits for a timer
1061
+ // like any other event.
1062
+ const next = pendingAfterCycle(pendingPaths, stop.aborted)
1063
+ if (next !== undefined) trigger(next[1], next[0])
367
1064
  }
368
1065
  }
369
1066
 
@@ -371,84 +1068,337 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
371
1068
  // project's dir recursively, so a `node_modules` write under a
372
1069
  // project would otherwise trigger every save during `bun install` —
373
1070
  // and vx's own cache writes would trigger a cycle that writes again.
374
- const isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
1071
+ let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1072
+ let matters = makeRootEventFilter(
1073
+ workspaceRoot,
1074
+ projectDirs,
1075
+ workspaceInputs,
1076
+ args.claimedRootFiles,
1077
+ )
1078
+ /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
1079
+ let reread = false
375
1080
 
376
- const watchers: fs.FSWatcher[] = []
1081
+ const watchers: WatchHandle[] = []
377
1082
  const proofs: Promise<void>[] = []
378
- const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
1083
+ // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
1084
+ // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
1085
+ // network mount, a container bind — the attempt costs a denied syscall
1086
+ // and a two-second wait before the fallback takes over anyway.
1087
+ const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
1088
+ if (forcePoll)
1089
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
1090
+ // The poller skips exactly what the event filter would drop: the
1091
+ // unconditional segments AND this run's declared output containers. Read
1092
+ // through `isIgnoredPath` rather than captured, because `rearm` replaces
1093
+ // the filter when the selection changes.
1094
+ const skipUnder =
1095
+ (dir: string) =>
1096
+ (rel: string): boolean =>
1097
+ isIgnoredPath(dir, rel)
1098
+ // By slot, not by handle: an OS watcher that never proves delivery is
1099
+ // swapped for a poller in place, and a drop must close what is there.
1100
+ const arm = (
1101
+ dir: string,
1102
+ recursive: boolean,
1103
+ onEvent: (filename: string) => void,
1104
+ ): WatchHandle => {
1105
+ const at = watchers.length
1106
+ const handle: WatchHandle = {
1107
+ close: () => {
1108
+ watchers[at]?.close()
1109
+ watchers[at] = CLOSED
1110
+ },
1111
+ }
1112
+ if (forcePoll) {
1113
+ watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
1114
+ return handle
1115
+ }
379
1116
  const armed = armWatcher(dir, recursive, onEvent)
380
1117
  watchers.push(armed.watcher)
381
1118
  proofs.push(
382
1119
  armed.ready.then((ok) => {
383
- if (!ok) {
384
- process.stderr.write(
385
- `vx watch: ${dir}: the watcher gave no sign of life within ${WATCH_PROBE_TIMEOUT_MS} ms; early edits there may be missed\n`,
386
- )
387
- }
1120
+ if (ok) return
1121
+ armed.watcher.close()
1122
+ // Dropped by a rearm before its proof settled: nothing to swap in.
1123
+ if (watchers[at] !== armed.watcher) return
1124
+ // The watcher never proved delivery, so it is not one: an FSEvents
1125
+ // stream the OS refused, a filesystem that reports nothing. Swap in
1126
+ // the poller rather than run a loop that silently never fires.
1127
+ watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
1128
+ process.stderr.write(
1129
+ `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
1130
+ )
388
1131
  }),
389
1132
  )
1133
+ return handle
390
1134
  }
391
1135
 
392
- if (workspaceWide) {
393
- // workspaceFiles inputs in play: any file in the workspace can be
394
- // an input, so one recursive root watcher replaces the per-project
395
- // ones (it also covers lockfile / pnpm-workspace.yaml edits). The
396
- // ignore filter keeps node_modules / .git / .vx churn out; edits
397
- // outside any task's inputs still cost only a cache-hit cycle.
1136
+ /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
1137
+ const armedAt = fsClockNow(cacheDir)
1138
+ /** Per-project arms by directory, so `rearm` can add and drop them. */
1139
+ const perProject = new Map<string, WatchHandle>()
1140
+ /** The root arm: recursive when workspace-wide, else the root's own files only. */
1141
+ let rootArm: WatchHandle = CLOSED
1142
+ const armProject = (proj: ProjectMeta): void => {
398
1143
  try {
399
- arm(workspaceRoot, true, (filename) => {
400
- if (isIgnoredPath(workspaceRoot, filename)) return
401
- trigger(`root ${filename}`)
1144
+ const handle = arm(proj.dir, true, (filename) => {
1145
+ if (isIgnoredPath(proj.dir, filename)) return
1146
+ if (shapesWatchedSet(filename)) reread = true
1147
+ trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
402
1148
  })
1149
+ perProject.set(proj.dir, handle)
1150
+ } catch (err) {
1151
+ const msg = err instanceof Error ? err.message : String(err)
1152
+ process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
1153
+ }
1154
+ }
1155
+ const armRoot = (): void => {
1156
+ try {
1157
+ rootArm = workspaceWide
1158
+ ? // workspaceFiles inputs in play: a root-relative glob can name a
1159
+ // file anywhere, so one recursive root watcher replaces the
1160
+ // per-project ones (it also covers lockfile / pnpm-workspace.yaml
1161
+ // edits). `matters` keeps the events a key can see — project
1162
+ // trees, root fingerprint files, the declared globs — and drops
1163
+ // the rest of the tree; the ignore filter then keeps node_modules
1164
+ // / .git / .vx and declared outputs out of what remains.
1165
+ arm(workspaceRoot, true, (filename) => {
1166
+ if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
1167
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1168
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1169
+ })
1170
+ : // The root itself, non-recursive, beside the per-project arms, so
1171
+ // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
1172
+ // no project dir saw the change.
1173
+ arm(workspaceRoot, false, (filename) => {
1174
+ if (
1175
+ isWorkspaceFingerprintFile(filename) ||
1176
+ isWorkspaceConfigFile(filename) ||
1177
+ args.claimedRootFiles.has(filename)
1178
+ ) {
1179
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1180
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1181
+ }
1182
+ })
403
1183
  } catch (err) {
404
1184
  const msg = err instanceof Error ? err.message : String(err)
405
1185
  process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
406
1186
  }
407
- } else {
408
- // One recursive watcher per project. Each project owns its own
409
- // subtree; we don't watch the workspace root recursively (would
410
- // cover every project + node_modules + caches).
411
- for (const proj of projects) {
1187
+ }
1188
+ // One recursive watcher per project, unless workspace-wide: each project
1189
+ // owns its own subtree; the root is never watched recursively for them
1190
+ // (it would cover every project + node_modules + caches).
1191
+ const armMode = (watched: readonly ProjectMeta[]): void => {
1192
+ if (!workspaceWide) for (const proj of watched) armProject(proj)
1193
+ armRoot()
1194
+ }
1195
+ const dropMode = (): void => {
1196
+ for (const handle of perProject.values()) handle.close()
1197
+ perProject.clear()
1198
+ rootArm.close()
1199
+ rootArm = CLOSED
1200
+ }
1201
+
1202
+ /**
1203
+ * A directory under a member base with no package in it yet. The base's
1204
+ * own watcher is not recursive, so the `package.json` that makes it a
1205
+ * package is an event only here; an editor or `git checkout` that makes
1206
+ * the directory first and the manifest after left the package unwatched
1207
+ * for good (item 891). On a rearm a new entry is armed before its
1208
+ * manifest is checked, so one that landed after the re-read is an event
1209
+ * or is seen by the check. At start there is no re-read to race, and a
1210
+ * manifest already there is a directory the globs exclude.
1211
+ */
1212
+ const pending = new Map<string, WatchHandle>()
1213
+ const armPending = (afterReread: boolean): void => {
1214
+ const want = new Set<string>()
1215
+ for (const base of memberBases)
1216
+ for (const name of memberEntries(base)) {
1217
+ const dir = path.join(base, name)
1218
+ if (!packageDirs.has(dir)) want.add(dir)
1219
+ }
1220
+ for (const [dir, handle] of pending) {
1221
+ if (want.has(dir)) continue
1222
+ handle.close()
1223
+ pending.delete(dir)
1224
+ }
1225
+ for (const dir of want) {
1226
+ if (pending.has(dir)) continue
1227
+ const manifest = path.join(dir, 'package.json')
1228
+ const arrived = (): void => {
1229
+ reread = true
1230
+ trigger(path.relative(workspaceRoot, manifest), manifest)
1231
+ }
1232
+ try {
1233
+ pending.set(
1234
+ dir,
1235
+ arm(dir, false, (filename) => {
1236
+ if (filename === 'package.json') arrived()
1237
+ }),
1238
+ )
1239
+ } catch (err) {
1240
+ const msg = err instanceof Error ? err.message : String(err)
1241
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
1242
+ continue
1243
+ }
1244
+ if (afterReread && fs.existsSync(manifest)) arrived()
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * A config's imports from outside the watched projects: a shared preset
1250
+ * (`../../shared/preset.mjs`) changed what a run evaluates and no arm
1251
+ * saw it (item 949). One non-recursive arm per directory holding one.
1252
+ * A project config's import re-reads and runs; a workspace config's is
1253
+ * loaded once per process (Bun keeps the module), so its edit is named
1254
+ * with the restart it needs instead of a cycle that would run stale.
1255
+ */
1256
+ const importArms = new Map<string, WatchHandle>()
1257
+ let importNames = new Map<string, Set<string>>()
1258
+ const staleNamed = new Map<string, number>()
1259
+ const armImports = (): void => {
1260
+ const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
1261
+ const want = new Map<string, Set<string>>()
1262
+ for (const f of new Set([...configImportFiles, ...wsConfigImportFiles])) {
1263
+ if (inProject(f)) continue
1264
+ const names = want.get(path.dirname(f)) ?? new Set<string>()
1265
+ names.add(path.basename(f))
1266
+ want.set(path.dirname(f), names)
1267
+ }
1268
+ importNames = want
1269
+ for (const [dir, handle] of importArms) {
1270
+ if (want.has(dir)) continue
1271
+ handle.close()
1272
+ importArms.delete(dir)
1273
+ }
1274
+ for (const dir of want.keys()) {
1275
+ if (importArms.has(dir)) continue
412
1276
  try {
413
- arm(proj.dir, true, (filename) => {
414
- if (isIgnoredPath(proj.dir, filename)) return
415
- trigger(`${proj.name} ${filename}`)
416
- })
1277
+ importArms.set(
1278
+ dir,
1279
+ arm(dir, false, (filename) => {
1280
+ if (!(importNames.get(dir)?.has(filename) ?? false)) return
1281
+ const abs = path.join(dir, filename)
1282
+ const rel = path.relative(workspaceRoot, abs)
1283
+ if (configImportFiles.includes(abs)) {
1284
+ reread = true
1285
+ trigger(`config import ${rel}`, abs)
1286
+ return
1287
+ }
1288
+ // One line per save, not per event: a save is often two.
1289
+ const now = Date.now()
1290
+ if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
1291
+ staleNamed.set(abs, now)
1292
+ process.stdout.write(
1293
+ `vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
1294
+ )
1295
+ }),
1296
+ )
417
1297
  } catch (err) {
418
1298
  const msg = err instanceof Error ? err.message : String(err)
419
- process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
1299
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
420
1300
  }
421
1301
  }
1302
+ }
422
1303
 
423
- // Plus the workspace root itself (non-recursive) so lockfile +
424
- // pnpm-workspace.yaml edits trigger re-runs even when no project
425
- // dir saw the change.
1304
+ const watchingLine = (count: number): string =>
1305
+ workspaceWide
1306
+ ? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
1307
+ : `vx watch: watching ${count} project(s)`
1308
+
1309
+ const rearm = async (): Promise<void> => {
1310
+ let next: Rediscovered
1311
+ try {
1312
+ next = await args.rediscover()
1313
+ } catch (err) {
1314
+ const msg = err instanceof Error ? err.message : String(err)
1315
+ process.stderr.write(`vx watch: cannot re-read the workspace: ${msg}\n`)
1316
+ return
1317
+ }
1318
+ projectDirs = next.projects.map((p) => p.dir)
1319
+ workspaceInputs = next.workspaceInputs
1320
+ outputs = next.outputs
1321
+ inputs = next.inputs
1322
+ uncached = next.uncached
1323
+ configImportFiles = next.configImports
1324
+ wsConfigImportFiles = next.workspaceConfigImports
1325
+ packageDirs = next.packageDirs
1326
+ isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1327
+ matters = makeRootEventFilter(
1328
+ workspaceRoot,
1329
+ projectDirs,
1330
+ workspaceInputs,
1331
+ args.claimedRootFiles,
1332
+ )
1333
+ if (next.workspaceWide !== workspaceWide) {
1334
+ // A task started or stopped declaring `workspaceFiles`: the other
1335
+ // arm's shape. Until item 891 the choice was made once, at start, and
1336
+ // a root file a config newly named was never an event.
1337
+ dropMode()
1338
+ workspaceWide = next.workspaceWide
1339
+ armMode(next.projects)
1340
+ } else if (!workspaceWide) {
1341
+ const keep = new Set(projectDirs)
1342
+ for (const [dir, handle] of perProject) {
1343
+ if (keep.has(dir)) continue
1344
+ handle.close()
1345
+ perProject.delete(dir)
1346
+ }
1347
+ for (const proj of next.projects) if (!perProject.has(proj.dir)) armProject(proj)
1348
+ }
1349
+ armPending(true)
1350
+ armImports()
1351
+ // A new arm proves delivery like the first ones: an edit in the new
1352
+ // package right after this cycle is seen, not lost in the gap.
1353
+ await Promise.all(proofs)
1354
+ process.stdout.write(`${watchingLine(next.projects.length)}\n`)
1355
+ }
1356
+
1357
+ armMode(projects)
1358
+
1359
+ // A package added while the loop runs is a directory entry appearing
1360
+ // under the glob's directory (`packages/` for `packages/*`); one
1361
+ // non-recursive watcher there hears it come or go, and the cycle it
1362
+ // triggers re-reads the workspace (`rearm`) so the new package's own
1363
+ // edits are cycles from then on. Until 2026-09-10 the watched set was
1364
+ // fixed when the loop armed: the next cycle ran the new package, and
1365
+ // every edit inside it after that was silence.
1366
+ for (const base of memberBases) {
1367
+ let members = memberEntries(base)
426
1368
  try {
427
- arm(workspaceRoot, false, (filename) => {
428
- if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
1369
+ arm(base, false, (filename) => {
1370
+ if (isIgnoredWatchPath(filename)) return
1371
+ // Only a member coming or going. On macOS a non-recursive watcher
1372
+ // also reports a member whose CONTENTS changed (FSEvents names the
1373
+ // directory a write landed in), so a task writing into its own
1374
+ // project — or the arm's own probe file — read as a member event and
1375
+ // cost an uncached task one execution per cycle (CI, 2026-09-10).
1376
+ const now = memberEntries(base)
1377
+ if (sameMembers(members, now)) return
1378
+ members = now
1379
+ reread = true
1380
+ trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
429
1381
  })
430
1382
  } catch (err) {
431
1383
  const msg = err instanceof Error ? err.message : String(err)
432
- process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
1384
+ process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
433
1385
  }
434
1386
  }
1387
+ armPending(false)
1388
+ armImports()
435
1389
 
436
- // The orchestrator's writes into `.vx/cache/` don't trigger
437
- // re-runs because IGNORED_SEGMENTS includes `.vx`. Users who
438
- // relocate the cache dir outside `.vx/` need their own filtering.
1390
+ // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
1391
+ // closes over the RESOLVED cache dir (relocated or not) and the tasks'
1392
+ // declared outputs.
439
1393
 
440
1394
  // "watching" is a promise that an edit from now on is seen; every
441
1395
  // watcher has proved (or been given 2 s to prove) delivery first.
442
1396
  await Promise.all(proofs)
443
1397
 
444
- process.stdout.write(
445
- workspaceWide
446
- ? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
447
- : `\nvx watch: watching ${projects.length} project(s); press Ctrl+C to stop\n`,
448
- )
1398
+ process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
449
1399
 
450
1400
  return await new Promise<number>((resolve) => {
451
- const cleanup = (): void => {
1401
+ const cleanup = async (): Promise<void> => {
452
1402
  for (const w of watchers) {
453
1403
  try {
454
1404
  w.close()
@@ -457,16 +1407,112 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
457
1407
  }
458
1408
  }
459
1409
  if (debounceTimer) clearTimeout(debounceTimer)
1410
+ // The aborted cycle is tearing its children down; resolve only once
1411
+ // it has returned, so the process never exits over a live child.
1412
+ await inFlight
1413
+ await held?.stop(forwardedSignal(stop.reason))
460
1414
  resolve(0)
461
1415
  }
462
- process.once('SIGINT', () => {
463
- process.stdout.write('\nvx watch: stopped\n')
464
- cleanup()
465
- })
466
- process.once('SIGTERM', cleanup)
1416
+ if (stop.aborted) {
1417
+ void cleanup()
1418
+ return
1419
+ }
1420
+ stop.addEventListener('abort', () => void cleanup(), { once: true })
467
1421
  })
468
1422
  }
469
1423
 
1424
+ /**
1425
+ * The event a finished cycle hands back to the timer, if any.
1426
+ *
1427
+ * The inner loop re-runs while anything is pending, so this covers only the
1428
+ * narrow gap between its LAST judgement and the loop going idle: an event
1429
+ * landing there would otherwise sit in `pendingPaths` with no timer armed
1430
+ * and no cycle to notice it — watch quietly idle over an edit the user
1431
+ * made. Insertion order, because the map is "first label wins" and the
1432
+ * label is what the cycle announces.
1433
+ *
1434
+ * A branch, not a race: extracted so it can be pinned at all. Deleting the
1435
+ * arming left every green test in the repo green (2026-09-20) — the e2e
1436
+ * watch fixture spawns a real `vx watch`, so it cannot deliver an event at
1437
+ * that instant, and the suites that could are the ones this repo's cloud
1438
+ * container already fails for timing reasons. What this pins is the
1439
+ * decision; the delivery window is the process's own.
1440
+ */
1441
+ export function pendingAfterCycle(
1442
+ pending: ReadonlyMap<string, string>,
1443
+ aborted: boolean,
1444
+ ): [abs: string, label: string] | undefined {
1445
+ if (aborted || pending.size === 0) return undefined
1446
+ const [abs, label] = [...pending][0]!
1447
+ return [abs, label]
1448
+ }
1449
+
1450
+ /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
1451
+ export function modifiedBefore(abs: string, t: number): boolean {
1452
+ try {
1453
+ // The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
1454
+ // carry a file's OLD mtime onto the new one, and judged by mtime alone
1455
+ // such an edit was "before the arm" and never ran (item 945). No
1456
+ // process can set a ctime, and a rename or a write moves it.
1457
+ const st = fs.statSync(abs)
1458
+ return Math.max(st.mtimeMs, st.ctimeMs) < t
1459
+ } catch {
1460
+ return false
1461
+ }
1462
+ }
1463
+
1464
+ /**
1465
+ * "Now" as the filesystem will stamp the next write, not as `Date.now()`
1466
+ * reads it. A file's mtime comes from the kernel's coarse clock, which
1467
+ * runs up to a tick behind the fine clock `Date.now()` reads — measured
1468
+ * on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
1469
+ * mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
1470
+ * more under CPU load, where the tick is skipped. An `armedAt` from the
1471
+ * fine clock then judged an edit made right after the ready line as
1472
+ * "modified before the arm" and dropped it (the watch e2e flake, three
1473
+ * shard runs that day; traced: raw event, trigger, `same=true`). A stamp
1474
+ * read off a file written here is on the mtime clock itself, and every
1475
+ * later write's mtime is at or after it.
1476
+ */
1477
+ export function fsClockNow(dir: string): number {
1478
+ const stamp = path.join(dir, WATCH_CLOCK_STAMP)
1479
+ try {
1480
+ fs.writeFileSync(stamp, '')
1481
+ const at = fs.statSync(stamp).mtimeMs
1482
+ fs.unlinkSync(stamp)
1483
+ return at
1484
+ } catch {
1485
+ return Date.now()
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * The directory entries under a package glob's directory that can be
1491
+ * members: directories (or links) whose name is not dotted and not
1492
+ * `node_modules`, the same rule discovery applies. A watcher on that
1493
+ * directory reacts only when this set changes.
1494
+ */
1495
+ export function memberEntries(base: string): ReadonlySet<string> {
1496
+ const out = new Set<string>()
1497
+ let entries: fs.Dirent[]
1498
+ try {
1499
+ entries = fs.readdirSync(base, { withFileTypes: true })
1500
+ } catch {
1501
+ return out
1502
+ }
1503
+ for (const e of entries) {
1504
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
1505
+ if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
1506
+ }
1507
+ return out
1508
+ }
1509
+
1510
+ function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
1511
+ if (a.size !== b.size) return false
1512
+ for (const name of a) if (!b.has(name)) return false
1513
+ return true
1514
+ }
1515
+
470
1516
  /**
471
1517
  * True for a root file that re-keys EVERY task, so a change to one must trigger
472
1518
  * a cycle even though it lives in no project dir.
@@ -483,3 +1529,16 @@ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FIL
483
1529
  function isWorkspaceFingerprintFile(name: string): boolean {
484
1530
  return FINGERPRINT_FILES.has(name)
485
1531
  }
1532
+
1533
+ /**
1534
+ * The workspace config is the one root file that shapes a run without
1535
+ * being any task's input — its plugins, `config` stage, concurrency, cache
1536
+ * dir — and a cycle re-evaluates it (its import is keyed on its bytes).
1537
+ * Until 2026-09-10 neither root arm listened for it: a plugin added under
1538
+ * `vx watch` waited for a restart while the loop looked alive.
1539
+ */
1540
+ const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
1541
+
1542
+ function isWorkspaceConfigFile(name: string): boolean {
1543
+ return WORKSPACE_CONFIGS.has(name)
1544
+ }