@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
package/src/cli/watch.ts CHANGED
@@ -16,6 +16,7 @@ import fs from 'node:fs'
16
16
  import path from 'node:path'
17
17
  import { parseRunArgs, resolveRunOptions, type RunArgs, spacedHint } from './run.js'
18
18
  import { refuse, seeHelp } from './help.js'
19
+ import { fsClockNow, namedIgnoredReach, repoFacts } from '../cache/index.js'
19
20
  import {
20
21
  fingerprintClaims,
21
22
  forwardedSignal,
@@ -37,12 +38,13 @@ import {
37
38
  isProjectConfigFile,
38
39
  isWorkspaceConfigFile,
39
40
  gitFiles,
41
+ lockedExtras,
40
42
  makeFence,
41
43
  makeRootEventFilter,
42
44
  makeWatchIgnore,
43
45
  shapesWatchedSet,
44
46
  } from './watch-filter.js'
45
- import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
47
+ import { CLOSED, type WatchHandle, WatcherPool } from './watch-fs.js'
46
48
  import { ChangeJudge } from './watch-judge.js'
47
49
  import { type WatchCycle, watchCycle } from './watch-cycle.js'
48
50
  import { hangupIgnored, maskedLine, printable, restartTimings } from '../util/index.js'
@@ -70,6 +72,14 @@ function sayAllCached(opts: RunOptions, outcomes: readonly { status: string }[])
70
72
  ui.print('out', ui.line(' ', n === 1 ? '1 task' : `${n} tasks`, ' cached, nothing ran'))
71
73
  }
72
74
 
75
+ function isDirectory(abs: string): boolean {
76
+ try {
77
+ return fs.statSync(abs).isDirectory()
78
+ } catch {
79
+ return false
80
+ }
81
+ }
82
+
73
83
  /** Wait this long after the last filesystem event before re-running. */
74
84
  const DEBOUNCE_MS = 150
