@vzn/vx 0.0.638 → 0.0.639

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 (103) hide show
  1. package/docs/caching.md +44 -15
  2. package/docs/cli.md +168 -345
  3. package/docs/execution.md +18 -43
  4. package/docs/features.md +7 -10
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +47 -49
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/skills/vx/SKILL.md +6 -4
  10. package/src/cache/cache.ts +40 -10
  11. package/src/cache/git-inputs.ts +6 -0
  12. package/src/cache/index.ts +2 -2
  13. package/src/cache/layer.ts +25 -11
  14. package/src/cache/layered-cache.ts +108 -315
  15. package/src/cache/plugin-cache.ts +2 -10
  16. package/src/cache/remote-lane.ts +383 -0
  17. package/src/cache/run-history.ts +24 -8
  18. package/src/cache/run-store.ts +4 -4
  19. package/src/cache/schema.ts +6 -4
  20. package/src/cache/scoped-cache.ts +7 -0
  21. package/src/cli/completions.ts +2 -2
  22. package/src/cli/foreign-flags.ts +10 -22
  23. package/src/cli/help.ts +6 -31
  24. package/src/cli/info.ts +2 -1
  25. package/src/cli/last.ts +6 -3
  26. package/src/cli/plan-format.ts +5 -58
  27. package/src/cli/plugin-commands.ts +2 -2
  28. package/src/cli/run.ts +23 -185
  29. package/src/cli/show.ts +6 -0
  30. package/src/cli/upgrade.ts +123 -39
  31. package/src/cli/watch-cycle.ts +2 -1
  32. package/src/cli/watch.ts +29 -14
  33. package/src/cli/why.ts +4 -4
  34. package/src/config.ts +0 -6
  35. package/src/exec/executor.ts +1 -1
  36. package/src/exec/sandbox-runtime.ts +1 -1
  37. package/src/exec/sandbox-violations.ts +3 -1
  38. package/src/graph/index.ts +9 -1
  39. package/src/graph/priorities.ts +1 -1
  40. package/src/graph/scheduler.ts +4 -16
  41. package/src/graph/task-graph.ts +87 -17
  42. package/src/graph/task-status.ts +95 -0
  43. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  44. package/src/{orchestrator → history}/history.ts +1 -1
  45. package/src/history/index.ts +48 -0
  46. package/src/{orchestrator → history}/metrics.ts +7 -20
  47. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  48. package/src/index.ts +10 -28
  49. package/src/orchestrator/doctor.ts +1 -1
  50. package/src/orchestrator/execute-args.ts +80 -0
  51. package/src/orchestrator/execute-task.ts +21 -386
  52. package/src/orchestrator/fingerprint-watch.ts +2 -2
  53. package/src/orchestrator/index.ts +1 -73
  54. package/src/orchestrator/miss-save.ts +33 -3
  55. package/src/orchestrator/options.ts +1 -36
  56. package/src/orchestrator/output-host.ts +4 -3
  57. package/src/orchestrator/persistent-task.ts +321 -0
  58. package/src/orchestrator/persistent.ts +130 -3
  59. package/src/orchestrator/placement.ts +3 -3
  60. package/src/orchestrator/plan.ts +18 -5
  61. package/src/orchestrator/plugin-host.ts +67 -47
  62. package/src/orchestrator/plugin.ts +6 -6
  63. package/src/orchestrator/prepare.ts +28 -29
  64. package/src/orchestrator/remote-prefetch.ts +14 -1
  65. package/src/orchestrator/run-artifacts.ts +5 -177
  66. package/src/orchestrator/run-context.ts +55 -0
  67. package/src/orchestrator/run-failures.ts +15 -3
  68. package/src/orchestrator/run-records.ts +35 -3
  69. package/src/orchestrator/run-report.ts +81 -179
  70. package/src/orchestrator/run-settings.ts +1 -16
  71. package/src/orchestrator/run-stop.ts +153 -0
  72. package/src/orchestrator/run.ts +260 -601
  73. package/src/orchestrator/sandbox-fix.ts +0 -16
  74. package/src/orchestrator/signals.ts +14 -6
  75. package/src/orchestrator/stable-keys.ts +107 -3
  76. package/src/orchestrator/steps.ts +50 -0
  77. package/src/orchestrator/task-hash.ts +3 -16
  78. package/src/orchestrator/task-log-buffer.ts +1 -1
  79. package/src/orchestrator/task-process.ts +94 -0
  80. package/src/orchestrator/telemetry-host.ts +1 -1
  81. package/src/orchestrator/telemetry.ts +5 -82
  82. package/src/{orchestrator → report}/events.ts +41 -8
  83. package/src/{orchestrator → report}/forecast.ts +35 -2
  84. package/src/{orchestrator → report}/framed-output.ts +39 -33
  85. package/src/report/index.ts +36 -0
  86. package/src/{orchestrator → report}/logger.ts +158 -20
  87. package/src/{orchestrator → report}/status-line.ts +84 -7
  88. package/src/{orchestrator → report}/summary.ts +126 -35
  89. package/src/report/tally.ts +83 -0
  90. package/src/settings.ts +1 -22
  91. package/src/util/colors.ts +44 -4
  92. package/src/util/git-ignored.ts +143 -0
  93. package/src/util/index.ts +19 -4
  94. package/src/util/report.ts +13 -5
  95. package/src/util/settle.ts +14 -39
  96. package/src/util/tail.ts +1 -1
  97. package/src/util/ui.ts +17 -6
  98. package/schemas/summary.json +0 -133
  99. package/src/cache/chained-cache.ts +0 -280
  100. package/src/orchestrator/tally.ts +0 -112
  101. /package/src/{orchestrator → report}/path-links.ts +0 -0
  102. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  103. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -10,6 +10,8 @@
