@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -44,6 +44,12 @@ export interface RunSummaryRow {
44
44
  hash: string
45
45
  cpuMs: number | null
46
46
  peakRssBytes: number | null
47
+ /** The most cores one sampling interval burned (v43); null when nothing sampled. */
48
+ peakCores: number | null
49
+ /** The mean resident memory over the samples, in bytes (v43); null when nothing sampled. */
50
+ avgRssBytes: number | null
51
+ /** Where an executor ran it (`small#1`, v44); null = here. */
52
+ placement: string | null
47
53
  // High-precision spans relative to run start. Decimal strings on the wire
48
54
  // (bigints aren't JSON-safe); use BigInt or Number on the client.
49
55
  wallclockStartNs: string | null
@@ -57,6 +63,8 @@ export interface RunSummaryRow {
57
63
  forwarded: boolean
58
64
  /** Each remote read or write of its key that failed, `upload: <message>`, `; `-joined (v40). */
59
65
  remoteError: string | null
66
+ /** The attempts a retried task took (>1, v23); null when it ran once. */
67
+ attempts: number | null
60
68
  }
61
69
 
62
70
  export interface ListRunsArgs {
@@ -114,10 +122,11 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
114
122
  duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
115
123
  cache_hit AS cacheHit, restored, cached, hash,
116
124
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
125
+ peak_cores AS peakCores, avg_rss_bytes AS avgRssBytes, placement,
117
126
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
118
127
  blocked_by AS blockedBy, timed_out AS timedOut,
119
128
  sandbox_violations AS sandboxViolations, not_ready AS notReady,
120
- forward_args IS NOT NULL AS forwarded, remote_error AS remoteError
129
+ forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts
121
130
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
122
131
  )
123
132
  .all(...params, limit) as RawRow[]
@@ -285,7 +294,9 @@ export interface RunDetail {
285
294
  }
286
295
 
287
296
  export function getRun(db: Database, runId: string): RunDetail | null {
288
- const tasks = listRuns(db, { runId, limit: 100_000 })
297
+ // In the order the run recorded them, as it printed them: `listRuns` is
298
+ // newest first, and the replay listed a dependency below its dependant.
299
+ const tasks = listRuns(db, { runId, limit: 100_000 }).reverse()
289
300
  if (tasks.length === 0) return null
290
301
  const startedAt = Math.min(...tasks.map((t) => t.startedAt))
291
302
  const endedAt = Math.max(...tasks.map((t) => t.endedAt))
@@ -378,6 +389,18 @@ export interface WhyDidThisRerun {
378
389
  note: string
379
390
  }
380
391
 
392
+ /**
393
+ * Failed tasks in a run that wrote the cache: a passing task beside one
394
+ * ran past a failed dependency (--continue) and was never saved.
395
+ */
396
+ function failedWithWrites(db: Database, id: string | null): number {
397
+ if (id === null) return 0
398
+ const row = db
399
+ .query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
400
+ .get(id) as { p: string; f: number } | null
401
+ return row !== null && writesCache(row.p) ? row.f : 0
402
+ }
403
+
381
404
  /**
382
405
  * The endings an UNCHANGED cache key can have. A re-execution is told apart
383
406
  * by the evidence the index holds: every one used to blame `--no-cache` /
@@ -389,7 +412,7 @@ function unchangedKeyNote(
389
412
  db: Database,
390
413
  runId: string,
391
414
  this_: { hash: string; status: string; cacheHit: number | null; startedAt: number },
392
- prev: { status: string; cacheHit: number | null; runId: string | null },
415
+ prev: { status: string; cacheHit: number | null; runId: string | null; blockedBy: string | null },
393
416
  ): string {
394
417
  if (this_.cacheHit === null) {
395
418
  return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
@@ -403,8 +426,12 @@ function unchangedKeyNote(
403
426
  p: string
404
427
  } | null
405
428
  )?.p
429
+ // `--no-cache` reads and writes nothing, `--force` writes: each named
430
+ // apart, or a `--no-cache` run was told it had passed `--force`.
406
431
  if (policy !== undefined && !readsCache(policy)) {
407
- return 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
432
+ return writesCache(policy)
433
+ ? 'cache key unchanged — re-executed because this run did not read the cache (--force, or a --cache-policy without read)'
434
+ : 'cache key unchanged — re-executed because this run did not use the cache (--no-cache, or a --cache-policy without read or write)'
408
435
  }
409
436
  // `get` answers null, not undefined, for no row.
410
437
  const createdAt = (
@@ -424,17 +451,9 @@ function unchangedKeyNote(
424
451
  prev.status === 'success' &&
425
452
  prev.cacheHit === 0
426
453
  ) {
427
- const failedWithWrites = (id: string | null): number => {
428
- if (id === null) return 0
429
- const row = db
430
- .query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
431
- .get(id) as { p: string; f: number } | null
432
- const writes = row !== null && writesCache(row.p)
433
- return writes ? (row?.f ?? 0) : 0
434
- }
435
- const before = failedWithWrites(prev.runId)
454
+ const before = failedWithWrites(db, prev.runId)
436
455
  if (before > 0) {
437
- const now = failedWithWrites(runId)
456
+ const now = failedWithWrites(db, runId)
438
457
  if (createdAt === undefined && now > 0) {
439
458
  return `cache key unchanged — neither run saved it: each ran beside a failed task (${now} failed in this one), and a task run past a failed dependency (--continue) is never cached`
440
459
  }
@@ -466,6 +485,18 @@ function unchangedKeyNote(
466
485
  if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
467
486
  return 'cache key unchanged — the previous run on this key did not write the cache (--no-cache, or a --cache-policy without write), so there was nothing to hit'
468
487
  }
488
+ // A task that passed behind a dependency its run never recorded: the
489
+ // one `--exclude-dependencies` left out, keyed but not run, so nothing
490
+ // built on it was saved (excluded-keys.ts).
491
+ if (
492
+ prev.blockedBy !== null &&
493
+ prev.runId !== null &&
494
+ db
495
+ .query('SELECT 1 FROM runs WHERE run_id = ? AND project = ? AND task = ?')
496
+ .get(prev.runId, ...splitTaskId(prev.blockedBy)) === null
497
+ ) {
498
+ return `cache key unchanged — the previous run on this key built on a dependency it left out (${prev.blockedBy}, --exclude-dependencies), so it saved nothing and there was nothing to hit`
499
+ }
469
500
  return 'cache key unchanged — the previous run on this key executed but no entry for it is in the cache (its save failed, or it was pruned since), so there was nothing to hit'
470
501
  }
471
502
  return 'cache key unchanged — re-executed on the same key though this run read the cache; vx cannot name the cause'
@@ -486,11 +517,19 @@ export function latestRunId(db: Database, taskId: string): string | null {
486
517
  return row?.runId ?? null
487
518
  }
488
519
 
520
+ /** Why a key that folds a task with no `inputs` never hits, and the fix. */
521
+ function keylessNote(taskId: string, keylessBy: string): string {
522
+ return keylessBy === taskId
523
+ ? 'never cached: this task declares no `inputs`, so each run derives a new key; declare its `inputs`'
524
+ : `never cached: its key includes ${keylessBy}, which declares no \`inputs\`, so each run derives a new key; declare inputs on ${keylessBy}`
525
+ }
526
+
489
527
  export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
490
528
  const [project, task] = splitTaskId(taskId)
491
529
  const this_ = db
492
530
  .query(
493
- `SELECT id, hash, status, cache_hit AS cacheHit, restored, cached, started_at AS startedAt
531
+ `SELECT id, hash, status, cache_hit AS cacheHit, restored, cached, started_at AS startedAt,
532
+ keyless_by AS keylessBy
494
533
  FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
495
534
  )
496
535
  .get(runId, project, task) as
@@ -502,6 +541,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
502
541
  restored: number | null
503
542
  cached: number | null
504
543
  startedAt: number
544
+ keylessBy: string | null
505
545
  }
506
546
  | undefined
507
547
  if (!this_) {
@@ -519,7 +559,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
519
559
  const prev = db
520
560
  .query(
521
561
  `SELECT hash, status, cache_hit AS cacheHit, restored, started_at AS startedAt,
522
- run_id AS runId
562
+ run_id AS runId, blocked_by AS blockedBy
523
563
  FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
524
564
  ORDER BY id DESC LIMIT 1`,
525
565
  )
@@ -531,6 +571,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
531
571
  restored: number | null
532
572
  startedAt: number
533
573
  runId: string | null
574
+ blockedBy: string | null
534
575
  }
535
576
  | undefined
536
577
  // …and this run must have one too, or there is nothing to compare.
@@ -561,19 +602,27 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
561
602
  ? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
562
603
  : this_.cached === 0
563
604
  ? // Not a cache decision at all: the task runs every time by design,
564
- // and its key is derived only so dependents can fold it.
565
- 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
566
- : prev && prev.hash !== this_.hash
567
- ? 'cache key changed between the previous run and this one (inputs differ)'
568
- : prev
569
- ? // An unchanged key has two very different endings, and calling
570
- // both a "re-run" answered the question wrong: a cache HIT did not
571
- // re-run at all, so blaming `--no-cache` named a cause that cannot
572
- // have applied. Only a run that EXECUTED on an unchanged key is
573
- // the case this verb exists to explain. A row with no recorded
574
- // cacheHit (older rows) is neither — say that, do not guess.
575
- unchangedKeyNote(db, runId, this_, prev)
576
- : 'no prior run for this (project, task)',
605
+ // and its key is derived only so dependents can fold it. A keyless
606
+ // one's is new each run, so every dependent misses: "folded by
607
+ // dependents" read as if they could hit.
608
+ this_.keylessBy === null
609
+ ? 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
610
+ : this_.keylessBy === taskId
611
+ ? 'this task declares no `cache` block — it runs on every invocation; it declares no `inputs` either, so each run derives a new key and every task that depends on it misses; declare its `inputs`'
612
+ : `this task declares no \`cache\` block — it runs on every invocation; its key includes ${this_.keylessBy}, which declares no \`inputs\`, so each run derives a new key and every task that depends on it misses; declare inputs on ${this_.keylessBy}`
613
+ : this_.keylessBy !== null
614
+ ? keylessNote(taskId, this_.keylessBy)
615
+ : prev && prev.hash !== this_.hash
616
+ ? 'cache key changed between the previous run and this one (inputs differ)'
617
+ : prev
618
+ ? // An unchanged key has two very different endings, and calling
619
+ // both a "re-run" answered the question wrong: a cache HIT did not
620
+ // re-run at all, so blaming `--no-cache` named a cause that cannot
621
+ // have applied. Only a run that EXECUTED on an unchanged key is
622
+ // the case this verb exists to explain. A row with no recorded
623
+ // cacheHit (older rows) is neither — say that, do not guess.
624
+ unchangedKeyNote(db, runId, this_, prev)
625
+ : 'no prior run for this (project, task)',
577
626
  }