75
85
  /**
@@ -148,6 +158,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
148
158
  handleSignals: false,
149
159
  signal: stop.signal,
150
160
  holdPersistent: true,
161
+ watch: true,
151
162
  }
152
163
  // Every cycle's `invocations` row names the verb, as `vx run`'s does;
153
164
  // run()'s process.argv fallback put the bin's absolute path there, so
@@ -157,7 +168,10 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
157
168
  // worth; every cycle after an edit must load and list live.
158
169
  delete opts.staged
159
170
  delete opts.discovered
160
- process.once('SIGINT', () => stop.abort('SIGINT'))
171
+ // `on`, not `once`: a second signal during the teardown is absorbed (an
172
+ // abort is once), where `once` left it to Bun's default, exit 130 with
173
+ // the cycle's children mid-grace.
174
+ process.on('SIGINT', () => stop.abort('SIGINT'))
161
175
  // Said once the loop is down, the last line: written as the signal
162
176
  // landed, the aborted cycle's summary printed below it.
163
177
  const stopped = (): number => {
@@ -167,11 +181,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
167
181
  }
168
182
  return 0
169
183
  }
170
- process.once('SIGTERM', () => stop.abort('SIGTERM'))
184
+ process.on('SIGTERM', () => stop.abort('SIGTERM'))
171
185
  // A task runs in its own session (exec/kill-tree.ts): the terminal
172
186
  // closing reaches the loop alone, and the loop passes it on — unless vx
173
187
  // was started with it ignored (nohup).
174
- if (!hangupIgnored()) process.once('SIGHUP', () => stop.abort('SIGHUP'))
188
+ if (!hangupIgnored()) process.on('SIGHUP', () => stop.abort('SIGHUP'))
175
189
 
176
190
  // Enumerate projects-in-scope so we know what dirs to watch: the bare
177
191
  // tasks' scope (`opts.projects`; undefined means "every project", or
@@ -223,8 +237,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
223
237
  // ahead of the run to decide which watchers to install, and that marks every
224
238
  // config loaded — so the initial run's own loads become REPEATs and pay a
225
239
  // worker round-trip each (see config-eval.ts). Trading a hot-path regression
226
- // on every `vx watch` for a window a user rarely edits into is a bad deal;
227
- // configs.
240
+ // on every `vx watch` for a window a user rarely edits into is a bad deal.
241
+ // An edit to the inputs of a task the run was running is caught anyway:
242
+ // the run leaves that task unsaved, and the loop starts on it (`drifted`).
228
243
  // The exception: once the run waits on a server's readiness alone, every
229
244
  // config is loaded, and the loop arms with the run as its cycle in
230
245
  // flight. Unarmed, a server that never got ready held the initial run
@@ -244,8 +259,11 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
244
259
  // Uncaught, it ended watch at start while the same break mid-watch did
245
260
  // not (item 1017).
246
261
  let refusedToStart = false
262
+ /** A task whose inputs the user edited while the initial run ran it: no watcher heard the edit. */
263
+ let drifted: string | undefined
247
264
  initial.held = runOrchestrator(initial.cycle.opts).then(
248
265
  async (run) => {
266
+ drifted = run.outcomes.find((o) => o.unkeyed === true)?.node.id
249
267
  if (run.refused !== undefined) {
250
268
  const hint = run.refused.startsWith('no projects declare')
251
269
  ? await spacedHint(parsed, cwd)
@@ -288,10 +306,14 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
288
306
  sayNotice('error', 'vx watch', 'no projects in scope')
289
307
  return 1
290
308
  }
291
- await armLoop(undefined, held)
309
+ await armLoop(undefined, held, drifted)
292
310
  return stopped()
293
311
 
294
- async function armLoop(initial?: InitialCycle, held?: HeldPersistent): Promise<void> {
312
+ async function armLoop(
313
+ initial?: InitialCycle,
314
+ held?: HeldPersistent,
315
+ drifted?: string,
316
+ ): Promise<void> {
295
317
  // `--affected`'s diff is the tree at start; judged again, it held every
296
318
  // later edit out of the scope it picked. A cycle is an edit, and the
297
319
  // cache keys decide what in the scope it re-runs.
@@ -309,6 +331,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
309
331
  held,
310
332
  initial,
311
333
  tasks: openingTasks,
334
+ drifted,
312
335
  stop: stop.signal,
313
336
  workspaceRoot,
314
337
  projects: watched,
@@ -330,12 +353,18 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
330
353
  const all = await discoverCliProjects(workspaceRoot, load)
331
354
  const sweep = await sweepConfigs(all, workspaceRoot, load, opts.tasks)
332
355
  const now = await watchedProjects(workspaceRoot, all, inScope(all), load, sweep.staged)
356
+ const wsNow = await loadCliWorkspace(workspaceRoot, quiet)
333
357
  return {
334
358
  projects: now,
335
359
  // A plugin the workspace config gained since claims its file from
336
360
  // the cycle that loaded it; read once, its edits started nothing
337
361
  // until a restart.
338
- claimedRootFiles: claimsOf((await loadCliWorkspace(workspaceRoot, quiet)).plugins),
362
+ claimedRootFiles: claimsOf(wsNow.plugins),
363
+ // A re-lock that records a new `file:` directory or patch.
364
+ extraFiles: lockedExtras(workspaceRoot),
365
+ // The dir the cycle just wrote its store to: kept from the start,
366
+ // a `cache.dir` edit let those writes start the next cycle.
367
+ cacheDir: opts.cacheDir ?? wsNow.cacheDir,
339
368
  workspaceWide: sweep.workspaceWide,
340
369
  workspaceInputs: sweep.workspaceInputs,
341
370
  outputs: sweep.outputs,
@@ -351,6 +380,7 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
351
380
  // the one edit that changes what a cycle runs; unheard, the loop ran
352
381
  // the old lock until a restart (item 971).
353
382
  claimedRootFiles: claimsOf(ws.plugins),
383
+ extraFiles: lockedExtras(workspaceRoot),
354
384
  // The RESOLVED cache dir, not the `.vx` literal — see `makeWatchIgnore`.
355
385
  cacheDir: opts.cacheDir ?? ws.cacheDir,
356
386
  poll: resolveSetting('watch.poll', ws.workspaceConfig, parsed.poll).value === true,
@@ -375,6 +405,8 @@ interface WatchLoopArgs {
375
405
  initial: InitialCycle | undefined
376
406
  /** The initial run's task count, once it has started. */
377
407
  tasks: () => number | undefined
408
+ /** A task the initial run left unsaved because its inputs moved under it (`TaskOutcome.unkeyed`). */
409
+ drifted: string | undefined
378
410
  /** Aborted by the SIGINT/SIGTERM handlers `watchCmd` installed; the loop drains its cycle and resolves. */
379
411
  stop: AbortSignal
380
412
  workspaceRoot: string
@@ -404,6 +436,8 @@ interface WatchLoopArgs {
404
436
  * loaded once per process, so the set is fixed for the loop.
405
437
  */
406
438
  claimedRootFiles: ReadonlySet<string>
439
+ /** The files a claimed file's recorded digests read besides it (`lockedExtras`), absolute. */
440
+ extraFiles: ReadonlySet<string>
407
441
  /** Every project, in scope or not: a project's key leaves out what lies in one nested under it. */
408
442
  fenceDirs: readonly string[]
409
443
  /** The watched set again, after a cycle that followed an event which can change it. */
@@ -414,9 +448,20 @@ interface WatchLoopArgs {
414
448
 
415
449
  const fenceDirs = (all: readonly ProjectMeta[]): string[] => all.map((p) => p.dir)
416
450
 
451
+ /** The input globs that name a `node_modules` segment, per directory: what the tree watcher enters. */
452
+ const modulesNamed = (inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>>): string =>
453
+ JSON.stringify(
454
+ [...inputs].map(([dir, tasks]) => [
455
+ dir,
456
+ tasks.flat().filter((g) => g.split('/').includes('node_modules')),
457
+ ]),
458
+ )
459
+
417
460
  interface Rediscovered {
418
461
  projects: readonly ProjectMeta[]
462
+ cacheDir: string
419
463
  claimedRootFiles: ReadonlySet<string>
464
+ extraFiles: ReadonlySet<string>
420
465
  workspaceWide: boolean
421
466
  workspaceInputs: readonly string[]
422
467
  outputs: ReadonlyMap<string, readonly string[]>
@@ -429,9 +474,10 @@ interface Rediscovered {
429
474
  }
430
475
 
431
476
  async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
432
- const { opts, stop, workspaceRoot, projects, cacheDir } = args
477
+ const { opts, stop, workspaceRoot, projects } = args
433
478
  // The watched set as of the last cycle: `rearm` replaces these after an
434
479
  // event that can change it, and every filter below reads the current one.
480
+ let cacheDir = args.cacheDir
435
481
  let workspaceWide = args.workspaceWide
436
482
  let projectDirs = args.projectDirs
437
483
  let workspaceInputs = args.workspaceInputs
@@ -440,6 +486,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
440
486
  let uncached = args.uncached
441
487
  let namedIgnored = args.namedIgnored
442
488
  let claimedRootFiles = args.claimedRootFiles
489
+ let extraFiles = args.extraFiles
443
490
  let configImportFiles = args.configImports
444
491
  let wsConfigImportFiles = args.workspaceConfigImports
445
492
  // A dev server stays up while the loop idles and across cycles; a cycle
@@ -517,11 +564,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
517
564
  }
518
565
  const trigger = (label: string, abs: string): void => {
519
566
  if (!changes.pending.has(abs)) changes.pending.set(abs, label)
567
+ changes.sawEvent(abs)
520
568
  if (running) return interruptIfWaiting()
521
569
  if (debounceTimer) {
522
- if (Date.now() - windowOpened >= DEBOUNCE_MAX_MS) return
570
+ if (performance.now() - windowOpened >= DEBOUNCE_MAX_MS) return
523
571
  clearTimeout(debounceTimer)
524
- } else windowOpened = Date.now()
572
+ } else windowOpened = performance.now()
525
573
  debounceTimer = setTimeout(() => {
526
574
  debounceTimer = null
527
575
  if (running) return
@@ -539,10 +587,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
539
587
  const runCycle = async (first: string | undefined, initial?: InitialCycle): Promise<void> => {
540
588
  try {
541
589
  let label: string | undefined = first
542
- let lockAtRun = ''
590
+ /** The lock this cycle's run read; none for the initial run. */
591
+ let lockAtRun: string | undefined
543
592
  // The initial run is collected even after a stop: its server is the
544
593
  // loop's to stop.
545
594
  while (initial !== undefined || (label !== undefined && !stop.aborted)) {
595
+ /** This cycle's failure, as said: a re-read failing on the same cause says nothing more. */
596
+ let failed: string | undefined
597
+ /** The judged cycle this run is (`ChangeJudge.cycle`): its window closes when the run ends. */
598
+ const judged = changes.cycle
546
599
  if (initial !== undefined) {
547
600
  held = await initial.held
548
601
  initial = undefined
@@ -565,6 +618,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
565
618
  `no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
566
619
  )
567
620
  }
621
+ // A failed re-read is said once, by the re-read after the
622
+ // cycle: said here too, it printed above the cycle's header.
568
623
  if (reread) await rearm(false)
569
624
  ui.print('out', ui.gap())
570
625
  ui.say('info', `changed ${label}`, 'out')
@@ -575,9 +630,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
575
630
  // as a file born and gone since, and ran nothing.
576
631
  if (
577
632
  atArm !== undefined &&
578
- changes.changed.some((p) => path.basename(p) === '.gitignore')
579
- )
633
+ changes.changed.some((p) => path.basename(p) === '.gitignore' || p === excludeFile)
634
+ ) {
580
635
  for (const p of gitFiles(workspaceRoot)?.listed ?? []) atArm.listed.add(p)
636
+ reachOf = undefined
637
+ }
581
638
  restartTimings()
582
639
  // On the mtime clock, as the arm is: from `Date.now()` a write the
583
640
  // run made within a tick of it carried an earlier mtime and read as
@@ -588,11 +645,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
588
645
  // takes it from there. The run stops the others before its graph.
589
646
  const before = held
590
647
  current = watchCycle(
591
- { ...opts, ...(before ? { keep: before } : {}) },
648
+ {
649
+ ...opts,
650
+ ...(before ? { keep: before } : {}),
651
+ ...(judged ? { commandWindows: judged.windows } : {}),
652
+ },
592
653
  stop,
593
654
  interruptIfWaiting,
594
655
  )
595
656
  const cycle = await runOrchestrator(current.opts)
657
+ changes.unseen(
658
+ cycle.outcomes.flatMap((o) => (o.driftedInput === undefined ? [] : [o.driftedInput])),
659
+ )
596
660
  sayAllCached(opts, cycle.outcomes)
597
661
  lastTasks = current.tasks ?? lastTasks
598
662
  current = undefined
@@ -610,23 +674,30 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
610
674
  // FS event (if any) will retry. The dispose() on SIGINT
611
675
  // is the canonical exit; we don't unilaterally abort here.
612
676
  // A config's own throw or a plugin's failure may quote a secret (L-11).
613
- const message = maskedLine(err instanceof Error ? err.message : String(err))
614
- sayNotice('error', 'vx watch', `cycle failed: ${message}`)
615
- } finally {
616
- // A re-lock since this cycle began: the re-arm below can
617
- // close the watcher its event was still on its way to (FSEvents'
618
- // latency, a poller's tick), and the new lock ran nothing until
619
- // the next edit (macOS CI, 2026-10-23). A cycle that read the
620
- // lock mid-write failed on it, and is the same case.
621
- if (lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
677
+ failed = err instanceof Error ? err.message : String(err)
678
+ sayNotice('error', 'vx watch', `cycle failed: ${maskedLine(failed)}`)
622
679
  }
680
+ if (judged !== undefined) judged.to = Math.min(judged.to, Date.now())
623
681
  // After a failed cycle too: a package added with a config that does
624
682
  // not load yet fails its cycle, and unarmed, the fix to that config
625
683
  // was never an event.
626
684
  if (reread && !stop.aborted && hasWorkspaceFile()) {
627
685
  reread = false
628
- await rearm()
686
+ const err = await rearm()
687
+ // No lock is the cycle's own failure to say; said here too, it was two lines.
688
+ const why = err instanceof Error ? err.message : String(err)
689
+ if (err !== undefined && why !== NO_LOCK && why !== failed)
690
+ sayCannot(`cannot re-read the workspace`, err)
629
691
  }
692
+ // A re-lock since this cycle began: the re-arm can close the watcher
693
+ // its event was still on its way to (FSEvents' latency, a poller's
694
+ // tick), and the new lock ran nothing until the next edit (macOS CI,
695
+ // 2026-10-23). A cycle that read the lock mid-write failed on it, and
696
+ // is the same case. Asked after the re-arm, not when the run ends: a
697
+ // lock written in between was the re-arm's to read, its new pollers
698
+ // took it as their baseline, and it was never an event (macOS CI,
699
+ // 2026-11-06).
700
+ if (lockAtRun !== undefined && lockStamp() !== lockAtRun) rootFileEvent(LOCKFILE_NAME)
630
701
  // What landed mid-run is judged on settled state, one window
631
702
  // after the run, under the label of what actually arrived.
632
703
  current = undefined
@@ -676,15 +747,30 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
676
747
  const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
677
748
  pool.arm(dir, recursive, onEvent)
678
749
 
750
+ // Only the named entries a key reads ignored files under: `src` of
751
+ // `src/**` over a listed `src` reaches none, and a dev server's ignored
752
+ // `src/dev.log` started ~3 cycles a second. Reckoned per sweep.
753
+ let reachOf: readonly string[] | undefined
754
+ let reach: readonly string[] = []
679
755
  /** 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. */
680
756
  const armedAt = fsClockNow(cacheDir)
681
757
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
682
758
  const changes = new ChangeJudge({
683
759
  workspaceRoot,
684
760
  armedAt,
761
+ clock: () => fsClockNow(cacheDir),
685
762
  held: () => held !== undefined,
686
763
  uncached: () => uncached,
687
- namedIgnored: () => namedIgnored,
764
+ namedIgnored: () => {
765
+ if (reachOf !== namedIgnored) {
766
+ reachOf = namedIgnored
767
+ reach =
768
+ atArm === undefined
769
+ ? namedIgnored
770
+ : namedIgnoredReach(workspaceRoot, namedIgnored, atArm.listed)
771
+ }
772
+ return reach
773
+ },
688
774
  fenced: (ownDir, abs) => fenced(ownDir, abs),
689
775
  awaited: (abs) => ancestorNames.get(path.dirname(abs))?.has(path.basename(abs)) === true,
690
776
  ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
@@ -697,6 +783,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
697
783
  // went live in 8 ms (tests/watch-one-stream.test.ts). Polling keeps its
698
784
  // per-project walks, which skip what lies between projects.
699
785
  const oneStream = process.platform === 'darwin' && !args.poll
786
+ const treeArms = process.platform === 'linux' && !args.poll
700
787
  /** The projects the one stream hands events to, by their real directory. */
701
788
  let streamed: { proj: ProjectMeta; real: string }[] = []
702
789
  const realRoot = ((): string => {
@@ -1034,6 +1121,64 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1034
1121
  }
1035
1122
  }
1036
1123
 
1124
+ /**
1125
+ * The files a claimed file's recorded digests read besides it (a `file:`
1126
+ * dependency's directory, a patch; `lockedExtras`): a run keys them, and
1127
+ * no project's arm hears one outside its tree. A directory recursively,
1128
+ * a file (or a path gone for now) from its directory; armed again when a
1129
+ * re-lock changes the set.
1130
+ */
1131
+ const extraArms = new Map<string, { at: string; handle: WatchHandle }>()
1132
+ const armExtras = (): void => {
1133
+ const want = new Map<string, { at: string; dir: boolean }>()
1134
+ for (const abs of extraFiles) {
1135
+ const dir = isDirectory(abs)
1136
+ want.set(abs, { at: dir ? abs : path.dirname(abs), dir })
1137
+ }
1138
+ for (const [abs, armed] of extraArms) {
1139
+ if (want.get(abs)?.at === armed.at && !stale(armed.at)) continue
1140
+ armed.handle.close()
1141
+ extraArms.delete(abs)
1142
+ }
1143
+ for (const [abs, { at, dir }] of want) {
1144
+ if (extraArms.has(abs) || inodeOf(at) === undefined) continue
1145
+ const name = path.basename(abs)
1146
+ try {
1147
+ const handle = arm(at, dir, (filename) => {
1148
+ if (!dir && filename !== name) return
1149
+ const changed = dir ? path.join(abs, filename) : abs
1150
+ // A directory made where a file or nothing was armed: armed again, recursively.
1151
+ if (!dir && isDirectory(abs)) reread = true
1152
+ trigger(`root ${path.relative(workspaceRoot, changed)}`, changed)
1153
+ })
1154
+ extraArms.set(abs, { at, handle })
1155
+ armedAs.set(at, inodeOf(at) ?? '')
1156
+ } catch (err) {
1157
+ sayCannot(`cannot watch ${at}`, err)
1158
+ }
1159
+ }
1160
+ }
1161
+ /**
1162
+ * `info/exclude` ignores as a `.gitignore` does, and keys are
1163
+ * gitignore-aware (X-136), so its edit is a cycle; the ignore filter drops
1164
+ * every path under `.git/`. Armed once, from the common dir the run's own
1165
+ * enumeration asked git for (`repoFacts`, memoised).
1166
+ */
1167
+ const commonDir = repoFacts(workspaceRoot)?.commonDir
1168
+ const excludeFile =
1169
+ commonDir === undefined ? undefined : path.resolve(workspaceRoot, commonDir, 'info', 'exclude')
1170
+ const armExclude = (): void => {
1171
+ if (excludeFile === undefined || inodeOf(path.dirname(excludeFile)) === undefined) return
1172
+ try {
1173
+ arm(path.dirname(excludeFile), false, (filename) => {
1174
+ if (filename === 'exclude')
1175
+ trigger(`root ${path.relative(workspaceRoot, excludeFile)}`, excludeFile)
1176
+ })
1177
+ } catch (err) {
1178
+ sayCannot(`cannot watch ${path.dirname(excludeFile)}`, err)
1179
+ }
1180
+ }
1181
+
1037
1182
  // Said once, when the loop first arms: a re-arm repeated it after every
1038
1183
  // cycle that re-read the watched set, between task rows. `debug.watch`
1039
1184
  // says each arm on stderr, for whoever needs to see the set change.
@@ -1052,23 +1197,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1052
1197
  )
1053
1198
  }
1054
1199
 
1055
- const rearm = async (say = true): Promise<void> => {
1200
+ /** Re-read the workspace and re-arm on it; a failed re-read is returned, for the caller to say. */
1201
+ const rearm = async (say = true): Promise<unknown> => {
1056
1202
  let next: Rediscovered
1057
1203
  try {
1058
1204
  next = await args.rediscover()
1059
1205
  } catch (err) {
1060
- // No lock is the cycle's own failure to say; said here too, it was two lines.
1061
- if (!(err instanceof Error && err.message === NO_LOCK))
1062
- sayCannot(`cannot re-read the workspace`, err)
1063
- return
1206
+ return err
1064
1207
  }
1208
+ cacheDir = next.cacheDir
1065
1209
  projectDirs = next.projects.map((p) => p.dir)
1066
1210
  workspaceInputs = next.workspaceInputs
1067
1211
  outputs = next.outputs
1212
+ // Linux's tree watcher picks the `node_modules` it enters when it arms
1213
+ // (watch-fs.ts): an input newly named there needs the trees armed again.
1214
+ const modulesRenamed = treeArms && modulesNamed(next.inputs) !== modulesNamed(inputs)
1068
1215
  inputs = next.inputs
1069
1216
  uncached = next.uncached
1070
1217
  namedIgnored = next.namedIgnored
1071
1218
  claimedRootFiles = next.claimedRootFiles
1219
+ extraFiles = next.extraFiles
1072
1220
  configImportFiles = next.configImports
1073
1221
  wsConfigImportFiles = next.workspaceConfigImports
1074
1222
  armAncestors()
@@ -1081,7 +1229,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1081
1229
  claimedRootFiles,
1082
1230
  fenced,
1083
1231
  )
1084
- if (next.workspaceWide !== workspaceWide) {
1232
+ if (next.workspaceWide !== workspaceWide || modulesRenamed) {
1085
1233
  // A task started or stopped declaring `workspaceFiles`: the other
1086
1234
  // arm's shape. Until item 891 the choice was made once, at start, and
1087
1235
  // a root file a config newly named was never an event.
@@ -1105,10 +1253,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1105
1253
  watched = next.projects
1106
1254
  links = gitFiles(workspaceRoot)?.links ?? links
1107
1255
  armLinks()
1256
+ armExtras()
1108
1257
  // A new arm proves delivery like the first ones: an edit in the new
1109
1258
  // project right after this cycle is seen, not lost in the gap.
1110
1259
  await pool.proved()
1111
1260
  if (say) sayArmed(next.projects.length)
1261
+ return undefined
1112
1262
  }
1113
1263
 
1114
1264
  armMode(projects)
@@ -1190,6 +1340,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1190
1340
  armAncestors()
1191
1341
  armImports()
1192
1342
  armLinks()
1343
+ armExtras()
1344
+ armExclude()
1193
1345
 
1194
1346
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
1195
1347
  // closes over the RESOLVED cache dir (relocated or not) and the tasks'
@@ -1208,6 +1360,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1208
1360
  lastTasks = args.tasks()
1209
1361
  sayWatching(projects.length)
1210
1362
  sayArmed(projects.length)
1363
+ // An edit made while the initial run ran landed before any watcher, and
1364
+ // its mtime predates the arm, so no event or judgement can find it: the
1365
+ // run's own re-check did (it left the task unsaved), and that starts the
1366
+ // first cycle. Unheard, watch idled over output built from the old bytes.
1367
+ if (args.drifted !== undefined) void cycle(`${args.drifted} inputs, edited during the first run`)
1211
1368
 
1212
1369
  return await new Promise<void>((resolve) => {
1213
1370
  const cleanup = async (): Promise<void> => {