@vzn/vx 0.0.21 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +611 -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,11 @@ 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
+ const initial = await runOrchestrator(opts)
508
+ if (stop.signal.aborted) {
509
+ await initial.persistent?.stop(forwardedSignal(stop.signal.reason))
510
+ return 0
511
+ }
396
512
 
397
513
  const load: CliLoadOptions = {
398
514
  ...(opts.cacheDir !== undefined ? { cacheDir: opts.cacheDir } : {}),
@@ -400,8 +516,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
400
516
  }
401
517
  const swept = await sweepConfigs(allProjects, workspaceRoot, load)
402
518
  const watched = await watchedProjects(workspaceRoot, allProjects, scope, load, swept.staged)
519
+ const ws = await loadCliWorkspace(workspaceRoot)
403
520
  return await runWatchLoop({
404
521
  opts,
522
+ held: initial.persistent,
405
523
  stop: stop.signal,
406
524
  workspaceRoot,
407
525
  projects: watched,
@@ -409,7 +527,12 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
409
527
  projectDirs: watched.map((p) => p.dir),
410
528
  workspaceInputs: swept.workspaceInputs,
411
529
  outputs: swept.outputs,
530
+ inputs: swept.inputs,
531
+ uncached: swept.uncached,
532
+ configImports: swept.configImports,
533
+ workspaceConfigImports: swept.workspaceConfigImports,
412
534
  memberBases: memberBaseDirs(workspace),
535
+ packageDirs: new Set(allProjects.map((p) => p.dir)),
413
536
  // The workspace as the cycle that just ran saw it: a package added or
414
537
  // removed since the loop armed joins or leaves the watched set. The
415
538
  // scope is the one resolved at start; a new package joins it only as a
@@ -418,10 +541,27 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
418
541
  const all = await listProjects(await loadWorkspace(workspaceRoot))
419
542
  const sweep = await sweepConfigs(all, workspaceRoot, load)
420
543
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
421
- return { projects: now, workspaceInputs: sweep.workspaceInputs, outputs: sweep.outputs }
544
+ return {
545
+ projects: now,
546
+ workspaceWide: sweep.workspaceWide,
547
+ workspaceInputs: sweep.workspaceInputs,
548
+ outputs: sweep.outputs,
549
+ inputs: sweep.inputs,
550
+ uncached: sweep.uncached,
551
+ configImports: sweep.configImports,
552
+ workspaceConfigImports: sweep.workspaceConfigImports,
553
+ packageDirs: new Set(all.map((p) => p.dir)),
554
+ }
422
555
  },
556
+ // Under --frozen every cycle's configs are the lock's, so a re-lock is
557
+ // the one edit that changes what a cycle runs; unheard, the loop ran
558
+ // the old lock until a restart (item 971).
559
+ claimedRootFiles: new Set([
560
+ ...fingerprintClaims(ws.plugins).keys(),
561
+ ...(opts.frozen === true ? [LOCKFILE_NAME] : []),
562
+ ]),
423
563
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
424
- cacheDir: opts.cacheDir ?? (await loadCliWorkspace(workspaceRoot)).cacheDir,
564
+ cacheDir: opts.cacheDir ?? ws.cacheDir,
425
565
  })
426
566
  }
427
567
 
