@vzn/vx 0.0.0 → 0.0.14

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 +349 -1
  3. package/launcher.mjs +92 -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 +28 -0
  10. package/src/cache/inputs.ts +839 -0
  11. package/src/cache/layered-cache.ts +355 -0
  12. package/src/cache/tar.ts +343 -0
  13. package/src/cli/backend.ts +23 -0
  14. package/src/cli/cache.ts +96 -0
  15. package/src/cli/format.ts +15 -0
  16. package/src/cli/help.ts +129 -0
  17. package/src/cli/index.ts +85 -0
  18. package/src/cli/info.ts +80 -0
  19. package/src/cli/lock.ts +135 -0
  20. package/src/cli/mcp-rpc.ts +292 -0
  21. package/src/cli/mcp.ts +92 -0
  22. package/src/cli/migrate-nx.ts +450 -0
  23. package/src/cli/migrate-turbo.ts +364 -0
  24. package/src/cli/migrate.ts +219 -0
  25. package/src/cli/plan-format.ts +148 -0
  26. package/src/cli/run.ts +683 -0
  27. package/src/cli/show.ts +201 -0
  28. package/src/cli/upgrade.ts +103 -0
  29. package/src/cli/watch.ts +287 -0
  30. package/src/config.ts +460 -0
  31. package/src/exec/env.ts +74 -0
  32. package/src/exec/index.ts +13 -0
  33. package/src/exec/runner.ts +470 -0
  34. package/src/exec/sandbox-runtime.ts +578 -0
  35. package/src/graph/dependency-spec.ts +85 -0
  36. package/src/graph/index.ts +27 -0
  37. package/src/graph/scheduler.ts +677 -0
  38. package/src/graph/task-graph.ts +377 -0
  39. package/src/index.ts +222 -0
  40. package/src/orchestrator/colors.ts +63 -0
  41. package/src/orchestrator/devframe-surface.ts +74 -0
  42. package/src/orchestrator/events.ts +277 -0
  43. package/src/orchestrator/execute-task.ts +939 -0
  44. package/src/orchestrator/framed-output.ts +461 -0
  45. package/src/orchestrator/history.ts +148 -0
  46. package/src/orchestrator/index.ts +174 -0
  47. package/src/orchestrator/local-shortcircuit.ts +147 -0
  48. package/src/orchestrator/logger.ts +526 -0
  49. package/src/orchestrator/metrics.ts +1927 -0
  50. package/src/orchestrator/options.ts +197 -0
  51. package/src/orchestrator/plan.ts +129 -0
  52. package/src/orchestrator/plugin-host.ts +185 -0
  53. package/src/orchestrator/plugin.ts +283 -0
  54. package/src/orchestrator/predict.ts +106 -0
  55. package/src/orchestrator/prepare.ts +306 -0
  56. package/src/orchestrator/protocol.ts +156 -0
  57. package/src/orchestrator/remote-prefetch.ts +81 -0
  58. package/src/orchestrator/resources.ts +59 -0
  59. package/src/orchestrator/run-artifacts.ts +111 -0
  60. package/src/orchestrator/run-context.ts +205 -0
  61. package/src/orchestrator/run-report.ts +132 -0
  62. package/src/orchestrator/run-state.ts +145 -0
  63. package/src/orchestrator/run.ts +887 -0
  64. package/src/orchestrator/stable-keys.ts +177 -0
  65. package/src/orchestrator/status-line.ts +287 -0
  66. package/src/orchestrator/summary.ts +315 -0
  67. package/src/orchestrator/tally.ts +69 -0
  68. package/src/orchestrator/task-hash.ts +253 -0
  69. package/src/orchestrator/telemetry-host.ts +80 -0
  70. package/src/orchestrator/telemetry.ts +358 -0
  71. package/src/orchestrator/upstream.ts +121 -0
  72. package/src/orchestrator/verify.ts +164 -0
  73. package/src/orchestrator/wire-render.ts +92 -0
  74. package/src/orchestrator/wire.ts +212 -0
  75. package/src/util/errors.ts +11 -0
  76. package/src/util/hash.ts +25 -0
  77. package/src/util/index.ts +8 -0
  78. package/src/util/paths.ts +16 -0
  79. package/src/util/size.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 +512 -0
  90. package/src/workspace/workspace.ts +176 -0
