@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
package/src/cli/watch.ts CHANGED
@@ -14,15 +14,25 @@
14
14
 
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
+ import { normalizeGlob, staticPrefix, xxh3 } from '../util/index.js'
18
+ import { asTrees } from '../cache/index.js'
17
19
  import { parseRunArgs, resolveRunOptions } from './run.js'
18
20
  import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
19
21
  import {
22
+ buildPackageGraph,
20
23
  findWorkspaceRoot,
21
24
  listProjects,
22
25
  loadProjectConfig,
23
26
  loadWorkspace,
27
+ memberBaseDirs,
28
+ WORKSPACE_CONFIG_FILENAMES,
29
+ WORKSPACE_FINGERPRINT_FILES,
30
+ type ProjectEntry,
24
31
  type ProjectMeta,
25
32
  } from '../workspace/index.js'
33
+ import type { ProjectConfig } from '../config.js'
34
+ import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
35
+ import { taskEdges, taskEdgesFrom } from './select.js'
26
36
 
27
37
  /** Wait this long after the last filesystem event before re-running. */
28
38
  const DEBOUNCE_MS = 150
@@ -31,6 +41,263 @@ const DEBOUNCE_MS = 150
31
41
  const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
32
42
  const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
33
43
 
44
+ /**
45
+ * True for a path the ignore filter drops. Every project dir is watched
46
+ * RECURSIVELY, so without this a `bun install` would re-run the graph on every
47
+ * file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
48
+ * writes to `.vx/cache` again.
49
+ *
50
+ * Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
51
+ * in the path, not only at the root.
52
+ */
53
+ export function isIgnoredWatchPath(rel: string): boolean {
54
+ const segments = rel.split(path.sep)
55
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
56
+ if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
57
+ return false
58
+ }
59
+
60
+ /**
61
+ * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
62
+ * directory.
63
+ *
64
+ * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
65
+ * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
66
+ * writes land in a watched subtree, so every cycle triggers the next one.
67
+ * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
68
+ * it and the loop then ran **22 more times during 6 seconds of total silence**
69
+ * (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
70
+ * A hard-coded literal cannot see a configured path — the resolved one can.
71
+ */
72
+ export function makeWatchIgnore(
73
+ cacheDir: string,
74
+ outputs: ReadonlyMap<string, readonly string[]> = new Map(),
75
+ ): (base: string, filename: string) => boolean {
76
+ const cacheAbs = path.resolve(cacheDir)
77
+ // A task's own outputs are not edits: without this every cycle that
78
+ // writes `dist/` (or `out.txt`) re-runs once more, reporting
79
+ // "up-to-date" for the trouble. Matched under the directory the globs
80
+ // are relative to, whichever watcher delivered the event.
81
+ // The directory that HOLDS an output tree is the task's too: `dist/**`
82
+ // does not match `dist`, and since the clean before a miss prunes an
83
+ // emptied `dist` (2026-09-10) the task re-creates it, which the watcher
84
+ // reports as a change to `dist` itself — a second cycle per edit,
85
+ // reporting "up-to-date". The literal prefix of each glob (`dist` for
86
+ // `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
87
+ // every ancestor of it under the dir are output containers.
88
+ // A literal entry means the file or its whole tree — the resolver's own
89
+ // rule (`asTrees`), so the tree's files never count as edits.
90
+ const declared = [...outputs].map(
91
+ ([dir, globs]) =>
92
+ [
93
+ path.resolve(dir),
94
+ asTrees(globs).map((g) => new Bun.Glob(g)),
95
+ globs.map(outputContainer).filter((c) => c !== ''),
96
+ ] as const,
97
+ )
98
+ return (base, filename) => {
99
+ if (isIgnoredWatchPath(filename)) return true
100
+ const abs = path.resolve(base, filename)
101
+ if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
102
+ for (const [dir, globs, containers] of declared) {
103
+ if (!abs.startsWith(dir + path.sep)) continue
104
+ const rel = abs
105
+ .slice(dir.length + 1)
106
+ .split(path.sep)
107
+ .join('/')
108
+ if (globs.some((g) => g.match(rel))) return true
109
+ if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
110
+ }
111
+ return false
112
+ }
113
+ }
114
+
115
+ /**
116
+ * The literal directory a glob's matches live under (`''` when the glob
117
+ * starts with a pattern, or negates). A literal entry is a file or its
118
+ * whole tree (schema: literal → tree), so the entry itself is the
119
+ * container; a pattern's is `staticPrefix` — the same rule the sandbox
120
+ * baseline and the deferral gate read.
121
+ */
122
+ export function outputContainer(raw: string): string {
123
+ const glob = normalizeGlob(raw)
124
+ if (glob.startsWith('!')) return ''
125
+ if (!/[*?[\]{}]/.test(glob)) return glob.replace(/\/+$/, '')
126
+ const prefix = staticPrefix(glob)
127
+ return prefix === '.' || prefix === '/' ? '' : prefix
128
+ }
129
+
130
+ /**
131
+ * The file `armWatcher` writes under a watched directory to prove the
132
+ * watcher delivers. Intercepted by name before any other handling, so it
133
+ * can never trigger a cycle, and removed before "watching" is printed.
134
+ */
135
+ export const WATCH_PROBE = '.vx-watch-probe'
136
+
137
+ /** How long a watcher gets to report its own probe before the loop goes on without proof. */
138
+ const WATCH_PROBE_TIMEOUT_MS = 2_000
139
+ /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
140
+ const WATCH_CLOCK_STAMP = '.vx-watch-clock'
141
+
142
+ /** Anything the loop needs to shut down at exit. */
143
+ export interface WatchHandle {
144
+ close(): void
145
+ }
146
+
147
+ export interface ArmedWatcher {
148
+ watcher: fs.FSWatcher
149
+ /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
150
+ ready: Promise<boolean>
151
+ }
152
+
153
+ /** How often the fallback re-walks a watched tree. */
154
+ const POLL_INTERVAL_MS = 250
155
+
156
+ /**
157
+ * Directory names the fallback never descends into. `makeWatchIgnore`
158
+ * already drops their EVENTS, but a poller pays for the walk itself, and
159
+ * `node_modules` is the difference between a cheap fallback and one that
160
+ * re-stats 40 000 files four times a second.
161
+ */
162
+ const POLL_SKIP = new Set(['node_modules', '.git', '.vx', 'dist'])
163
+
164
+ /**
165
+ * A watcher built from `stat`, for when the OS one cannot deliver.
166
+ *
167
+ * `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
168
+ * `com.apple.FSEvents`; inside a sandbox that does not grant it the call
169
+ * SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
170
+ * 0 non-recursive, against 3 and 2 for the same writes outside — while
171
+ * `fs.watchFile` polling delivered in both). A network filesystem or a
172
+ * container bind mount fails the same way. Polling is slower and coarser,
173
+ * and it is the difference between `vx watch` working there and silently
174
+ * doing nothing.
175
+ */
176
+ export function pollWatcher(
177
+ dir: string,
178
+ recursive: boolean,
179
+ onEvent: (filename: string) => void,
180
+ intervalMs = POLL_INTERVAL_MS,
181
+ ): WatchHandle {
182
+ let previous = new Map<string, number>()
183
+ let first = true
184
+ const scan = (): void => {
185
+ const current = new Map<string, number>()
186
+ const walk = (abs: string, rel: string): void => {
187
+ let entries: fs.Dirent[]
188
+ try {
189
+ entries = fs.readdirSync(abs, { withFileTypes: true })
190
+ } catch {
191
+ return // vanished or unreadable: its files simply stop appearing
192
+ }
193
+ for (const e of entries) {
194
+ if (e.name === WATCH_PROBE) continue
195
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
196
+ if (e.isDirectory()) {
197
+ if (recursive && !POLL_SKIP.has(e.name)) walk(path.join(abs, e.name), childRel)
198
+ continue
199
+ }
200
+ if (!e.isFile()) continue
201
+ try {
202
+ current.set(childRel, fs.statSync(path.join(abs, e.name)).mtimeMs)
203
+ } catch {
204
+ // raced with a delete; the next scan settles it
205
+ }
206
+ }
207
+ }
208
+ walk(dir, '')
209
+ if (!first) {
210
+ for (const [rel, mtime] of current) {
211
+ if (previous.get(rel) !== mtime) onEvent(rel)
212
+ }
213
+ for (const rel of previous.keys()) {
214
+ if (!current.has(rel)) onEvent(rel)
215
+ }
216
+ }
217
+ previous = current
218
+ first = false
219
+ }
220
+ scan()
221
+ // Deliberately NOT unref'd: once the native watcher is closed this timer
222
+ // is the only thing keeping `vx watch` alive.
223
+ const timer = setInterval(scan, intervalMs)
224
+ return {
225
+ close(): void {
226
+ clearInterval(timer)
227
+ },
228
+ }
229
+ }
230
+
231
+ /**
232
+ * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
233
+ * FSEvents stream that another thread schedules AFTER the call returns, and
234
+ * a change landing in that gap is never delivered — MEASURED 2026-09-03: a
235
+ * write made immediately after `fs.watch` was lost 5 times in 30 under CPU
236
+ * load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
237
+ * width, so no pause is the answer and no timeout on the waiting side ever
238
+ * was (the e2e flake this closes had one of 45 s). A probe file written
239
+ * under the watcher and waited for is: once ITS event arrives, the stream
240
+ * is live for everything after it.
241
+ *
242
+ * `onEvent` never sees the probe (create or unlink), and the probe is
243
+ * removed before `ready` resolves.
244
+ */
245
+ export function armWatcher(
246
+ dir: string,
247
+ recursive: boolean,
248
+ onEvent: (filename: string) => void,
249
+ timeoutMs = WATCH_PROBE_TIMEOUT_MS,
250
+ ): ArmedWatcher {
251
+ let markReady: (ok: boolean) => void = () => {}
252
+ const seen = new Promise<boolean>((resolve) => {
253
+ markReady = resolve
254
+ })
255
+ const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
256
+ if (filename == null || typeof filename !== 'string') return
257
+ // An event naming the watched directory itself (macOS reports the
258
+ // directory a write landed in as its own item) carries nothing a key
259
+ // can see; the write's own event names the file.
260
+ if (filename === '' || filename === '.') return
261
+ if (filename === WATCH_PROBE) {
262
+ markReady(true)
263
+ return
264
+ }
265
+ onEvent(filename)
266
+ })
267
+ const probe = path.join(dir, WATCH_PROBE)
268
+ const ready = (async (): Promise<boolean> => {
269
+ // The probe is subject to the very race it detects: a write that lands
270
+ // in the gap is lost like any other (1 in 20 under a full gate's load,
271
+ // measured 2026-09-03, with every delivered event under 60 ms). So it is
272
+ // re-written on a short backoff until its event arrives — the first write
273
+ // after the stream goes live is the one that proves it.
274
+ const deadline = Date.now() + timeoutMs
275
+ let ok = false
276
+ let step = 50
277
+ while (!ok) {
278
+ try {
279
+ fs.writeFileSync(probe, String(Date.now()))
280
+ } catch {
281
+ break // an unwritable dir gets no proof; the watcher is kept
282
+ }
283
+ const remaining = deadline - Date.now()
284
+ if (remaining <= 0) break
285
+ const pause = new Promise<boolean>((resolve) => {
286
+ setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
287
+ })
288
+ ok = await Promise.race([seen, pause])
289
+ step = Math.min(step * 2, 400)
290
+ }
291
+ try {
292
+ fs.unlinkSync(probe)
293
+ } catch {
294
+ // already gone
295
+ }
296
+ return ok
297
+ })()
298
+ return { watcher, ready }
299
+ }
300
+
34
301
  export async function watchCmd(args: readonly string[]): Promise<number> {
35
302
  const parsed = parseRunArgs(args)
36
303
  if (parsed.error) {
@@ -48,6 +315,15 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
48
315
  )
49
316
  return 1
50
317
  }
