@vzn/vx 0.0.18 → 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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
package/src/cli/watch.ts CHANGED
@@ -14,18 +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,
24
- loadWorkspaceConfig,
25
- resolveCacheDir,
27
+ memberBaseDirs,
28
+ WORKSPACE_CONFIG_FILENAMES,
26
29
  WORKSPACE_FINGERPRINT_FILES,
30
+ type ProjectEntry,
27
31
  type ProjectMeta,
28
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'
29
36
 
30
37
  /** Wait this long after the last filesystem event before re-running. */
31
38
  const DEBOUNCE_MS = 150
@@ -71,25 +78,55 @@ export function makeWatchIgnore(
71
78
  // writes `dist/` (or `out.txt`) re-runs once more, reporting
72
79
  // "up-to-date" for the trouble. Matched under the directory the globs
73
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.
74
90
  const declared = [...outputs].map(
75
- ([dir, globs]) => [path.resolve(dir), globs.map((g) => new Bun.Glob(g))] as const,
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,
76
97
  )
77
98
  return (base, filename) => {
78
99
  if (isIgnoredWatchPath(filename)) return true
79
100
  const abs = path.resolve(base, filename)
80
101
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
81
- for (const [dir, globs] of declared) {
102
+ for (const [dir, globs, containers] of declared) {
82
103
  if (!abs.startsWith(dir + path.sep)) continue
83
104
  const rel = abs
84
105
  .slice(dir.length + 1)
85
106
  .split(path.sep)
86
107
  .join('/')
87
108
  if (globs.some((g) => g.match(rel))) return true
109
+ if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
88
110
  }
89
111
  return false
90
112
  }
91
113
  }
92
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
+
93
130
  /**
94
131
  * The file `armWatcher` writes under a watched directory to prove the
95
132
  * watcher delivers. Intercepted by name before any other handling, so it
@@ -99,6 +136,13 @@ export const WATCH_PROBE = '.vx-watch-probe'
99
136
 
100
137
  /** How long a watcher gets to report its own probe before the loop goes on without proof. */
101
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
+ }
102
146
 
103
147
  export interface ArmedWatcher {
104
148
  watcher: fs.FSWatcher
@@ -106,6 +150,84 @@ export interface ArmedWatcher {
106
150
  ready: Promise<boolean>
107
151
  }
108
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
+
109
231
  /**
110
232
  * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
111
233
  * FSEvents stream that another thread schedules AFTER the call returns, and
@@ -132,6 +254,10 @@ export function armWatcher(
132
254
  })
133
255
  const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
134
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
135
261
  if (filename === WATCH_PROBE) {
136
262
  markReady(true)
137
263
  return
@@ -213,23 +339,40 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
213
339
  process.stderr.write(`vx watch: ${resolved.nothingSelected}\n`)
214
340
  return 0
215
341
  }
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 }
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())
222
361
 
223
362
  // Enumerate projects-in-scope so we know what dirs to watch.
224
363
  // `opts.projects` is the resolved scope; undefined means "every
225
- // project". We watch only those (plus the workspace root, for
226
- // 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.
227
370
  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))
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)
233
376
 
234
377
  if (scope.length === 0) {
235
378
  process.stderr.write(`vx watch: no projects in scope\n`)
@@ -249,106 +392,339 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
249
392
  // configs.
250
393
  process.stdout.write('vx watch: initial run...\n\n')
251
394
  await runOrchestrator(opts)
395
+ if (stop.signal.aborted) return 0
252
396
 
253
- const swept = await sweepConfigs(allProjects, workspaceRoot)
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)
254
403
  return await runWatchLoop({
255
404
  opts,
405
+ stop: stop.signal,
256
406
  workspaceRoot,
257
- projects: scope,
407
+ projects: watched,
258
408
  workspaceWide: swept.workspaceWide,
409
+ projectDirs: watched.map((p) => p.dir),
410
+ workspaceInputs: swept.workspaceInputs,
259
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
+ },
260
423
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
261
- cacheDir:
262
- opts.cacheDir ?? resolveCacheDir(workspaceRoot, await loadWorkspaceConfig(workspaceRoot)),
424
+ cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
263
425
  })
264
426
  }
265
427
 
428
+ /**
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.
438
+ */
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
+
266
462
  /**
267
463
  * One sweep over every config for the two things the loop needs: whether
268
464
  * 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).
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.
276
473
  */
277
- async function sweepConfigs(
474
+ export async function sweepConfigs(
278
475
  projects: readonly ProjectMeta[],
279
476
  workspaceRoot: string,
280
- ): Promise<{ workspaceWide: boolean; outputs: Map<string, 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
+ }> {
281
485
  const outputs = new Map<string, string[]>()
282
486
  const add = (dir: string, globs: readonly string[] | undefined): void => {
283
487
  if (globs === undefined || globs.length === 0) return
284
488
  outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
285
489
  }
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
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
293
524
  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
525
+ fold(p.dir, await loadProjectConfig(p.configPath))
302
526
  } catch {
303
527
  // broken config — out of this concern's scope
304
- return false
305
528
  }
306
529
  }),
307
530
  )
308
- return { workspaceWide: uses.includes(true), outputs }
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
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
569
+ }
309
570
  }
310
571
 
311
572
  interface WatchLoopArgs {
312
573
  opts: RunOptions
574
+ /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
575
+ stop: AbortSignal
313
576
  workspaceRoot: string
314
577
  projects: readonly ProjectMeta[]
315
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[]
316
583
  /** Absolute, already-resolved — the loop must never re-derive it. */
317
584
  cacheDir: string
318
585
  /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
319
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[]>
320
597
  }