10
10
  // package manager (npm, pnpm, bun, yarn; global or a project's
11
11
  // dependency) is upgraded by that package manager, never by a rename over
12
12
  // a file it owns. vx never installs Node or a package manager.
13
+ // Handed a call by a global vx, the project's vx moves that global too,
14
+ // to the same version (`upgradeGlobal`).
13
15
  // Named `upgrade` (not `update`) per CLI convention: bun upgrade,
14
16
  // deno upgrade — "update" is what package managers do to indexes.
15
17
 
@@ -290,17 +292,19 @@ export function binaryFromTarball(tgz: Uint8Array): Uint8Array {
290
292
  /**
291
293
  * The binary of `pkg@spec`, verified: the registry document's signature,
292
294
  * then the tarball's integrity, then the extraction, each refusing before
293
- * the next step runs. Null when the release is this vx's own version;
294
- * `announce` hears the version before the download starts.
295
+ * the next step runs. Null when the release is `current`, the version of
296
+ * the vx being replaced; `announce` hears the version before the download
297
+ * starts.
295
298
  */
296
299
  export async function releaseBinary(
297
300
  pkg: string,
298
301
  spec: string,
299
302
  announce: (version: string) => void,
300
303
  key: SigningKey = NPM_SIGNING_KEY,
304
+ current = VERSION,
301
305
  ): Promise<Uint8Array | null> {
302
306
  const release = verifiedRelease(await fetchManifest(pkg, spec), pkg, key)
303
- if (release.version === VERSION) return null
307
+ if (release.version === current) return null
304
308
  announce(release.version)
305
309
  return binaryFromTarball(await downloadVerified(release))
306
310
  }
@@ -518,15 +522,24 @@ async function targetVersion(tag: string | undefined): Promise<string> {
518
522
  return v
519
523
  }
520
524
 
521
- /** Run the package manager that owns this vx; vx never installs one. */
522
- async function upgradeManaged(install: ManagedInstall, tag: string | undefined): Promise<number> {
523
- const version = await targetVersion(tag)
524
- if (version === VERSION) {
525
- ui.print('out', ui.status('info', 'vx upgrade', `already at ${VERSION}`))
526
- return 0
525
+ /**
526
+ * Run the package manager that owns a vx at `current` to bring it to
527
+ * `version`; vx never installs one. `step` names the install when a run
528
+ * moves two (`project `, `global `).
529
+ */
530
+ async function upgradeManaged(
531
+ install: ManagedInstall,
532
+ version: string,
533
+ current = VERSION,
534
+ step = '',
535
+ ): Promise<void> {
536
+ if (version === current) {
537
+ ui.print('out', ui.status('info', 'vx upgrade', `${step}already at ${current}`))
538
+ return
527
539
  }
528
540
  const argv = upgradeArgv(install, version)
529
- const bin = Bun.which(argv[0]!)
541
+ // PATH as it is now: `Bun.which` alone reads the one the process started with.
542
+ const bin = Bun.which(argv[0]!, { PATH: process.env['PATH'] ?? '' })
530
543
  if (bin === null) {
531
544
  throw new UserError(
532
545
  `vx upgrade: this vx was installed by ${argv[0]}, which is not on PATH — vx does not install package managers; run it yourself: ${argv.join(' ')} (in ${install.cwd})`,
@@ -534,7 +547,7 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
534
547
  }
535
548
  ui.print(
536
549
  'out',
537
- ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
550
+ ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
538
551
  ui.text([ui.cmd(argv.join(' ')), ui.dim(` in ${install.cwd}`)]),
539
552
  )
540
553
  const proc = Bun.spawnSync({
@@ -547,7 +560,90 @@ async function upgradeManaged(install: ManagedInstall, tag: string | undefined):
547
560
  `vx upgrade: ${argv.join(' ')} failed (exit ${proc.exitCode}) — vx changed nothing itself`,
548
561
  )
549
562
  }
550
- return 0
563
+ }
564
+
565
+ /**
566
+ * Replace the binary at `dest`, at `current`, with the verified release
567
+ * `spec` names (`latest` or a version). `step` as in `upgradeManaged`.
568
+ */
569
+ async function upgradeBinary(
570
+ dest: string,
571
+ spec: string,
572
+ current = VERSION,
573
+ step = '',
574
+ key: SigningKey = NPM_SIGNING_KEY,
575
+ ): Promise<void> {
576
+ const pkg = platformPackage()
577
+ let installed: string | undefined
578
+ const replaced = await replaceBinary(
579
+ dest,
580
+ () =>
581
+ releaseBinary(
582
+ pkg,
583
+ spec,
584
+ (version) =>
585
+ ui.print(
586
+ 'out',
587
+ ui.status('info', 'vx upgrade', `${step}${current} → ${version}`),
588
+ ui.text(ui.dim(dest)),
589
+ ),
590
+ key,
591
+ current,
592
+ ),
593
+ // The replaced binary's own version: the new build speaks for itself
594
+ // rather than this process guessing, and one that cannot is rolled back.
595
+ (bin) => {
596
+ installed = answeredVersion(bin)
597
+ return installed !== undefined
598
+ },
599
+ )
600
+ ui.print(
601
+ 'out',
602
+ replaced
603
+ ? ui.status('ok', 'vx upgrade', `${step}installed ${installed}`)
604
+ : ui.status('info', 'vx upgrade', `${step}already at ${current}`),
605
+ )
606
+ }
607
+
608
+ /** The version `bin --version` answers, without the hand-off note a handed-off run adds. */
609
+ function answeredVersion(bin: string): string | undefined {
610
+ return startedVersion(bin)?.split(' ')[1]
611
+ }
612
+
613
+ /**
614
+ * Bring `from`, the global vx that handed this run to the project's own
615
+ * (`VX_HANDED_OFF`), to `version`, the way it was installed: a binary
616
+ * installed by hand is replaced, a package manager's global install is
617
+ * updated by that manager. A refusal names it.
618
+ */
619
+ export async function upgradeGlobal(
620
+ from: string,
621
+ version: string,
622
+ key: SigningKey = NPM_SIGNING_KEY,
623
+ ): Promise<void> {
624
+ await named(`global vx ${from}`, async () => {
625
+ const current = answeredVersion(from)
626
+ if (current === undefined) {
627
+ throw new UserError('vx upgrade: it did not answer `--version`, so it is left as it is')
628
+ }
629
+ if (current === version) {
630
+ ui.print('out', ui.status('info', 'vx upgrade', `global already at ${current}`))
631
+ return
632
+ }
633
+ const install = managedInstall(from, path.dirname(from))
634
+ if (install === null) await upgradeBinary(from, version, current, 'global ', key)
635
+ else await upgradeManaged(install, version, current, 'global ')
636
+ })
637
+ }
638
+
639
+ /** `run`, its refusal naming `what` after the verb: a run that moves two installs says which failed. */
640
+ async function named(what: string, run: () => Promise<void>): Promise<void> {
641
+ try {
642
+ await run()
643
+ } catch (err) {
644
+ if (!(err instanceof UserError)) throw err
645
+ throw new UserError(err.message.replace(/^vx upgrade: /, `vx upgrade: ${what}: `), err.code)
646
+ }
551
647
  }
552
648
 
553
649
  export async function upgradeCmd(args: readonly string[]): Promise<number> {
@@ -575,33 +671,21 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
575
671
  }
576
672
  const dest = process.execPath
577
673
  const install = managedInstall(dest, process.cwd())
578
- if (install !== null) return await upgradeManaged(install, tag)
579
- const pkg = platformPackage()
580
- const spec = versionSpec(tag)
581
- let installed = ''
582
- const replaced = await replaceBinary(
583
- dest,
584
- () =>
585
- releaseBinary(pkg, spec, (version) =>
586
- ui.print(
587
- 'out',
588
- ui.status('info', 'vx upgrade', `${VERSION} → ${version}`),
589
- ui.text(ui.dim(dest)),
590
- ),
591
- ),
592
- // The replaced binary's own version: the new build speaks for itself
593
- // rather than this process guessing, and one that cannot is rolled back.
594
- (bin) => {
595
- installed = startedVersion(bin) ?? ''
596
- return installed !== ''
597
- },
598
- )
599
- ui.print(
600
- 'out',
601
- replaced
602
- ? ui.status('ok', 'vx upgrade', `installed ${installed.replace(/^vx /, '')}`)
603
- : ui.status('info', 'vx upgrade', `already at ${VERSION}`),
604
- )
674
+ // A global vx handed this run to the project's own: both move, to one
675
+ // version, the project first (owner, 2026-10-15).
676
+ const from = process.env['VX_HANDED_OFF']
677
+ if (from !== undefined) {
678
+ const version = await targetVersion(tag)
679
+ await named('project vx', () =>
680
+ install === null
681
+ ? upgradeBinary(dest, version, VERSION, 'project ')
682
+ : upgradeManaged(install, version, VERSION, 'project '),
683
+ )
684
+ await upgradeGlobal(from, version)
685
+ return 0
686
+ }
687
+ if (install !== null) await upgradeManaged(install, await targetVersion(tag))
688
+ else await upgradeBinary(dest, versionSpec(tag))
605
689
  return 0
606
690
  }
607
691
 
@@ -5,7 +5,8 @@
5
5
  // as Ctrl-C would and the loop starts the next one; a cycle running any
6
6
  // other work is never stopped.
7
7
 
8
- import { createEventBus, type RunOptions } from '../orchestrator/index.js'
8
+ import { type RunOptions } from '../orchestrator/index.js'
9
+ import { createEventBus } from '../report/index.js'
9
10
 
10
11
  export interface WatchCycle {
11
12
  /** The options for this cycle's run: its own bus and a stop of its own beside watch's. */
package/src/cli/watch.ts CHANGED
@@ -61,22 +61,17 @@ const DEBOUNCE_MAX_MS = 1_000
61
61
  * the help line and the completions, and a test holds the two together.
62
62
  */
63
63
  export function watchRefusal(parsed: RunArgs): string | null {
64
- if (parsed.dry !== undefined || parsed.graph !== undefined) {
65
- return 'vx watch: --dry / --graph are not supported in watch mode'
64
+ if (parsed.dry !== undefined) {
65
+ return 'vx watch: --dry is not supported in watch mode'
66
66
  }
67
- if (parsed.summarize !== undefined || parsed.profile !== undefined) {
68
- return 'vx watch: --summarize / --profile are not supported in watch mode (would overwrite per cycle)'
67
+ if (parsed.profile !== undefined) {
68
+ return 'vx watch: --profile is not supported in watch mode (would overwrite per cycle)'
69
69
  }
70
- // All three format ONE run's result and are consumed by `runCmd` alone, so
71
- // a watch loop silently ignored them. `--verbosity 0` is not rejected: it
70
+ // It formats ONE run's result and is consumed by `runCmd` alone, so a
71
+ // watch loop silently ignored it. `--verbosity 0` is not rejected: it
72
72
  // asks for the output watch already gives.
73
- if (
74
- parsed.report !== undefined ||
75
- parsed.reportFile !== undefined ||
76
- parsed.verbosity > 0 ||
77
- parsed.format !== 'pretty'
78
- ) {
79
- return 'vx watch: --report / --report-file / --verbosity / --format are not supported in watch mode (they report a single run)'
73
+ if (parsed.verbosity > 0) {
74
+ return 'vx watch: --verbosity is not supported in watch mode (it reports a single run)'
80
75
  }
81
76
  return null
82
77
  }
@@ -626,6 +621,19 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
626
621
  }
627
622
  }
628
623
  const stale = (dir: string): boolean => armedAs.get(dir) !== inodeOf(dir)
624
+ /** The projects the workspace-wide arm names an event by. */
625
+ let wide: readonly ProjectMeta[] = []
626
+ // The deepest, as a nested project's file is its own.
627
+ const ownerOf = (abs: string): ProjectMeta | undefined => {
628
+ let best: ProjectMeta | undefined
629
+ for (const proj of wide)
630
+ if (
631
+ abs.startsWith(proj.dir + path.sep) &&
632
+ (best === undefined || proj.dir.length > best.dir.length)
633
+ )
634
+ best = proj
635
+ return best
636
+ }
629
637
  /** The root arm: recursive when workspace-wide or the one stream, else the root's own files only. */
630
638
  let rootArm: WatchHandle = CLOSED
631
639
  let dotVxArm: WatchHandle = CLOSED
@@ -700,7 +708,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
700
708
  configImportFiles.includes(abs)
701
709
  )
702
710
  reread = true
703
- trigger(`root ${filename}`, abs)
711
+ const owner = ownerOf(abs)
712
+ trigger(
713
+ owner === undefined
714
+ ? `root ${filename}`
715
+ : `${owner.name} ${path.relative(owner.dir, abs)}`,
716
+ abs,
717
+ )
704
718
  })
705
719
  : oneStream
706
720
  ? arm(workspaceRoot, true, streamEvent)
@@ -725,6 +739,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<void> {
725
739
  // (it would cover every project + node_modules + caches). On macOS one
726
740
  // recursive stream at the root stands for all of them (`oneStream`).
727
741
  const armMode = (watched: readonly ProjectMeta[]): void => {
742
+ wide = workspaceWide ? watched : []
728
743
  if (!workspaceWide) {
729
744
  streamed = oneStream ? toStream(watched) : []
730
745
  const inStream = new Set(streamed.map((s) => s.proj))
package/src/cli/why.ts CHANGED
@@ -9,13 +9,13 @@ import { flagHint, formatValue, seeHelp } from './help.js'
9
9
  import { splitTaskId } from '../graph/index.js'
10
10
  import {
11
11
  cacheKeyDiff,
12
- explainCacheKeyQuery as explainCacheKey,
12
+ explainCacheKey,
13
13
  inputWriters,
14
14
  latestRunId,
15
15
  rootCauses,
16
- whyDidThisRerunQuery as whyDidThisRerun,
17
- resolveRunId,
18
- } from '../orchestrator/index.js'
16
+ whyDidThisRerun,
17
+ } from '../history/index.js'
18
+ import { resolveRunId } from '../orchestrator/index.js'
19
19
  import { MASKED, nearMatches, printable, refusal, UserError } from '../util/index.js'
20
20
  import { findWorkspaceRoot } from '../workspace/index.js'
21
21
  import { findCwdProject } from './select.js'
package/src/config.ts CHANGED
@@ -62,12 +62,6 @@ export interface RunSettings {
62
62
  * default) skips its dependents, `always` runs them too.
63
63
  */
64
64
  continue?: 'never' | 'deps-ok' | 'always'
65
- /** How long a task that ignores SIGTERM gets before SIGKILL. Omitted → `'2s'`. */
66
- killGrace?: Duration
67
- /** The bound on each plugin's end-of-run flush and teardown. Omitted → `'3s'`. */
68
- teardownTimeout?: Duration
69
- /** How long a persistent task may take to match `readyWhen` before vx says it waits. Omitted → `'10s'`. */
70
- readyNotice?: Duration
71
65
  /**
72
66
  * Graph rules checked by `vx lock` and `vx lock --check`, never by a
73
67
  * run. Each is on
@@ -142,7 +142,7 @@ export interface ExecuteRequest {
142
142
  * work and returns: a child an executor spawned is not one core can
143
143
  * reach (H-10, H-12). One already aborted is work not to start (B-55).
144
144
  * Any exit after the timeout's abort, a 0 included, is a timeout. Core
145
- * waits the kill grace (`VX_RUN_KILL_GRACE`, 2 s) after the abort, then
145
+ * waits the kill grace (2 s) after the abort, then
146
146
  * settles the attempt without `execute` and abandons it (H-14).
147
147
  */
148
148
  readonly signal?: AbortSignal
@@ -2332,7 +2332,7 @@ function outsideWritesHint(
2332
2332
  ? `${refused} The task has its own temp directory, empty at its start: write under ` +
2333
2333
  `$TMPDIR (os.tmpdir() in Node and Bun) instead of a fixed path.`
2334
2334
  : `${refused} If the task needs one, grant its directory, e.g. ` +
2335
- `\`allow: { write: [${jsString(`${spelled}/`)}] }\`.`
2335
+ `\`allow: { write: [${jsString(spelled.endsWith('/') ? spelled : `${spelled}/`)}] }\`.`
2336
2336
  return { timestamp: new Date(), hint: true, line }
2337
2337
  }
2338
2338
 
@@ -578,7 +578,9 @@ export function refusedWritesOutside(
578
578
  if (v.path === undefined || !path.isAbsolute(v.path) || !write || reported(v)) continue
579
579
  if (!skip.some((s) => atOrUnder(v.path!, s))) paths.add(v.path)
580
580
  }
581
- return [...paths]
581
+ // `mkdir -p ~/.cache/tool` is refused at each directory on its way;
582
+ // the one it wanted is the deepest.
583
+ return [...paths].filter((p) => ![...paths].some((q) => q !== p && atOrUnder(q, p)))
582
584
  }
583
585
 
584
586
  /**
@@ -8,6 +8,7 @@ export {
8
8
  parseDependencySpec,
9
9
  type DependencySpec,
10
10
  } from './dependency-spec.js'
11
+ export { computeReverseDepCount } from './priorities.js'
11
12
  export {
12
13
  type ContinueMode,
13
14
  deadServerBehind,
@@ -16,8 +17,15 @@ export {
16
17
  RestoreDemoted,
17
18
  runGraph,
18
19
  type TaskOutcome,
19
- type TaskStatus,
20
20
  } from './scheduler.js'
21
+ export {
22
+ type CacheSource,
23
+ deriveCacheSource,
24
+ isCacheHit,
25
+ isPassStatus,
26
+ TASK_STATUSES,
27
+ type TaskStatus,
28
+ } from './task-status.js'
21
29
  export {
22
30
  buildTaskGraph,
23
31
  checkGraph,
@@ -85,7 +85,7 @@ export function tieredReverseDepCount(
85
85
  * dependents only (`calculateReverseDeps` in
86
86
  * `packages/nx/src/tasks-runner/utils.ts`, read 2026-09-24).
87
87
  */
88
- export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
88
+ export function computeReverseDepCount(nodes: ReadonlyMap<string, TaskNode>): Map<string, number> {
89
89
  // Exact transitive-dependent COUNTS need closure SETS (diamonds
90
90
  // double-count under naive summing). Set-of-strings closures are
91
91
  // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
@@ -13,6 +13,7 @@ import {
13
13
  type ScheduleStrategy,
14
14
  } from './priorities.js'
15
15
  import { isGroupTask, type TaskNode } from './task-graph.js'
16
+ import type { TaskStatus } from './task-status.js'
16
17
 
17
18
  /**
18
19
  * Thrown by `execute` for a restore-tier task that turned out to have
@@ -29,19 +30,6 @@ export class RestoreDemoted extends Error {
29
30
  }
30
31
  }
31
32
 
32
- export type TaskStatus =
33
- | 'success'
34
- | 'cache-hit'
35
- | 'cache-hit-remote'
36
- | 'failed'
37
- | 'skipped'
38
- // A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
39
- // the task never finished on its own terms, so it reverts to aborted —
40
- // excluded from the tally and the run history. It still PROPAGATES like
41
- // a failure (see `willSkip`): the child died mid-write, so its outputs
42
- // are partial.
43
- | 'aborted'
44
-
45
33
  /** What moved in a missed task's key since the last entry saved for it. */
46
34
  export interface InputChanges {
47
35
  /** How many components changed, appeared or went. 0: the same key, its entry gone. */
@@ -79,8 +67,8 @@ export interface TaskOutcome {
79
67
  * How long an `admit` policy held this task after it was ready with a
80
68
  * free worker — the wait the plugin, not the count gate, imposed. Set
81
69
  * only when a policy refused it at least once (so a run with no policy
82
- * carries the field on no outcome); `--summarize` rows, the event
83
- * stream and the summary footer show it.
70
+ * carries the field on no outcome); the event stream and the summary
71
+ * footer show it.
84
72
  */
85
73
  admissionHeldMs?: number
86
74
  /**
@@ -136,7 +124,7 @@ export interface TaskOutcome {
136
124
  * On a `skipped` outcome: the task at the ROOT of what blocked it — the
137
125
  * failed (or aborted) upstream, followed through any chain of skips
138
126
  * between. Absent when the skip was fail-fast's (no upstream failed).
139
- * The footer, `--summarize`, the telemetry record and the run row all
127
+ * The footer, the telemetry record and the run row all
140
128
  * read this one field (item 267).
141
129
  */
142
130
  blockedBy?: string
@@ -1,7 +1,10 @@
1
1
  import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
2
2
  import {
3
3
  asTrees,
4
+ checkIgnoreSync,
5
+ ignoreProbes,
4
6
  isLiteralPattern,
7
+ listedSync,
5
8
  normalizeGlob,
6
9
  relPosix,
7
10
  splitNegations,
@@ -9,6 +12,7 @@ import {
9
12
  taskGlob,
10
13
  refusal,
11
14
  UserError,
15
+ wholeIgnored,
12
16
  wholeSubtreePrefixes,
13
17
  } from '../util/index.js'
14
18
  import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
@@ -584,7 +588,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
584
588
  // task behind it would fold no upstream either.
585
589
  if (!packageGraph.edgeSource) {
586
590
  throw new UserError(
587
- `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() or a plugin that returns \`deps\``,
591
+ `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
588
592
  'VX_E_DEPENDS_ON',
589
593
  )
590
594
  }
@@ -1128,6 +1132,15 @@ function detectOutputCollisions(
1128
1132
  }
1129
1133
  }
1130
1134
 
1135
+ /**
1136
+ * What a task's `!` output entries take back (A-44). A glob of the other
1137
+ * task's that one takes back whole is a path this task never cleans or
1138
+ * captures, so the pair cannot collide there.
1139
+ */
1140
+ function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
1141
+ return splitNegations(n.config.cache?.outputs[field] ?? []).negative
1142
+ }
1143
+
1131
1144
  /**
1132
1145
  * `collide` for a root-anchored output against another project's `files`
1133
1146
  * output rebased to the root. With an edge the dependant adds to its
@@ -1146,9 +1159,12 @@ function collideAcross(
1146
1159
  exclusive: boolean,
1147
1160
  ): void {
1148
1161
  if (neverWritesLocally(ws) || neverWritesLocally(files)) return
1162
+ const wsBack = takenBackOf(ws, 'workspaceFiles')
1163
+ const filesBack = takenBackOf(files, 'files').map((g) => (rel === '' ? g : `${rel}/${g}`))
1149
1164
  for (const ga of wsGlobs) {
1150
1165
  for (const gb of rebased) {
1151
- if (!outputsOverlap(ga, gb)) continue
1166
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
1167
+ continue
1152
1168
  const head =
1153
1169
  `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
1154
1170
  `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
@@ -1405,9 +1421,12 @@ function collide(
1405
1421
  exclusive: boolean,
1406
1422
  ): void {
1407
1423
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
1424
+ const aBack = takenBackOf(a, field)
1425
+ const bBack = takenBackOf(b, field)
1408
1426
  for (const ga of aGlobs ?? []) {
1409
1427
  for (const gb of bGlobs ?? []) {
1410
- if (!outputsOverlap(ga, gb)) continue
1428
+ if (!outputsOverlap(ga, gb) || outputTakenBack(gb, aBack) || outputTakenBack(ga, bBack))
1429
+ continue
1411
1430
  const head =
1412
1431
  `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1413
1432
  (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
@@ -1550,6 +1569,8 @@ interface Side {
1550
1569
  reads: boolean
1551
1570
  /** The project's root-relative dir for rebased `files` globs. */
1552
1571
  rel?: string
1572
+ /** The root-relative dir project-relative `globs` sit in, for git. */
1573
+ anchor?: string | undefined
1553
1574
  }
1554
1575
 
1555
1576
  /**
@@ -1583,6 +1604,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1583
1604
  let projectReaders = false
1584
1605
  let rootReaders = false
1585
1606
  const memo: TakeBackMemo = { bySide: new Map(), byList: new Map() }
1607
+ const found: Found = new Map()
1586
1608
  const bucket = (name: string): Side[] => {
1587
1609
  let list = byProject.get(name)
1588
1610
  if (list === undefined) byProject.set(name, (list = []))
@@ -1594,7 +1616,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1594
1616
  const outFiles = positives(cache.outputs.files)
1595
1617
  const outWs = positives(cache.outputs.workspaceFiles ?? [])
1596
1618
  if (outFiles.length > 0) {
1597
- bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1619
+ const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1620
+ bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
1598
1621
  filesWriters.push(n)
1599
1622
  }
1600
1623
  if (outWs.length > 0) {
@@ -1623,7 +1646,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1623
1646
  (s) => s.globs,
1624
1647
  (s) => s.reads,
1625
1648
  )) {
1626
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1649
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1627
1650
  }
1628
1651
  }
1629
1652
  }
@@ -1641,10 +1664,20 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1641
1664
  (s) => s.globs,
1642
1665
  (s) => s.reads,
1643
1666
  )) {
1644
- readsOutputs(memo, sides[i]!, sides[j]!, 'files')
1667
+ readsOutputs(memo, found, sides[i]!, sides[j]!, 'files')
1645
1668
  }
1646
1669
  }
1647
- if (!rootReaders) return
1670
+ if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
1671
+ refuseUnlessIgnored(found, workspaceRoot)
1672
+ }
1673
+
1674
+ function readsRootedOutputs(
1675
+ memo: TakeBackMemo,
1676
+ found: Found,
1677
+ rooted: Side[],
1678
+ filesWriters: readonly TaskNode[],
1679
+ workspaceRoot: string | undefined,
1680
+ ): void {
1648
1681
  if (workspaceRoot !== undefined) {
1649
1682
  for (const node of filesWriters) {
1650
1683
  const rel = relPosix(workspaceRoot, node.projectDir)
@@ -1659,8 +1692,37 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1659
1692
  (s) => s.globs,
1660
1693
  (s) => s.reads,
1661
1694
  )) {
1662
- readsOutputs(memo, rooted[i]!, rooted[j]!, 'workspaceFiles')
1695
+ readsOutputs(memo, found, rooted[i]!, rooted[j]!, 'workspaceFiles')
1696
+ }
1697
+ }
1698
+
1699
+ /** Each overlapped output, root-relative, and the refusal it meets; the first pair found per output. */
1700
+ type Found = Map<string, () => UserError>
1701
+
1702
+ /**
1703
+ * An output git ignores is no overlap (M, 2026-10-15): a key reads only what
1704
+ * git lists, so such an output never reaches it. Asked of git once, for
1705
+ * every overlapped output together; an output git cannot speak for (no work
1706
+ * tree, a glob no few paths stand for) is refused as before. The answer
1707
+ * holds for the lock; a run's stability gate asks git again for itself.
1708
+ */
1709
+ function refuseUnlessIgnored(found: Found, workspaceRoot: string | undefined): void {
1710
+ if (found.size === 0) return
1711
+ if (workspaceRoot !== undefined) {
1712
+ const asks = [...found.keys()].map(ignoreProbes)
1713
+ const paths = asks.flatMap((a) => a?.paths ?? [])
1714
+ const ignored = checkIgnoreSync(workspaceRoot, paths)
1715
+ let listed: string[] | null | undefined
1716
+ const pass = wholeIgnored(found.keys(), ignored, (g) => {
1717
+ listed ??= listedSync(workspaceRoot, [
1718
+ ...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
1719
+ ])
1720
+ return listed === null || listed.some((f) => taskGlob(g).match(f))
1721
+ })
1722
+ for (const [g, refuse] of found) if (!pass.has(g)) throw refuse()
1723
+ return
1663
1724
  }
1725
+ throw found.values().next().value!()
1664
1726
  }
1665
1727
 
1666
1728
  /**
@@ -1707,6 +1769,7 @@ function takenBack(
1707
1769
 
1708
1770
  function readsOutputs(
1709
1771
  memo: TakeBackMemo,
1772
+ found: Found,
1710
1773
  x: Side,
1711
1774
  y: Side,
1712
1775
  field: 'files' | 'workspaceFiles',
@@ -1715,21 +1778,28 @@ function readsOutputs(
1715
1778
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1716
1779
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1717
1780
  for (const go of writer.globs) {
1718
- if (takenBack(memo, reader, field, go)) continue
1781
+ const rooted =
1782
+ writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
1783
+ if (found.has(rooted) || takenBack(memo, reader, field, go)) continue
1719
1784
  for (const gi of reader.globs) {
1720
1785
  if (!outputsOverlap(gi, go)) continue
1721
1786
  const shown =
1722
1787
  writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1723
1788
  const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
1724
- throw new UserError(
1725
- `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1726
- `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1727
- `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1728
- `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1729
- `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1730
- `let it wait for its producer.`,
1731
- 'VX_E_INPUT_IS_OUTPUT',
1789
+ found.set(
1790
+ rooted,
1791
+ () =>
1792
+ new UserError(
1793
+ `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1794
+ `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1795
+ `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1796
+ `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1797
+ `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1798
+ `let it wait for its producer.`,
1799
+ 'VX_E_INPUT_IS_OUTPUT',
1800
+ ),
1732
1801
  )
1802
+ break
1733
1803
  }
1734
1804
  }
1735
1805
  }