@@ -479,18 +619,38 @@ export async function sweepConfigs(
479
619
  workspaceWide: boolean
480
620
  workspaceInputs: string[]
481
621
  outputs: Map<string, string[]>
622
+ /** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
623
+ inputs: Map<string, string[]>
624
+ /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
625
+ uncached: Set<string>
626
+ /** Files the project configs import by relative path (item 949). */
627
+ configImports: string[]
628
+ /** Files the workspace config imports by relative path: loaded once per process (item 949). */
629
+ workspaceConfigImports: string[]
482
630
  /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
483
631
  staged: Map<string, ProjectEntry> | null
484
632
  }> {
485
633
  const outputs = new Map<string, string[]>()
486
- const add = (dir: string, globs: readonly string[] | undefined): void => {
634
+ const inputs = new Map<string, string[]>()
635
+ const addTo = (
636
+ into: Map<string, string[]>,
637
+ dir: string,
638
+ globs: readonly string[] | undefined,
639
+ ): void => {
487
640
  if (globs === undefined || globs.length === 0) return
488
- outputs.set(dir, [...(outputs.get(dir) ?? []), ...globs])
641
+ into.set(dir, [...(into.get(dir) ?? []), ...globs])
489
642
  }
643
+ const add = (dir: string, globs: readonly string[] | undefined): void =>
644
+ addTo(outputs, dir, globs)
490
645
  const workspaceInputs = new Set<string>()
646
+ const uncached = new Set<string>()
491
647
  const fold = (dir: string, config: ProjectConfig): void => {
492
648
  for (const task of Object.values(config.tasks ?? {})) {
649
+ if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
650
+ uncached.add(dir)
493
651
  for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
652
+ addTo(inputs, dir, task.cache?.inputs?.files)
653
+ addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
494
654
  add(dir, task.cache?.outputs?.files)
495
655
  add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
496
656
  }
@@ -501,13 +661,32 @@ export async function sweepConfigs(
501
661
  workspaceWide: boolean
502
662
  workspaceInputs: string[]
503
663
  outputs: Map<string, string[]>
664
+ inputs: Map<string, string[]>
665
+ uncached: Set<string>
666
+ configImports: string[]
667
+ workspaceConfigImports: string[]
504
668
  staged: Map<string, ProjectEntry> | null
505
669
  } => ({
506
670
  workspaceWide: workspaceInputs.size > 0,
507
671
  workspaceInputs: [...workspaceInputs],
508
672
  outputs,
673
+ inputs,
674
+ uncached,
675
+ configImports: [...projectImports],
676
+ workspaceConfigImports: [...wsImports],
509
677
  staged,
510
678
  })
679
+ const projectImports = new Set<string>()
680
+ const wsImports = new Set<string>()
681
+ await Promise.all([
682
+ ...projects.map(async (p) => {
683
+ if (p.configPath === null) return
684
+ for (const f of await configImports(p.configPath)) projectImports.add(f)
685
+ }),
686
+ ...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
687
+ for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
688
+ }),
689
+ ])
511
690
  let staged: Map<string, ProjectEntry> | null = null
512
691
  try {
513
692
  staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
@@ -551,15 +730,29 @@ export function makeRootEventFilter(
551
730
  workspaceRoot: string,
552
731
  projectDirs: readonly string[],
553
732
  workspaceInputs: readonly string[],
733
+ claimedRootFiles: ReadonlySet<string> = new Set(),
554
734
  ): (filename: string) => boolean {
555
735
  const dirs = projectDirs.map((d) => path.resolve(d))
556
736
  const globs = workspaceInputs
557
737
  .map(normalizeGlob)
558
738
  .filter((g) => !g.startsWith('!'))
559
- .map((g) => new Bun.Glob(g))
739
+ .map((g) => taskGlob(g))
560
740
  return (filename: string): boolean => {
561
741
  const rel = filename.split(path.sep).join('/')
562
- if (!rel.includes('/') && (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel))) {
742
+ // The depth test is a READING AID, not a guard: both predicates below
743
+ // are exact membership in a set of BARE names, so a `rel` carrying a
744
+ // slash can never be in one. Measured with it removed (item 502) over
745
+ // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
746
+ // eight more: every answer identical. The two table rows that look
747
+ // like they pin it — `nested/pnpm-lock.yaml → false` and
748
+ // `nested/vx.workspace.ts → false` — pass either way; what they
749
+ // actually pin is that the predicates stay exact rather than becoming
750
+ // a basename or suffix match, which is the change that WOULD make
751
+ // this line load-bearing.
752
+ if (
753
+ !rel.includes('/') &&
754
+ (isWorkspaceFingerprintFile(rel) || isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel))
755
+ ) {
563
756
  return true
564
757
  }
565
758
  const abs = path.resolve(workspaceRoot, filename)
@@ -571,6 +764,8 @@ export function makeRootEventFilter(
571
764
 
572
765
  interface WatchLoopArgs {
573
766
  opts: RunOptions
767
+ /** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
768
+ held: HeldPersistent | undefined
574
769
  /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
575
770
  stop: AbortSignal
576
771
  workspaceRoot: string
@@ -584,25 +779,74 @@ interface WatchLoopArgs {
584
779
  cacheDir: string
585
780
  /** Declared output globs per directory they are relative to (project dir, or the root for `workspaceFiles`). */
586
781
  outputs: ReadonlyMap<string, readonly string[]>
782
+ /** Declared input globs per directory: never ignored as another task's output. */
783
+ inputs: ReadonlyMap<string, readonly string[]>
784
+ /** Projects whose uncached task may read a git-ignored file: such a path there is still an edit. */
785
+ uncached: ReadonlySet<string>
786
+ /** Files the configs import: an edit to one outside the watched projects is a cycle that re-reads them. */
787
+ configImports: readonly string[]
788
+ /** Files the workspace config imports: loaded once per process, so an edit is named, not run. */
789
+ workspaceConfigImports: readonly string[]
790
+ /**
791
+ * Root files a plugin claims (`VxPlugin.fingerprint`): a lockfile, or a
792
+ * file its stages read (`turbo.json`, item 961). The workspace config is
793
+ * loaded once per process, so the set is fixed for the loop.
794
+ */
795
+ claimedRootFiles: ReadonlySet<string>
587
796
  /** The directory each `<dir>/*` package glob names; a member coming or going there is a cycle. */
588
797
  memberBases: readonly string[]
589
- /** The watched set again, after a cycle that followed a member event. */
798
+ /** Every package's directory, in scope or not: a member base's other entries are packages still to come. */
799
+ packageDirs: ReadonlySet<string>
800
+ /** The watched set again, after a cycle that followed an event which can change it. */
590
801
  rediscover: () => Promise<Rediscovered>
591
802
  }
592
803
 
593
804
  interface Rediscovered {
594
805
  projects: readonly ProjectMeta[]
806
+ workspaceWide: boolean
595
807
  workspaceInputs: readonly string[]
596
808
  outputs: ReadonlyMap<string, readonly string[]>
809
+ inputs: ReadonlyMap<string, readonly string[]>
810
+ uncached: ReadonlySet<string>
811
+ configImports: readonly string[]
812
+ workspaceConfigImports: readonly string[]
813
+ packageDirs: ReadonlySet<string>
814
+ }
815
+
816
+ const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
817
+
818
+ /**
819
+ * A file whose edit can change what the loop watches: a `package.json`
820
+ * (a dependency added under `--filter` widens the closure), a project
821
+ * config (a task that starts declaring `workspaceFiles` needs the root
822
+ * watcher, a new output must stop being an event) or the workspace config
823
+ * (a plugin's `project` stage). Until item 891 only a member directory
824
+ * coming or going re-read the set, so each of these waited for a restart
825
+ * while the loop looked alive.
826
+ */
827
+ function shapesWatchedSet(filename: string): boolean {
828
+ const base = path.basename(filename)
829
+ return base === 'package.json' || PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(base)
597
830
  }
598
831
 
832
+ const CLOSED: WatchHandle = { close() {} }
833
+
599
834
  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.
835
+ const { opts, stop, workspaceRoot, projects, cacheDir, memberBases } = args
836
+ // The watched set as of the last cycle: `rearm` replaces these after an
837
+ // event that can change it, and every filter below reads the current one.
838
+ let workspaceWide = args.workspaceWide
839
+ let packageDirs = args.packageDirs
603
840
  let projectDirs = args.projectDirs
604
841
  let workspaceInputs = args.workspaceInputs
605
842
  let outputs = args.outputs
843
+ let inputs = args.inputs
844
+ let uncached = args.uncached
845
+ let configImportFiles = args.configImports
846
+ let wsConfigImportFiles = args.workspaceConfigImports
847
+ // A dev server stays up while the loop idles; the cycle that replaces it
848
+ // stops it first, so the new one never meets the old one's port.
849
+ let held = args.held
606
850
 
607
851
  // Reentrancy guard — never two orchestrator runs in flight. Events that
608
852
  // land while one is running wait in `pendingPaths` and are judged, on
@@ -663,7 +907,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
663
907
  return xxh3(Buffer.from(entries.join('\n')))
664
908
  }
665
909
  // 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
910
+ // the watchers went live (its mtime or ctime, `modifiedBefore`). macOS delivers the initial run's own writes
667
911
  // AFTER the arm (CI, 2026-09-11: `app dist; re-running...` with no edit
668
912
  // made — FSEvents hands a stream what landed just before it started),
669
913
  // and a first sighting used to pass unconditionally; the path's mtime
@@ -688,12 +932,77 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
688
932
  // are judged one window after the run ends, all together — an edit made
689
933
  // meanwhile still differs from what the loop last saw and re-runs.
690
934
  const pendingPaths = new Map<string, string>()
935
+ // A path that starts cycle after cycle from the run's own writes is a
936
+ // task rewriting a file with different bytes every run (a pid file, a
937
+ // timestamped log): the state gate cannot settle it, and nothing here
938
+ // can tell the third such write from a user's third save mid-run — so
939
+ // watch names it once, with the remedy, and keeps going. "The run's
940
+ // own write" is read off the path itself: its mtime falls inside the
941
+ // previous cycle's window. Not off which judgement started the cycle:
942
+ // macOS delivers a run's writes late, after the loop's own post-run
943
+ // judgement found nothing and broke out, so there every such cycle
944
+ // starts from the idle timer (CI, 2026-09-16: the storm ran, the
945
+ // notice never came).
946
+ const streak = { abs: '', n: 0 }
947
+ const noticed = new Set<string>()
948
+ let lastCycle: { start: number; end: number } | undefined
949
+ const writtenDuringLastCycle = (abs: string, openWhileHeld = false): boolean => {
950
+ // A server the last cycle left running is still that cycle's: its
951
+ // writes land after the cycle ended, and with a closed window a dev
952
+ // server that rewrites a log in its project restarted itself forever
953
+ // with no word of it, 12 restarts in 8 s (item 948). The initial run's
954
+ // server is one too, from the arm on.
955
+ const open = openWhileHeld && held !== undefined
956
+ if (lastCycle === undefined && !open) return false
957
+ try {
958
+ const m = fs.statSync(abs).mtimeMs
959
+ const start = lastCycle?.start ?? armedAt
960
+ return m >= start && (open || m <= lastCycle!.end)
961
+ } catch {
962
+ return false
963
+ }
964
+ }
691
965
  const judge = (): string | undefined => {
966
+ const ignored = gitIgnored(workspaceRoot, [...pendingPaths.keys()])
692
967
  let first: string | undefined
968
+ let firstAbs: string | undefined
969
+ // `sameState` before the `first` test, never after it: it is what
970
+ // RECORDS a path's settled state, and every path this judgement saw
971
+ // must be recorded even though only the first change names the cycle.
972
+ // Short-circuiting after the winner leaves the rest unjudged, and
973
+ // their next event is a first sighting stamped after the arm — so a
974
+ // batch edit (a `git checkout`) makes the same bytes written to any
975
+ // of the others a change.
976
+ // A git-ignored path keys nothing, but a task with no cache has no key:
977
+ // it reads what it likes, and its `.env.local` edit re-ran nothing
978
+ // (item 947). Under such a project an ignored path is judged when the
979
+ // user wrote it; one the last cycle wrote (the pid file an uncached
980
+ // task rewrites every run, the loop the filter exists for) stays out.
981
+ const editForUncached = (p: string): boolean =>
982
+ [...uncached].some((dir) => p.startsWith(dir + path.sep)) && !writtenDuringLastCycle(p)
693
983
  for (const [p, l] of pendingPaths) {
694
- if (!sameState(p)) first ??= l
984
+ if (ignored.has(p) && !editForUncached(p)) continue
985
+ if (!sameState(p) && first === undefined) {
986
+ first = l
987
+ firstAbs = p
988
+ }
695
989
  }
696
990
  pendingPaths.clear()
991
+ const byServer = firstAbs !== undefined && held !== undefined
992
+ if (firstAbs === undefined || !writtenDuringLastCycle(firstAbs, true)) {
993
+ streak.n = 0
994
+ return first
995
+ }
996
+ streak.n = streak.abs === firstAbs ? streak.n + 1 : 1
997
+ streak.abs = firstAbs
998
+ if (streak.n >= 3 && !noticed.has(firstAbs)) {
999
+ noticed.add(firstAbs)
1000
+ process.stdout.write(
1001
+ byServer
1002
+ ? `vx watch: ${first} has started 3 cycles in a row, written while a server the cycle before started was running — a persistent task rewrites it. Add it to .gitignore (a git-ignored path never starts a cycle); until then every write restarts the server.\n`
1003
+ : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
1004
+ )
1005
+ }
697
1006
  return first
698
1007
  }
699
1008
  const trigger = (label: string, abs: string): void => {
@@ -720,9 +1029,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
720
1029
  while (label !== undefined && !stop.aborted) {
721
1030
  process.stdout.write(`\nvx watch: ${label}; re-running...\n\n`)
722
1031
  try {
723
- await runOrchestrator(opts)
724
- if (membersChanged && !stop.aborted) {
725
- membersChanged = false
1032
+ await held?.stop()
1033
+ held = undefined
1034
+ const start = Date.now()
1035
+ held = (await runOrchestrator(opts)).persistent
1036
+ lastCycle = { start, end: Date.now() }
1037
+ if (reread && !stop.aborted) {
1038
+ reread = false
726
1039
  await rearm()
727
1040
  }
728
1041
  } catch (err) {
@@ -746,10 +1059,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
746
1059
  running = false
747
1060
  // Anything that landed after the last judgement waits for a timer
748
1061
  // like any other event.
749
- if (pendingPaths.size > 0 && !stop.aborted) {
750
- const [abs, label] = [...pendingPaths][0]!
751
- trigger(label, abs)
752
- }
1062
+ const next = pendingAfterCycle(pendingPaths, stop.aborted)
1063
+ if (next !== undefined) trigger(next[1], next[0])
753
1064
  }
754
1065
  }
755
1066
 
@@ -757,10 +1068,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
757
1068
  // project's dir recursively, so a `node_modules` write under a
758
1069
  // project would otherwise trigger every save during `bun install` —
759
1070
  // 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
1071
+ let isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1072
+ let matters = makeRootEventFilter(
1073
+ workspaceRoot,
1074
+ projectDirs,
1075
+ workspaceInputs,
1076
+ args.claimedRootFiles,
1077
+ )
1078
+ /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
1079
+ let reread = false
764
1080
 
765
1081
  const watchers: WatchHandle[] = []
766
1082
  const proofs: Promise<void>[] = []
@@ -769,47 +1085,227 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
769
1085
  // network mount, a container bind — the attempt costs a denied syscall
770
1086
  // and a two-second wait before the fallback takes over anyway.
771
1087
  const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
772
- const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): void => {
1088
+ if (forcePoll)
1089
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
1090
+ // The poller skips exactly what the event filter would drop: the
1091
+ // unconditional segments AND this run's declared output containers. Read
1092
+ // through `isIgnoredPath` rather than captured, because `rearm` replaces
1093
+ // the filter when the selection changes.
1094
+ const skipUnder =
1095
+ (dir: string) =>
1096
+ (rel: string): boolean =>
1097
+ isIgnoredPath(dir, rel)
1098
+ // By slot, not by handle: an OS watcher that never proves delivery is
1099
+ // swapped for a poller in place, and a drop must close what is there.
1100
+ const arm = (
1101
+ dir: string,
1102
+ recursive: boolean,
1103
+ onEvent: (filename: string) => void,
1104
+ ): WatchHandle => {
1105
+ const at = watchers.length
1106
+ const handle: WatchHandle = {
1107
+ close: () => {
1108
+ watchers[at]?.close()
1109
+ watchers[at] = CLOSED
1110
+ },
1111
+ }
773
1112
  if (forcePoll) {
774
- watchers.push(pollWatcher(dir, recursive, onEvent))
775
- return
1113
+ watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
1114
+ return handle
776
1115
  }
777
1116
  const armed = armWatcher(dir, recursive, onEvent)
778
1117
  watchers.push(armed.watcher)
779
1118
  proofs.push(
780
1119
  armed.ready.then((ok) => {
781
1120
  if (ok) return
1121
+ armed.watcher.close()
1122
+ // Dropped by a rearm before its proof settled: nothing to swap in.
1123
+ if (watchers[at] !== armed.watcher) return
782
1124
  // The watcher never proved delivery, so it is not one: an FSEvents
783
1125
  // stream the OS refused, a filesystem that reports nothing. Swap in
784
1126
  // 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)
1127
+ watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
787
1128
  process.stderr.write(
788
1129
  `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
789
1130
  )
790
1131
  }),
791
1132
  )
1133
+ return handle
792
1134
  }
793
1135
 
794
1136
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
795
1137
  const armedAt = fsClockNow(cacheDir)
796
1138
  /** Per-project arms by directory, so `rearm` can add and drop them. */
797
1139
  const perProject = new Map<string, WatchHandle>()
1140
+ /** The root arm: recursive when workspace-wide, else the root's own files only. */
1141
+ let rootArm: WatchHandle = CLOSED
798
1142
  const armProject = (proj: ProjectMeta): void => {
799
1143
  try {
800
- const at = watchers.length
801
- arm(proj.dir, true, (filename) => {
1144
+ const handle = arm(proj.dir, true, (filename) => {
802
1145
  if (isIgnoredPath(proj.dir, filename)) return
1146
+ if (shapesWatchedSet(filename)) reread = true
803
1147
  trigger(`${proj.name} ${filename}`, path.join(proj.dir, filename))
804
1148
  })
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() })
1149
+ perProject.set(proj.dir, handle)
808
1150
  } catch (err) {
809
1151
  const msg = err instanceof Error ? err.message : String(err)
810
1152
  process.stderr.write(`vx watch: cannot watch ${proj.dir}: ${msg}\n`)
811
1153
  }
812
1154
  }
1155
+ const armRoot = (): void => {
1156
+ try {
1157
+ rootArm = workspaceWide
1158
+ ? // workspaceFiles inputs in play: a root-relative glob can name a
1159
+ // file anywhere, so one recursive root watcher replaces the
1160
+ // per-project ones (it also covers lockfile / pnpm-workspace.yaml
1161
+ // edits). `matters` keeps the events a key can see — project
1162
+ // trees, root fingerprint files, the declared globs — and drops
1163
+ // the rest of the tree; the ignore filter then keeps node_modules
1164
+ // / .git / .vx and declared outputs out of what remains.
1165
+ arm(workspaceRoot, true, (filename) => {
1166
+ if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
1167
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1168
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1169
+ })
1170
+ : // The root itself, non-recursive, beside the per-project arms, so
1171
+ // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
1172
+ // no project dir saw the change.
1173
+ arm(workspaceRoot, false, (filename) => {
1174
+ if (
1175
+ isWorkspaceFingerprintFile(filename) ||
1176
+ isWorkspaceConfigFile(filename) ||
1177
+ args.claimedRootFiles.has(filename)
1178
+ ) {
1179
+ if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
1180
+ trigger(`root ${filename}`, path.join(workspaceRoot, filename))
1181
+ }
1182
+ })
1183
+ } catch (err) {
1184
+ const msg = err instanceof Error ? err.message : String(err)
1185
+ process.stderr.write(`vx watch: cannot watch workspace root: ${msg}\n`)
1186
+ }
1187
+ }
1188
+ // One recursive watcher per project, unless workspace-wide: each project
1189
+ // owns its own subtree; the root is never watched recursively for them
1190
+ // (it would cover every project + node_modules + caches).
1191
+ const armMode = (watched: readonly ProjectMeta[]): void => {
1192
+ if (!workspaceWide) for (const proj of watched) armProject(proj)
1193
+ armRoot()
1194
+ }
1195
+ const dropMode = (): void => {
1196
+ for (const handle of perProject.values()) handle.close()
1197
+ perProject.clear()
1198
+ rootArm.close()
1199
+ rootArm = CLOSED
1200
+ }
1201
+
1202
+ /**
1203
+ * A directory under a member base with no package in it yet. The base's
1204
+ * own watcher is not recursive, so the `package.json` that makes it a
1205
+ * package is an event only here; an editor or `git checkout` that makes
1206
+ * the directory first and the manifest after left the package unwatched
1207
+ * for good (item 891). On a rearm a new entry is armed before its
1208
+ * manifest is checked, so one that landed after the re-read is an event
1209
+ * or is seen by the check. At start there is no re-read to race, and a
1210
+ * manifest already there is a directory the globs exclude.
1211
+ */
1212
+ const pending = new Map<string, WatchHandle>()
1213
+ const armPending = (afterReread: boolean): void => {
1214
+ const want = new Set<string>()
1215
+ for (const base of memberBases)
1216
+ for (const name of memberEntries(base)) {
1217
+ const dir = path.join(base, name)
1218
+ if (!packageDirs.has(dir)) want.add(dir)
1219
+ }
1220
+ for (const [dir, handle] of pending) {
1221
+ if (want.has(dir)) continue
1222
+ handle.close()
1223
+ pending.delete(dir)
1224
+ }
1225
+ for (const dir of want) {
1226
+ if (pending.has(dir)) continue
1227
+ const manifest = path.join(dir, 'package.json')
1228
+ const arrived = (): void => {
1229
+ reread = true
1230
+ trigger(path.relative(workspaceRoot, manifest), manifest)
1231
+ }
1232
+ try {
1233
+ pending.set(
1234
+ dir,
1235
+ arm(dir, false, (filename) => {
1236
+ if (filename === 'package.json') arrived()
1237
+ }),
1238
+ )
1239
+ } catch (err) {
1240
+ const msg = err instanceof Error ? err.message : String(err)
1241
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
1242
+ continue
1243
+ }
1244
+ if (afterReread && fs.existsSync(manifest)) arrived()
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * A config's imports from outside the watched projects: a shared preset
1250
+ * (`../../shared/preset.mjs`) changed what a run evaluates and no arm
1251
+ * saw it (item 949). One non-recursive arm per directory holding one.
1252
+ * A project config's import re-reads and runs; a workspace config's is
1253
+ * loaded once per process (Bun keeps the module), so its edit is named
1254
+ * with the restart it needs instead of a cycle that would run stale.
1255
+ */
1256
+ const importArms = new Map<string, WatchHandle>()
1257
+ let importNames = new Map<string, Set<string>>()
1258
+ const staleNamed = new Map<string, number>()
1259
+ const armImports = (): void => {
1260
+ const inProject = (f: string): boolean => projectDirs.some((d) => f.startsWith(d + path.sep))
1261
+ const want = new Map<string, Set<string>>()
1262
+ for (const f of new Set([...configImportFiles, ...wsConfigImportFiles])) {
1263
+ if (inProject(f)) continue
1264
+ const names = want.get(path.dirname(f)) ?? new Set<string>()
1265
+ names.add(path.basename(f))
1266
+ want.set(path.dirname(f), names)
1267
+ }
1268
+ importNames = want
1269
+ for (const [dir, handle] of importArms) {
1270
+ if (want.has(dir)) continue
1271
+ handle.close()
1272
+ importArms.delete(dir)
1273
+ }
1274
+ for (const dir of want.keys()) {
1275
+ if (importArms.has(dir)) continue
1276
+ try {
1277
+ importArms.set(
1278
+ dir,
1279
+ arm(dir, false, (filename) => {
1280
+ if (!(importNames.get(dir)?.has(filename) ?? false)) return
1281
+ const abs = path.join(dir, filename)
1282
+ const rel = path.relative(workspaceRoot, abs)
1283
+ if (configImportFiles.includes(abs)) {
1284
+ reread = true
1285
+ trigger(`config import ${rel}`, abs)
1286
+ return
1287
+ }
1288
+ // One line per save, not per event: a save is often two.
1289
+ const now = Date.now()
1290
+ if (now - (staleNamed.get(abs) ?? 0) < 1_000) return
1291
+ staleNamed.set(abs, now)
1292
+ process.stdout.write(
1293
+ `vx watch: ${rel} changed; the workspace config imports it, and this process loaded it at start — restart vx watch to apply the edit\n`,
1294
+ )
1295
+ }),
1296
+ )
1297
+ } catch (err) {
1298
+ const msg = err instanceof Error ? err.message : String(err)
1299
+ process.stderr.write(`vx watch: cannot watch ${dir}: ${msg}\n`)
1300
+ }
1301
+ }
1302
+ }
1303
+
1304
+ const watchingLine = (count: number): string =>
1305
+ workspaceWide
1306
+ ? 'vx watch: watching the workspace root (workspaceFiles inputs in use)'
1307
+ : `vx watch: watching ${count} project(s)`
1308
+
813
1309
  const rearm = async (): Promise<void> => {
814
1310
  let next: Rediscovered
815
1311
  try {
@@ -822,9 +1318,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
822
1318
  projectDirs = next.projects.map((p) => p.dir)
823
1319
  workspaceInputs = next.workspaceInputs
824
1320
  outputs = next.outputs
825
- isIgnoredPath = makeWatchIgnore(cacheDir, outputs)
826
- matters = makeRootEventFilter(workspaceRoot, projectDirs, workspaceInputs)
827
- if (!workspaceWide) {
1321
+ inputs = next.inputs
1322
+ uncached = next.uncached
1323
+ configImportFiles = next.configImports
1324
+ wsConfigImportFiles = next.workspaceConfigImports
1325
+ packageDirs = next.packageDirs
1326
+ isIgnoredPath = makeWatchIgnore(cacheDir, outputs, inputs)
1327
+ matters = makeRootEventFilter(
1328
+ workspaceRoot,
1329
+ projectDirs,
1330
+ workspaceInputs,
1331
+ args.claimedRootFiles,
1332
+ )
1333
+ if (next.workspaceWide !== workspaceWide) {
1334
+ // A task started or stopped declaring `workspaceFiles`: the other
1335
+ // arm's shape. Until item 891 the choice was made once, at start, and
1336
+ // a root file a config newly named was never an event.
1337
+ dropMode()
1338
+ workspaceWide = next.workspaceWide
1339
+ armMode(next.projects)
1340
+ } else if (!workspaceWide) {
828
1341
  const keep = new Set(projectDirs)
829
1342
  for (const [dir, handle] of perProject) {
830
1343
  if (keep.has(dir)) continue
@@ -832,50 +1345,16 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
832
1345
  perProject.delete(dir)
833
1346
  }
834
1347
  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
1348
  }
839
- process.stdout.write(`vx watch: watching ${next.projects.length} project(s)\n`)
1349
+ armPending(true)
1350
+ armImports()
1351
+ // A new arm proves delivery like the first ones: an edit in the new
1352
+ // package right after this cycle is seen, not lost in the gap.
1353
+ await Promise.all(proofs)
1354
+ process.stdout.write(`${watchingLine(next.projects.length)}\n`)
840
1355
  }
841
1356
 
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
- }
1357
+ armMode(projects)
879
1358
 
880
1359
  // A package added while the loop runs is a directory entry appearing
881
1360
  // under the glob's directory (`packages/` for `packages/*`); one
@@ -897,7 +1376,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
897
1376
  const now = memberEntries(base)
898
1377
  if (sameMembers(members, now)) return
899
1378
  members = now
900
- membersChanged = true
1379
+ reread = true
901
1380
  trigger(`${path.relative(workspaceRoot, base)}/${filename}`, path.join(base, filename))
902
1381
  })
903
1382
  } catch (err) {
@@ -905,6 +1384,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
905
1384
  process.stderr.write(`vx watch: cannot watch ${base}: ${msg}\n`)
906
1385
  }
907
1386
  }
1387
+ armPending(false)
1388
+ armImports()
908
1389
 
909
1390
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
910
1391
  // closes over the RESOLVED cache dir (relocated or not) and the tasks'
@@ -914,11 +1395,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
914
1395
  // watcher has proved (or been given 2 s to prove) delivery first.
915
1396
  await Promise.all(proofs)
916
1397
 
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
- )
1398
+ process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
922
1399
 
923
1400
  return await new Promise<number>((resolve) => {
924
1401
  const cleanup = async (): Promise<void> => {
@@ -933,6 +1410,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
933
1410
  // The aborted cycle is tearing its children down; resolve only once
934
1411
  // it has returned, so the process never exits over a live child.
935
1412
  await inFlight
1413
+ await held?.stop(forwardedSignal(stop.reason))
936
1414
  resolve(0)
937
1415
  }
938
1416
  if (stop.aborted) {
@@ -943,10 +1421,41 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
943
1421
  })
944
1422
  }
945
1423
 
1424
+ /**
1425
+ * The event a finished cycle hands back to the timer, if any.
1426
+ *
1427
+ * The inner loop re-runs while anything is pending, so this covers only the
1428
+ * narrow gap between its LAST judgement and the loop going idle: an event
1429
+ * landing there would otherwise sit in `pendingPaths` with no timer armed
1430
+ * and no cycle to notice it — watch quietly idle over an edit the user
1431
+ * made. Insertion order, because the map is "first label wins" and the
1432
+ * label is what the cycle announces.
1433
+ *
1434
+ * A branch, not a race: extracted so it can be pinned at all. Deleting the
1435
+ * arming left every green test in the repo green (2026-09-20) — the e2e
1436
+ * watch fixture spawns a real `vx watch`, so it cannot deliver an event at
1437
+ * that instant, and the suites that could are the ones this repo's cloud
1438
+ * container already fails for timing reasons. What this pins is the
1439
+ * decision; the delivery window is the process's own.
1440
+ */
1441
+ export function pendingAfterCycle(
1442
+ pending: ReadonlyMap<string, string>,
1443
+ aborted: boolean,
1444
+ ): [abs: string, label: string] | undefined {
1445
+ if (aborted || pending.size === 0) return undefined
1446
+ const [abs, label] = [...pending][0]!
1447
+ return [abs, label]
1448
+ }
1449
+
946
1450
  /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
947
1451
  export function modifiedBefore(abs: string, t: number): boolean {
948
1452
  try {
949
- return fs.statSync(abs).mtimeMs < t
1453
+ // The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
1454
+ // carry a file's OLD mtime onto the new one, and judged by mtime alone
1455
+ // such an edit was "before the arm" and never ran (item 945). No
1456
+ // process can set a ctime, and a rename or a write moves it.
1457
+ const st = fs.statSync(abs)
1458
+ return Math.max(st.mtimeMs, st.ctimeMs) < t
950
1459
  } catch {
951
1460
  return false
952
1461
  }