@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
package/src/cli/watch.ts CHANGED
@@ -14,18 +14,35 @@
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'
17
+ import {
18
+ executablePath,
19
+ isLiteralPattern,
20
+ normalizeGlob,
21
+ staticPrefix,
22
+ taskGlob,
23
+ xxh3,
24
+ } from '../util/index.js'
18
25
  import { asTrees } from '../cache/index.js'
19
26
  import { parseRunArgs, resolveRunOptions } from './run.js'
20
- import { run as runOrchestrator, type RunOptions } from '../orchestrator/index.js'
27
+ import {
28
+ fingerprintClaims,
29
+ forwardedSignal,
30
+ run as runOrchestrator,
31
+ type HeldPersistent,
32
+ type RunOptions,
33
+ } from '../orchestrator/index.js'
21
34
  import {
22
35
  buildPackageGraph,
23
36
  findWorkspaceRoot,
37
+ type LoadReads,
24
38
  listProjects,
25
39
  loadProjectConfig,
26
40
  loadWorkspace,
41
+ LOCKFILE_NAME,
27
42
  memberBaseDirs,
43
+ PROJECT_CONFIG_FILENAMES,
28
44
  WORKSPACE_CONFIG_FILENAMES,
45
+ configImports,
29
46
  WORKSPACE_FINGERPRINT_FILES,
30
47
  type ProjectEntry,
31
48
  type ProjectMeta,
@@ -72,6 +89,7 @@ export function isIgnoredWatchPath(rel: string): boolean {
72
89
  export function makeWatchIgnore(
73
90
  cacheDir: string,
74
91
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
92
+ inputs: ReadonlyMap<string, readonly string[]> = new Map(),
75
93
  ): (base: string, filename: string) => boolean {
76
94
  const cacheAbs = path.resolve(cacheDir)
77
95
  // A task's own outputs are not edits: without this every cycle that
@@ -91,14 +109,36 @@ export function makeWatchIgnore(
91
109
  ([dir, globs]) =>
92
110
  [
93
111
  path.resolve(dir),
94
- asTrees(globs).map((g) => new Bun.Glob(g)),
112
+ asTrees(globs).map((g) => taskGlob(g)),
95
113
  globs.map(outputContainer).filter((c) => c !== ''),
96
114
  ] as const,
97
115
  )
116
+ // A path some task takes as an INPUT is never an output to ignore, even
117
+ // when another task declares it one: an in-place formatter declaring
118
+ // `src/**` hid every `src` edit from a `build` watched beside it, and no
119
+ // cycle ran (item 946). Negations are not consulted — they only narrow,
120
+ // and a path they would exclude costs one cache-hit cycle.
121
+ const read = [...inputs].map(
122
+ ([dir, globs]) =>
123
+ [
124
+ path.resolve(dir),
125
+ asTrees(globs.filter((g) => !g.startsWith('!'))).map((g) => taskGlob(g)),
126
+ ] as const,
127
+ )
128
+ const isInput = (abs: string): boolean =>
129
+ read.some(([dir, globs]) => {
130
+ if (!abs.startsWith(dir + path.sep)) return false
131
+ const rel = abs
132
+ .slice(dir.length + 1)
133
+ .split(path.sep)
134
+ .join('/')
135
+ return globs.some((g) => g.match(rel))
136
+ })
98
137
  return (base, filename) => {
99
138
  if (isIgnoredWatchPath(filename)) return true
100
139
  const abs = path.resolve(base, filename)
101
140
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
141
+ if (isInput(abs)) return false
102
142
  for (const [dir, globs, containers] of declared) {
103
143
  if (!abs.startsWith(dir + path.sep)) continue
104
144
  const rel = abs
@@ -119,10 +159,10 @@ export function makeWatchIgnore(
119
159
  * container; a pattern's is `staticPrefix` — the same rule the sandbox
120
160
  * baseline and the deferral gate read.
121
161
  */
122
- export function outputContainer(raw: string): string {
162
+ function outputContainer(raw: string): string {
123
163
  const glob = normalizeGlob(raw)
124
164
  if (glob.startsWith('!')) return ''
125
- if (!/[*?[\]{}]/.test(glob)) return glob.replace(/\/+$/, '')
165
+ if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
126
166
  const prefix = staticPrefix(glob)
127
167
  return prefix === '.' || prefix === '/' ? '' : prefix
128
168
  }
@@ -134,6 +174,42 @@ export function outputContainer(raw: string): string {
134
174
  */
135
175
  export const WATCH_PROBE = '.vx-watch-probe'
136
176
 
177
+ /**
178
+ * The subset of `paths` git ignores, asked once per judgement (one
179
+ * `git check-ignore` per debounce window that has candidates, never per
180
+ * event). A git-ignored path is invisible to every cache key — inputs
181
+ * are tracked + untracked-not-ignored — so a cycle it starts can change
182
+ * nothing, and a task that writes one on every run (a pid file, a
183
+ * timestamped log, `.next/trace`) made the loop re-run itself forever
184
+ * (2026-09-16: 29 cycles in 8 s from one edit). A TRACKED file that
185
+ * matches a pattern is not reported, by git's own rule, so it stays an
186
+ * edit. Outside a repository (exit 128) nothing is ignored, as before.
187
+ */
188
+ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
189
+ const ignored = new Set<string>()
190
+ if (paths.length === 0) return ignored
191
+ let proc: ReturnType<typeof Bun.spawnSync>
192
+ try {
193
+ proc = Bun.spawnSync({
194
+ cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
195
+ cwd: workspaceRoot,
196
+ stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
197
+ stdout: 'pipe',
198
+ stderr: 'ignore',
199
+ })
200
+ } catch {
201
+ // No git on PATH: nothing is ignored, and the loop keeps judging —
202
+ // the initial run already said what vx requires.
203
+ return ignored
204
+ }
205
+ // 0: some ignored; 1: none. Anything else is git refusing (not a
206
+ // repository, a path inside a nested one): no path is ignored.
207
+ if (proc.exitCode !== 0) return ignored
208
+ for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
209
+ if (p.length > 0) ignored.add(p)
210
+ return ignored
211
+ }
212
+
137
213
  /** How long a watcher gets to report its own probe before the loop goes on without proof. */
138
214
  const WATCH_PROBE_TIMEOUT_MS = 2_000
139
215
  /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
@@ -154,12 +230,22 @@ export interface ArmedWatcher {
154
230
  const POLL_INTERVAL_MS = 250
155
231
 
156
232
  /**
157
- * Directory names the fallback never descends into. `makeWatchIgnore`
158
- * already drops their EVENTS, but a poller pays for the walk itself, and
233
+ * Directory names the fallback never descends into WHEN no better filter is
234
+ * supplied. The justification is that `makeWatchIgnore` already drops their
235
+ * EVENTS, so the walk buys nothing — a poller pays for it either way, and
159
236
  * `node_modules` is the difference between a cheap fallback and one that
160
237
  * re-stats 40 000 files four times a second.
238
+ *
239
+ * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
240
+ * It used to carry a fourth name, `dist`, which the justification does NOT
241
+ * cover: `dist` is dropped only when a project DECLARES it as an output, and
242
+ * a project that does not declare it had its `dist/` sources silently
243
+ * invisible to `vx watch` on every host the poller exists for — a macOS
244
+ * sandbox, a network mount, a container bind — while the native watcher
245
+ * delivered them. Two watchers disagreeing about what an edit is (item 482).
246
+ * The real per-project answer is the caller's `skipDir`, below.
161
247
  */
162
- const POLL_SKIP = new Set(['node_modules', '.git', '.vx', 'dist'])
248
+ const POLL_SKIP = new Set(IGNORED_SEGMENTS)
163
249
 
164
250
  /**
165
251
  * A watcher built from `stat`, for when the OS one cannot deliver.
@@ -178,6 +264,13 @@ export function pollWatcher(
178
264
  recursive: boolean,
179
265
  onEvent: (filename: string) => void,
180
266
  intervalMs = POLL_INTERVAL_MS,
267
+ /**
268
+ * Directories not worth descending into, by their path relative to `dir`.
269
+ * The watch loop passes its own event filter, so the poller skips exactly
270
+ * what the filter would drop anyway — every declared output container, not
271
+ * a hard-coded name. The default covers the unconditional segments alone.
272
+ */
273
+ skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
181
274
  ): WatchHandle {
182
275
  let previous = new Map<string, number>()
183
276
  let first = true
@@ -194,7 +287,7 @@ export function pollWatcher(
194
287
  if (e.name === WATCH_PROBE) continue
195
288
  const childRel = rel === '' ? e.name : `${rel}/${e.name}`
196
289
  if (e.isDirectory()) {
197
- if (recursive && !POLL_SKIP.has(e.name)) walk(path.join(abs, e.name), childRel)
290
+ if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
198
291
  continue
199
292
  }
200
293
  if (!e.isFile()) continue
@@ -262,6 +355,12 @@ export function armWatcher(
262
355
  markReady(true)
263
356
  return
264
357
  }
358
+ // Another arm's probe, seen by this recursive watcher under a nested
359
+ // directory (a nested project's, a member base inside a project): no
360
+ // key sees it, and passing it on ran a cycle, and restarted a dev
361
+ // server, with no edit made (item 1016). Only the watcher's own probe
362
+ // proves this stream live.
363
+ if (path.basename(filename) === WATCH_PROBE) return
265
364
  onEvent(filename)
266
365
  })
267
366
  const probe = path.join(dir, WATCH_PROBE)
@@ -343,21 +442,34 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
343
442
  // run() must not install its exit-the-process handlers — Ctrl-C
344
443
  // mid-cycle would kill the loop with 130 instead of the loop's own
345
444
  // 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.
445
+ // controller aborts with the signal's name as the reason, the in-flight
446
+ // cycle forwards it to its children (a SIGINT as SIGINT, the rest as
447
+ // SIGTERM; grace, SIGKILL) and returns, and the loop resolves 0.
348
448
  // Installed BEFORE the initial run: until 2026-09-10 the handlers went
349
449
  // in with the loop, so a SIGTERM during the initial run took Bun's
350
450
  // default (exit 143) and left the cycle's children running under init.
351
451
  const stop = new AbortController()
352
- const opts: RunOptions = { ...resolved, handleSignals: false, signal: stop.signal }
452
+ const opts: RunOptions = {
453
+ ...resolved,
454
+ handleSignals: false,
455
+ signal: stop.signal,
456
+ holdPersistent: true,
457
+ }
458
+ // Every cycle's `invocations` row names the verb, as `vx run`'s does;
459
+ // run()'s process.argv fallback put the bin's absolute path there, so
460
+ // `vx last --list` showed `$ /…/bin.ts watch build` beside `$ vx run build`.
461
+ opts.command = ['vx', 'watch', ...args].join(' ')
353
462
  // A staged load from the selection pass is one run's worth of configs;
354
463
  // every cycle after an edit must evaluate live.
355
464
  delete opts.staged
356
465
  process.once('SIGINT', () => {
357
466
  process.stdout.write('\nvx watch: stopped\n')
358
- stop.abort()
467
+ stop.abort('SIGINT')
359
468
  })
360
- process.once('SIGTERM', () => stop.abort())
469
+ process.once('SIGTERM', () => stop.abort('SIGTERM'))
470
+ // A task runs in its own session (exec/kill-tree.ts): the terminal
471
+ // closing reaches the loop alone, and the loop passes it on.
472
+ process.once('SIGHUP', () => stop.abort('SIGHUP'))
361
473
 
362
474
  // Enumerate projects-in-scope so we know what dirs to watch.
363
475
  // `opts.projects` is the resolved scope; undefined means "every
@@ -367,8 +479,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
367
479
  // closure `--filter 'app...'` walks, computed below once the initial
368
480
  // run has staged the configs. Plus the workspace root, for lockfile
369
481
  // changes.
370
- const workspaceRoot = await findWorkspaceRoot(cwd)
371
- const workspace = await loadWorkspace(workspaceRoot)
482
+ const reads: LoadReads = new Map()
483
+ const workspaceRoot = await findWorkspaceRoot(cwd, reads)
484
+ const workspace = await loadWorkspace(workspaceRoot, reads)
372
485
  const allProjects = await listProjects(workspace)
373
486
  const inScope = (all: readonly ProjectMeta[]): ProjectMeta[] =>
374
487
  opts.projects === undefined ? [...all] : all.filter((p) => opts.projects!.includes(p.name))
@@ -391,8 +504,22 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
391
504
  // closing it properly needs the workspaceWide decision made without loading
392
505
  // configs.
393
506
  process.stdout.write('vx watch: initial run...\n\n')
394
- await runOrchestrator(opts)
395
- if (stop.signal.aborted) return 0
507
+ // A run that throws (a config that does not parse) is a failed cycle, as
508
+ // it is once the loop runs: watch keeps watching, so the fix re-runs.
509
+ // Uncaught, it ended watch at start while the same break mid-watch did
510
+ // not (item 1017).
511
+ let held: HeldPersistent | undefined
512
+ try {
513
+ held = (await runOrchestrator(opts)).persistent
514
+ } catch (err) {
515
+ process.stderr.write(
516
+ `vx watch: cycle failed: ${err instanceof Error ? err.message : String(err)}\n`,
517
+ )
518
+ }
519
+ if (stop.signal.aborted) {
520
+ await held?.stop(forwardedSignal(stop.signal.reason))
521
+ return 0
522
+ }
396
523
 
397
524
  const load: CliLoadOptions = {
398
525
  ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
@@ -400,8 +527,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
400
527
  }
401
528
  const swept = await sweepConfigs(allProjects, workspaceRoot, load)
402
529
  const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
530
+ const ws = await loadCliWorkspace(workspaceRoot)
403
531
  return await runWatchLoop({
404
532
  opts,
533
+ held,
405
534
  stop: stop.signal,
406
535
  workspaceRoot,
407
536
  projects: watched,
@@ -409,7 +538,12 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
409
538
  projectDirs: watched.map((p) => p.dir),
410
539
  workspaceInputs: swept.workspaceInputs,
411
540
  outputs: swept.outputs,
541
+ inputs: swept.inputs,
542
+ uncached: swept.uncached,
543
+ configImports: swept.configImports,
544
+ workspaceConfigImports: swept.workspaceConfigImports,
412
545
  memberBases: memberBaseDirs(workspace),
546
+ packageDirs: new Set(allProjects.map((p) => p.dir)),
413
547
  // The workspace as the cycle that just ran saw it: a package added or
414
548
  // removed since the loop armed joins or leaves the watched set. The
415
549
  // scope is the one resolved at start; a new package joins it only as a
@@ -418,10 +552,27 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
418
552
  const all = await listProjects(await loadWorkspace(workspaceRoot))
419
553
  const sweep = await sweepConfigs(all, workspaceRoot, load)
420
554
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
421
- return { projects: now, workspaceInputs: sweep.workspaceInputs, outputs: sweep.outputs }
555
+ return {
556
+ projects: now,
557
+ workspaceWide: sweep.workspaceWide,
558
+ workspaceInputs: sweep.workspaceInputs,
559
+ outputs: sweep.outputs,
560
+ inputs: sweep.inputs,
561
+ uncached: sweep.uncached,
562
+ configImports: sweep.configImports,
563
+ workspaceConfigImports: sweep.workspaceConfigImports,
564
+ packageDirs: new Set(all.map((p) => p.dir)),
565
+ }
422
566
  },
567
+ // Under --frozen every cycle's configs are the lock's, so a re-lock is
568
+ // the one edit that changes what a cycle runs; unheard, the loop ran
569
+ // the old lock until a restart (item 971).
570
+ claimedRootFiles: new Set([
571
+ ...fingerprintClaims(ws.plugins).keys(),
572
+ ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
573
+ ]),
423
574
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
424
- cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
575
+ cacheDir: opts.cacheDir ?? ws.cacheDir,
425
576
  })
426
577
  }
427
578
 
@@ -479,18 +630,38 @@ export async function sweepConfigs(
479
630
  workspaceWide: boolean
480
631
  workspaceInputs: string[]
481
632
  outputs: Map<string, string[]>
633
+ /** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
634
+ inputs: Map<string, string[]>
635
+ /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
636
+ uncached: Set<string>
637
+ /** Files the project configs import by relative path (item 949). */
638
+ configImports: string[]
639
+ /** Files the workspace config imports by relative path: loaded once per process (item 949). */
640
+ workspaceConfigImports: string[]
482
641
  /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
483
642
  staged: Map<string, ProjectEntry> | null
484
643
  }> {
485
644
  const outputs = new Map<string, string[]>()
486
- const add = (dir: string, globs: readonly string[] | undefined): void => {
645
+ const inputs = new Map<string, string[]>()
646
+ const addTo = (
647
+ into: Map<string, string[]>,
648
+ dir: string,
649
+ globs: readonly string[] | undefined,
650
+ ): void => {
487
651
  if (globs === undefined || globs.length === 0) return
488
- outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
652
+ into.set(dir, [...(into.get(dir) ?? []), ...globs])
489
653
  }
654
+ const add = (dir: string, globs: readonly string[] | undefined): void =>
655
+ addTo(outputs, dir, globs)
490
656
  const workspaceInputs = new Set<string>()
657
+ const uncached = new Set<string>()
491
658
  const fold = (dir: string, config: ProjectConfig): void => {
492
659
  for (const task of Object.values(config.tasks ?? {})) {
660
+ if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
661
+ uncached.add(dir)
493
662
  for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
663
+ addTo(inputs, dir, task.cache?.inputs?.files)
664
+ addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
494
665
  add(dir, task.cache?.outputs?.files)
495
666
  add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
496
667
  }
@@ -501,13 +672,32 @@ export async function sweepConfigs(
501
672
  workspaceWide: boolean
502
673
  workspaceInputs: string[]
503
674
  outputs: Map<string, string[]>
675
+ inputs: Map<string, string[]>
676
+ uncached: Set<string>
677
+ configImports: string[]
678
+ workspaceConfigImports: string[]
504
679
  staged: Map<string, ProjectEntry> | null
505
680
  } => ({
506
681
  workspaceWide: workspaceInputs.size > 0,
507
682
  workspaceInputs: [...workspaceInputs],
508
683
  outputs,
684
+ inputs,
685
+ uncached,
686
+ configImports: [...projectImports],
687
+ workspaceConfigImports: [...wsImports],
509
688
  staged,
510
689
  })
690
+ const projectImports = new Set<string>()
691
+ const wsImports = new Set<string>()
692
+ await Promise.all([
693
+ ...projects.map(async (p) => {
694
+ if (p.configPath === null) return
695
+ for (const f of await configImports(p.configPath)) projectImports.add(f)
696
+ }),
697
+ ...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
698
+ for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
699
+ }),
700
+ ])
511
701
  let staged: Map<string, ProjectEntry> | null = null
512
702
  try {
513
703
  staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
@@ -551,15 +741,29 @@ export function makeRootEventFilter(
551
741
  workspaceRoot: string,
552
742
  projectDirs: readonly string[],
553
743
  workspaceInputs: readonly string[],
744
+ claimedRootFiles: ReadonlySet<string> = new Set(),
554
745
  ): (filename: string) => boolean {
555
746
  const dirs = projectDirs.map((d) => path.resolve(d))
556
747
  const globs = workspaceInputs
557
748
  .map(normalizeGlob)
558
749
  .filter((g) => !g.startsWith('!'))
559
- .map((g) => new Bun.Glob(g))
750
+ .map((g) => taskGlob(g))
560
751
  return (filename: string): boolean => {
561
752
  const rel = filename.split(path.sep).join('/')
562
- if (!rel.includes('/') && (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel))) {
753
+ // The depth test is a READING AID, not a guard: both predicates below
754
+ // are exact membership in a set of BARE names, so a `rel` carrying a
755
+ // slash can never be in one. Measured with it removed (item 502) over
756
+ // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
757
+ // eight more: every answer identical. The two table rows that look
758
+ // like they pin it — `nested/pnpm-lock.yaml → false` and
759
+ // `nested/vx.workspace.ts → false` — pass either way; what they
760
+ // actually pin is that the predicates stay exact rather than becoming
761
+ // a basename or suffix match, which is the change that WOULD make
762
+ // this line load-bearing.
763
+ if (
764
+ !rel.includes('/') &&
765
+ (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
766
+ ) {
563
767
  return true
564
768
  }
565
769
  const abs = path.resolve(workspaceRoot, filename)
@@ -571,6 +775,8 @@ export function makeRootEventFilter(
571
775
 
572
776
  interface WatchLoopArgs {
573
777
  opts: RunOptions
778
+ /** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
779
+ held: HeldPersistent | undefined
574
780
  /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
575
781
  stop: AbortSignal
576
782
  workspaceRoot: string
@@ -584,25 +790,74 @@ interface WatchLoopArgs {
584
790
  cacheDir: string
585
791
  /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
586
792
  outputs: ReadonlyMap<string, readonly string[]>
793
+ /** Declared input globs per directory: never ignored as another task's output. */
794
+ inputs: ReadonlyMap<string, readonly string[]>
795
+ /** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
796
+ uncached: ReadonlySet<string>
797
+ /** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
798
+ configImports: readonly string[]
799
+ /** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
800
+ workspaceConfigImports: readonly string[]
801
+ /**
802
+ * Root files a plugin claims (`VxPlugin.fingerprint`): a lockfile, or a
803
+ * file its stages read (`turbo.json`, item 961). The workspace config is
804
+ * loaded once per process, so the set is fixed for the loop.
805
+ */
806
+ claimedRootFiles: ReadonlySet<string>
587
807
  /** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
588
808
  memberBases: readonly string[]
589
- /** The watched set again, after a cycle that followed a member event. */
809
+ /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
810
+ packageDirs: ReadonlySet<string>
811
+ /** The watched set again, after a cycle that followed an event which can change it. */
590
812
  rediscover: () => Promise<Rediscovered>
591
813
  }
592
814
 
593
815
  interface Rediscovered {
594
816
  projects: readonly ProjectMeta[]
817
+ workspaceWide: boolean
595
818
  workspaceInputs: readonly string[]
596
819
  outputs: ReadonlyMap<string, readonly string[]>
820
+ inputs: ReadonlyMap<string, readonly string[]>
821
+ uncached: ReadonlySet<string>
822
+ configImports: readonly string[]
823
+ workspaceConfigImports: readonly string[]
824
+ packageDirs: ReadonlySet<string>
597
825
  }
598
826
 
827
+ const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
828
+
829
+ /**
830
+ * A file whose edit can change what the loop watches: a `package.json`
831
+ * (a dependency added under `--filter` widens the closure), a project
832
+ * config (a task that starts declaring `workspaceFiles` needs the root
833
+ * watcher, a new output must stop being an event) or the workspace config
834
+ * (a plugin's `project` stage). Until item 891 only a member directory
835
+ * coming or going re-read the set, so each of these waited for a restart
836
+ * while the loop looked alive.
837
+ */
838
+ function shapesWatchedSet(filename: string): boolean {
839
+ const base = path.basename(filename)
840
+ return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
841
+ }
842
+
843
+ const CLOSED: WatchHandle = { close() {} }
844
+
599
845
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
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.
846
+ const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
847
+ // The watched set as of the last cycle: `rearm` replaces these after an
848
+ // event that can change it, and every filter below reads the current one.
849
+ let workspaceWide = args.workspaceWide
850
+ let packageDirs = args.packageDirs
603
851
  let projectDirs = args.projectDirs
604
852
  let workspaceInputs = args.workspaceInputs
605
853
  let outputs = args.outputs
854
+ let inputs = args.inputs
855
+ let uncached = args.uncached
856
+ let configImportFiles = args.configImports
857
+ let wsConfigImportFiles = args.workspaceConfigImports
858
+ // A dev server stays up while the loop idles; the cycle that replaces it
859
+ // stops it first, so the new one never meets the old one's port.
860
+ let held = args.held
606
861
 
607
862
  // Reentrancy guard — never two orchestrator runs in flight. Events that
608
863
  // land while one is running wait in `pendingPaths` and are judged, on
@@ -663,7 +918,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
663
918
  return xxh3(Buffer.from(entries.join('\n')))
664
919
  }
665
920
  // 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
921
+ // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
667
922
  // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
668
923
  // made — FSEvents hands a stream what landed just before it started),
669
924
  // and a first sighting used to pass unconditionally; the path's mtime
@@ -688,12 +943,77 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
688
943
  // are judged one window after the run ends, all together — an edit made
689
944
  // meanwhile still differs from what the loop last saw and re-runs.
690
945
  const pendingPaths = new Map<string, string>()
946
+ // A path that starts cycle after cycle from the run's own writes is a
947
+ // task rewriting a file with different bytes every run (a pid file, a
948
+ // timestamped log): the state gate cannot settle it, and nothing here
949
+ // can tell the third such write from a user's third save mid-run — so
950
+ // watch names it once, with the remedy, and keeps going. "The run's
951
+ // own write" is read off the path itself: its mtime falls inside the
952
+ // previous cycle's window. Not off which judgement started the cycle:
953
+ // macOS delivers a run's writes late, after the loop's own post-run
954
+ // judgement found nothing and broke out, so there every such cycle
955
+ // starts from the idle timer (CI, 2026-09-16: the storm ran, the
956
+ // notice never came).
957
+ const streak = { abs: '', n: 0 }
958
+ const noticed = new Set<string>()
959
+ let lastCycle: { start: number; end: number } | undefined
960
+ const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
961
+ // A server the last cycle left running is still that cycle's: its
962
+ // writes land after the cycle ended, and with a closed window a dev
963
+ // server that rewrites a log in its project restarted itself forever
964
+ // with no word of it, 12 restarts in 8 s (item 948). The initial run's
965
+ // server is one too, from the arm on.
966
+ const open = openWhileHeld && held !== undefined
967
+ if (lastCycle === undefined && !open) return false
968
+ try {
969
+ const m = fs.statSync(abs).mtimeMs
970
+ const start = lastCycle?.start ?? armedAt
971
+ return m >= start && (open || m <= lastCycle!.end)
972
+ } catch {
973
+ return false
974
+ }
975
+ }
691
976
  const judge = (): string | undefined => {
977
+ const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
692
978
  let first: string | undefined
979
+ let firstAbs: string | undefined
980
+ // `sameState` before the `first` test, never after it: it is what
981
+ // RECORDS a path's settled state, and every path this judgement saw
982
+ // must be recorded even though only the first change names the cycle.
983
+ // Short-circuiting after the winner leaves the rest unjudged, and
984
+ // their next event is a first sighting stamped after the arm — so a
985
+ // batch edit (a `git checkout`) makes the same bytes written to any
986
+ // of the others a change.
987
+ // A git-ignored path keys nothing, but a task with no cache has no key:
988
+ // it reads what it likes, and its `.env.local` edit re-ran nothing
989
+ // (item 947). Under such a project an ignored path is judged when the
990
+ // user wrote it; one the last cycle wrote (the pid file an uncached
991
+ // task rewrites every run, the loop the filter exists for) stays out.
992
+ const editForUncached = (p: string): boolean =>
993
+ [...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
693
994
  for (const [p, l] of pendingPaths) {
694
- if (!sameState(p)) first ??= l
995
+ if (ignored.has(p) && !editForUncached(p)) continue
996
+ if (!sameState(p) && first === undefined) {
997
+ first = l
998
+ firstAbs = p
999
+ }
695
1000
  }
696
1001
  pendingPaths.clear()
1002
+ const byServer = firstAbs !== undefined && held !== undefined
1003
+ if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
1004
+ streak.n = 0
1005
+ return first
1006
+ }
1007
+ streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
1008
+ streak.abs = firstAbs
1009
+ if (streak.n >= 3 && !noticed.has(firstAbs)) {
1010
+ noticed.add(firstAbs)
1011
+ process.stdout.write(
1012
+ byServer
1013
+ ? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
1014
+ : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
1015
+ )
1016
+ }
697
1017
  return first
698
1018
  }
699
1019
  const trigger = (label: string, abs: string): void => {
@@ -720,9 +1040,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
720
1040
  while (label !== undefined && !stop.aborted) {
721
1041
  process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
722
1042
  try {
723
- await runOrchestrator(opts)
724
- if (membersChanged && !stop.aborted) {
725
- membersChanged = false
1043
+ await held?.stop()
1044
+ held = undefined
1045
+ const start = Date.now()
1046
+ held = (await runOrchestrator(opts)).persistent
1047
+ lastCycle = { start, end: Date.now() }
1048
+ if (reread && !stop.aborted) {
1049
+ reread = false
726
1050
  await rearm()
727
1051
  }
728
1052
  } catch (err) {
@@ -746,10 +1070,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
746
1070
  running = false
747
1071
  // Anything that landed after the last judgement waits for a timer
748
1072
  // like any other event.
749
- if (pendingPaths.size > 0 && !stop.aborted) {
750
- const [abs, label] = [...pendingPaths][0]!
751
- trigger(label, abs)
752
- }
1073
+ const next = pendingAfterCycle(pendingPaths, stop.aborted)
1074
+ if (next !== undefined) trigger(next[1], next[0])
753
1075
  }
754
1076
  }
755
1077
 
@@ -757,10 +1079,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
757
1079
  // project's dir recursively, so a `node_modules` write under a
758
1080
  // project would otherwise trigger every save during `bun install` —
759
1081
  // 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
1082
+ let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1083
+ let matters = makeRootEventFilter(
1084
+ workspaceRoot,
1085
+ projectDirs,
1086
+ workspaceInputs,
1087
+ args.claimedRootFiles,
1088
+ )
1089
+ /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
1090
+ let reread = false
764
1091
 
765
1092
  const watchers: WatchHandle[] = []
766
1093
  const proofs: Promise<void>[] = []
@@ -769,47 +1096,227 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
769
1096
  // network mount, a container bind — the attempt costs a denied syscall
770
1097
  // and a two-second wait before the fallback takes over anyway.
771
1098
  const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
772
- const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
1099
+ if (forcePoll)
1100
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
1101
+ // The poller skips exactly what the event filter would drop: the
1102
+ // unconditional segments AND this run's declared output containers. Read
1103
+ // through `isIgnoredPath` rather than captured, because `rearm` replaces
1104
+ // the filter when the selection changes.
1105
+ const skipUnder =
1106
+ (dir: string) =>
1107
+ (rel: string): boolean =>
1108
+ isIgnoredPath(dir, rel)
1109
+ // By slot, not by handle: an OS watcher that never proves delivery is
1110
+ // swapped for a poller in place, and a drop must close what is there.
1111
+ const arm = (
1112
+ dir: string,
1113
+ recursive: boolean,
1114
+ onEvent: (filename: string) => void,
1115
+ ): WatchHandle => {
1116
+ const at = watchers.length
1117
+ const handle: WatchHandle = {
1118
+ close: () => {
1119
+ watchers[at]?.close()
1120
+ watchers[at] = CLOSED
1121
+ },
1122
+ }
773
1123
  if (forcePoll) {
774
- watchers.push(pollWatcher(dir, recursive, onEvent))
775
- return
1124
+ watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
1125
+ return handle
776
1126
  }
777
1127
  const armed = armWatcher(dir, recursive, onEvent)
778
1128
  watchers.push(armed.watcher)
779
1129
  proofs.push(
780
1130
  armed.ready.then((ok) => {
781
1131
  if (ok) return
1132
+ armed.watcher.close()
1133
+ // Dropped by a rearm before its proof settled: nothing to swap in.
1134
+ if (watchers[at] !== armed.watcher) return
782
1135
  // The watcher never proved delivery, so it is not one: an FSEvents
783
1136
  // stream the OS refused, a filesystem that reports nothing. Swap in
784
1137
  // 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)
1138
+ watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
787
1139
  process.stderr.write(
788
1140
  `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
789
1141
  )
790
1142
  }),
791
1143
  )
1144
+ return handle
792
1145
  }
793
1146
 
794
1147
  /** 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
1148
  const armedAt = fsClockNow(cacheDir)
796
1149
  /** Per-project arms by directory, so `rearm` can add and drop them. */
797
1150
  const perProject = new Map<string, WatchHandle>()
1151
+ /** The root arm: recursive when workspace-wide, else the root's own files only. */
1152
+ let rootArm: WatchHandle = CLOSED
798
1153
  const armProject = (proj: ProjectMeta): void => {
799
1154
  try {
800
- const at = watchers.length
801
- arm(proj.dir, true, (filename) => {
1155
+ const handle = arm(proj.dir, true, (filename) => {
802
1156
  if (isIgnoredPath(proj.dir, filename)) return
1157
+ if (shapesWatchedSet(filename)) reread = true
803
1158
  trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
804
1159
  })
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() })
1160
+ perProject.set(proj.dir, handle)
808
1161
  } catch (err) {
809
1162
  const msg = err instanceof Error ? err.message : String(err)
810
1163
  process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
811
1164
  }
812
1165
  }
1166
+ const armRoot = (): void => {
1167
+ try {
1168
+ rootArm = workspaceWide
1169
+ ? // workspaceFiles inputs in play: a root-relative glob can name a
1170
+ // file anywhere, so one recursive root watcher replaces the
1171
+ // per-project ones (it also covers lockfile / pnpm-workspace.yaml
1172
+ // edits). `matters` keeps the events a key can see — project
1173
+ // trees, root fingerprint files, the declared globs — and drops
1174
+ // the rest of the tree; the ignore filter then keeps node_modules
1175
+ // / .git / .vx and declared outputs out of what remains.
1176
+ arm(workspaceRoot, true, (filename) => {
1177
+ if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
1178
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1179
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1180
+ })
1181
+ : // The root itself, non-recursive, beside the per-project arms, so
1182
+ // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
1183
+ // no project dir saw the change.
1184
+ arm(workspaceRoot, false, (filename) => {
1185
+ if (
1186
+ isWorkspaceFingerprintFile(filename) ||
1187
+ isWorkspaceConfigFile(filename) ||
1188
+ args.claimedRootFiles.has(filename)
1189
+ ) {
1190
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1191
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1192
+ }
1193
+ })
1194
+ } catch (err) {
1195
+ const msg = err instanceof Error ? err.message : String(err)
1196
+ process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
1197
+ }
1198
+ }
1199
+ // One recursive watcher per project, unless workspace-wide: each project
1200
+ // owns its own subtree; the root is never watched recursively for them
1201
+ // (it would cover every project + node_modules + caches).
1202
+ const armMode = (watched: readonly ProjectMeta[]): void => {
1203
+ if (!workspaceWide) for (const proj of watched) armProject(proj)
1204
+ armRoot()
1205
+ }
1206
+ const dropMode = (): void => {
1207
+ for (const handle of perProject.values()) handle.close()
1208
+ perProject.clear()
1209
+ rootArm.close()
1210
+ rootArm = CLOSED
1211
+ }
1212
+
1213
+ /**
1214
+ * A directory under a member base with no package in it yet. The base's
1215
+ * own watcher is not recursive, so the `package.json` that makes it a
1216
+ * package is an event only here; an editor or `git checkout` that makes
1217
+ * the directory first and the manifest after left the package unwatched
1218
+ * for good (item 891). On a rearm a new entry is armed before its
1219
+ * manifest is checked, so one that landed after the re-read is an event
1220
+ * or is seen by the check. At start there is no re-read to race, and a
1221
+ * manifest already there is a directory the globs exclude.
1222
+ */
1223
+ const pending = new Map<string, WatchHandle>()
1224
+ const armPending = (afterReread: boolean): void => {
1225
+ const want = new Set<string>()
1226
+ for (const base of memberBases)
1227
+ for (const name of memberEntries(base)) {
1228
+ const dir = path.join(base, name)
1229
+ if (!packageDirs.has(dir)) want.add(dir)
1230
+ }
1231
+ for (const [dir, handle] of pending) {
1232
+ if (want.has(dir)) continue
1233
+ handle.close()
1234
+ pending.delete(dir)
1235
+ }
1236
+ for (const dir of want) {
1237
+ if (pending.has(dir)) continue
1238
+ const manifest = path.join(dir, 'package.json')
1239
+ const arrived = (): void => {
1240
+ reread = true
1241
+ trigger(path.relative(workspaceRoot, manifest), manifest)
1242
+ }
1243
+ try {
1244
+ pending.set(
1245
+ dir,
1246
+ arm(dir, false, (filename) => {
1247
+ if (filename === 'package.json') arrived()
1248
+ }),
1249
+ )
1250
+ } catch (err) {
1251
+ const msg = err instanceof Error ? err.message : String(err)
1252
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
1253
+ continue
1254
+ }
1255
+ if (afterReread && fs.existsSync(manifest)) arrived()
1256
+ }
1257
+ }
1258
+
1259
+ /**
1260
+ * A config's imports from outside the watched projects: a shared preset
1261
+ * (`../../shared/preset.mjs`) changed what a run evaluates and no arm
1262
+ * saw it (item 949). One non-recursive arm per directory holding one.
1263
+ * A project config's import re-reads and runs; a workspace config's is
1264
+ * loaded once per process (Bun keeps the module), so its edit is named
1265
+ * with the restart it needs instead of a cycle that would run stale.
1266
+ */
1267
+ const importArms = new Map<string, WatchHandle>()
1268
+ let importNames = new Map<string, Set<string>>()
1269
+ const staleNamed = new Map<string, number>()
1270
+ const armImports = (): void => {
1271
+ const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
1272
+ const want = new Map<string, Set<string>>()
1273
+ for (const f of new Set([...configImportFiles, ...wsConfigImportFiles])) {
1274
+ if (inProject(f)) continue
1275
+ const names = want.get(path.dirname(f)) ?? new Set<string>()
1276
+ names.add(path.basename(f))
1277
+ want.set(path.dirname(f), names)
1278
+ }
1279
+ importNames = want
1280
+ for (const [dir, handle] of importArms) {
1281
+ if (want.has(dir)) continue
1282
+ handle.close()
1283
+ importArms.delete(dir)
1284
+ }
1285
+ for (const dir of want.keys()) {
1286
+ if (importArms.has(dir)) continue
1287
+ try {
1288
+ importArms.set(
1289
+ dir,
1290
+ arm(dir, false, (filename) => {
1291
+ if (!(importNames.get(dir)?.has(filename) ?? false)) return
1292
+ const abs = path.join(dir, filename)
1293
+ const rel = path.relative(workspaceRoot, abs)
1294
+ if (configImportFiles.includes(abs)) {
1295
+ reread = true
1296
+ trigger(`config import ${rel}`, abs)
1297
+ return
1298
+ }
1299
+ // One line per save, not per event: a save is often two.
1300
+ const now = Date.now()
1301
+ if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
1302
+ staleNamed.set(abs, now)
1303
+ process.stdout.write(
1304
+ `vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
1305
+ )
1306
+ }),
1307
+ )
1308
+ } catch (err) {
1309
+ const msg = err instanceof Error ? err.message : String(err)
1310
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ const watchingLine = (count: number): string =>
1316
+ workspaceWide
1317
+ ? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
1318
+ : `vx watch: watching ${count} project(s)`
1319
+
813
1320
  const rearm = async (): Promise<void> => {
814
1321
  let next: Rediscovered
815
1322
  try {
@@ -822,9 +1329,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
822
1329
  projectDirs = next.projects.map((p) => p.dir)
823
1330
  workspaceInputs = next.workspaceInputs
824
1331
  outputs = next.outputs
825
- isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
826
- matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
827
- if (!workspaceWide) {
1332
+ inputs = next.inputs
1333
+ uncached = next.uncached
1334
+ configImportFiles = next.configImports
1335
+ wsConfigImportFiles = next.workspaceConfigImports
1336
+ packageDirs = next.packageDirs
1337
+ isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1338
+ matters = makeRootEventFilter(
1339
+ workspaceRoot,
1340
+ projectDirs,
1341
+ workspaceInputs,
1342
+ args.claimedRootFiles,
1343
+ )
1344
+ if (next.workspaceWide !== workspaceWide) {
1345
+ // A task started or stopped declaring `workspaceFiles`: the other
1346
+ // arm's shape. Until item 891 the choice was made once, at start, and
1347
+ // a root file a config newly named was never an event.
1348
+ dropMode()
1349
+ workspaceWide = next.workspaceWide
1350
+ armMode(next.projects)
1351
+ } else if (!workspaceWide) {
828
1352
  const keep = new Set(projectDirs)
829
1353
  for (const [dir, handle] of perProject) {
830
1354
  if (keep.has(dir)) continue
@@ -832,50 +1356,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
832
1356
  perProject.delete(dir)
833
1357
  }
834
1358
  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
1359
  }
839
- process.stdout.write(`vx watch: watching ${next.projects.length} project(s)\n`)
1360
+ armPending(true)
1361
+ armImports()
1362
+ // A new arm proves delivery like the first ones: an edit in the new
1363
+ // package right after this cycle is seen, not lost in the gap.
1364
+ await Promise.all(proofs)
1365
+ process.stdout.write(`${watchingLine(next.projects.length)}\n`)
840
1366
  }
841
1367
 
842
- if (workspaceWide) {
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.
850
- try {
851
- arm(workspaceRoot, true, (filename) => {
852
- if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
853
- trigger(`root ${filename}`, path.join(workspaceRoot, filename))
854
- })
855
- } catch (err) {
856
- const msg = err instanceof Error ? err.message : String(err)
857
- process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
858
- }
859
- } else {
860
- // One recursive watcher per project. Each project owns its own
861
- // subtree; we don't watch the workspace root recursively (would
862
- // cover every project + node_modules + caches).
863
- for (const proj of projects) armProject(proj)
864
-
865
- // Plus the workspace root itself (non-recursive) so lockfile +
866
- // pnpm-workspace.yaml edits trigger re-runs even when no project
867
- // dir saw the change.
868
- try {
869
- arm(workspaceRoot, false, (filename) => {
870
- if (isWorkspaceFingerprintFile(filename) || isWorkspaceConfigFile(filename)) {
871
- trigger(`root ${filename}`, path.join(workspaceRoot, filename))
872
- }
873
- })
874
- } catch (err) {
875
- const msg = err instanceof Error ? err.message : String(err)
876
- process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
877
- }
878
- }
1368
+ armMode(projects)
879
1369
 
880
1370
  // A package added while the loop runs is a directory entry appearing
881
1371
  // under the glob's directory (`packages/` for `packages/*`); one
@@ -897,7 +1387,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
897
1387
  const now = memberEntries(base)
898
1388
  if (sameMembers(members, now)) return
899
1389
  members = now
900
- membersChanged = true
1390
+ reread = true
901
1391
  trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
902
1392
  })
903
1393
  } catch (err) {
@@ -905,6 +1395,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
905
1395
  process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
906
1396
  }
907
1397
  }
1398
+ armPending(false)
1399
+ armImports()
908
1400
 
909
1401
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
910
1402
  // closes over the RESOLVED cache dir (relocated or not) and the tasks'
@@ -914,11 +1406,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
914
1406
  // watcher has proved (or been given 2 s to prove) delivery first.
915
1407
  await Promise.all(proofs)
916
1408
 
917
- process.stdout.write(
918
- workspaceWide
919
- ? `\nvx watch: watching the workspace root (workspaceFiles inputs in use); press Ctrl+C to stop\n`
920
- : `\nvx watch: watching ${projects.length} project(s); press Ctrl+C to stop\n`,
921
- )
1409
+ process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
922
1410
 
923
1411
  return await new Promise<number>((resolve) => {
924
1412
  const cleanup = async (): Promise<void> => {
@@ -933,6 +1421,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
933
1421
  // The aborted cycle is tearing its children down; resolve only once
934
1422
  // it has returned, so the process never exits over a live child.
935
1423
  await inFlight
1424
+ await held?.stop(forwardedSignal(stop.reason))
936
1425
  resolve(0)
937
1426
  }
938
1427
  if (stop.aborted) {
@@ -943,10 +1432,41 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
943
1432
  })
944
1433
  }
945
1434
 
1435
+ /**
1436
+ * The event a finished cycle hands back to the timer, if any.
1437
+ *
1438
+ * The inner loop re-runs while anything is pending, so this covers only the
1439
+ * narrow gap between its LAST judgement and the loop going idle: an event
1440
+ * landing there would otherwise sit in `pendingPaths` with no timer armed
1441
+ * and no cycle to notice it — watch quietly idle over an edit the user
1442
+ * made. Insertion order, because the map is "first label wins" and the
1443
+ * label is what the cycle announces.
1444
+ *
1445
+ * A branch, not a race: extracted so it can be pinned at all. Deleting the
1446
+ * arming left every green test in the repo green (2026-09-20) — the e2e
1447
+ * watch fixture spawns a real `vx watch`, so it cannot deliver an event at
1448
+ * that instant, and the suites that could are the ones this repo's cloud
1449
+ * container already fails for timing reasons. What this pins is the
1450
+ * decision; the delivery window is the process's own.
1451
+ */
1452
+ export function pendingAfterCycle(
1453
+ pending: ReadonlyMap<string, string>,
1454
+ aborted: boolean,
1455
+ ): [abs: string, label: string] | undefined {
1456
+ if (aborted || pending.size === 0) return undefined
1457
+ const [abs, label] = [...pending][0]!
1458
+ return [abs, label]
1459
+ }
1460
+
946
1461
  /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
947
1462
  export function modifiedBefore(abs: string, t: number): boolean {
948
1463
  try {
949
- return fs.statSync(abs).mtimeMs < t
1464
+ // The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
1465
+ // carry a file's OLD mtime onto the new one, and judged by mtime alone
1466
+ // such an edit was "before the arm" and never ran (item 945). No
1467
+ // process can set a ctime, and a rename or a write moves it.
1468
+ const st = fs.statSync(abs)
1469
+ return Math.max(st.mtimeMs, st.ctimeMs) < t
950
1470
  } catch {
951
1471
  return false
952
1472
  }