@vzn/vx 0.0.652 → 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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  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 +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. 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
@@ -544,6 +592,10 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
544
592
  // The initial run is collected even after a stop: its server is the
545
593
  // loop's to stop.
546
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
547
599
  if (initial !== undefined) {
548
600
  held = await initial.held
549
601
  initial = undefined
@@ -566,6 +618,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
566
618
  `no .vx/workspace.ts in ${workspaceRoot}; restore it, or stop vx watch`,
567
619
  )
568
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.
569
623
  if (reread) await rearm(false)
570
624
  ui.print('out', ui.gap())
571
625
  ui.say('info', `changed ${label}`, 'out')
@@ -576,9 +630,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
576
630
  // as a file born and gone since, and ran nothing.
577
631
  if (
578
632
  atArm !== undefined &&
579
- changes.changed.some((p) => path.basename(p) === '.gitignore')
580
- )
633
+ changes.changed.some((p) => path.basename(p) === '.gitignore' || p === excludeFile)
634
+ ) {
581
635
  for (const p of gitFiles(workspaceRoot)?.listed ?? []) atArm.listed.add(p)
636
+ reachOf = undefined
637
+ }
582
638
  restartTimings()
583
639
  // On the mtime clock, as the arm is: from `Date.now()` a write the
584
640
  // run made within a tick of it carried an earlier mtime and read as
@@ -589,11 +645,18 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
589
645
  // takes it from there. The run stops the others before its graph.
590
646
  const before = held
591
647
  current = watchCycle(
592
- { ...opts, ...(before ? { keep: before } : {}) },
648
+ {
649
+ ...opts,
650
+ ...(before ? { keep: before } : {}),
651
+ ...(judged ? { commandWindows: judged.windows } : {}),
652
+ },
593
653
  stop,
594
654
  interruptIfWaiting,
595
655
  )
596
656
  const cycle = await runOrchestrator(current.opts)
657
+ changes.unseen(
658
+ cycle.outcomes.flatMap((o) => (o.driftedInput === undefined ? [] : [o.driftedInput])),
659
+ )
597
660
  sayAllCached(opts, cycle.outcomes)
598
661
  lastTasks = current.tasks ?? lastTasks
599
662
  current = undefined
@@ -611,15 +674,20 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
611
674
  // FS event (if any) will retry. The dispose() on SIGINT
612
675
  // is the canonical exit; we don't unilaterally abort here.
613
676
  // A config's own throw or a plugin's failure may quote a secret (L-11).
614
- const message = maskedLine(err instanceof Error ? err.message : String(err))
615
- sayNotice('error', 'vx watch', `cycle failed: ${message}`)
677
+ failed = err instanceof Error ? err.message : String(err)
678
+ sayNotice('error', 'vx watch', `cycle failed: ${maskedLine(failed)}`)
616
679
  }
680
+ if (judged !== undefined) judged.to = Math.min(judged.to, Date.now())
617
681
  // After a failed cycle too: a package added with a config that does
618
682
  // not load yet fails its cycle, and unarmed, the fix to that config
619
683
  // was never an event.
620
684
  if (reread && !stop.aborted && hasWorkspaceFile()) {
621
685
  reread = false
622
- 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)
623
691
  }
624
692
  // A re-lock since this cycle began: the re-arm can close the watcher
625
693
  // its event was still on its way to (FSEvents' latency, a poller's
@@ -679,15 +747,30 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
679
747
  const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
680
748
  pool.arm(dir, recursive, onEvent)
681
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[] = []
682
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. */
683
756
  const armedAt = fsClockNow(cacheDir)
684
757
  /** Which settled paths are changes (watch-judge.ts); `pending` holds what fired since. */
