@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,4 +1,4 @@
1
- import { UserError } from '../util/index.js'
1
+ import { isUserError } from '../util/index.js'
2
2
  import type { TaskNode } from './task-graph.js'
3
3
 
4
4
  export type TaskStatus =
@@ -8,65 +8,12 @@ export type TaskStatus =
8
8
  | 'failed'
9
9
  | 'skipped'
10
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).
11
+ // the task never finished on its own terms, so it reverts to aborted —
12
+ // excluded from the tally and the run history. It still PROPAGATES like
13
+ // a failure (see `willSkip`): the child died mid-write, so its outputs
14
+ // are partial.
13
15
  | 'aborted'
14
16
 
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
17
  export interface TaskOutcome {
71
18
  node: TaskNode
72
19
  status: TaskStatus
@@ -75,9 +22,51 @@ export interface TaskOutcome {
75
22
  /** Cache key hash, if one was computed. Folded into dependents' keys
76
23
  * (pure-input transitive — the upstream's input key, not its output). */
77
24
  hash?: string
25
+ /**
26
+ * For cache-hit statuses: the exec time the ENTRY was stored with — i.e.
27
+ * the work this hit skipped. Distinct from `durationMs`, which is always
28
+ * what THIS run spent (a hit's restore cost). The two differ by whatever
29
+ * the exec:restore ratio happens to be, so a surface reporting "time
30
+ * saved" must read this one; a surface reporting "time spent" must not.
31
+ * Undefined on every non-hit outcome — nothing was skipped.
32
+ */
33
+ storedDurationMs?: number
34
+ /**
35
+ * For cache-hit statuses: what the PRODUCING execution used, read from
36
+ * the entry (it rides the artifact, so a remote hit on a fresh machine
37
+ * carries it too). The same split as `storedDurationMs`: `cpuMs` /
38
+ * `peakRssBytes` are what THIS run spent and stay absent on a hit.
39
+ */
40
+ storedCpuMs?: number
41
+ storedPeakRssBytes?: number
42
+ /**
43
+ * How long an `admit` policy held this task after it was ready with a
44
+ * free worker — the wait the plugin, not the count gate, imposed. Set
45
+ * only when a policy refused it at least once (so a run with no policy
46
+ * carries the field on no outcome); `--summarize` rows, the event
47
+ * stream and the summary footer show it.
48
+ */
49
+ admissionHeldMs?: number
78
50
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
79
51
  cpuMs?: number
80
52
  peakRssBytes?: number
53
+ /**
54
+ * A GROUP task's own dependency outcomes — the tasks it stands for.
55
+ * A group has no `exec` and therefore no outputs and no cache entry: its
56
+ * hash is a synthetic roll-up (`computeGroupHash`), so asking the local
57
+ * index what it produced returns nothing. A dependent needs the real
58
+ * tasks beneath it to describe its own input set, and only the group
59
+ * itself is ever in a position to say which those are. Set on group
60
+ * outcomes only; never folded into any key.
61
+ */
62
+ groupUpstream?: readonly TaskOutcome[]
63
+ /** Executor-reported placement label (`ExecuteResult.where`) — set only
64
+ * when the task ran somewhere other than this host. Telemetry-only. */
65
+ where?: string
66
+ /** `'deferred'` when the task's outputs were left in the remote store
67
+ * (`--download=none`) instead of landing on this machine. Absent for
68
+ * every ordinary outcome. Telemetry-only. */
69
+ outputs?: 'deferred'
81
70
  /**
82
71
  * v11 analytics: hrtime span relative to the parent run's t=0.
83
72
  * Lets downstream analytics reconstruct the actual parallel timeline
@@ -102,10 +91,10 @@ export interface TaskOutcome {
102
91
  */
103
92
  attempts?: number
104
93
  /**
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.
94
+ * Count of sandbox violations captured during this task's exec (a task
95
+ * declaring `exec.sandbox`). Non-zero means the task touched something
96
+ * it did not declare; execute-task fails the task on any violation, so
97
+ * nothing built that way is ever saved or replayed.
109
98
  */
110
99
  sandboxViolations?: number
111
100
  /**
@@ -114,58 +103,46 @@ export interface TaskOutcome {
114
103
  * inline in the task's block instead of as loose status output.
115
104
  */
116
105
  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
106
  }
129
107
 
130
108
  export type ContinueMode = 'never' | 'deps-ok' | 'always'
131
109
 
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
110
  export interface ScheduleOptions {
147
111
  nodes: Map<string, TaskNode>
148
112
  concurrency: number
113
+ /**
114
+ * An aborted run dispatches nothing further: every task not yet
115
+ * started completes `aborted` (the ones in flight are being killed by
116
+ * the orchestrator and report `aborted` through the runner's signal).
117
+ */
118
+ signal?: AbortSignal
149
119
  /**
150
120
  * Failure propagation (default 'deps-ok' — the historical behavior):
151
- * - 'deps-ok': a failed/skipped upstream skips its dependents;
152
- * independent siblings keep running.
121
+ * - 'deps-ok': a failed/skipped/aborted upstream skips its
122
+ * dependents; independent siblings keep running.
153
123
  * - 'never': the first failure stops dispatch — in-flight tasks
154
124
  * finish naturally, everything not yet started completes as
155
125
  * skipped (restore-tier included: a fail-fast run stops
156
126
  * 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.
127
+ * - 'always': dependents run even when an upstream failed. Their
128
+ * keys are the ones a healthy run derives (failed outcomes carry
129
+ * the upstream's INPUT key, computed before exec), which is
130
+ * exactly why the orchestrator withholds their SAVE: the bytes
131
+ * were built on a partial tree, and a healthy key over them would
132
+ * be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
164
133
  */
165
134
  continueMode?: ContinueMode
166
135
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
167
136
  onStart?: (node: TaskNode) => void
168
137
  onFinish?: (outcome: TaskOutcome) => void
138
+ /**
139
+ * What an outcome still owes before its dependents may start — a cache
140
+ * save that runs off the execution slot (the orchestrator's save lane):
141
+ * a dependent reads the upstream's entry (its output rows travel in the
142
+ * execute request), so it waits for the save to land, while the freed
143
+ * slot admits other work at once. Undefined: nothing owed.
144
+ */
145
+ settledOf?: (outcome: TaskOutcome) => Promise<void> | undefined
169
146
  /**
170
147
  * Optional priority override: callers pass their own per-node weight
171
148
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -193,17 +170,22 @@ export interface ScheduleOptions {
193
170
  */
194
171
  restoreTier?: ReadonlySet<string>
195
172
  /**
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.
173
+ * Pool assignment for tasks placed on an executor with its own capacity
174
+ * (a remote pool). Such a task occupies one of the pool's `capacity` slots
175
+ * instead of a local worker slot and reserves no local resources, so a
176
+ * remote pool is never throttled by the local CPU count. Undefined (or
177
+ * a `undefined` result) = the local pool.
201
178
  */
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
179
+ poolOf?: (id: string) => { name: string; capacity: number } | undefined
180
+ /**
181
+ * Admission over the worker count: asked for every exec-tier task about
182
+ * to start on this machine, with the ids of the exec-tier tasks running
183
+ * here right now (added on dispatch, so two asks in one tick see each
184
+ * other). `false` parks the task until something finishes. Undefined →
185
+ * count-only, the legacy path byte for byte. Restore-tier hits and
186
+ * pooled tasks hold no local resources and are never asked.
187
+ */
188
+ admit?: (id: string, running: ReadonlySet<string>) => boolean
207
189
  }
208
190
 
209
191
  /**
@@ -426,73 +408,76 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
426
408
  }
427
409
 
428
410
  let active = 0
411
+ // Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
412
+ // filesystem round trips and no CPU — so it gets its own lane, twice
413
+ // the exec cap: on the 1,000-project bench with every task a restore,
414
+ // 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
415
+ // no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
416
+ // two lanes never wait on each other. `--concurrency 1` stays serial.
417
+ let activeRestore = 0
418
+ const restoreConcurrency = concurrency === 1 ? 1 : 2 * concurrency
429
419
  let resolved = false
430
420
 
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
421
+ // Admission policy over the count limit (a plugin's `admit`). Inactive
422
+ // → the tick loop short-circuits before any of this and behaves
423
+ // byte-identically to the count-only scheduler.
424
+ const admitPolicy = options.admit
425
+ const admitActive = admitPolicy !== undefined
426
+ const running = new Set<string>()
427
+ // When a policy first refused each parked task — so its outcome can say
428
+ // how long the policy, not the count gate, held it. Nothing with no
429
+ // policy: the map is never touched.
430
+ const heldSince = new Map<string, number>()
456
431
 
457
432
  // A restore-tier task is a confirmed local cache hit: its "execution"
458
433
  // is a cheap tar extract, not the task's real work — it reserves ZERO
459
434
  // regardless of what the config declares, so it never holds budget
460
435
  // 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
- )
436
+ const poolOf = options.poolOf
437
+ const poolActive = new Map<string, number>()
438
+ // A pooled task is admitted against ITS pool's capacity, a local one
439
+ // against `concurrency`; pooled tasks reserve no local resources.
440
+ const hasRoom = (id: string): boolean => {
441
+ const pool = poolOf?.(id)
442
+ if (pool === undefined) {
443
+ return options.restoreTier?.has(id)
444
+ ? activeRestore < restoreConcurrency
445
+ : active < concurrency
446
+ }
447
+ return (poolActive.get(pool.name) ?? 0) < pool.capacity
477
448
  }
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++
449
+ const admit = (id: string): (() => void) => {
450
+ const pool = poolOf?.(id)
451
+ if (pool === undefined) {
452
+ if (options.restoreTier?.has(id)) {
453
+ activeRestore++
454
+ return () => {
455
+ activeRestore--
456
+ }
457
+ }
458
+ active++
459
+ return () => {
460
+ active--
461
+ }
485
462
  }
486
- if (c.mem > 0) {
487
- reservedMem += c.mem
488
- holdersMem++
463
+ poolActive.set(pool.name, (poolActive.get(pool.name) ?? 0) + 1)
464
+ return () => {
465
+ poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
489
466
  }
490
467
  }
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
468
+ // The policy sees exec-tier local tasks only; a restore is a tar
469
+ // extract and a pooled task runs on someone else's capacity.
470
+ const local = (id: string): boolean => !options.restoreTier?.has(id) && poolOf?.(id) === undefined
471
+ const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
472
+ // With no policy nothing reads `running`, so nothing is tracked: the
473
+ // count-only dispatch allocates no closure and touches no set per task.
474
+ const untracked = (): void => {}
475
+ const track = (id: string): (() => void) => {
476
+ if (!admitActive || !local(id)) return untracked
477
+ running.add(id)
478
+ return () => {
479
+ running.delete(id)
480
+ }
496
481
  }
497
482
 
498
483
  return new Promise<Map<string, TaskOutcome>>((resolve) => {
@@ -502,7 +487,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
502
487
  // Observer hook (the logger's taskComplete). Crash-isolated: a
503
488
  // throwing observer must NOT break scheduling — it would otherwise
504
489
  // skip the dependent-enqueue + tick below and hang the run. Same
505
- // "observability never breaks a run" rule the telemetry/eventSink
490
+ // "observability never breaks a run" rule the telemetry
506
491
  // paths hold. Isolated here (not the completion arm) so a throw
507
492
  // can't be mistaken for the task itself failing.
508
493
  try {
@@ -523,7 +508,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
523
508
  }
524
509
 
525
510
  // True when the task would be finished as `skipped` without running
526
- // (fail-fast tripped, or a failed/skipped upstream under
511
+ // (fail-fast tripped, or a failed/skipped/aborted upstream under
527
512
  // continueMode !== 'always'). ONE predicate shared by the admission
528
513
  // parker and the dispatch loop's skip branch — a would-skip task
529
514
  // executes nothing, so it must never park on a resource fit (a
@@ -534,43 +519,66 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
534
519
  // cached output is reported `cache-hit` even if a dep failed — and
535
520
  // they're dep-independent, so they typically restore before a dep
536
521
  // could fail anyway.
522
+ const aborted = (): boolean => options.signal?.aborted === true
537
523
  const willSkip = (id: string): boolean => {
538
- if (failFastTripped) return true
524
+ if (failFastTripped || aborted()) return true
539
525
  if (restoreTier?.has(id)) return false
540
526
  if (continueMode === 'always') return false
541
527
  const node = nodes.get(id) as TaskNode
542
528
  return node.deps.some((d) => {
543
529
  const u = outcomes.get(d)
544
- return u?.status === 'failed' || u?.status === 'skipped'
530
+ // `aborted` propagates even though it is counted nowhere: the
531
+ // upstream was killed mid-write, so its declared outputs are
532
+ // partial. A dependent that ran anyway would CACHE what it built
533
+ // from them — under the key a healthy run derives, since the fold
534
+ // takes the upstream's INPUT key — so the next run replays those
535
+ // bytes as a green hit.
536
+ return u?.status === 'failed' || u?.status === 'skipped' || u?.status === 'aborted'
545
537
  })
546
538
  }
547
539
 
548
540
  const tick = (): void => {
549
541
  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)).
542
+ // Exec-tier tasks parked THIS tick on a refused admission. Within
543
+ // one synchronous tick `running` only grows (tasks leave it in the
544
+ // async completion callbacks, which run a fresh tick), so a task
545
+ // refused now cannot be admitted later in the same tick — parking
546
+ // it for the tick's remainder is exact, and each id pops at most
547
+ // once per tick (O(R log R)).
556
548
  const parked: Array<[string, number]> = []
557
549
 
558
550
  // Highest-priority admissible task: exec tier first (misses own
559
- // the pool), then restore tier. With no reservations declared this
551
+ // the pool), then restore tier. With no admission policy this
560
552
  // 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.
553
+ // task returns without asking (finishing it is free); restore
554
+ // tasks are never asked, so they never park.
563
555
  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])
556
+ // No pools, no policy and a full exec lane: nothing on the
557
+ // exec queue can be admitted, so it is not scanned — scanning it
558
+ // would pop and re-park every ready exec task on every tick, and
559
+ // with a wide frontier that is O(R) per tick, O(R²) per run (the
560
+ // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
561
+ // first cut let the scan run past a full exec lane, 2026-09-10).
562
+ // This is the legacy O(1) gate, kept per lane.
563
+ const execAdmissible = poolOf !== undefined || admitActive || active < concurrency
564
+ if (execAdmissible) {
565
+ while (execReady.size > 0) {
566
+ const seq = execReady.peekSeq()
567
+ const id = execReady.pop() as string
568
+ if (willSkip(id)) return id
569
+ if (hasRoom(id)) {
570
+ if (!admitActive || admits(id)) return id
571
+ // A free worker, refused by the policy: the hold starts now.
572
+ if (!heldSince.has(id)) heldSince.set(id, Date.now())
573
+ }
574
+ parked.push([id, seq])
575
+ }
569
576
  }
570
- return restoreReady.pop()
577
+ // Restore-tier tasks are local disk work, on their own lane.
578
+ return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
571
579
  }
572
580
 
573
- while (active < concurrency) {
581
+ for (;;) {
574
582
  const id = takeFitting()
575
583
  if (id === undefined) break // nothing ready is admissible right now
576
584
  const node = nodes.get(id) as TaskNode
@@ -586,17 +594,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
586
594
  // because dependents are pushed when `pending` hits 0 regardless
587
595
  // of outcome — keeps the propagation logic in one place.
588
596
  if (willSkip(id)) {
589
- finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
597
+ finishOne(id, {
598
+ node,
599
+ status: aborted() ? 'aborted' : 'skipped',
600
+ exitCode: 1,
601
+ durationMs: 0,
602
+ })
590
603
  continue
591
604
  }
592
605
 
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)
606
+ const leave = admit(id)
607
+ // Listed as running on dispatch, so the policy's next ask in this
608
+ // tick sees it; the completion callbacks unlist it.
609
+ const untrack = track(id)
610
+ const since = admitActive ? heldSince.get(id) : undefined
611
+ const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
612
+ if (since !== undefined) heldSince.delete(id)
613
+ const withHold = (o: TaskOutcome): TaskOutcome =>
614
+ heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
598
615
  // Crash-isolated observer hook — a throwing onStart must not abort
599
- // the dispatch loop (it would strand the tick with reservations held).
616
+ // the dispatch loop (it would strand the tick with the slot held).
600
617
  try {
601
618
  onStart?.(node)
602
619
  } catch (err) {
@@ -607,33 +624,45 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
607
624
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
608
625
  // rejection arm handles ONLY `execute()` rejecting; a throw from
609
626
  // 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.
627
+ // run the rejection arm, or `active` releases twice — and a
628
+ // double release wedges the count gate.
613
629
  execute(node, upstream).then(
614
- (outcome) => {
615
- active--
616
- release(cost)
617
- finishOne(id, outcome)
630
+ (raw) => {
631
+ const outcome = withHold(raw)
632
+ leave()
633
+ untrack()
634
+ // The slot is free now; the dependents wait for what the
635
+ // outcome still owes (a save landing), if anything.
636
+ const settled = options.settledOf?.(outcome)
637
+ if (settled === undefined) {
638
+ finishOne(id, outcome)
639
+ tick()
640
+ return
641
+ }
618
642
  tick()
643
+ const done = (): void => {
644
+ finishOne(id, outcome)
645
+ tick()
646
+ }
647
+ void settled.then(done, done)
619
648
  },
620
649
  (err: unknown) => {
621
650
  const message = err instanceof Error ? err.message : String(err)
622
- const outcome: TaskOutcome = {
651
+ const outcome: TaskOutcome = withHold({
623
652
  node,
624
653
  status: 'failed',
625
654
  exitCode: 1,
626
655
  durationMs: 0,
627
- }
628
- active--
629
- release(cost)
656
+ })
657
+ leave()
658
+ untrack()
630
659
  finishOne(id, outcome)
631
660
  // Surface the error live; the outcome itself doesn't
632
661
  // carry captured stderr (that's the logger's job). A
633
662
  // UserError is a config/input failure (e.g. a failed
634
663
  // `cache.inputs.runtime` command), not a vx bug — report it
635
664
  // plainly, never as an "internal error".
636
- if (err instanceof UserError) {
665
+ if (isUserError(err)) {
637
666
  process.stderr.write(`[vx] ${id}: ${message}\n`)
638
667
  } else {
639
668
  const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
@@ -648,7 +677,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
648
677
  // is exactly preserved for the next tick's admission pass.
649
678
  for (const [id, seq] of parked) execReady.push(id, seq)
650
679
 
651
- if (outcomes.size === nodes.size && active === 0) {
680
+ if (outcomes.size === nodes.size && active === 0 && activeRestore === 0) {
652
681
  resolved = true
653
682
  resolve(outcomes)
654
683
  }
@@ -658,7 +687,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
658
687
  })
659
688
  }
660
689
 
661
- function mergePriorities(
690
+ export function mergePriorities(
662
691
  baseline: ReadonlyMap<string, number>,
663
692
  overrides: ReadonlyMap<string, number>,
664
693
  ): ReadonlyMap<string, number> {