578
627
  }
579
628
 
@@ -617,7 +666,7 @@ export function loadEntryInputs(db: Database, entryHash: string): TaskInputCompo
617
666
  }
618
667
 
619
668
  /**
620
- * The rule `vx why` and the site's playground both name a moved key by: join
669
+ * The rule `vx why` names a moved key by: join
621
670
  * two keys' components over `(kind, name)`. A component in both with a
622
671
  * different hash is `changed`, one only in `after` is `added`, one only in
623
672
  * `before` is `removed`, and an equal one is counted. Entries are ordered by
@@ -659,8 +708,7 @@ export function diffKeyComponents(
659
708
  }
660
709
  }
661
710
  // Code-unit order, not `localeCompare`: collation follows the machine's
662
- // locale, and the playground runs this same join in the reader's browser,
663
- // so the CLI and the page could list one diff in two orders. Code units
711
+ // locale, so two machines could list one diff in two orders. Code units
664
712
  // are also SQLite's BINARY order, the `entry_inputs` scan's own.
665
713
  const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
666
714
  entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
@@ -688,10 +736,17 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
688
736
  const [project, task] = splitTaskId(taskId)
689
737
  const this_ = db
690
738
  .query(
691
- 'SELECT id, hash, cached, status, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
739
+ 'SELECT id, hash, cached, status, started_at AS startedAt, keyless_by AS keylessBy FROM runs WHERE run_id = ? AND project = ? AND task = ?',
692
740
  )
693
741
  .get(runId, project, task) as
694
- | { id: number; hash: string; cached: number | null; status: string; startedAt: number }
742
+ | {
743
+ id: number
744
+ hash: string
745
+ cached: number | null
746
+ status: string
747
+ startedAt: number
748
+ keylessBy: string | null
749
+ }
695
750
  | undefined
696
751
  if (!this_) {
697
752
  return {
@@ -720,6 +775,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
720
775
  }
721
776
  }
722
777
 
778
+ if (this_.keylessBy !== null) {
779
+ return {
780
+ runId,
781
+ taskId,
782
+ found: true,
783
+ previousRunId: null,
784
+ entries: [],
785
+ unchangedCount: 0,
786
+ note: `${this_.keylessBy} declares no \`inputs\`, so no fingerprints are kept to diff`,
787
+ }
788
+ }
789
+
723
790
  const prev = db
724
791
  .query(
725
792
  `SELECT run_id AS runId, hash, status FROM runs
@@ -760,7 +827,9 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
760
827
  // A failed previous run kept no fingerprints; the last run that saved an
761
828
  // entry still names what moved since then (the edit an agent made to fix
762
829
  // the failure, or the one that broke it).
763
- if (cur.length > 0 && old.length === 0 && !isPassStatus(prev.status)) {
830
+ // So did one that passed beside a failed task (--continue).
831
+ const tainted = isPassStatus(prev.status) && failedWithWrites(db, prev.runId) > 0
832
+ if (cur.length > 0 && old.length === 0 && (!isPassStatus(prev.status) || tainted)) {
764
833
  const saved = db
765
834
  .query(
766
835
  `SELECT run_id AS runId, hash FROM runs
@@ -771,7 +840,7 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
771
840
  .get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
772
841
  if (saved) {
773
842
  const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
774
- const since = `run ${saved.runId}, the last that saved an entry (the previous run ended ${prev.status})`
843
+ const since = `run ${saved.runId}, the last that saved an entry (${tainted ? 'the previous run ran beside a failed task, so it was not saved' : `the previous run ended ${prev.status}`})`
775
844
  return {
776
845
  runId,
777
846
  taskId,
@@ -798,7 +867,9 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
798
867
  ? `this run ended ${this_.status}`
799
868
  : old.length === 0 && !isPassStatus(prev.status)
800
869
  ? `the previous run ended ${prev.status}`
801
- : null
870
+ : old.length === 0 && tainted
871
+ ? 'the previous run ran beside a failed task'
872
+ : null
802
873
  return {
803
874
  runId,
804
875
  taskId,
@@ -809,8 +880,8 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
809
880
  note:
810
881
  this_.cached === 0
811
882
  ? // The verdict already says the task is uncached; this line says
812
- // what moved a key that declares nothing.
813
- 'no `cache` block declares its inputs, so its key folds every file in its project — a file it wrote, or any untracked file, moves it — and no fingerprints are kept to say which'
883
+ // why no input can be named.
884
+ 'with no `cache` block it saves no entry, so no input fingerprints are kept to say which of its `inputs` moved its key; with no `inputs`, every run gives it a new key'
814
885
  : unsaved !== null
815
886
  ? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
816
887
  : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
@@ -833,6 +904,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
833
904
  }
834
905
  }
835
906
 
907
+ /**
908
+ * Joins a group and a task under it in an `upstream` row's name: a group
909
+ * records no run, so its dependant's key names its members through it.
910
+ */
911
+ export const GROUP_VIA = ' \u2190 '
912
+
913
+ /** The task an `upstream` row names: the last one, past any group it came through. */
914
+ export function upstreamTask(name: string): string {
915
+ const i = name.lastIndexOf(GROUP_VIA)
916
+ return i < 0 ? name : name.slice(i + GROUP_VIA.length)
917
+ }
918
+
836
919
  export interface RootCause {
837
920
  /** From the asked task down to the one whose own inputs moved. */
838
921
  chain: string[]
@@ -842,23 +925,37 @@ export interface RootCause {
842
925
  /**
843
926
  * The tasks under `taskId`'s moved upstreams whose OWN key components
844
927
  * moved in the same run, each with the chain that carried it up. A task
845
- * visited once is not walked again (a diamond names its root once).
928
+ * visited once is not walked again (a diamond names its root once). A
929
+ * group records no run, so `groups` (group id → the tasks it gathers)
930
+ * carries the walk through it.
846
931
  */
847
932
  export function rootCauses(
848
933
  db: Database,
849
934
  runId: string,
850
935
  taskId: string,
851
936
  entries: CacheKeyDiff['entries'],
937
+ groups: ReadonlyMap<string, readonly string[]> = new Map(),
852
938
  ): RootCause[] {
853
939
  const roots: RootCause[] = []
854
940
  const seen = new Set([taskId])
855
941
  const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
856
942
  for (const e of moved) {
857
- if (e.kind !== 'upstream' || seen.has(e.name)) continue
858
- seen.add(e.name)
859
- const next = [...chain, e.name]
860
- const d = cacheKeyDiff(db, runId, e.name)
861
- if (!d.found) continue
943
+ if (e.kind !== 'upstream') continue
944
+ const task = upstreamTask(e.name)
945
+ if (seen.has(task)) continue
946
+ seen.add(task)
947
+ const next = [...chain, ...e.name.split(GROUP_VIA)]
948
+ const d = cacheKeyDiff(db, runId, task)
949
+ // A group: its members carry the walk, named in the key through it
950
+ // (`group ← task`) or, in a row that names the group alone, by the graph.
951
+ if (!d.found) {
952
+ const members = groups.get(task) ?? []
953
+ walk(
954
+ next,
955
+ members.map((name) => ({ ...e, name })),
956
+ )
957
+ continue
958
+ }
862
959
  const own = d.entries.filter((x) => x.kind !== 'upstream')
863
960
  if (own.length > 0) roots.push({ chain: next, entries: own })
864
961
  walk(next, d.entries)
package/src/index.ts CHANGED
@@ -16,13 +16,18 @@ export { UserError, isUserError } from './util/index.js'
16
16
  export { PLUGIN_HOOKS, type PluginHook } from './config.js'
17
17
  // The machine as this process may use it — cores and memory capped by the
18
18
  // cgroup a container runs under. Core's default worker count reads the
19
- // first; a policy that budgets memory (`@vzn/vx-schedule-history`) reads
19
+ // first; a policy that budgets memory (`@vzn/vx-history`) reads
20
20
  // the second, so the cgroup walk has one implementation.
21
21
  export { machineMemoryBytes, machineParallelism } from './util/index.js'
22
22
  // How core reads a task glob — its spelling, and which characters are
23
23
  // wildcards (`[` `]` and an escape are) — for a plugin that turns declared
24
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
25
- export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
25
+ export {
26
+ isLiteralPattern,
27
+ normalizeGlob,
28
+ outputDirPrefixes,
29
+ restorableOutput,
30
+ } from './util/index.js'
26
31
 
27
32
  // Schema types and helpers (used by user vx.config files and presets).
28
33
  export type {
@@ -43,10 +48,13 @@ export type {
43
48
  SandboxConfig,
44
49
  SandboxGrants,
45
50
  SandboxDenials,
51
+ Platform,
46
52
  } from './config.js'
47
- export { defineProject, defineWorkspace } from './config.js'
53
+ export { defineProject, defineWorkspace, PLATFORMS } from './config.js'
48
54
  export { definePlugin, refuseUnknownOptions } from './orchestrator/index.js'
49
55
  export { branchCacheScope } from './workspace/index.js'
56
+ // A plugin reading the base branch's history: a shallow clone fetches it.
57
+ export { mergeBaseWith } from './workspace/index.js'
50
58
 
51
59
  // Programmatic engine API: run / plan / prepare (docs/cli.md § Programmatic
52
60
  // API). The graph primitives, the cache-key hashing seam and the git / host
@@ -55,7 +63,7 @@ export { branchCacheScope } from './workspace/index.js'
55
63
  // consumer is re-added when one appears, shaped by its use.
56
64
  export { run, planRun, prepareRun } from './orchestrator/index.js'
57
65
  // The per-task duration history a `schedule` plugin learns from
58
- // (`@vzn/vx-schedule-history` does; a plugin package reaches core only
66
+ // (`@vzn/vx-history` does; a plugin package reaches core only
59
67
  // through this façade, which is what put these here).
60
68
  export { currentBranch } from './orchestrator/index.js'
61
69
  export { LocalHistoryProvider } from './history/index.js'
@@ -75,7 +83,7 @@ export type { Logger, OutputView } from './report/index.js'
75
83
  // with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
76
84
  // consumers roll their own `split('#', 2)` and drift from the graph, which
77
85
  // splits on the FIRST '#'.
78
- export { outputsOverlap, splitTaskId } from './graph/index.js'
86
+ export { criticalPath, outputsOverlap, splitTaskId, taskDefinitions } from './graph/index.js'
79
87
  export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
80
88
 
81
89
  // Cache classes + the layer interface (the `cache` capability's currency).
@@ -84,14 +92,16 @@ export type {
84
92
  CacheLayer,
85
93
  PluginCache,
86
94
  ArtifactInfo,
95
+ LentAccess,
87
96
  RemoteCacheLayer,
88
97
  RemoteOpenContext,
89
98
  RunRecord,
90
99
  InvocationRecord,
91
100
  } from './cache/index.js'
92
- // A plugin's keychain (`ctx.secret` on `setup` and a remote's `open`), and
93
- // the guard that tells a missing secret from a fault.
94
- export { isSecretUnavailable } from './util/index.js'
101
+ // A plugin's keychain (`ctx.secret` on `setup` and a remote's `open`), the
102
+ // guard that tells a missing secret from a fault, and the names whose values
103
+ // vx masks, for a plugin that must not send one where it is shown.
104
+ export { isSecretUnavailable, secretNamed } from './util/index.js'
95
105
  export type {
96
106
  PluginSecrets,
97
107
  SecretRequest,
@@ -110,7 +120,7 @@ export { loadProjectConfig } from './workspace/index.js'
110
120
  export type { ProjectMeta } from './workspace/index.js'
111
121
  // The run path's RESOLVED view — plugin `config` and `project` stages
112
122
  // applied — for a reader outside the CLI
113
- // (`@vzn/vx-schedule-history`, an embedder's task catalog). What `vx show`
123
+ // (`@vzn/vx-history`, an embedder's task catalog). What `vx show`
114
124
  // prints.
115
125
  export { loadResolvedProjects } from './orchestrator/index.js'
116
126
  export type { ProjectEntry } from './workspace/index.js'
@@ -121,6 +131,7 @@ export type { ProjectEntry } from './workspace/index.js'
121
131
  export {
122
132
  applyMigration,
123
133
  foldScriptHooks,
134
+ isStarterConfig,
124
135
  PERSISTENT_TODO,
125
136
  pruneOrphanPersistentNotes,
126
137
  quoteTsLiteral,
@@ -163,6 +174,10 @@ export type {
163
174
  ExecutorContext,
164
175
  OutputContext,
165
176
  CommandContext,
177
+ ArtifactRun,
178
+ ArtifactTask,
179
+ CacheFacts,
180
+ TaskArtifacts,
166
181
  FingerprintChange,
167
182
  FingerprintClaim,
168
183
  FingerprintContext,
@@ -190,18 +205,35 @@ export type { RunStartInfo } from './report/index.js'
190
205
  // speaks a wire — and a seam with no consumer is a special case in waiting.)
191
206
  // A remote executor gives a task back to the local floor by rejecting with
192
207
  // this (B-100): core runs it here and says why once.
193
- export { executorFallback } from './exec/index.js'
208
+ export { executorFallback, hostPlatform, sampleTrees, type TreeUsage } from './exec/index.js'
194
209
  // How the local executor puts the args after `--` on a command, for an
195
210
  // executor that runs `ExecuteRequest.command` itself: `@vzn/vx-reapi` and
196
211
  // the `vx init --plugin executor` example each joined their own, and a
197
212
  // trailing comment or a template literal's closing newline lost the args
198
213
  // there while the local floor kept them (X-112).
199
214
  export { withForwardArgs } from './exec/index.js'
215
+ // The `npm_execpath` core gives a local task: a `@vzn/vx-dte` worker gives
216
+ // its tasks the same, its own machine's, not what launched it.
217
+ export { packageManagerPath } from './exec/index.js'
218
+ // A task's output both streams in the order written, bounded as core's
219
+ // capture is: what a worker saves through `TaskArtifacts` (`@vzn/vx-dte`).
220
+ export { BoundedCapture, type CapturedChunk } from './exec/index.js'
221
+ // Core's local executor, sandbox included, for a plugin that runs tasks
222
+ // itself: `@vzn/vx-dte`'s orchestrator and its workers.
223
+ export { localFloor, type LocalFloor } from './orchestrator/index.js'
224
+ // A task's sandbox request built where a worker runs it: the paths and the
225
+ // walls are that machine's (`@vzn/vx-dte`, an orchestrator of another OS).
226
+ // Its sweep takes back each placeholder the task never wrote, as core does.
227
+ export { sandboxRequestFor, sweepPlaceholders, type SandboxRequest } from './orchestrator/index.js'
228
+ // A runtime probe answered where a `runtime` executor runs the task.
229
+ export { answerRuntime } from './cache/index.js'
200
230
  export type {
201
231
  ExecuteRequest,
202
232
  ExecuteResult,
203
233
  ExecuteSandbox,
234
+ MachineReport,
204
235
  ResolvedSandboxConfig,
236
+ ServerSpec,
205
237
  TaskExecutor,
206
238
  TaskPlacement,
207
239
  } from './exec/index.js'
@@ -212,12 +212,16 @@ export function admitTasks(
212
212
  upstream,
213
213
  nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
214
214
  })
215
- const existing = inflight.get(hash)
216
- if (existing !== undefined) {
217
- // Join a sibling already computing this exact task: wait, then
218
- // executeTask cache-hits on the artifact it just saved.
215
+ // Join a sibling already computing this exact task: wait, then
216
+ // executeTask cache-hits on the artifact it just saved. A sibling that
217
+ // saved nothing (it failed) leaves this task to run, so the joiners
218
+ // released together take the barrier in turn: each returning straight
219
+ // to executeTask ran the task side by side in one directory.
220
+ let joined = false
221
+ for (let existing = inflight.get(hash); existing !== undefined;) {
222
+ joined = true
219
223
  await existing.catch(() => {})
220
- return executeTask(buildExecuteArgs(node, upstream, false))
224
+ existing = inflight.get(hash)
221
225
  }
222
226
  // Become the executor: register a barrier siblings await. get→set has
223
227
  // no await between, so registration is atomic — at most one executor
@@ -229,7 +233,7 @@ export function admitTasks(
229
233
  release = resolve
230
234
  }),
231
235
  )
232
- const execArgs = buildExecuteArgs(node, upstream)
236
+ const execArgs = buildExecuteArgs(node, upstream, !joined)
233
237
  try {
234
238
  // `return await`, deliberately: the finally must run after the task
235
239
  // settles, not when its promise is handed back.
@@ -1,4 +1,4 @@
1
- // `--affected` per task (owner, 2026-10-04). The diff seeds the tasks it
1
+ // `--affected` per task (2026-10-04). The diff seeds the tasks it
2
2
  // reaches; a requested task runs when its `dependsOn` closure holds one.
3
3
  // So a change reaches another project only along a task edge: `web#test`
4
4
  // behind `^build` runs when `ui#build` (or what it depends on) is reached,
@@ -26,7 +26,7 @@ import {
26
26
  import { applyGraphStage } from './graph-stage.js'
27
27
  import { hasHook } from './plugin-host.js'
28
28
  import type { VxPlugin } from './plugin.js'
29
- import { isDefaultBuild } from './projects.js'
29
+ import { isDefaultBuild } from '../graph/index.js'
30
30
 
31
31
  /** Every file in this set re-keys each of its project's tasks, whatever their inputs. */
32
32
  const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
@@ -50,8 +50,10 @@ function wholeProjects(
50
50
  const keyed = Object.values(projects.get(name)?.config.tasks ?? {}).filter(
51
51
  (t) => t.exec !== undefined && t.inputs !== undefined,
52
52
  )
53
+ const links = changes.dirLinksIn?.get(name)
53
54
  const unclaimed = (rel: string): boolean =>
54
- KEYED_BY_EVERY_TASK.has(rel) || (!byKeys && !keyed.some((t) => declaresInput(t, rel, null)))
55
+ KEYED_BY_EVERY_TASK.has(rel) ||
56
+ (!byKeys && !keyed.some((t) => declaresInput(t, rel, null, links?.has(rel))))
55
57
  const rel = rels.find(unclaimed)
56
58
  if (rel !== undefined) whole.set(name, rel)
57
59
  }
@@ -118,7 +120,7 @@ export function affectedRoots(
118
120
  // it passes through: a holder's own node is already in `deps`.
119
121
  const passesChanged = (n: TaskNode): boolean => {
120
122
  for (const raw of n.config.dependsOn ?? []) {
121
- const spec = parseDependencySpec(raw)
123
+ const spec = parseDependencySpec(raw, 'dependsOn')
122
124
  if (spec.kind !== 'deps' || spec.negated) continue
123
125
  const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
124
126
  const holds = (name: string): boolean => {
@@ -147,7 +149,9 @@ export function affectedRoots(
147
149
  // Asked of every node, not only the changed projects' (the
148
150
  // `workspaceFiles` owners): a `graph` hook may have given the glob.
149
151
  if (keyed) {
150
- const file = changes.changed.find((rel) => declaresInput(task, null, rel))
152
+ const file = changes.changed.find((rel) =>
153
+ declaresInput(task, null, rel, changes.dirLinks?.has(rel)),
154
+ )
151
155
  const dir =
152
156
  file === undefined
153
157
  ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(task, d))
@@ -171,7 +175,8 @@ export function affectedRoots(
171
175
  seeds?.set(n.id, inProject(n.projectName, whole.get(n.projectName)))
172
176
  return true
173
177
  }
174
- const rel = rels.find((r) => declaresInput(task, r, null))
178
+ const links = changes.dirLinksIn?.get(n.projectName)
179
+ const rel = rels.find((r) => declaresInput(task, r, null, links?.has(r)))
175
180
  if (rel === undefined) return false
176
181
  if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
177
182
  return true
@@ -156,6 +156,8 @@ export class DeferredOutputs {
156
156
  await this.args.cache.save({
157
157
  hash: entry.hash,
158
158
  projectDir: producer.projectDir,
159
+ // As saveMiss: a link is bounded by the workspace, not the project.
160
+ workspaceRoot: this.args.workspaceRoot,
159
161
  outputFiles,
160
162
  ...(emptyDirs.length > 0 ? { outputDirs: emptyDirs } : {}),
161
163
  entry: entry.entry,