@@ -0,0 +1,677 @@
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
+ /**
46
+ * Content fingerprint of a task's output tree, computed under a `--verify*`
47
+ * mode on the executed (miss) path. Never hashed into any key; pure telemetry
48
+ * side-channel for the cross-machine diff (a serve pairs fingerprints for the
49
+ * SAME cache key across platforms and names diverging outputs). Declared here
50
+ * (structurally) because `graph` can't import `orchestrator` — the
51
+ * `VerifyVerdict` pattern. See docs/design/verify-cross-machine-2026-07.md.
52
+ */
53
+ export interface OutputFingerprint {
54
+ /** Roll-up: xxh3hex over the sorted (key, hash) pairs, folded as
55
+ * `key \0 hash \n` (\0 boundaries — the v18 lesson). Always present;
56
+ * divergence DETECTION never depends on the per-file map. */
57
+ tree: string
58
+ /** Total files in the tree (pre-truncation). */
59
+ fileCount: number
60
+ /** Per-file map as sorted [outputKey, xxh3hex] pairs, capped at
61
+ * FP_MAX_FILES (500). Deterministic truncation — sorted by key,
62
+ * first N — so two machines' truncated maps cover the same subset
63
+ * and partial diffs still name real rels. */
64
+ files?: ReadonlyArray<readonly [string, string]>
65
+ /** Set when `files` was truncated to the cap (or dropped by the
66
+ * sink's run budget). */
67
+ truncated?: boolean
68
+ }
69
+
70
+ export interface TaskOutcome {
71
+ node: TaskNode
72
+ status: TaskStatus
73
+ exitCode: number
74
+ durationMs: number
75
+ /** Cache key hash, if one was computed. Folded into dependents' keys
76
+ * (pure-input transitive — the upstream's input key, not its output). */
77
+ hash?: string
78
+ /** v11 analytics: CPU time + peak RSS for this task's child process. */
79
+ cpuMs?: number
80
+ peakRssBytes?: number
81
+ /**
82
+ * v11 analytics: hrtime span relative to the parent run's t=0.
83
+ * Lets downstream analytics reconstruct the actual parallel timeline
84
+ * (overlapping tasks, idle gaps) rather than just summing durations.
85
+ */
86
+ wallclockStartNs?: bigint
87
+ wallclockEndNs?: bigint
88
+ /**
89
+ * For cache-hit statuses: true if outputs were actually written to
90
+ * disk this run, false if the on-disk state already matched the
91
+ * cached snapshot (no materialization needed). Lets the formatter
92
+ * surface "up-to-date" vs "local-cache" / "remote-cache" in the
93
+ * framed block. Undefined on non-cache-hit outcomes (success /
94
+ * failed / skipped) — irrelevant there.
95
+ */
96
+ restored?: boolean
97
+ /**
98
+ * How many times the task executed this run, when `exec.retries` /
99
+ * `--retry` re-ran failed attempts. Set only when > 1 — a plain
100
+ * single-attempt run carries nothing. Not persisted; telemetry-side
101
+ * flaky detection reads it off the outcome stream.
102
+ */
103
+ attempts?: number
104
+ /**
105
+ * Count of sandbox violations captured during this task's exec.
106
+ * Populated only when `--sandbox` was set and the task is cached.
107
+ * Non-zero values mean the task read files outside its declared
108
+ * inputs; `cache.save()` was skipped so the result can't be replayed.
109
+ */
110
+ sandboxViolations?: number
111
+ /**
112
+ * Raw violation log lines (one per access denial). Populated alongside
113
+ * `sandboxViolations` so the framed-output renderer can show them
114
+ * inline in the task's block instead of as loose status output.
115
+ */
116
+ sandboxViolationLines?: string[]
117
+ /**
118
+ * Cache-correctness verdict under `vx run --verify`. Set only in verify
119
+ * mode; a plain run leaves it undefined. Pure side-channel (never hashed).
120
+ */
121
+ verify?: VerifyVerdict
122
+ /**
123
+ * Output-tree fingerprint under a fingerprinting `--verify*` mode
124
+ * (`--verify` / `=all` / `=fingerprint`), executed + cacheable +
125
+ * output-declaring tasks only. A plain run leaves it undefined.
126
+ */
127
+ outputFp?: OutputFingerprint
128
+ }
129
+
130
+ export type ContinueMode = 'never' | 'deps-ok' | 'always'
131
+
132
+ /**
133
+ * Resolved per-task resource reservation, in absolute units (cpu may be
134
+ * fractional; mem is bytes). Declared here (structurally) because `graph`
135
+ * can't import `orchestrator`, where the resolver lives — same pattern as
136
+ * `VerifyVerdict`. A `0` axis means "reserve nothing, run freely": the
137
+ * task is exempt from that axis entirely (needs no headroom, holds none).
138
+ */
139
+ export interface ResourceCost {
140
+ cpu: number
141
+ mem: number
142
+ }
143
+
144
+ export const ZERO_COST: ResourceCost = { cpu: 0, mem: 0 }
145
+
146
+ export interface ScheduleOptions {
147
+ nodes: Map<string, TaskNode>
148
+ concurrency: number
149
+ /**
150
+ * Failure propagation (default 'deps-ok' — the historical behavior):
151
+ * - 'deps-ok': a failed/skipped upstream skips its dependents;
152
+ * independent siblings keep running.
153
+ * - 'never': the first failure stops dispatch — in-flight tasks
154
+ * finish naturally, everything not yet started completes as
155
+ * skipped (restore-tier included: a fail-fast run stops
156
+ * restoring too).
157
+ * - 'always': dependents run even when an upstream failed. Sound
158
+ * under pure-input transitive hashing: failed outcomes carry the
159
+ * upstream's INPUT key (computed before exec), so dependents'
160
+ * keys fold exactly what a healthy run folds. An upstream that
161
+ * died before hashing folds nothing — that key is derivable by
162
+ * no healthy run, so the worst case is an unreachable cache
163
+ * entry, never a stale hit.
164
+ */
165
+ continueMode?: ContinueMode
166
+ execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
167
+ onStart?: (node: TaskNode) => void
168
+ onFinish?: (outcome: TaskOutcome) => void
169
+ /**
170
+ * Optional priority override: callers pass their own per-node weight
171
+ * (e.g. `computePredictedPriorities` from the orchestrator's history
172
+ * data). The scheduler picks the highest-weight ready task next.
173
+ * When undefined, falls back to the static reverse-deps-count
174
+ * heuristic (the default and historically the only behavior).
175
+ */
176
+ priorities?: ReadonlyMap<string, number>
177
+ /**
178
+ * Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
179
+ * local short-circuit). A restore-tier task:
180
+ * - becomes READY IMMEDIATELY — its `pending` does NOT gate it, since
181
+ * a stable hit's restore needs none of its deps' output;
182
+ * - is LOW priority — exec-tier work (cache misses + unstable tasks)
183
+ * owns the worker pool, restores only backfill idle capacity (or
184
+ * run when an exec is blocked on a restorable dep and nothing else
185
+ * is runnable — then the restore is the only ready task and unblocks
186
+ * it);
187
+ * - bypasses the `failedDep`→`skipped` check (its key is independent
188
+ * of dep success — pure-input transitive hashing — so a valid cached
189
+ * output is reported `cache-hit` regardless of a dep failing).
190
+ * It still runs through `execute()` (so the logger frame is unchanged);
191
+ * the orchestrator's execute reuses the up-front probe, so there is no
192
+ * second cache.get. When undefined/empty, behavior is byte-identical.
193
+ */
194
+ restoreTier?: ReadonlySet<string>
195
+ /**
196
+ * Resolved per-task resource reservations (`exec.resources`, resolved
197
+ * by the orchestrator against the run's budgets). An absent id means
198
+ * zero cost; undefined/empty means no task opted in — the scheduler
199
+ * takes the legacy path byte-identically. Admission control only —
200
+ * nothing is enforced on the child process.
201
+ */
202
+ resourceCosts?: ReadonlyMap<string, ResourceCost>
203
+ /** CPU budget reservations pack against. Defaults to `concurrency`. */
204
+ cpuBudget?: number
205
+ /** Memory budget (bytes). Defaults to Infinity (axis off). */
206
+ memBudget?: number
207
+ }
208
+
209
+ /**
210
+ * Compute, for each task in the graph, how many OTHER tasks are
211
+ * transitively blocked on it. Tasks with the highest count are the
212
+ * most valuable to schedule first — finishing them unlocks the most
213
+ * downstream work and minimizes worker idle time at the end of the
214
+ * run. Matches Nx's `calculateReverseDeps`-driven schedule sort
215
+ * (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
216
+ */
217
+ // Exported for the perf guard in tests/scheduler.test.ts, which times
218
+ // this function in isolation — end-to-end runGraph timing drowns the
219
+ // closure cost in task-promise overhead and flakes on slow CI.
220
+ export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
221
+ // Exact transitive-dependent COUNTS need closure SETS (diamonds
222
+ // double-count under naive summing). Set-of-strings closures are
223
+ // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
224
+ // bitsets make the same closure O(E·N/32) time and N²/8 bits of
225
+ // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
226
+ const ids = [...nodes.keys()]
227
+ const index = new Map<string, number>()
228
+ for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
229
+ const n = ids.length
230
+ const words = (n + 31) >>> 5
231
+
232
+ // Direct dependents as index lists + in-degree for the topo pass.
233
+ const directReverse: number[][] = Array.from({ length: n }, () => [])
234
+ const indegree = new Uint32Array(n)
235
+ for (const node of nodes.values()) {
236
+ const ni = index.get(node.id)!
237
+ for (const dep of node.deps) {
238
+ const di = index.get(dep)
239
+ if (di === undefined) continue
240
+ directReverse[di]!.push(ni)
241
+ indegree[ni]!++
242
+ }
243
+ }
244
+
245
+ // Kahn topo order over dependency edges (deps before dependents).
246
+ // Insertion order is topo today, but the closure's correctness
247
+ // must not hinge on an unstated property of buildTaskGraph.
248
+ const topo = new Int32Array(n)
249
+ let head = 0
250
+ let tail = 0
251
+ for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
252
+ while (head < tail) {
253
+ const v = topo[head++]!
254
+ for (const r of directReverse[v]!) {
255
+ if (--indegree[r]! === 0) topo[tail++] = r
256
+ }
257
+ }
258
+
259
+ // Reverse-topo sweep: every direct dependent's closure is final
260
+ // before its dependency folds it in. closure[i] = bitset over node
261
+ // indices of i's transitive dependents.
262
+ const closure = new Uint32Array(n * words)
263
+ const counts = new Map<string, number>()
264
+ for (let t = tail - 1; t >= 0; t--) {
265
+ const i = topo[t]!
266
+ const base = i * words
267
+ for (const r of directReverse[i]!) {
268
+ closure[base + (r >>> 5)]! |= 1 << (r & 31)
269
+ const rbase = r * words
270
+ for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
271
+ }
272
+ let count = 0
273
+ for (let w = 0; w < words; w++) {
274
+ let v = closure[base + w]!
275
+ v = v - ((v >>> 1) & 0x55555555)
276
+ v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
277
+ count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
278
+ }
279
+ counts.set(ids[i]!, count)
280
+ }
281
+ // Cycle-stranded nodes (never topo-visited) can't occur — the graph
282
+ // builder rejects cycles — but a missing Map entry would silently
283
+ // sort as undefined, so default them defensively to 0.
284
+ for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
285
+ return counts
286
+ }
287
+
288
+ /**
289
+ * Run the task graph. Independent tasks run in parallel up to `concurrency`.
290
+ * If a task fails, its dependents are marked `skipped` but unrelated tasks
291
+ * keep running so the user gets maximum information per invocation.
292
+ *
293
+ * Scheduling: when more than one task is ready, the scheduler picks the
294
+ * one that blocks the most downstream work (most transitive reverse
295
+ * dependents). Ties break in graph-insertion order (which is the topo
296
+ * order produced by `buildTaskGraph`). Minimizes worker idle at the
297
+ * end of the run.
298
+ */
299
+ /**
300
+ * A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
301
+ * ASC) so `pop()` returns the highest-priority task and equal-priority ties
302
+ * break in enqueue order — the exact contract the prior sorted-array kept, but
303
+ * O(log R) push/pop instead of O(R) splice/shift. On a wide ready frontier (the
304
+ * 1000-package startup enqueue, or a fan-out completion), that turns the
305
+ * queue's O(R²) maintenance into O(R log R).
306
+ */
307
+ class ReadyHeap {
308
+ private readonly ids: string[] = []
309
+ private readonly seq: number[] = []
310
+ private next = 0
311
+ constructor(private readonly priority: ReadonlyMap<string, number>) {}
312
+ get size(): number {
313
+ return this.ids.length
314
+ }
315
+ /** True if slot i outranks slot j (higher priority, or equal priority + earlier seq). */
316
+ private higher(i: number, j: number): boolean {
317
+ const pi = this.priority.get(this.ids[i]!) ?? 0
318
+ const pj = this.priority.get(this.ids[j]!) ?? 0
319
+ return pi !== pj ? pi > pj : this.seq[i]! < this.seq[j]!
320
+ }
321
+ private swap(i: number, j: number): void {
322
+ const ti = this.ids[i]!
323
+ this.ids[i] = this.ids[j]!
324
+ this.ids[j] = ti
325
+ const si = this.seq[i]!
326
+ this.seq[i] = this.seq[j]!
327
+ this.seq[j] = si
328
+ }
329
+ /**
330
+ * `seq` defaults to a fresh monotonic counter. A parked-then-repushed
331
+ * task passes its ORIGINAL seq back in so FIFO-among-equals survives
332
+ * the round trip (a fresh seq would demote it behind later arrivals).
333
+ */
334
+ push(id: string, seq: number = this.next++): void {
335
+ this.ids.push(id)
336
+ this.seq.push(seq)
337
+ let i = this.ids.length - 1
338
+ while (i > 0) {
339
+ const parent = (i - 1) >> 1
340
+ if (!this.higher(i, parent)) break
341
+ this.swap(i, parent)
342
+ i = parent
343
+ }
344
+ }
345
+ /** The head's enqueue seq (capture before `pop` for a possible repush). */
346
+ peekSeq(): number {
347
+ return this.seq[0] ?? -1
348
+ }
349
+ pop(): string | undefined {
350
+ const n = this.ids.length
351
+ if (n === 0) return undefined
352
+ const top = this.ids[0]!
353
+ const lastId = this.ids.pop()!
354
+ const lastSeq = this.seq.pop()!
355
+ if (n > 1) {
356
+ this.ids[0] = lastId
357
+ this.seq[0] = lastSeq
358
+ let i = 0
359
+ for (;;) {
360
+ const l = 2 * i + 1
361
+ const r = 2 * i + 2
362
+ let best = i
363
+ if (l < this.ids.length && this.higher(l, best)) best = l
364
+ if (r < this.ids.length && this.higher(r, best)) best = r
365
+ if (best === i) break
366
+ this.swap(i, best)
367
+ i = best
368
+ }
369
+ }
370
+ return top
371
+ }
372
+ }
373
+
374
+ export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
375
+ const { nodes, concurrency, execute, onStart, onFinish } = options
376
+ const continueMode = options.continueMode ?? 'deps-ok'
377
+ // 'never': set on the first failed outcome; from then on every
378
+ // dequeued task is skipped instead of dispatched (in-flight tasks
379
+ // finish naturally and their dependents drain through the same path).
380
+ let failFastTripped = false
381
+ const outcomes = new Map<string, TaskOutcome>()
382
+
383
+ // Reverse adjacency + pending dep counts. Built once. A task becomes
384
+ // ready when its `pending` hits 0, at which point it's pushed to the
385
+ // ready queue. This replaces the old "scan all of scheduleOrder on
386
+ // every tick" pattern which was O(N²) over a full run.
387
+ const dependents = new Map<string, string[]>()
388
+ const pending = new Map<string, number>()
389
+ for (const node of nodes.values()) {
390
+ pending.set(node.id, node.deps.length)
391
+ for (const dep of node.deps) {
392
+ const list = dependents.get(dep)
393
+ if (list) list.push(node.id)
394
+ else dependents.set(dep, [node.id])
395
+ }
396
+ }
397
+
398
+ // Override hook (predictive scheduling): when the caller passes a
399
+ // priorities map (e.g. from history-aware critical-path estimation),
400
+ // use those weights. Falls back to the reverse-deps-count heuristic
401
+ // for nodes the caller didn't score, so partial coverage works.
402
+ const baseline = computeReverseDepCount(nodes)
403
+ const priority: ReadonlyMap<string, number> = options.priorities
404
+ ? mergePriorities(baseline, options.priorities)
405
+ : baseline
406
+
407
+ // Two ready queues — exec-tier (cache misses + unstable tasks) and
408
+ // restore-tier (confirmed stable local hits). Each is a max-heap keyed by
409
+ // (priority DESC, enqueue-seq ASC), so the highest-priority task pops first
410
+ // and equal-priority ties break in graph-insertion order — same contract the
411
+ // prior `scheduleOrder` sort provided via stable sort. The tick loop drains
412
+ // execReady FIRST, so misses own the worker pool and restores only backfill
413
+ // idle capacity (or run when an exec is blocked on a restorable dep and
414
+ // nothing else is runnable).
415
+ const restoreTier = options.restoreTier
416
+ const execReady = new ReadyHeap(priority)
417
+ const restoreReady = new ReadyHeap(priority)
418
+ // A restore-tier task is dep-independent (a stable hit's restore needs
419
+ // none of its deps' output), so it's ready immediately; its `pending`
420
+ // decrements still happen (in finishOne) but never re-enqueue it.
421
+ // Everything else enqueues on the exec-tier the moment its deps
422
+ // complete.
423
+ for (const node of nodes.values()) {
424
+ if (restoreTier?.has(node.id)) restoreReady.push(node.id)
425
+ else if (node.deps.length === 0) execReady.push(node.id)
426
+ }
427
+
428
+ let active = 0
429
+ let resolved = false
430
+
431
+ // Resource admission (2-D bin packing over the count limit). Inactive
432
+ // (no task opted in) → the tick loop short-circuits before any of this
433
+ // and behaves byte-identically to the count-only scheduler.
434
+ const costs = options.resourceCosts
435
+ const resourcesActive = costs !== undefined && costs.size > 0
436
+ const cpuBudget = options.cpuBudget ?? concurrency
437
+ const memBudget = options.memBudget ?? Infinity
438
+ // Reservations are FLOAT sums (fractional cpus, and percent-of-budget
439
+ // resolves to non-representable values like `0.30000000000000004`), so
440
+ // add/release cycles leave ~1e-17 residue instead of an exact 0. Two
441
+ // guards keep that residue from corrupting admission:
442
+ // - the solo-clamp gate ("is the axis idle?") reads INTEGER holder
443
+ // counts, never the float sum === 0 — a residue would otherwise
444
+ // park an over-budget task forever (active===0, no future tick =
445
+ // a silent hang / exit-0-without-running);
446
+ // - `reserved` snaps back to EXACT 0 whenever its holder count hits
447
+ // 0, so residue can't accumulate across busy periods.
448
+ // The within-budget comparison also carries a tiny relative epsilon so
449
+ // an exact-fill (`reserved + cost == budget`) can't mis-round into a
450
+ // spurious block. Admission is a hint, so the epsilon's sub-ulp
451
+ // over-admission is harmless.
452
+ let reservedCpu = 0
453
+ let reservedMem = 0
454
+ let holdersCpu = 0
455
+ let holdersMem = 0
456
+
457
+ // A restore-tier task is a confirmed local cache hit: its "execution"
458
+ // is a cheap tar extract, not the task's real work — it reserves ZERO
459
+ // regardless of what the config declares, so it never holds budget
460
+ // against a real executor (and never parks).
461
+ const costOf = (id: string): ResourceCost =>
462
+ options.restoreTier?.has(id) ? ZERO_COST : (costs?.get(id) ?? ZERO_COST)
463
+
464
+ // Zero never blocks; a within-budget cost needs headroom (with an
465
+ // exact-fill epsilon); an over-budget cost can never have headroom, so
466
+ // it solo-clamps: admitted only when the axis is idle (no holders — an
467
+ // idle pool always admits at least one ready task, no deadlock).
468
+ const fitsAxis = (cost: number, reserved: number, holders: number, budget: number): boolean =>
469
+ cost === 0 ? true : cost <= budget ? reserved + cost <= budget + budget * 1e-9 : holders === 0
470
+
471
+ const fits = (id: string): boolean => {
472
+ const c = costOf(id)
473
+ return (
474
+ fitsAxis(c.cpu, reservedCpu, holdersCpu, cpuBudget) &&
475
+ fitsAxis(c.mem, reservedMem, holdersMem, memBudget)
476
+ )
477
+ }
478
+
479
+ // Reserve/release keep the float sum AND the integer holder count in
480
+ // lockstep; releasing the last holder on an axis snaps its sum to 0.
481
+ const reserve = (c: ResourceCost): void => {
482
+ if (c.cpu > 0) {
483
+ reservedCpu += c.cpu
484
+ holdersCpu++
485
+ }
486
+ if (c.mem > 0) {
487
+ reservedMem += c.mem
488
+ holdersMem++
489
+ }
490
+ }
491
+ const release = (c: ResourceCost): void => {
492
+ if (c.cpu > 0 && --holdersCpu === 0) reservedCpu = 0
493
+ else reservedCpu -= c.cpu
494
+ if (c.mem > 0 && --holdersMem === 0) reservedMem = 0
495
+ else reservedMem -= c.mem
496
+ }
497
+
498
+ return new Promise<Map<string, TaskOutcome>>((resolve) => {
499
+ const finishOne = (id: string, outcome: TaskOutcome): void => {
500
+ if (continueMode === 'never' && outcome.status === 'failed') failFastTripped = true
501
+ outcomes.set(id, outcome)
502
+ // Observer hook (the logger's taskComplete). Crash-isolated: a
503
+ // throwing observer must NOT break scheduling — it would otherwise
504
+ // skip the dependent-enqueue + tick below and hang the run. Same
505
+ // "observability never breaks a run" rule the telemetry/eventSink
506
+ // paths hold. Isolated here (not the completion arm) so a throw
507
+ // can't be mistaken for the task itself failing.
508
+ try {
509
+ onFinish?.(outcome)
510
+ } catch (err) {
511
+ const m = err instanceof Error ? err.message : String(err)
512
+ process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
513
+ }
514
+ const ds = dependents.get(id)
515
+ if (!ds) return
516
+ for (const d of ds) {
517
+ const rem = (pending.get(d) ?? 0) - 1
518
+ pending.set(d, rem)
519
+ // Restore-tier dependents were already enqueued at startup (they
520
+ // don't wait on deps); only re-enqueue an exec-tier dependent.
521
+ if (rem === 0 && !restoreTier?.has(d)) execReady.push(d)
522
+ }
523
+ }
524
+
525
+ // True when the task would be finished as `skipped` without running
526
+ // (fail-fast tripped, or a failed/skipped upstream under
527
+ // continueMode !== 'always'). ONE predicate shared by the admission
528
+ // parker and the dispatch loop's skip branch — a would-skip task
529
+ // executes nothing, so it must never park on a resource fit (a
530
+ // too-big doomed task parking forever would hang the run).
531
+ //
532
+ // Restore-tier tasks BYPASS the dep check: their key is independent
533
+ // of any dep's success (pure-input transitive hashing), so a valid
534
+ // cached output is reported `cache-hit` even if a dep failed — and
535
+ // they're dep-independent, so they typically restore before a dep
536
+ // could fail anyway.
537
+ const willSkip = (id: string): boolean => {
538
+ if (failFastTripped) return true
539
+ if (restoreTier?.has(id)) return false
540
+ if (continueMode === 'always') return false
541
+ const node = nodes.get(id) as TaskNode
542
+ return node.deps.some((d) => {
543
+ const u = outcomes.get(d)
544
+ return u?.status === 'failed' || u?.status === 'skipped'
545
+ })
546
+ }
547
+
548
+ const tick = (): void => {
549
+ if (resolved) return
550
+ // Exec-tier tasks parked THIS tick on a failed resource fit.
551
+ // Within one synchronous tick `reserved` only increases (release
552
+ // happens in the async completion callbacks, which run a fresh
553
+ // tick), so a task that doesn't fit now cannot fit later in the
554
+ // same tick — parking it for the tick's remainder is exact, and
555
+ // each id pops at most once per tick (O(R log R)).
556
+ const parked: Array<[string, number]> = []
557
+
558
+ // Highest-priority admissible task: exec tier first (misses own
559
+ // the pool), then restore tier. With no reservations declared this
560
+ // short-circuits to exactly the legacy takeReady. A would-skip
561
+ // task returns without a fit check (finishing it is free); restore
562
+ // tasks cost zero by construction, so they never park.
563
+ const takeFitting = (): string | undefined => {
564
+ while (execReady.size > 0) {
565
+ const seq = execReady.peekSeq()
566
+ const id = execReady.pop() as string
567
+ if (!resourcesActive || willSkip(id) || fits(id)) return id
568
+ parked.push([id, seq])
569
+ }
570
+ return restoreReady.pop()
571
+ }
572
+
573
+ while (active < concurrency) {
574
+ const id = takeFitting()
575
+ if (id === undefined) break // nothing ready is admissible right now
576
+ const node = nodes.get(id) as TaskNode
577
+
578
+ // For a restore-tier task running BEFORE its deps finish, the
579
+ // `upstream` entries below can be undefined (the cast lies) —
580
+ // the preProbed hit path in execute-task never reads them, and
581
+ // nothing on the restore path may.
582
+ const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
583
+
584
+ // If any upstream failed/skipped, propagate skip synchronously
585
+ // without running. Skipped tasks still flow through this queue
586
+ // because dependents are pushed when `pending` hits 0 regardless
587
+ // of outcome — keeps the propagation logic in one place.
588
+ if (willSkip(id)) {
589
+ finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
590
+ continue
591
+ }
592
+
593
+ active++
594
+ // Reserve on dispatch; capture the cost so the release in the
595
+ // completion callbacks is symmetric even if the maps change.
596
+ const cost = costOf(id)
597
+ reserve(cost)
598
+ // Crash-isolated observer hook — a throwing onStart must not abort
599
+ // the dispatch loop (it would strand the tick with reservations held).
600
+ try {
601
+ onStart?.(node)
602
+ } catch (err) {
603
+ const m = err instanceof Error ? err.message : String(err)
604
+ process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
605
+ }
606
+
607
+ // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
608
+ // rejection arm handles ONLY `execute()` rejecting; a throw from
609
+ // the fulfillment arm (finishOne / onFinish / tick) must NOT also
610
+ // run the rejection arm, or `active`/`reserved` release twice —
611
+ // and a double release drives `reserved` negative, permanently
612
+ // wedging the solo-clamp gate.
613
+ execute(node, upstream).then(
614
+ (outcome) => {
615
+ active--
616
+ release(cost)
617
+ finishOne(id, outcome)
618
+ tick()
619
+ },
620
+ (err: unknown) => {
621
+ const message = err instanceof Error ? err.message : String(err)
622
+ const outcome: TaskOutcome = {
623
+ node,
624
+ status: 'failed',
625
+ exitCode: 1,
626
+ durationMs: 0,
627
+ }
628
+ active--
629
+ release(cost)
630
+ finishOne(id, outcome)
631
+ // Surface the error live; the outcome itself doesn't
632
+ // carry captured stderr (that's the logger's job). A
633
+ // UserError is a config/input failure (e.g. a failed
634
+ // `cache.inputs.runtime` command), not a vx bug — report it
635
+ // plainly, never as an "internal error".
636
+ if (err instanceof UserError) {
637
+ process.stderr.write(`[vx] ${id}: ${message}\n`)
638
+ } else {
639
+ const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
640
+ process.stderr.write(`[vx] internal error in ${id}: ${named}${message}\n`)
641
+ }
642
+ tick()
643
+ },
644
+ )
645
+ }
646
+
647
+ // Repush parked ids with their ORIGINAL seqs — FIFO-among-equals
648
+ // is exactly preserved for the next tick's admission pass.
649
+ for (const [id, seq] of parked) execReady.push(id, seq)
650
+
651
+ if (outcomes.size === nodes.size && active === 0) {
652
+ resolved = true
653
+ resolve(outcomes)
654
+ }
655
+ }
656
+
657
+ tick()
658
+ })
659
+ }
660
+
661
+ function mergePriorities(
662
+ baseline: ReadonlyMap<string, number>,
663
+ overrides: ReadonlyMap<string, number>,
664
+ ): ReadonlyMap<string, number> {
665
+ // The override caller scored "expected critical-path duration" in ms.
666
+ // Baseline reverse-deps counts are bounded by N; scale the override so
667
+ // it sorts above the baseline for any node it covers, and add the
668
+ // baseline as a tie-break for parity within the override set.
669
+ const SCALE = 1 << 20
670
+ const out = new Map<string, number>()
671
+ for (const [id, w] of baseline) out.set(id, w)
672
+ for (const [id, w] of overrides) {
673
+ const b = baseline.get(id) ?? 0
674
+ out.set(id, w * SCALE + b)
675
+ }
676
+ return out
677
+ }