318
+ // All three format ONE run's result and are consumed by `runCmd` alone, so
319
+ // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
320
+ // asks for the output watch already gives.
321
+ if (parsed.report !== undefined || parsed.reportFile !== undefined || parsed.verbosity > 0) {
322
+ process.stderr.write(
323
+ `vx watch: --report / --report-file / --verbosity are not supported in watch mode (they report a single run)\n`,
324
+ )
325
+ return 1
326
+ }
51
327
  if (parsed.tasks.length === 0) {
52
328
  process.stderr.write(`vx watch: missing task name\n`)
53
329
  return 1
@@ -59,23 +335,44 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
59
335
  process.stderr.write(`vx watch: ${resolved.error}\n`)
60
336
  return 1
61
337
  }
62
- // The watch loop owns SIGINT/SIGTERM for its whole lifetime (the
63
- // process.once handlers below close watchers and resolve 0). A
64
- // cycle's run() must not install its exit-the-process handlers —
65
- // Ctrl-C mid-cycle would kill the loop with 130 instead of the
66
- // loop's own clean shutdown.
67
- const opts: RunOptions = { ...resolved, handleSignals: false }
338
+ if ('nothingSelected' in resolved) {
339
+ process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
340
+ return 0
341
+ }
342
+ // The watch loop owns SIGINT/SIGTERM for its whole lifetime. A cycle's
343
+ // run() must not install its exit-the-process handlers — Ctrl-C
344
+ // mid-cycle would kill the loop with 130 instead of the loop's own
345
+ // clean shutdown — so it gets `signal` instead: on SIGINT/SIGTERM the
346
+ // controller aborts, the in-flight cycle tears its children down
347
+ // (SIGTERM, grace, SIGKILL) and returns, and the loop resolves 0.
348
+ // Installed BEFORE the initial run: until 2026-09-10 the handlers went
349
+ // in with the loop, so a SIGTERM during the initial run took Bun's
350
+ // default (exit 143) and left the cycle's children running under init.
351
+ const stop = new AbortController()
352
+ const opts: RunOptions = { ...resolved, handleSignals: false, signal: stop.signal }
353
+ // A staged load from the selection pass is one run's worth of configs;
354
+ // every cycle after an edit must evaluate live.
355
+ delete opts.staged
356
+ process.once('SIGINT', () => {
357
+ process.stdout.write('\nvx watch: stopped\n')
358
+ stop.abort()
359
+ })
360
+ process.once('SIGTERM', () => stop.abort())
68
361
 
