@vzn/vx 0.0.0 → 0.0.12

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,482 @@
1
+ import { UserError } from '../util/index.js'
2
+ import type { TaskNode } from './task-graph.js'
3
+
4
+ export type TaskStatus =
5
+ | 'success'
6
+ | 'cache-hit'
7
+ | 'cache-hit-remote'
8
+ | 'failed'
9
+ | 'skipped'
10
+ // A child killed by a shutdown signal (SIGINT/SIGTERM — e.g. Ctrl-C):
11
+ // the task never finished on its own terms, so it reverts to
12
+ // aborted — not counted, not shown (the run is tearing down).
13
+ | 'aborted'
14
+
15
+ /**
16
+ * Cache-correctness verdict for a task under `vx run --verify` (Phase 1:
17
+ * determinism; Phase 2: input-completeness). Declared here (structurally)
18
+ * because `graph` can't import `orchestrator` where the verifier lives —
19
+ * same pattern as `inputComponents`. A pure side-channel: never hashed,
20
+ * never persisted in Phase 1/2.
21
+ */
22
+ export type VerifyVerdict =
23
+ | { kind: 'proven-deterministic' }
24
+ /** Phase 2: input-completeness proved — the task read nothing outside its
25
+ * declared inputs (from `--verify=inputs` when determinism wasn't also
26
+ * requested; an `--verify=all` pass reports the stronger deterministic). */
27
+ | { kind: 'proven-complete' }
28
+ /** Re-ran; outputs differ from the cached ones. `changed` names the rels. */
29
+ | { kind: 'nondeterministic'; changed: readonly string[] }
30
+ /** Phase 2: read a workspace path outside the declared inputs — the declared
31
+ * `cache.inputs` are incomplete, so a hit could serve stale bytes. `paths`
32
+ * names the undeclared reads (workspace-relative; empty when the sandbox
33
+ * denied structurally but strace wasn't available to name them). */
34
+ | { kind: 'undeclared-inputs'; paths: readonly string[] }
35
+ /** Diverged but the task is on `--verify-allow` — reported, not failed. */
36
+ | { kind: 'allowed-nondeterministic'; changed: readonly string[] }
37
+ /** The verify re-run exited non-zero / timed out (nondeterministic by
38
+ * definition — identical inputs, different outcome). */
39
+ | { kind: 'rerun-failed'; exitCode: number }
40
+ /** Cacheable + executed but declares no outputs — nothing to replay. */
41
+ | { kind: 'no-outputs' }
42
+ /** Didn't execute (cache hit) / not cacheable / group / persistent. */
43
+ | { kind: 'not-verified' }
44
+
45
+ export interface TaskOutcome {
46
+ node: TaskNode
47
+ status: TaskStatus
48
+ exitCode: number
49
+ durationMs: number
50
+ /** Cache key hash, if one was computed. Folded into dependents' keys
51
+ * (pure-input transitive — the upstream's input key, not its output). */
52
+ hash?: string
53
+ /** v11 analytics: CPU time + peak RSS for this task's child process. */
54
+ cpuMs?: number
55
+ peakRssBytes?: number
56
+ /**
57
+ * v11 analytics: hrtime span relative to the parent run's t=0.
58
+ * Lets downstream analytics reconstruct the actual parallel timeline
59
+ * (overlapping tasks, idle gaps) rather than just summing durations.
60
+ */
61
+ wallclockStartNs?: bigint
62
+ wallclockEndNs?: bigint
63
+ /**
64
+ * For cache-hit statuses: true if outputs were actually written to
65
+ * disk this run, false if the on-disk state already matched the
66
+ * cached snapshot (no materialization needed). Lets the formatter
67
+ * surface "up-to-date" vs "local-cache" / "remote-cache" in the
68
+ * framed block. Undefined on non-cache-hit outcomes (success /
69
+ * failed / skipped) — irrelevant there.
70
+ */
71
+ restored?: boolean
72
+ /**
73
+ * How many times the task executed this run, when `exec.retries` /
74
+ * `--retry` re-ran failed attempts. Set only when > 1 — a plain
75
+ * single-attempt run carries nothing. Not persisted; telemetry-side
76
+ * flaky detection reads it off the outcome stream.
77
+ */
78
+ attempts?: number
79
+ /**
80
+ * Count of sandbox violations captured during this task's exec.
81
+ * Populated only when `--sandbox` was set and the task is cached.
82
+ * Non-zero values mean the task read files outside its declared
83
+ * inputs; `cache.save()` was skipped so the result can't be replayed.
84
+ */
85
+ sandboxViolations?: number
86
+ /**
87
+ * Raw violation log lines (one per access denial). Populated alongside
88
+ * `sandboxViolations` so the framed-output renderer can show them
89
+ * inline in the task's block instead of as loose status output.
90
+ */
91
+ sandboxViolationLines?: string[]
92
+ /**
93
+ * Cache-correctness verdict under `vx run --verify`. Set only in verify
94
+ * mode; a plain run leaves it undefined. Pure side-channel (never hashed).
95
+ */
96
+ verify?: VerifyVerdict
97
+ }
98
+
99
+ export type ContinueMode = 'never' | 'deps-ok' | 'always'
100
+
101
+ export interface ScheduleOptions {
102
+ nodes: Map<string, TaskNode>
103
+ concurrency: number
104
+ /**
105
+ * Failure propagation (default 'deps-ok' — the historical behavior):
106
+ * - 'deps-ok': a failed/skipped upstream skips its dependents;
107
+ * independent siblings keep running.
108
+ * - 'never': the first failure stops dispatch — in-flight tasks
109
+ * finish naturally, everything not yet started completes as
110
+ * skipped (restore-tier included: a fail-fast run stops
111
+ * restoring too).
112
+ * - 'always': dependents run even when an upstream failed. Sound
113
+ * under pure-input transitive hashing: failed outcomes carry the
114
+ * upstream's INPUT key (computed before exec), so dependents'
115
+ * keys fold exactly what a healthy run folds. An upstream that
116
+ * died before hashing folds nothing — that key is derivable by
117
+ * no healthy run, so the worst case is an unreachable cache
118
+ * entry, never a stale hit.
119
+ */
120
+ continueMode?: ContinueMode
121
+ execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
122
+ onStart?: (node: TaskNode) => void
123
+ onFinish?: (outcome: TaskOutcome) => void
124
+ /**
125
+ * Optional priority override: callers pass their own per-node weight
126
+ * (e.g. `computePredictedPriorities` from the orchestrator's history
127
+ * data). The scheduler picks the highest-weight ready task next.
128
+ * When undefined, falls back to the static reverse-deps-count
129
+ * heuristic (the default and historically the only behavior).
130
+ */
131
+ priorities?: ReadonlyMap<string, number>
132
+ /**
133
+ * Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
134
+ * local short-circuit). A restore-tier task:
135
+ * - becomes READY IMMEDIATELY — its `pending` does NOT gate it, since
136
+ * a stable hit's restore needs none of its deps' output;
137
+ * - is LOW priority — exec-tier work (cache misses + unstable tasks)
138
+ * owns the worker pool, restores only backfill idle capacity (or
139
+ * run when an exec is blocked on a restorable dep and nothing else
140
+ * is runnable — then the restore is the only ready task and unblocks
141
+ * it);
142
+ * - bypasses the `failedDep`→`skipped` check (its key is independent
143
+ * of dep success — pure-input transitive hashing — so a valid cached
144
+ * output is reported `cache-hit` regardless of a dep failing).
145
+ * It still runs through `execute()` (so the logger frame is unchanged);
146
+ * the orchestrator's execute reuses the up-front probe, so there is no
147
+ * second cache.get. When undefined/empty, behavior is byte-identical.
148
+ */
149
+ restoreTier?: ReadonlySet<string>
150
+ }
151
+
152
+ /**
153
+ * Compute, for each task in the graph, how many OTHER tasks are
154
+ * transitively blocked on it. Tasks with the highest count are the
155
+ * most valuable to schedule first — finishing them unlocks the most
156
+ * downstream work and minimizes worker idle time at the end of the
157
+ * run. Matches Nx's `calculateReverseDeps`-driven schedule sort
158
+ * (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
159
+ */
160
+ // Exported for the perf guard in tests/scheduler.test.ts, which times
161
+ // this function in isolation — end-to-end runGraph timing drowns the
162
+ // closure cost in task-promise overhead and flakes on slow CI.
163
+ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
164
+ // Exact transitive-dependent COUNTS need closure SETS (diamonds
165
+ // double-count under naive summing). Set-of-strings closures are
166
+ // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
167
+ // bitsets make the same closure O(E·N/32) time and N²/8 bits of
168
+ // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
169
+ const ids = [...nodes.keys()]
170
+ const index = new Map<string, number>()
171
+ for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
172
+ const n = ids.length
173
+ const words = (n + 31) >>> 5
174
+
175
+ // Direct dependents as index lists + in-degree for the topo pass.
176
+ const directReverse: number[][] = Array.from({ length: n }, () => [])
177
+ const indegree = new Uint32Array(n)
178
+ for (const node of nodes.values()) {
179
+ const ni = index.get(node.id)!
180
+ for (const dep of node.deps) {
181
+ const di = index.get(dep)
182
+ if (di === undefined) continue
183
+ directReverse[di]!.push(ni)
184
+ indegree[ni]!++
185
+ }
186
+ }
187
+
188
+ // Kahn topo order over dependency edges (deps before dependents).
189
+ // Insertion order is topo today, but the closure's correctness
190
+ // must not hinge on an unstated property of buildTaskGraph.
191
+ const topo = new Int32Array(n)
192
+ let head = 0
193
+ let tail = 0
194
+ for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
195
+ while (head < tail) {
196
+ const v = topo[head++]!
197
+ for (const r of directReverse[v]!) {
198
+ if (--indegree[r]! === 0) topo[tail++] = r
199
+ }
200
+ }
201
+
202
+ // Reverse-topo sweep: every direct dependent's closure is final
203
+ // before its dependency folds it in. closure[i] = bitset over node
204
+ // indices of i's transitive dependents.
205
+ const closure = new Uint32Array(n * words)
206
+ const counts = new Map<string, number>()
207
+ for (let t = tail - 1; t >= 0; t--) {
208
+ const i = topo[t]!
209
+ const base = i * words
210
+ for (const r of directReverse[i]!) {
211
+ closure[base + (r >>> 5)]! |= 1 << (r & 31)
212
+ const rbase = r * words
213
+ for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
214
+ }
215
+ let count = 0
216
+ for (let w = 0; w < words; w++) {
217
+ let v = closure[base + w]!
218
+ v = v - ((v >>> 1) & 0x55555555)
219
+ v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
220
+ count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
221
+ }
222
+ counts.set(ids[i]!, count)
223
+ }
224
+ // Cycle-stranded nodes (never topo-visited) can't occur — the graph
225
+ // builder rejects cycles — but a missing Map entry would silently
226
+ // sort as undefined, so default them defensively to 0.
227
+ for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
228
+ return counts
229
+ }
230
+
231
+ /**
232
+ * Run the task graph. Independent tasks run in parallel up to `concurrency`.
233
+ * If a task fails, its dependents are marked `skipped` but unrelated tasks
234
+ * keep running so the user gets maximum information per invocation.
235
+ *
236
+ * Scheduling: when more than one task is ready, the scheduler picks the
237
+ * one that blocks the most downstream work (most transitive reverse
238
+ * dependents). Ties break in graph-insertion order (which is the topo
239
+ * order produced by `buildTaskGraph`). Minimizes worker idle at the
240
+ * end of the run.
241
+ */
242
+ /**
243
+ * A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
244
+ * ASC) so `pop()` returns the highest-priority task and equal-priority ties
245
+ * break in enqueue order — the exact contract the prior sorted-array kept, but
246
+ * O(log R) push/pop instead of O(R) splice/shift. On a wide ready frontier (the
247
+ * 1000-package startup enqueue, or a fan-out completion), that turns the
248
+ * queue's O(R²) maintenance into O(R log R).
249
+ */
250
+ class ReadyHeap {
251
+ private readonly ids: string[] = []
252
+ private readonly seq: number[] = []
253
+ private next = 0
254
+ constructor(private readonly priority: ReadonlyMap<string, number>) {}
255
+ get size(): number {
256
+ return this.ids.length
257
+ }
258
+ /** True if slot i outranks slot j (higher priority, or equal priority + earlier seq). */
259
+ private higher(i: number, j: number): boolean {
260
+ const pi = this.priority.get(this.ids[i]!) ?? 0
261
+ const pj = this.priority.get(this.ids[j]!) ?? 0
262
+ return pi !== pj ? pi > pj : this.seq[i]! < this.seq[j]!
263
+ }
264
+ private swap(i: number, j: number): void {
265
+ const ti = this.ids[i]!
266
+ this.ids[i] = this.ids[j]!
267
+ this.ids[j] = ti
268
+ const si = this.seq[i]!
269
+ this.seq[i] = this.seq[j]!
270
+ this.seq[j] = si
271
+ }
272
+ push(id: string): void {
273
+ this.ids.push(id)
274
+ this.seq.push(this.next++)
275
+ let i = this.ids.length - 1
276
+ while (i > 0) {
277
+ const parent = (i - 1) >> 1
278
+ if (!this.higher(i, parent)) break
279
+ this.swap(i, parent)
280
+ i = parent
281
+ }
282
+ }
283
+ pop(): string | undefined {
284
+ const n = this.ids.length
285
+ if (n === 0) return undefined
286
+ const top = this.ids[0]!
287
+ const lastId = this.ids.pop()!
288
+ const lastSeq = this.seq.pop()!
289
+ if (n > 1) {
290
+ this.ids[0] = lastId
291
+ this.seq[0] = lastSeq
292
+ let i = 0
293
+ for (;;) {
294
+ const l = 2 * i + 1
295
+ const r = 2 * i + 2
296
+ let best = i
297
+ if (l < this.ids.length && this.higher(l, best)) best = l
298
+ if (r < this.ids.length && this.higher(r, best)) best = r
299
+ if (best === i) break
300
+ this.swap(i, best)
301
+ i = best
302
+ }
303
+ }
304
+ return top
305
+ }
306
+ }
307
+
308
+ export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
309
+ const { nodes, concurrency, execute, onStart, onFinish } = options
310
+ const continueMode = options.continueMode ?? 'deps-ok'
311
+ // 'never': set on the first failed outcome; from then on every
312
+ // dequeued task is skipped instead of dispatched (in-flight tasks
313
+ // finish naturally and their dependents drain through the same path).
314
+ let failFastTripped = false
315
+ const outcomes = new Map<string, TaskOutcome>()
316
+
317
+ // Reverse adjacency + pending dep counts. Built once. A task becomes
318
+ // ready when its `pending` hits 0, at which point it's pushed to the
319
+ // ready queue. This replaces the old "scan all of scheduleOrder on
320
+ // every tick" pattern which was O(N²) over a full run.
321
+ const dependents = new Map<string, string[]>()
322
+ const pending = new Map<string, number>()
323
+ for (const node of nodes.values()) {
324
+ pending.set(node.id, node.deps.length)
325
+ for (const dep of node.deps) {
326
+ const list = dependents.get(dep)
327
+ if (list) list.push(node.id)
328
+ else dependents.set(dep, [node.id])
329
+ }
330
+ }
331
+
332
+ // Override hook (predictive scheduling): when the caller passes a
333
+ // priorities map (e.g. from history-aware critical-path estimation),
334
+ // use those weights. Falls back to the reverse-deps-count heuristic
335
+ // for nodes the caller didn't score, so partial coverage works.
336
+ const baseline = computeReverseDepCount(nodes)
337
+ const priority: ReadonlyMap<string, number> = options.priorities
338
+ ? mergePriorities(baseline, options.priorities)
339
+ : baseline
340
+
341
+ // Two ready queues — exec-tier (cache misses + unstable tasks) and
342
+ // restore-tier (confirmed stable local hits). Each is a max-heap keyed by
343
+ // (priority DESC, enqueue-seq ASC), so the highest-priority task pops first
344
+ // and equal-priority ties break in graph-insertion order — same contract the
345
+ // prior `scheduleOrder` sort provided via stable sort. The tick loop drains
346
+ // execReady FIRST, so misses own the worker pool and restores only backfill
347
+ // idle capacity (or run when an exec is blocked on a restorable dep and
348
+ // nothing else is runnable).
349
+ const restoreTier = options.restoreTier
350
+ const execReady = new ReadyHeap(priority)
351
+ const restoreReady = new ReadyHeap(priority)
352
+ // A restore-tier task is dep-independent (a stable hit's restore needs
353
+ // none of its deps' output), so it's ready immediately; its `pending`
354
+ // decrements still happen (in finishOne) but never re-enqueue it.
355
+ // Everything else enqueues on the exec-tier the moment its deps
356
+ // complete.
357
+ for (const node of nodes.values()) {
358
+ if (restoreTier?.has(node.id)) restoreReady.push(node.id)
359
+ else if (node.deps.length === 0) execReady.push(node.id)
360
+ }
361
+
362
+ let active = 0
363
+ let resolved = false
364
+
365
+ return new Promise<Map<string, TaskOutcome>>((resolve) => {
366
+ const finishOne = (id: string, outcome: TaskOutcome): void => {
367
+ if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
368
+ outcomes.set(id, outcome)
369
+ onFinish?.(outcome)
370
+ const ds = dependents.get(id)
371
+ if (!ds) return
372
+ for (const d of ds) {
373
+ const rem = (pending.get(d) ?? 0) - 1
374
+ pending.set(d, rem)
375
+ // Restore-tier dependents were already enqueued at startup (they
376
+ // don't wait on deps); only re-enqueue an exec-tier dependent.
377
+ if (rem === 0 && !restoreTier?.has(d)) execReady.push(d)
378
+ }
379
+ }
380
+
381
+ // Drain a worker slot: prefer an exec-tier task (misses own the
382
+ // pool); only when none is ready does a restore-tier task backfill.
383
+ const takeReady = (): string | undefined =>
384
+ execReady.size > 0 ? execReady.pop() : restoreReady.pop()
385
+
386
+ const tick = (): void => {
387
+ if (resolved) return
388
+
389
+ while (active < concurrency && (execReady.size > 0 || restoreReady.size > 0)) {
390
+ const id = takeReady() as string
391
+ const node = nodes.get(id) as TaskNode
392
+ const isRestore = restoreTier?.has(id) === true
393
+
394
+ // If any upstream failed/skipped, propagate skip synchronously
395
+ // without running. Skipped tasks still flow through this queue
396
+ // because dependents are pushed when `pending` hits 0 regardless
397
+ // of outcome — keeps the propagation logic in one place.
398
+ //
399
+ // Restore-tier tasks BYPASS this check: their key is independent
400
+ // of any dep's success (pure-input transitive hashing), so a
401
+ // valid cached output is reported `cache-hit` even if a dep
402
+ // failed — and they're dep-independent, so they typically
403
+ // restore before a dep could fail anyway.
404
+ //
405
+ // For a restore-tier task running BEFORE its deps finish, the
406
+ // `upstream` entries below can be undefined (the cast lies) —
407
+ // the preProbed hit path in execute-task never reads them, and
408
+ // nothing on the restore path may.
409
+ const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
410
+ if (failFastTripped) {
411
+ finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
412
+ continue
413
+ }
414
+ if (!isRestore && continueMode !== 'always') {
415
+ const failedDep = upstream.find((u) => u.status === 'failed' || u.status === 'skipped')
416
+ if (failedDep) {
417
+ finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
418
+ continue
419
+ }
420
+ }
421
+
422
+ active++
423
+ onStart?.(node)
424
+
425
+ execute(node, upstream)
426
+ .then((outcome) => {
427
+ active--
428
+ finishOne(id, outcome)
429
+ tick()
430
+ })
431
+ .catch((err: unknown) => {
432
+ const message = err instanceof Error ? err.message : String(err)
433
+ const outcome: TaskOutcome = {
434
+ node,
435
+ status: 'failed',
436
+ exitCode: 1,
437
+ durationMs: 0,
438
+ }
439
+ active--
440
+ finishOne(id, outcome)
441
+ // Surface the error live; the outcome itself doesn't
442
+ // carry captured stderr (that's the logger's job). A
443
+ // UserError is a config/input failure (e.g. a failed
444
+ // `cache.inputs.runtime` command), not a vx bug — report it
445
+ // plainly, never as an "internal error".
446
+ if (err instanceof UserError) {
447
+ process.stderr.write(`[vx] ${id}: ${message}\n`)
448
+ } else {
449
+ const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
450
+ process.stderr.write(`[vx] internal error in ${id}: ${named}${message}\n`)
451
+ }
452
+ tick()
453
+ })
454
+ }
455
+
456
+ if (outcomes.size === nodes.size && active === 0) {
457
+ resolved = true
458
+ resolve(outcomes)
459
+ }
460
+ }
461
+
462
+ tick()
463
+ })
464
+ }
465
+
466
+ function mergePriorities(
467
+ baseline: ReadonlyMap<string, number>,
468
+ overrides: ReadonlyMap<string, number>,
469
+ ): ReadonlyMap<string, number> {
470
+ // The override caller scored "expected critical-path duration" in ms.
471
+ // Baseline reverse-deps counts are bounded by N; scale the override so
472
+ // it sorts above the baseline for any node it covers, and add the
473
+ // baseline as a tie-break for parity within the override set.
474
+ const SCALE = 1 << 20
475
+ const out = new Map<string, number>()
476
+ for (const [id, w] of baseline) out.set(id, w)
477
+ for (const [id, w] of overrides) {
478
+ const b = baseline.get(id) ?? 0
479
+ out.set(id, w * SCALE + b)
480
+ }
481
+ return out
482
+ }