685
758
  const changes = new ChangeJudge({
686
759
  workspaceRoot,
687
760
  armedAt,
761
+ clock: () => fsClockNow(cacheDir),
688
762
  held: () => held !== undefined,
689
763
  uncached: () => uncached,
690
- 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
+ },
691
774
  fenced: (ownDir, abs) => fenced(ownDir, abs),
692
775
  awaited: (abs) => ancestorNames.get(path.dirname(abs))?.has(path.basename(abs)) === true,
693
776
  ...(atArm !== undefined ? { existedAtArm: atArm.listed, trackedAtArm: atArm.tracked } : {}),
@@ -700,6 +783,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
700
783
  // went live in 8 ms (tests/watch-one-stream.test.ts). Polling keeps its
701
784
  // per-project walks, which skip what lies between projects.
702
785
  const oneStream = process.platform === 'darwin' && !args.poll
786
+ const treeArms = process.platform === 'linux' && !args.poll
703
787
  /** The projects the one stream hands events to, by their real directory. */
704
788
  let streamed: { proj: ProjectMeta; real: string }[] = []
705
789
  const realRoot = ((): string => {
@@ -1037,6 +1121,64 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1037
1121
  }
1038
1122
  }
1039
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
+
1040
1182
  // Said once, when the loop first arms: a re-arm repeated it after every
1041
1183
  // cycle that re-read the watched set, between task rows. `debug.watch`
1042
1184
  // says each arm on stderr, for whoever needs to see the set change.
@@ -1055,23 +1197,26 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1055
1197
  )
1056
1198
  }
1057
1199
 
1058
- 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> => {
1059
1202
  let next: Rediscovered
1060
1203
  try {
1061
1204
  next = await args.rediscover()
1062
1205
  } catch (err) {
1063
- // No lock is the cycle's own failure to say; said here too, it was two lines.
1064
- if (!(err instanceof Error && err.message === NO_LOCK))
1065
- sayCannot(`cannot re-read the workspace`, err)
1066
- return
1206
+ return err
1067
1207
  }
1208
+ cacheDir = next.cacheDir
1068
1209
  projectDirs = next.projects.map((p) => p.dir)
1069
1210
  workspaceInputs = next.workspaceInputs
1070
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)
1071
1215
  inputs = next.inputs
1072
1216
  uncached = next.uncached
1073
1217
  namedIgnored = next.namedIgnored
1074
1218
  claimedRootFiles = next.claimedRootFiles
1219
+ extraFiles = next.extraFiles
1075
1220
  configImportFiles = next.configImports
1076
1221
  wsConfigImportFiles = next.workspaceConfigImports
1077
1222
  armAncestors()
@@ -1084,7 +1229,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1084
1229
  claimedRootFiles,
1085
1230
  fenced,
1086
1231
  )
1087
- if (next.workspaceWide !== workspaceWide) {
1232
+ if (next.workspaceWide !== workspaceWide || modulesRenamed) {
1088
1233
  // A task started or stopped declaring `workspaceFiles`: the other
1089
1234
  // arm's shape. Until item 891 the choice was made once, at start, and
1090
1235
  // a root file a config newly named was never an event.
@@ -1108,10 +1253,12 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1108
1253
  watched = next.projects
1109
1254
  links = gitFiles(workspaceRoot)?.links ?? links
1110
1255
  armLinks()
1256
+ armExtras()
1111
1257
  // A new arm proves delivery like the first ones: an edit in the new
1112
1258
  // project right after this cycle is seen, not lost in the gap.
1113
1259
  await pool.proved()
1114
1260
  if (say) sayArmed(next.projects.length)
1261
+ return undefined
1115
1262
  }
1116
1263
 
1117
1264
  armMode(projects)
@@ -1193,6 +1340,8 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1193
1340
  armAncestors()
1194
1341
  armImports()
1195
1342
  armLinks()
1343
+ armExtras()
1344
+ armExclude()
1196
1345
 
1197
1346
  // The orchestrator's own writes never kick the loop: `makeWatchIgnore`
1198
1347
  // closes over the RESOLVED cache dir (relocated or not) and the tasks'
@@ -1211,6 +1360,11 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
1211
1360
  lastTasks = args.tasks()
1212
1361
  sayWatching(projects.length)
1213
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`)
1214
1368
 
1215
1369
  return await new Promise<void>((resolve) => {
1216
1370
  const cleanup = async (): Promise<void> => {