69
362
  // Enumerate projects-in-scope so we know what dirs to watch.
70
363
  // `opts.projects` is the resolved scope; undefined means "every
71
- // project". We watch only those (plus the workspace root, for
72
- // lockfile changes).
364
+ // project". The watched set is what a cycle can RUN: the scope plus
365
+ // its transitive dependencies (a cycle runs `lib#build` for
366
+ // `app#build`'s `^build`, so a `lib` edit is an edit) — the same
367
+ // closure `--filter 'app...'` walks, computed below once the initial
368
+ // run has staged the configs. Plus the workspace root, for lockfile
369
+ // changes.
73
370
  const workspaceRoot = await findWorkspaceRoot(cwd)
74
- const allProjects = await listProjects(await loadWorkspace(workspaceRoot))
75
- const scope =
76
- opts.projects === undefined
77
- ? allProjects
78
- : allProjects.filter((p) => opts.projects!.includes(p.name))
371
+ const workspace = await loadWorkspace(workspaceRoot)
372
+ const allProjects = await listProjects(workspace)
373
+ const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
374
+ opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
375
+ const scope = inScope(allProjects)
79
376
 
80
377
  if (scope.length === 0) {
81
378
  process.stderr.write(`vx watch: no projects in scope\n`)
@@ -83,78 +380,351 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
83
380
  }
84
381
 
85
382
  // Initial run — same code path as `vx run`.
383
+ //
384
+ // Watchers do not exist yet, so an edit made WHILE this run is executing is
385
+ // dropped by the OS and never triggers a re-run. Known and deliberate: the
386
+ // obvious fix (install watchers first) forces `anyTaskUsesWorkspaceFiles`
387
+ // ahead of the run to decide which watchers to install, and that marks every
388
+ // config loaded — so the initial run's own loads become REPEATs and pay a
389
+ // worker round-trip each (see config-eval.ts). Trading a hot-path regression
390
+ // on every `vx watch` for a window a user rarely edits into is a bad deal;
391
+ // closing it properly needs the workspaceWide decision made without loading
392
+ // configs.
86
393
  process.stdout.write('vx watch: initial run...\n\n')
87
394
  await runOrchestrator(opts)
395
+ if (stop.signal.aborted) return 0
88
396
 
397
+ const load: CliLoadOptions = {
398
+ ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
399
+ ...(opts.frozen === true ? { frozen: true } : {}),
400
+ }
401
+ const swept = await sweepConfigs(allProjects, workspaceRoot, load)
402
+ const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
89
403
  return await runWatchLoop({
90
404
  opts,
405
+ stop: stop.signal,
91
406
  workspaceRoot,
92
- projects: scope,
93
- workspaceWide: await anyTaskUsesWorkspaceFiles(allProjects),
407
+ projects: watched,
408
+ workspaceWide: swept.workspaceWide,
409
+ projectDirs: watched.map((p) => p.dir),
410
+ workspaceInputs: swept.workspaceInputs,
411
+ outputs: swept.outputs,
412
+ memberBases: memberBaseDirs(workspace),
413
+ // The workspace as the cycle that just ran saw it: a package added or
414
+ // removed since the loop armed joins or leaves the watched set. The
415
+ // scope is the one resolved at start; a new package joins it only as a
416
+ // dependency of it.
417
+ rediscover: async () => {
418
+ const all = await listProjects(await loadWorkspace(workspaceRoot))
419
+ const sweep = await sweepConfigs(all, workspaceRoot, load)
420
+ const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
421
+ return { projects: now, workspaceInputs: sweep.workspaceInputs, outputs: sweep.outputs }
422
+ },
423
+ // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
424
+ cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
94
425
  })
