@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -63,6 +63,8 @@ export interface RunSummaryRow {
63
63
  forwarded: boolean
64
64
  /** Each remote read or write of its key that failed, `upload: <message>`, `; `-joined (v40). */
65
65
  remoteError: string | null
66
+ /** The attempts a retried task took (>1, v23); null when it ran once. */
67
+ attempts: number | null
66
68
  }
67
69
 
68
70
  export interface ListRunsArgs {
@@ -124,7 +126,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
124
126
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
125
127
  blocked_by AS blockedBy, timed_out AS timedOut,
126
128
  sandbox_violations AS sandboxViolations, not_ready AS notReady,
127
- forward_args IS NOT NULL AS forwarded, remote_error AS remoteError
129
+ forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts
128
130
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
129
131
  )
130
132
  .all(...params, limit) as RawRow[]
@@ -292,7 +294,9 @@ export interface RunDetail {
292
294
  }
293
295
 
294
296
  export function getRun(db: Database, runId: string): RunDetail | null {
295
- 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()
296
300
  if (tasks.length === 0) return null
297
301
  const startedAt = Math.min(...tasks.map((t) => t.startedAt))
298
302
  const endedAt = Math.max(...tasks.map((t) => t.endedAt))
@@ -385,6 +389,18 @@ export interface WhyDidThisRerun {
385
389
  note: string
386
390
  }
387
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
+
388
404
  /**
389
405
  * The endings an UNCHANGED cache key can have. A re-execution is told apart
390
406
  * by the evidence the index holds: every one used to blame `--no-cache` /
@@ -396,7 +412,7 @@ function unchangedKeyNote(
396
412
  db: Database,
397
413
  runId: string,
398
414
  this_: { hash: string; status: string; cacheHit: number | null; startedAt: number },
399
- prev: { status: string; cacheHit: number | null; runId: string | null },
415
+ prev: { status: string; cacheHit: number | null; runId: string | null; blockedBy: string | null },
400
416
  ): string {
401
417
  if (this_.cacheHit === null) {
402
418
  return 'cache key unchanged — this run recorded no cache outcome, so whether it re-ran is unknown'
@@ -410,8 +426,12 @@ function unchangedKeyNote(
410
426
  p: string
411
427
  } | null
412
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`.
413
431
  if (policy !== undefined && !readsCache(policy)) {
414
- 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)'
415
435
  }
416
436
  // `get` answers null, not undefined, for no row.
417
437
  const createdAt = (
@@ -431,17 +451,9 @@ function unchangedKeyNote(
431
451
  prev.status === 'success' &&
432
452
  prev.cacheHit === 0
433
453
  ) {
434
- const failedWithWrites = (id: string | null): number => {
435
- if (id === null) return 0
436
- const row = db
437
- .query('SELECT cache_policy AS p, failed_count AS f FROM invocations WHERE run_id = ?')
438
- .get(id) as { p: string; f: number } | null
439
- const writes = row !== null && writesCache(row.p)
440
- return writes ? (row?.f ?? 0) : 0
441
- }
442
- const before = failedWithWrites(prev.runId)
454
+ const before = failedWithWrites(db, prev.runId)
443
455
  if (before > 0) {
444
- const now = failedWithWrites(runId)
456
+ const now = failedWithWrites(db, runId)
445
457
  if (createdAt === undefined && now > 0) {
446
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`
447
459
  }
@@ -473,6 +485,18 @@ function unchangedKeyNote(
473
485
  if (prevPolicy !== undefined && !writesCache(prevPolicy)) {
474
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'
475
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
+ }
476
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'
477
501
  }
478
502
  return 'cache key unchanged — re-executed on the same key though this run read the cache; vx cannot name the cause'
@@ -497,7 +521,7 @@ export function latestRunId(db: Database, taskId: string): string | null {
497
521
  function keylessNote(taskId: string, keylessBy: string): string {
498
522
  return keylessBy === taskId
499
523
  ? 'never cached: this task declares no `inputs`, so each run derives a new key; declare its `inputs`'
500
- : `never cached: its key folds ${keylessBy}, which declares no \`inputs\`, so each run derives a new key; declare inputs on ${keylessBy}`
524
+ : `never cached: its key includes ${keylessBy}, which declares no \`inputs\`, so each run derives a new key; declare inputs on ${keylessBy}`
501
525
  }
502
526
 
503
527
  export function whyDidThisRerun(db: Database, runId: string, taskId: string): WhyDidThisRerun {
@@ -535,7 +559,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
535
559
  const prev = db
536
560
  .query(
537
561
  `SELECT hash, status, cache_hit AS cacheHit, restored, started_at AS startedAt,
538
- run_id AS runId
562
+ run_id AS runId, blocked_by AS blockedBy
539
563
  FROM runs WHERE project = ? AND task = ? AND id < ? AND ${KEYED_RUNS_SQL}
540
564
  ORDER BY id DESC LIMIT 1`,
541
565
  )
@@ -547,6 +571,7 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
547
571
  restored: number | null
548
572
  startedAt: number
549
573
  runId: string | null
574
+ blockedBy: string | null
550
575
  }
551
576
  | undefined
552
577
  // …and this run must have one too, or there is nothing to compare.
@@ -577,8 +602,14 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
577
602
  ? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
578
603
  : this_.cached === 0
579
604
  ? // Not a cache decision at all: the task runs every time by design,
580
- // and its key is derived only so dependents can fold it.
581
- 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
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}`
582
613
  : this_.keylessBy !== null
583
614
  ? keylessNote(taskId, this_.keylessBy)
584
615
  : prev && prev.hash !== this_.hash
@@ -635,7 +666,7 @@ export function loadEntryInputs(db: Database, entryHash: string): TaskInputCompo
635
666
  }
636
667
 
637
668
  /**
638
- * 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
639
670
  * two keys' components over `(kind, name)`. A component in both with a
640
671
  * different hash is `changed`, one only in `after` is `added`, one only in
641
672
  * `before` is `removed`, and an equal one is counted. Entries are ordered by
@@ -677,8 +708,7 @@ export function diffKeyComponents(
677
708
  }
678
709
  }
679
710
  // Code-unit order, not `localeCompare`: collation follows the machine's
680
- // locale, and the playground runs this same join in the reader's browser,
681
- // 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
682
712
  // are also SQLite's BINARY order, the `entry_inputs` scan's own.
683
713
  const byCodeUnit = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
684
714
  entries.sort((x, y) => byCodeUnit(x.kind, y.kind) || byCodeUnit(x.name, y.name))
@@ -797,7 +827,9 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
797
827
  // A failed previous run kept no fingerprints; the last run that saved an
798
828
  // entry still names what moved since then (the edit an agent made to fix
799
829
  // the failure, or the one that broke it).
800
- 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)) {
801
833
  const saved = db
802
834
  .query(
803
835
  `SELECT run_id AS runId, hash FROM runs
@@ -808,7 +840,7 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
808
840
  .get(project, task, this_.id) as { runId: string | null; hash: string } | undefined
809
841
  if (saved) {
810
842
  const { entries, unchangedCount } = diffKeyComponents(loadEntryInputs(db, saved.hash), cur)
811
- 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}`})`
812
844
  return {
813
845
  runId,
814
846
  taskId,
@@ -835,7 +867,9 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
835
867
  ? `this run ended ${this_.status}`
836
868
  : old.length === 0 && !isPassStatus(prev.status)
837
869
  ? `the previous run ended ${prev.status}`
838
- : null
870
+ : old.length === 0 && tainted
871
+ ? 'the previous run ran beside a failed task'
872
+ : null
839
873
  return {
840
874
  runId,
841
875
  taskId,
@@ -870,6 +904,18 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
870
904
  }
871
905
  }
872
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
+
873
919
  export interface RootCause {
874
920
  /** From the asked task down to the one whose own inputs moved. */
875
921
  chain: string[]
@@ -879,23 +925,37 @@ export interface RootCause {
879
925
  /**
880
926
  * The tasks under `taskId`'s moved upstreams whose OWN key components
881
927
  * moved in the same run, each with the chain that carried it up. A task
882
- * 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.
883
931
  */
884
932
  export function rootCauses(
885
933
  db: Database,
886
934
  runId: string,
887
935
  taskId: string,
888
936
  entries: CacheKeyDiff['entries'],
937
+ groups: ReadonlyMap<string, readonly string[]> = new Map(),
889
938
  ): RootCause[] {
890
939
  const roots: RootCause[] = []
891
940
  const seen = new Set([taskId])
892
941
  const walk = (chain: string[], moved: CacheKeyDiff['entries']): void => {
893
942
  for (const e of moved) {
894
- if (e.kind !== 'upstream' || seen.has(e.name)) continue
895
- seen.add(e.name)
896
- const next = [...chain, e.name]
897
- const d = cacheKeyDiff(db, runId, e.name)
898
- 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
+ }
899
959
  const own = d.entries.filter((x) => x.kind !== 'upstream')
900
960
  if (own.length > 0) roots.push({ chain: next, entries: own })
901
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).
@@ -90,9 +98,10 @@ export type {
90
98
  RunRecord,
91
99
  InvocationRecord,
92
100
  } from './cache/index.js'
93
- // A plugin's keychain (`ctx.secret` on `setup` and a remote's `open`), and
94
- // the guard that tells a missing secret from a fault.
95
- 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'
96
105
  export type {
97
106
  PluginSecrets,
98
107
  SecretRequest,
@@ -111,7 +120,7 @@ export { loadProjectConfig } from './workspace/index.js'
111
120
  export type { ProjectMeta } from './workspace/index.js'
112
121
  // The run path's RESOLVED view — plugin `config` and `project` stages
113
122
  // applied — for a reader outside the CLI
114
- // (`@vzn/vx-schedule-history`, an embedder's task catalog). What `vx show`
123
+ // (`@vzn/vx-history`, an embedder's task catalog). What `vx show`
115
124
  // prints.
116
125
  export { loadResolvedProjects } from './orchestrator/index.js'
117
126
  export type { ProjectEntry } from './workspace/index.js'
@@ -122,6 +131,7 @@ export type { ProjectEntry } from './workspace/index.js'
122
131
  export {
123
132
  applyMigration,
124
133
  foldScriptHooks,
134
+ isStarterConfig,
125
135
  PERSISTENT_TODO,
126
136
  pruneOrphanPersistentNotes,
127
137
  quoteTsLiteral,
@@ -195,19 +205,26 @@ export type { RunStartInfo } from './report/index.js'
195
205
  // speaks a wire — and a seam with no consumer is a special case in waiting.)
196
206
  // A remote executor gives a task back to the local floor by rejecting with
197
207
  // this (B-100): core runs it here and says why once.
198
- export { executorFallback } from './exec/index.js'
208
+ export { executorFallback, hostPlatform, sampleTrees, type TreeUsage } from './exec/index.js'
199
209
  // How the local executor puts the args after `--` on a command, for an
200
210
  // executor that runs `ExecuteRequest.command` itself: `@vzn/vx-reapi` and
201
211
  // the `vx init --plugin executor` example each joined their own, and a
202
212
  // trailing comment or a template literal's closing newline lost the args
203
213
  // there while the local floor kept them (X-112).
204
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'
205
221
  // Core's local executor, sandbox included, for a plugin that runs tasks
206
- // itself: `@vzn/vx-dte`'s orchestrator and its agents.
222
+ // itself: `@vzn/vx-dte`'s orchestrator and its workers.
207
223
  export { localFloor, type LocalFloor } from './orchestrator/index.js'
208
- // A task's sandbox request built where an agent runs it: the paths and the
224
+ // A task's sandbox request built where a worker runs it: the paths and the
209
225
  // walls are that machine's (`@vzn/vx-dte`, an orchestrator of another OS).
210
- export { sandboxRequestFor, type SandboxRequest } from './orchestrator/index.js'
226
+ // Its sweep takes back each placeholder the task never wrote, as core does.
227
+ export { sandboxRequestFor, sweepPlaceholders, type SandboxRequest } from './orchestrator/index.js'
211
228
  // A runtime probe answered where a `runtime` executor runs the task.
212
229
  export { answerRuntime } from './cache/index.js'
213
230
  export type {
@@ -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,