321
598
 
322
599
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
323
- const { opts, workspaceRoot, projects, workspaceWide, cacheDir, outputs } = 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
324
606
 
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.
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.
328
610
  let running = false
329
- 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()
330
613
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
331
614
 
332
- 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
333
702
  if (debounceTimer) clearTimeout(debounceTimer)
334
703
  debounceTimer = setTimeout(() => {
335
704
  debounceTimer = null
336
- void cycle(label)
705
+ if (running) return
706
+ const first = judge()
707
+ if (first !== undefined) void cycle(first)
337
708
  }, DEBOUNCE_MS)
338
709
  }
339
710
 
340
711
  const cycle = async (label: string): Promise<void> => {
341
- if (running) {
342
- pending = true
343
- return
344
- }
712
+ if (stop.aborted || running) return
345
713
  running = true
714
+ const done = (inFlight = runCycle(label))
715
+ await done
716
+ }
717
+ const runCycle = async (first: string): Promise<void> => {
346
718
  try {
347
- do {
348
- pending = false
719
+ let label: string | undefined = first
720
+ while (label !== undefined && !stop.aborted) {
349
721
  process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
350
722
  try {
351
723
  await runOrchestrator(opts)
724
+ if (membersChanged && !stop.aborted) {
725
+ membersChanged = false
726
+ await rearm()
727
+ }
352
728
  } catch (err) {
353
729
  // A re-run can fail catastrophically when the workspace
354
730
  // itself moved out from under us — e.g. the user deleted
@@ -360,10 +736,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
360
736
  const message = err instanceof Error ? err.message : String(err)
361
737
  process.stderr.write(`vx watch: cycle failed: ${message}\n`)
362
738
  }
363
- // If a change arrived mid-run, loop again immediately.
364
- } 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
+ }
365
745
  } finally {
366
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
+ }
367
753
  }
368
754
  }
369
755
 
@@ -371,34 +757,100 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
371
757
  // project's dir recursively, so a `node_modules` write under a
372
758
  // project would otherwise trigger every save during `bun install` —
373
759
  // and vx's own cache writes would trigger a cycle that writes again.
374
- const isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
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
375
764
 
376
- const watchers: fs.FSWatcher[] = []
765
+ const watchers: WatchHandle[] = []
377
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'] ?? '') !== ''
378
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
+ }
379
777
  const armed = armWatcher(dir, recursive, onEvent)
380
778
  watchers.push(armed.watcher)
381
779
  proofs.push(
382
780
  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
- }
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
+ )
388
790
  }),
389
791
  )
390
792
  }
391
793
 
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
+ }
841
+
392
842
  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.
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.
398
850
  try {
399
851
  arm(workspaceRoot, true, (filename) => {
400
- if (isIgnoredPath(workspaceRoot, filename)) return
401
- trigger(`root ${filename}`)
852
+ if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
853
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
402
854
  })
403
855
  } catch (err) {
404
856
  const msg = err instanceof Error ? err.message : String(err)
@@ -408,24 +860,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
408
860
  // One recursive watcher per project. Each project owns its own
409
861
  // subtree; we don't watch the workspace root recursively (would
410
862
  // cover every project + node_modules + caches).
411
- for (const proj of projects) {
412
- try {
413
- arm(proj.dir, true, (filename) => {
414
- if (isIgnoredPath(proj.dir, filename)) return
415
- trigger(`${proj.name} ${filename}`)
416
- })
417
- } catch (err) {
418
- const msg = err instanceof Error ? err.message : String(err)
419
- process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
420
- }
421
- }
863
+ for (const proj of projects) armProject(proj)
422
864
 
423
865
  // Plus the workspace root itself (non-recursive) so lockfile +
424
866
  // pnpm-workspace.yaml edits trigger re-runs even when no project
425
867
  // dir saw the change.
426
868
  try {
427
869
  arm(workspaceRoot, false, (filename) => {
428
- if (isWorkspaceFingerprintFile(filename)) trigger(`root ${filename}`)
870
+ if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
871
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
872
+ }
429
873
  })
430
874
  } catch (err) {
431
875
  const msg = err instanceof Error ? err.message : String(err)
@@ -433,9 +877,38 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
433
877
  }
434
878
  }
435
879
 
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.
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.
439
912
 
440
913
  // "watching" is a promise that an edit from now on is seen; every
441
914
  // watcher has proved (or been given 2 s to prove) delivery first.
@@ -448,7 +921,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
448
921
  )
449
922
 
450
923
  return await new Promise<number>((resolve) => {
451
- const cleanup = (): void => {
924
+ const cleanup = async (): Promise<void> => {
452
925
  for (const w of watchers) {
453
926
  try {
454
927
  w.close()
@@ -457,16 +930,80 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
457
930
  }
458
931
  }
459
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
460
936
  resolve(0)
461
937
  }
462
- process.once('SIGINT', () => {
463
- process.stdout.write('\nvx watch: stopped\n')
464
- cleanup()
465
- })
466
- process.once('SIGTERM', cleanup)
938
+ if (stop.aborted) {
939
+ void cleanup()
940
+ return
941
+ }
942
+ stop.addEventListener('abort', () => void cleanup(), { once: true })
467
943
  })
468
944
  }
469
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
+
470
1007
  /**
471
1008
  * True for a root file that re-keys EVERY task, so a change to one must trigger
472
1009
  * a cycle even though it lives in no project dir.
@@ -483,3 +1020,16 @@ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FIL
483
1020
  function isWorkspaceFingerprintFile(name: string): boolean {
484
1021
  return FINGERPRINT_FILES.has(name)
485
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)
1035
+ }