95
426
  }
96
427
 
97
428
  /**
98
- * `inputs.workspaceFiles` globs have no project boundary — any file in
99
- * the workspace can be an input. When any config declares them, the
100
- * per-project watchers can't see all triggering paths, so the loop
101
- * switches to one recursive root watcher. Checked across ALL projects
102
- * (not just the scope) because dependsOn can pull tasks from anywhere;
103
- * a broken out-of-scope config is skipped, matching scoped-run
104
- * semantics (it surfaces when that project enters scope).
429
+ * The projects a cycle can run, so both watcher arms cover the same dirs:
430
+ * the scope plus its transitive dependencies through the package graph
431
+ * and the cross-project `dependsOn` edges the task graph adds
432
+ * (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
433
+ * per-project arm watched the filter's answer only, so
434
+ * `vx watch build --filter app` never re-ran on a `lib` edit that
435
+ * `vx run build --filter app` would have rebuilt. A whole-workspace
436
+ * scope is every project already and walks nothing. A config the loader
437
+ * rejects contributes no edges here; the run that just happened said so.
105
438
  */
106
- async function anyTaskUsesWorkspaceFiles(projects: readonly ProjectMeta[]): Promise<boolean> {
107
- for (const p of projects) {
108
- if (p.configPath === null) continue
109
- try {
110
- const config = await loadProjectConfig(p.configPath)
111
- for (const task of Object.values(config.tasks ?? {})) {
112
- if ((task.cache?.inputs?.workspaceFiles?.length ?? 0) > 0) return true
439
+ export async function watchedProjects(
440
+ workspaceRoot: string,
441
+ allProjects: readonly ProjectMeta[],
442
+ scope: readonly ProjectMeta[],
443
+ load: CliLoadOptions = {},
444
+ staged: ReadonlyMap<string, ProjectEntry> | null = null,
445
+ ): Promise<ProjectMeta[]> {
446
+ if (scope.length === allProjects.length) return [...allProjects]
447
+ let edges: Map<string, string[]> | undefined
448
+ try {
449
+ edges =
450
+ staged !== null
451
+ ? taskEdgesFrom(staged)
452
+ : (await taskEdges(workspaceRoot, allProjects, load)).edges
453
+ } catch {
454
+ edges = undefined
455
+ }
456
+ const graph = buildPackageGraph([...allProjects], edges)
457
+ const names = new Set(scope.map((p) => p.name))
458
+ for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
459
+ return allProjects.filter((p) => names.has(p.name))
460
+ }
461
+
462
+ /**
463
+ * One sweep over every config for the two things the loop needs: whether
464
+ * any task declares `inputs.workspaceFiles` — globs with no project
465
+ * prefix, so the whole tree has to be watched — and every project's
466
+ * declared outputs, so their writes are not taken for edits. The run
467
+ * path's load (`loadProjects`): the plugin `project` stage applies, so an
468
+ * output a plugin gave a config-less package is ignored like a declared
469
+ * one, and a pure config is served from its cached evaluation rather than
470
+ * re-evaluated in a worker. A config that fails to load is out of this
471
+ * concern's scope — the run that just happened already said so, or will —
472
+ * and the sweep falls back to the files that do load, one by one.
473
+ */
474
+ export async function sweepConfigs(
475
+ projects: readonly ProjectMeta[],
476
+ workspaceRoot: string,
477
+ load: CliLoadOptions = {},
478
+ ): Promise<{
479
+ workspaceWide: boolean
480
+ workspaceInputs: string[]
481
+ outputs: Map<string, string[]>
482
+ /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
483
+ staged: Map<string, ProjectEntry> | null
484
+ }> {
485
+ const outputs = new Map<string, string[]>()
486
+ const add = (dir: string, globs: readonly string[] | undefined): void => {
487
+ if (globs === undefined || globs.length === 0) return
488
+ outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
489
+ }
490
+ const workspaceInputs = new Set<string>()
491
+ const fold = (dir: string, config: ProjectConfig): void => {
492
+ for (const task of Object.values(config.tasks ?? {})) {
493
+ for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
494
+ add(dir, task.cache?.outputs?.files)
495
+ add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
496
+ }
497
+ }
498
+ const result = (
499
+ staged: Map<string, ProjectEntry> | null,
500
+ ): {
501
+ workspaceWide: boolean
502
+ workspaceInputs: string[]
503
+ outputs: Map<string, string[]>
504
+ staged: Map<string, ProjectEntry> | null
505
+ } => ({
506
+ workspaceWide: workspaceInputs.size > 0,
507
+ workspaceInputs: [...workspaceInputs],
508
+ outputs,
509
+ staged,
510
+ })
511
+ let staged: Map<string, ProjectEntry> | null = null
512
+ try {
513
+ staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
514
+ } catch {
515
+ staged = null
516
+ }
517
+ if (staged !== null) {
518
+ for (const p of staged.values()) fold(p.dir, p.config)
519
+ return result(staged)
520
+ }
521
+ await Promise.all(
522
+ projects.map(async (p) => {
523
+ if (p.configPath === null) return
524
+ try {
525
+ fold(p.dir, await loadProjectConfig(p.configPath))
526
+ } catch {
527
+ // broken config — out of this concern's scope
113
528
  }
114
- } catch {
115
- // broken config — out of this concern's scope
529
+ }),
530
+ )
531
+ return result(null)
532
+ }
533
+
534
+ /**
535
+ * Under the recursive root watcher, the events that can move a key. The
536
+ * watcher hears every write in the workspace; a key can see three kinds
537
+ * of path and no other: a file inside a project's directory (its own
538
+ * `inputs.files`, or its outputs, which the ignore filter drops next), a
539
+ * workspace fingerprint file at the root, and a match of a declared
540
+ * `inputs.workspaceFiles` glob. Everything else — a log written at the
541
+ * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
542
+ * dropped before the trigger, so it costs no cycle. Before this rule the
543
+ * one Turbo idiom that puts every repo on this watcher
544
+ * (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
545
+ * inside the repo a loop that never settled: each cycle's output grew the
546
+ * log, the log was an event, the event was a cycle. Negated globs are not
547
+ * consulted: a `!` only narrows, and an event it would have excluded costs
548
+ * one cache-hit cycle, which is the wrong direction to be clever in.
549
+ */
550
+ export function makeRootEventFilter(
551
+ workspaceRoot: string,
552
+ projectDirs: readonly string[],
553
+ workspaceInputs: readonly string[],
554
+ ): (filename: string) => boolean {
555
+ const dirs = projectDirs.map((d) => path.resolve(d))
556
+ const globs = workspaceInputs
557
+ .map(normalizeGlob)
558
+ .filter((g) => !g.startsWith('!'))
559
+ .map((g) => new Bun.Glob(g))
560
+ return (filename: string): boolean => {
561
+ const rel = filename.split(path.sep).join('/')
562
+ if (!rel.includes('/') && (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel))) {
563
+ return true
116
564
  }
565
+ const abs = path.resolve(workspaceRoot, filename)
566
+ for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
567
+ for (const g of globs) if (g.match(rel)) return true
568
+ return false
117
569
  }
118
- return false
119
570
  }
120
571
 
121
572
  interface WatchLoopArgs {
122
573
  opts: RunOptions
574
+ /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
575
+ stop: AbortSignal
123
576
  workspaceRoot: string
124
577
  projects: readonly ProjectMeta[]
125
578
  workspaceWide: boolean
579
+ /** Every project's directory, in scope or not — under the root watcher an edit there is an edit. */
580
+ projectDirs: readonly string[]
581
+ /** Every declared `inputs.workspaceFiles` glob, root-relative. */
582
+ workspaceInputs: readonly string[]
583
+ /** Absolute, already-resolved — the loop must never re-derive it. */
584
+ cacheDir: string
585
+ /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
586
+ outputs: ReadonlyMap<string, readonly string[]>
587
+ /** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
588
+ memberBases: readonly string[]
589
+ /** The watched set again, after a cycle that followed a member event. */
590
+ rediscover: () => Promise<Rediscovered>
591
+ }
592
+
593
+ interface Rediscovered {
594
+ projects: readonly ProjectMeta[]
595
+ workspaceInputs: readonly string[]
596
+ outputs: ReadonlyMap<string, readonly string[]>
126
597
  }
127
598
 
128
599
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
129
- const { opts, workspaceRoot, projects, workspaceWide } = args
600
+ const { opts, stop, workspaceRoot, projects, workspaceWide, cacheDir, memberBases } = args
601
+ // The watched set as of the last cycle: `rearm` replaces these when a
602
+ // member came or went, and every filter below reads the current one.
603
+ let projectDirs = args.projectDirs
604
+ let workspaceInputs = args.workspaceInputs
605
+ let outputs = args.outputs
130
606
 
131
- // Reentrancy guard — never two orchestrator runs in flight. While
132
- // one is running, any further events set `pending = true` and the
133
- // loop drains it after the current run finishes.
607
+ // Reentrancy guard — never two orchestrator runs in flight. Events that
608
+ // land while one is running wait in `pendingPaths` and are judged, on
609
+ // settled bytes, one debounce window after it ends.
134
610
  let running = false
135
- let pending = false
611
+ /** The cycle in flight, so the stop path can wait for its teardown before resolving. */
612
+ let inFlight: Promise<void> = Promise.resolve()
136
613
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
137
614
 
138
- const trigger = (label: string): void => {
615
+ // Declared outputs are ignored by PATH above. A task with no `cache`
616
+ // block declares none and still writes into its project, and the
617
+ // watcher sees the write: run 1 writes dist/x, the event re-runs, run 2
618
+ // writes the same bytes, the event re-runs — forever (the init
619
+ // walkthrough, 2026-09-04: every fresh workspace, since `init` emits no
620
+ // cache block). An undeclared write is caught by STATE, judged on what
621
+ // has settled (see `trigger`): a path whose settled state equals what
622
+ // this loop last saw for it is not a change. A file's state is its
623
+ // bytes; a directory's is its entries' names and sizes (a nested edit
624
+ // arrives as that path's own event); a path that is gone is one more
625
+ // state. A real edit changes the state; a first sighting passes through.
626
+ // So `rm -rf dist && tsc` — the shape of most build scripts — settles
627
+ // to the same `dist` it left and is one redundant cycle, not a loop
628
+ // (2026-09-10: 780 executions in two minutes from one edit, when a
629
+ // deletion and a directory each passed the gate unconditionally).
630
+ const ABSENT = -1n
631
+ const lastState = new Map<string, bigint>()
632
+ const settledState = (abs: string): bigint => {
633
+ let st: fs.Stats
634
+ try {
635
+ st = fs.statSync(abs)
636
+ } catch {
637
+ return ABSENT
638
+ }
639
+ if (!st.isDirectory()) {
640
+ try {
641
+ return xxh3(fs.readFileSync(abs))
642
+ } catch {
643
+ return ABSENT
644
+ }
645
+ }
646
+ const entries: string[] = []
647
+ try {
648
+ for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
649
+ let size = 0
650
+ if (e.isFile()) {
651
+ try {
652
+ size = fs.statSync(path.join(abs, e.name)).size
653
+ } catch {
654
+ size = -1
655
+ }
656
+ }
657
+ entries.push(`${e.name}\0${e.isDirectory() ? 'd' : size}`)
658
+ }
659
+ } catch {
660
+ return ABSENT
661
+ }
662
+ entries.sort()
663
+ return xxh3(Buffer.from(entries.join('\n')))
664
+ }
665
+ // A path this loop has never judged is a change only if it moved since
666
+ // the watchers went live. macOS delivers the initial run's own writes
667
+ // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
668
+ // made — FSEvents hands a stream what landed just before it started),
669
+ // and a first sighting used to pass unconditionally; the path's mtime
670
+ // says which side of the arm it belongs to. A path already gone is a
671
+ // change: a deletion has no date to read.
672
+ const sameState = (abs: string): boolean => {
673
+ const state = settledState(abs)
674
+ const prev = lastState.get(abs)
675
+ lastState.set(abs, state)
676
+ if (prev !== undefined) return prev === state
677
+ return state !== ABSENT && modifiedBefore(abs, armedAt)
678
+ }
679
+
680
+ // Paths that fired since the last judgement, first label wins. The state
681
+ // check runs when the timer fires, on SETTLED state: per event it is
682
+ // wrong on Linux, where a shell redirect truncates the file (one event,
683
+ // empty) and then writes it (another, full), so consecutive events never
684
+ // agree and a self-write loops anyway (CI, 2026-09-04: 9 re-runs where
685
+ // macOS, which coalesces the two, saw 2). While a cycle runs, nothing is
686
+ // judged: the run's own writes are mid-flight (a `dist` deleted and not
687
+ // yet rebuilt is a state the tree will not keep), so the paths wait and
688
+ // are judged one window after the run ends, all together — an edit made
689
+ // meanwhile still differs from what the loop last saw and re-runs.
690
+ const pendingPaths = new Map<string, string>()
691
+ const judge = (): string | undefined => {
692
+ let first: string | undefined
693
+ for (const [p, l] of pendingPaths) {
694
+ if (!sameState(p)) first ??= l
695
+ }
696
+ pendingPaths.clear()
697
+ return first
698
+ }
699
+ const trigger = (label: string, abs: string): void => {
700
+ if (!pendingPaths.has(abs)) pendingPaths.set(abs, label)
701
+ if (running) return
139
702
  if (debounceTimer) clearTimeout(debounceTimer)
140
703
  debounceTimer = setTimeout(() => {
141
704
  debounceTimer = null
142
- void cycle(label)
705
+ if (running) return
706
+ const first = judge()
707
+ if (first !== undefined) void cycle(first)
143
708
  }, DEBOUNCE_MS)
144
709
  }
145
710
 
146
711
  const cycle = async (label: string): Promise<void> => {
147
- if (running) {
148
- pending = true
149
- return
150
- }
712
+ if (stop.aborted || running) return
151
713
  running = true
714
+ const done = (inFlight = runCycle(label))
715
+ await done
716
+ }
717
+ const runCycle = async (first: string): Promise<void> => {
152
718
  try {
153
- do {
154
- pending = false
719
+ let label: string | undefined = first
720
+ while (label !== undefined && !stop.aborted) {
155
721
  process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
156
722
  try {
157
723
  await runOrchestrator(opts)
724
+ if (membersChanged && !stop.aborted) {
725
+ membersChanged = false
726
+ await rearm()
727
+ }
158
728
  } catch (err) {
159
729
  // A re-run can fail catastrophically when the workspace
160
730
  // itself moved out from under us — e.g. the user deleted
@@ -166,42 +736,122 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
166
736
  const message = err instanceof Error ? err.message : String(err)
167
737
  process.stderr.write(`vx watch: cycle failed: ${message}\n`)
168
738
  }
169
- // If a change arrived mid-run, loop again immediately.
170
- } while (pending)
739
+ // What landed mid-run is judged on settled state, one window
740
+ // after the run, under the label of what actually arrived.
741
+ if (pendingPaths.size === 0 || stop.aborted) break
742
+ await Bun.sleep(DEBOUNCE_MS)
743
+ label = judge()
744
+ }
171
745
  } finally {
172
746
  running = false
747
+ // Anything that landed after the last judgement waits for a timer
748
+ // like any other event.
749
+ if (pendingPaths.size > 0 && !stop.aborted) {
750
+ const [abs, label] = [...pendingPaths][0]!
751
+ trigger(label, abs)
752
+ }
173
753
  }
174
754
  }
175
755
 
176
756
  // Filter out events for paths we don't care about. We watch each
177
757
  // project's dir recursively, so a `node_modules` write under a
178
- // project would otherwise trigger every save during `bun install`.
179
- const isIgnoredPath = (rel: string): boolean => {
180
- const segments = rel.split(path.sep)
181
- if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
182
- if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
183
- return false
758
+ // project would otherwise trigger every save during `bun install` —
759
+ // and vx's own cache writes would trigger a cycle that writes again.
760
+ let isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
761
+ let matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
762
+ /** A member came or went under a package glob's directory since the last cycle. */
763
+ let membersChanged = false
764
+
765
+ const watchers: WatchHandle[] = []
766
+ const proofs: Promise<void>[] = []
767
+ // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
768
+ // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
769
+ // network mount, a container bind — the attempt costs a denied syscall
770
+ // and a two-second wait before the fallback takes over anyway.
771
+ const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
772
+ const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
773
+ if (forcePoll) {
774
+ watchers.push(pollWatcher(dir, recursive, onEvent))
775
+ return
776
+ }
777
+ const armed = armWatcher(dir, recursive, onEvent)
778
+ watchers.push(armed.watcher)
779
+ proofs.push(
780
+ armed.ready.then((ok) => {
781
+ if (ok) return
782
+ // The watcher never proved delivery, so it is not one: an FSEvents
783
+ // stream the OS refused, a filesystem that reports nothing. Swap in
784
+ // the poller rather than run a loop that silently never fires.
785
+ armed.watcher.close()
786
+ watchers[watchers.indexOf(armed.watcher)] = pollWatcher(dir, recursive, onEvent)
787
+ process.stderr.write(
788
+ `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
789
+ )
790
+ }),
791
+ )
184
792
  }
185
793
 
186
- const watchers: fs.FSWatcher[] = []
794
+ /** 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. */
795
+ const armedAt = fsClockNow(cacheDir)
796
+ /** Per-project arms by directory, so `rearm` can add and drop them. */
797
+ const perProject = new Map<string, WatchHandle>()
798
+ const armProject = (proj: ProjectMeta): void => {
799
+ try {
800
+ const at = watchers.length
801
+ arm(proj.dir, true, (filename) => {
802
+ if (isIgnoredPath(proj.dir, filename)) return
803
+ trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
804
+ })
805
+ // By slot, not by handle: an OS watcher that never proves delivery is
806
+ // swapped for a poller in place, and a drop must close what is there.
807
+ perProject.set(proj.dir, { close: () => watchers[at]?.close() })
808
+ } catch (err) {
809
+ const msg = err instanceof Error ? err.message : String(err)
810
+ process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
811
+ }
812
+ }
813
+ const rearm = async (): Promise<void> => {
814
+ let next: Rediscovered
815
+ try {
816
+ next = await args.rediscover()
817
+ } catch (err) {
818
+ const msg = err instanceof Error ? err.message : String(err)
819
+ process.stderr.write(`vx watch: cannot re-read the workspace: ${msg}\n`)
820
+ return
821
+ }
822
+ projectDirs = next.projects.map((p) => p.dir)
823
+ workspaceInputs = next.workspaceInputs
824
+ outputs = next.outputs
825
+ isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
826
+ matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
827
+ if (!workspaceWide) {
828
+ const keep = new Set(projectDirs)
829
+ for (const [dir, handle] of perProject) {
830
+ if (keep.has(dir)) continue
831
+ handle.close()
832
+ perProject.delete(dir)
833
+ }
834
+ for (const proj of next.projects) if (!perProject.has(proj.dir)) armProject(proj)
835
+ // A new arm proves delivery like the first ones: an edit in the new
836
+ // package right after this cycle is seen, not lost in the gap.
837
+ await Promise.all(proofs)
838
+ }
839
+ process.stdout.write(`vx watch: watching ${next.projects.length} project(s)\n`)
840
+ }
187
841
 
188
842
  if (workspaceWide) {
189
- // workspaceFiles inputs in play: any file in the workspace can be
190
- // an input, so one recursive root watcher replaces the per-project
191
- // ones (it also covers lockfile / pnpm-workspace.yaml edits). The
192
- // ignore filter keeps node_modules / .git / .vx churn out; edits
193
- // outside any task's inputs still cost only a cache-hit cycle.
843
+ // workspaceFiles inputs in play: a root-relative glob can name a
844
+ // file anywhere, so one recursive root watcher replaces the
845
+ // per-project ones (it also covers lockfile / pnpm-workspace.yaml
846
+ // edits). `matters` keeps the events a key can see — project
847
+ // trees, root fingerprint files, the declared globs — and drops the
848
+ // rest of the tree; the ignore filter then keeps node_modules /
849
+ // .git / .vx and declared outputs out of what remains.
194
850
  try {
195
- const w = fs.watch(
196
- workspaceRoot,
197
- { recursive: true, persistent: true },
198
- (_event, filename) => {
199
- if (filename == null || typeof filename !== 'string') return
200
- if (isIgnoredPath(filename)) return
201
- trigger(`root ${filename}`)
202
- },
203
- )
204
- watchers.push(w)
851
+ arm(workspaceRoot, true, (filename) => {
852
+ if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
853
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
854
+ })
205
855
  } catch (err) {
206
856
  const msg = err instanceof Error ? err.message : String(err)
207
857
  process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
@@ -210,43 +860,59 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
210
860
  // One recursive watcher per project. Each project owns its own
211
861
  // subtree; we don't watch the workspace root recursively (would
212
862
  // cover every project + node_modules + caches).
213
- for (const proj of projects) {
214
- try {
215
- const w = fs.watch(proj.dir, { recursive: true, persistent: true }, (_event, filename) => {
216
- if (filename == null) return
217
- if (typeof filename !== 'string') return
218
- if (isIgnoredPath(filename)) return
219
- trigger(`${proj.name} ${filename}`)
220
- })
221
- watchers.push(w)
222
- } catch (err) {
223
- const msg = err instanceof Error ? err.message : String(err)
224
- process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
225
- }
226
- }
863
+ for (const proj of projects) armProject(proj)
227
864
 
228
865
  // Plus the workspace root itself (non-recursive) so lockfile +
229
866
  // pnpm-workspace.yaml edits trigger re-runs even when no project
230
867
  // dir saw the change.
231
868
  try {
232
- const w = fs.watch(
233
- workspaceRoot,
234
- { recursive: false, persistent: true },
235
- (_event, filename) => {
236
- if (filename == null || typeof filename !== 'string') return
237
- if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
238
- },
239
- )
240
- watchers.push(w)
869
+ arm(workspaceRoot, false, (filename) => {
870
+ if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
871
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
872
+ }
873
+ })
241
874
  } catch (err) {
242
875
  const msg = err instanceof Error ? err.message : String(err)
243
876
  process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
244
877
  }
245
878
  }
246
879
 
247
- // The orchestrator's writes into `.vx/cache/` don't trigger
248
- // re-runs because IGNORED_SEGMENTS includes `.vx`. Users who
249
- // relocate the cache dir outside `.vx/` need their own filtering.
880
+ // A package added while the loop runs is a directory entry appearing
881
+ // under the glob's directory (`packages/` for `packages/*`); one
882
+ // non-recursive watcher there hears it come or go, and the cycle it
883
+ // triggers re-reads the workspace (`rearm`) so the new package's own
884
+ // edits are cycles from then on. Until 2026-09-10 the watched set was
885
+ // fixed when the loop armed: the next cycle ran the new package, and
886
+ // every edit inside it after that was silence.
887
+ for (const base of memberBases) {
888
+ let members = memberEntries(base)
889
+ try {
890
+ arm(base, false, (filename) => {
891
+ if (isIgnoredWatchPath(filename)) return
892
+ // Only a member coming or going. On macOS a non-recursive watcher
893
+ // also reports a member whose CONTENTS changed (FSEvents names the
894
+ // directory a write landed in), so a task writing into its own
895
+ // project — or the arm's own probe file — read as a member event and
896
+ // cost an uncached task one execution per cycle (CI, 2026-09-10).
897
+ const now = memberEntries(base)
898
+ if (sameMembers(members, now)) return
899
+ members = now
900
+ membersChanged = true
901
+ trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
902
+ })
903
+ } catch (err) {
904
+ const msg = err instanceof Error ? err.message : String(err)
905
+ process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
906
+ }
907
+ }
908
+
909
+ // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
910
+ // closes over the RESOLVED cache dir (relocated or not) and the tasks'
911
+ // declared outputs.
912
+
913
+ // "watching" is a promise that an edit from now on is seen; every
914
+ // watcher has proved (or been given 2 s to prove) delivery first.
915
+ await Promise.all(proofs)
250
916
 
251
917
  process.stdout.write(
252
918
  workspaceWide
@@ -255,7 +921,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
255
921
  )
256
922
 
257
923
  return await new Promise<number>((resolve) => {
258
- const cleanup = (): void => {
924
+ const cleanup = async (): Promise<void> => {
259
925
  for (const w of watchers) {
260
926
  try {
261
927
  w.close()
@@ -264,24 +930,106 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
264
930
  }
265
931
  }
266
932
  if (debounceTimer) clearTimeout(debounceTimer)
933
+ // The aborted cycle is tearing its children down; resolve only once
934
+ // it has returned, so the process never exits over a live child.
935
+ await inFlight
267
936
  resolve(0)
268
937
  }
269
- process.once('SIGINT', () => {
270
- process.stdout.write('\nvx watch: stopped\n')
271
- cleanup()
272
- })
273
- process.once('SIGTERM', cleanup)
938
+ if (stop.aborted) {
939
+ void cleanup()
940
+ return
941
+ }
942
+ stop.addEventListener('abort', () => void cleanup(), { once: true })
274
943
  })
275
944
  }
276
945
 
946
+ /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
947
+ export function modifiedBefore(abs: string, t: number): boolean {
948
+ try {
949
+ return fs.statSync(abs).mtimeMs < t
950
+ } catch {
951
+ return false
952
+ }
953
+ }
954
+
955
+ /**
956
+ * "Now" as the filesystem will stamp the next write, not as `Date.now()`
957
+ * reads it. A file's mtime comes from the kernel's coarse clock, which
958
+ * runs up to a tick behind the fine clock `Date.now()` reads — measured
959
+ * on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
960
+ * mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
961
+ * more under CPU load, where the tick is skipped. An `armedAt` from the
962
+ * fine clock then judged an edit made right after the ready line as
963
+ * "modified before the arm" and dropped it (the watch e2e flake, three
964
+ * shard runs that day; traced: raw event, trigger, `same=true`). A stamp
965
+ * read off a file written here is on the mtime clock itself, and every
966
+ * later write's mtime is at or after it.
967
+ */
968
+ export function fsClockNow(dir: string): number {
969
+ const stamp = path.join(dir, WATCH_CLOCK_STAMP)
970
+ try {
971
+ fs.writeFileSync(stamp, '')
972
+ const at = fs.statSync(stamp).mtimeMs
973
+ fs.unlinkSync(stamp)
974
+ return at
975
+ } catch {
976
+ return Date.now()
977
+ }
978
+ }
979
+
980
+ /**
981
+ * The directory entries under a package glob's directory that can be
982
+ * members: directories (or links) whose name is not dotted and not
983
+ * `node_modules`, the same rule discovery applies. A watcher on that
984
+ * directory reacts only when this set changes.
985
+ */
986
+ export function memberEntries(base: string): ReadonlySet<string> {
987
+ const out = new Set<string>()
988
+ let entries: fs.Dirent[]
989
+ try {
990
+ entries = fs.readdirSync(base, { withFileTypes: true })
991
+ } catch {
992
+ return out
993
+ }
994
+ for (const e of entries) {
995
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
996
+ if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
997
+ }
998
+ return out
999
+ }
1000
+
1001
+ function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
1002
+ if (a.size !== b.size) return false
1003
+ for (const name of a) if (!b.has(name)) return false
1004
+ return true
1005
+ }
1006
+
1007
+ /**
1008
+ * True for a root file that re-keys EVERY task, so a change to one must trigger
1009
+ * a cycle even though it lives in no project dir.
1010
+ *
1011
+ * Reads the SHARED constant the fingerprint itself walks — this used to be a
1012
+ * third hand-rolled copy of that list, which is precisely the drift the
1013
+ * `--affected` wave exported the constant to prevent. A name added there but
1014
+ * not here would re-key every task while `vx watch` silently never re-ran on
1015
+ * it: the loop looks alive, and the one edit that invalidates the whole
1016
+ * workspace is the one it ignores.
1017
+ */
1018
+ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
1019
+
277
1020
  function isWorkspaceFingerprintFile(name: string): boolean {
278
- return (
279
- name === 'pnpm-lock.yaml' ||
280
- name === 'package-lock.json' ||
281
- name === 'npm-shrinkwrap.json' ||
282
- name === 'yarn.lock' ||
283
- name === 'bun.lock' ||
284
- name === 'bun.lockb' ||
285
- name === 'pnpm-workspace.yaml'
286
- )
1021
+ return FINGERPRINT_FILES.has(name)
1022
+ }
1023
+
1024
+ /**
1025
+ * The workspace config is the one root file that shapes a run without
1026
+ * being any task's input — its plugins, `config` stage, concurrency, cache
1027
+ * dir — and a cycle re-evaluates it (its import is keyed on its bytes).
1028
+ * Until 2026-09-10 neither root arm listened for it: a plugin added under
1029
+ * `vx watch` waited for a restart while the loop looked alive.
1030
+ */
1031
+ const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
1032
+
1033
+ function isWorkspaceConfigFile(name: string): boolean {
1034
+ return WORKSPACE_CONFIGS.has(name)
287
1035
  }