@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,6 +1,22 @@
1
- import { isUserError } from '../util/index.js'
1
+ import { fsRefusalHint, isFsRefusal, isUserError } from '../util/index.js'
2
+ import { computeReverseDepCount, mergePriorities, tieredReverseDepCount } from './priorities.js'
2
3
  import type { TaskNode } from './task-graph.js'
3
4
 
5
+ /**
6
+ * Thrown by `execute` for a restore-tier task that turned out to have
7
+ * nothing to restore (its artifact vanished after the up-front probe). It
8
+ * may be running ahead of its deps, so it cannot run its command in that
9
+ * slot: the scheduler releases the slot and dispatches it again as an
10
+ * exec-tier task once its deps are done. It records no outcome, and its
11
+ * dependents wait for the second dispatch.
12
+ */
13
+ export class RestoreDemoted extends Error {
14
+ constructor(readonly taskId: string) {
15
+ super(`${taskId}: restore demoted to a run`)
16
+ this.name = 'RestoreDemoted'
17
+ }
18
+ }
19
+
4
20
  export type TaskStatus =
5
21
  | 'success'
6
22
  | 'cache-hit'
@@ -14,65 +30,6 @@ export type TaskStatus =
14
30
  // are partial.
15
31
  | 'aborted'
16
32
 
17
- /**
18
- * Cache-correctness verdict for a task under `vx run --verify` (Phase 1:
19
- * determinism; Phase 2: input-completeness). Declared here (structurally)
20
- * because `graph` can't import `orchestrator` where the verifier lives —
21
- * same pattern as `inputComponents`. A pure side-channel: never hashed,
22
- * never persisted in Phase 1/2.
23
- */
24
- export type VerifyVerdict =
25
- | { kind: 'proven-deterministic' }
26
- /** Phase 2: input-completeness proved — the task read nothing outside its
27
- * declared inputs (from `--verify=inputs` when determinism wasn't also
28
- * requested; an `--verify=all` pass reports the stronger deterministic). */
29
- | { kind: 'proven-complete' }
30
- /** Re-ran; outputs differ from the cached ones. `changed` names the rels. */
31
- | { kind: 'nondeterministic'; changed: readonly string[] }
32
- /** Phase 2: read a workspace path outside the declared inputs — the declared
33
- * `cache.inputs` are incomplete, so a hit could serve stale bytes. `paths`
34
- * names the undeclared reads (workspace-relative; empty when the sandbox
35
- * denied structurally but strace wasn't available to name them). */
36
- | { kind: 'undeclared-inputs'; paths: readonly string[] }
37
- /** Diverged but the task is on `--verify-allow` — reported, not failed. */
38
- | { kind: 'allowed-nondeterministic'; changed: readonly string[] }
39
- /** The verify re-run exited non-zero / timed out (nondeterministic by
40
- * definition — identical inputs, different outcome). */
41
- | { kind: 'rerun-failed'; exitCode: number }
42
- /** Cacheable + executed but declares no outputs — nothing to replay. */
43
- | { kind: 'no-outputs' }
44
- /** Didn't execute (cache hit) / not cacheable / group / persistent. */
45
- | { kind: 'not-verified' }
46
- /** A `remote: 'only'` task under `--verify=inputs`: verify pins placement
47
- * local, so the task no-ops — there is no execution to sandbox, locally
48
- * or anywhere. Reported, not silent: the proof does not cover it. */
49
- | { kind: 'unverifiable-remote-only' }
50
-
51
- /**
52
- * Content fingerprint of a task's output tree, computed under a `--verify*`
53
- * mode on the executed (miss) path. Never hashed into any key; pure telemetry
54
- * side-channel for the cross-machine diff (a serve pairs fingerprints for the
55
- * SAME cache key across platforms and names diverging outputs). Declared here
56
- * (structurally) because `graph` can't import `orchestrator` — the
57
- * `VerifyVerdict` pattern. See docs/design/verify-cross-machine-2026-07.md.
58
- */
59
- export interface OutputFingerprint {
60
- /** Roll-up: xxh3hex over the sorted (key, hash) pairs, folded as
61
- * `key \0 hash \n` (\0 boundaries — the v18 lesson). Always present;
62
- * divergence DETECTION never depends on the per-file map. */
63
- tree: string
64
- /** Total files in the tree (pre-truncation). */
65
- fileCount: number
66
- /** Per-file map as sorted [outputKey, xxh3hex] pairs, capped at
67
- * FP_MAX_FILES (500). Deterministic truncation — sorted by key,
68
- * first N — so two machines' truncated maps cover the same subset
69
- * and partial diffs still name real rels. */
70
- files?: ReadonlyArray<readonly [string, string]>
71
- /** Set when `files` was truncated to the cap (or dropped by the
72
- * sink's run budget). */
73
- truncated?: boolean
74
- }
75
-
76
33
  export interface TaskOutcome {
77
34
  node: TaskNode
78
35
  status: TaskStatus
@@ -90,6 +47,22 @@ export interface TaskOutcome {
90
47
  * Undefined on every non-hit outcome — nothing was skipped.
91
48
  */
92
49
  storedDurationMs?: number
50
+ /**
51
+ * For cache-hit statuses: what the PRODUCING execution used, read from
52
+ * the entry (it rides the artifact, so a remote hit on a fresh machine
53
+ * carries it too). The same split as `storedDurationMs`: `cpuMs` /
54
+ * `peakRssBytes` are what THIS run spent and stay absent on a hit.
55
+ */
56
+ storedCpuMs?: number
57
+ storedPeakRssBytes?: number
58
+ /**
59
+ * How long an `admit` policy held this task after it was ready with a
60
+ * free worker — the wait the plugin, not the count gate, imposed. Set
61
+ * only when a policy refused it at least once (so a run with no policy
62
+ * carries the field on no outcome); `--summarize` rows, the event
63
+ * stream and the summary footer show it.
64
+ */
65
+ admissionHeldMs?: number
93
66
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
94
67
  cpuMs?: number
95
68
  peakRssBytes?: number
@@ -103,6 +76,26 @@ export interface TaskOutcome {
103
76
  * outcomes only; never folded into any key.
104
77
  */
105
78
  groupUpstream?: readonly TaskOutcome[]
79
+ /**
80
+ * On a `skipped` outcome: the task at the ROOT of what blocked it — the
81
+ * failed (or aborted) upstream, followed through any chain of skips
82
+ * between. Absent when the skip was fail-fast's (no upstream failed).
83
+ * The footer, `--summarize`, the telemetry record and the run row all
84
+ * read this one field (item 267).
85
+ */
86
+ blockedBy?: string
87
+ /**
88
+ * On a `failed` outcome: vx's own `timeout` killed the final attempt.
89
+ * The exit is the shell's 143, and every label says "timed out" instead
90
+ * of reading it as a signal (item 268).
91
+ */
92
+ timedOut?: true
93
+ /**
94
+ * On a failed persistent task: why it never became ready — the readiness
95
+ * deadline fired, the child exited first (`exitCode` is then its own), or
96
+ * the spawn itself failed. Every label reads this instead of the exit.
97
+ */
98
+ notReady?: 'timeout' | 'exited' | 'spawn'
106
99
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
107
100
  * when the task ran somewhere other than this host. Telemetry-only. */
108
101
  where?: string
@@ -134,10 +127,10 @@ export interface TaskOutcome {
134
127
  */
135
128
  attempts?: number
136
129
  /**
137
- * Count of sandbox violations captured during this task's exec.
138
- * Populated only when `--sandbox` was set and the task is cached.
139
- * Non-zero values mean the task read files outside its declared
140
- * inputs; `cache.save()` was skipped so the result can't be replayed.
130
+ * Count of sandbox violations captured during this task's exec (a task
131
+ * declaring `exec.sandbox`). Non-zero means the task touched something
132
+ * it did not declare; execute-task fails the task on any violation, so
133
+ * nothing built that way is ever saved or replayed.
141
134
  */
142
135
  sandboxViolations?: number
143
136
  /**
@@ -146,38 +139,19 @@ export interface TaskOutcome {
146
139
  * inline in the task's block instead of as loose status output.
147
140
  */
148
141
  sandboxViolationLines?: string[]
149
- /**
150
- * Cache-correctness verdict under `vx run --verify`. Set only in verify
151
- * mode; a plain run leaves it undefined. Pure side-channel (never hashed).
152
- */
153
- verify?: VerifyVerdict
154
- /**
155
- * Output-tree fingerprint under a fingerprinting `--verify*` mode
156
- * (`--verify` / `=all` / `=fingerprint`), executed + cacheable +
157
- * output-declaring tasks only. A plain run leaves it undefined.
158
- */
159
- outputFp?: OutputFingerprint
160
142
  }
161
143
 
162
144
  export type ContinueMode = 'never' | 'deps-ok' | 'always'
163
145
 
164
- /**
165
- * Resolved per-task resource reservation, in absolute units (cpu may be
166
- * fractional; mem is bytes). Declared here (structurally) because `graph`
167
- * can't import `orchestrator`, where the resolver lives — same pattern as
168
- * `VerifyVerdict`. A `0` axis means "reserve nothing, run freely": the
169
- * task is exempt from that axis entirely (needs no headroom, holds none).
170
- */
171
- export interface ResourceCost {
172
- cpu: number
173
- mem: number
174
- }
175
-
176
- const ZERO_COST: ResourceCost = { cpu: 0, mem: 0 }
177
-
178
146
  export interface ScheduleOptions {
179
147
  nodes: Map<string, TaskNode>
180
148
  concurrency: number
149
+ /**
150
+ * An aborted run dispatches nothing further: every task not yet
151
+ * started completes `aborted` (the ones in flight are being killed by
152
+ * the orchestrator and report `aborted` through the runner's signal).
153
+ */
154
+ signal?: AbortSignal
181
155
  /**
182
156
  * Failure propagation (default 'deps-ok' — the historical behavior):
183
157
  * - 'deps-ok': a failed/skipped/aborted upstream skips its
@@ -186,18 +160,25 @@ export interface ScheduleOptions {
186
160
  * finish naturally, everything not yet started completes as
187
161
  * skipped (restore-tier included: a fail-fast run stops
188
162
  * restoring too).
189
- * - 'always': dependents run even when an upstream failed. Sound
190
- * under pure-input transitive hashing: failed outcomes carry the
191
- * upstream's INPUT key (computed before exec), so dependents'
192
- * keys fold exactly what a healthy run folds. An upstream that
193
- * died before hashing folds nothing — that key is derivable by
194
- * no healthy run, so the worst case is an unreachable cache
195
- * entry, never a stale hit.
163
+ * - 'always': dependents run even when an upstream failed. Their
164
+ * keys are the ones a healthy run derives (failed outcomes carry
165
+ * the upstream's INPUT key, computed before exec), which is
166
+ * exactly why the orchestrator withholds their SAVE: the bytes
167
+ * were built on a partial tree, and a healthy key over them would
168
+ * be the next clean run's stale hit (`ExecuteArgs.taintedUpstream`).
196
169
  */
197
170
  continueMode?: ContinueMode
198
171
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
199
172
  onStart?: (node: TaskNode) => void
200
173
  onFinish?: (outcome: TaskOutcome) => void
174
+ /**
175
+ * What an outcome still owes before its dependents may start — a cache
176
+ * save that runs off the execution slot (the orchestrator's save lane):
177
+ * a dependent reads the upstream's entry (its output rows travel in the
178
+ * execute request), so it waits for the save to land, while the freed
179
+ * slot admits other work at once. Undefined: nothing owed.
180
+ */
181
+ settledOf?: (outcome: TaskOutcome) => Promise<void> | undefined
201
182
  /**
202
183
  * Optional priority override: callers pass their own per-node weight
203
184
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -218,7 +199,9 @@ export interface ScheduleOptions {
218
199
  * it);
219
200
  * - bypasses the `failedDep`→`skipped` check (its key is independent
220
201
  * of dep success — pure-input transitive hashing — so a valid cached
221
- * output is reported `cache-hit` regardless of a dep failing).
202
+ * output is reported `cache-hit` regardless of a dep failing);
203
+ * - leaves the tier when `execute` throws `RestoreDemoted`, and is
204
+ * dispatched again once its deps are done, with the dep check.
222
205
  * It still runs through `execute()` (so the logger frame is unchanged);
223
206
  * the orchestrator's execute reuses the up-front probe, so there is no
224
207
  * second cache.get. When undefined/empty, behavior is byte-identical.
@@ -233,109 +216,16 @@ export interface ScheduleOptions {
233
216
  */
234
217
  poolOf?: (id: string) => { name: string; capacity: number } | undefined
235
218
  /**
236
- * Resolved per-task resource reservations (`exec.resources`, resolved
237
- * by the orchestrator against the run's budgets). An absent id means
238
- * zero cost; undefined/empty means no task opted in — the scheduler
239
- * takes the legacy path byte-identically. Admission control only —
240
- * nothing is enforced on the child process.
219
+ * Admission over the worker count: asked for every exec-tier task about
220
+ * to start on this machine, with the ids of the exec-tier tasks running
221
+ * here right now (added on dispatch, so two asks in one tick see each
222
+ * other). `false` parks the task until something finishes. Undefined →
223
+ * count-only, the legacy path byte for byte. Restore-tier hits and
224
+ * pooled tasks hold no local resources and are never asked.
241
225
  */
242
- resourceCosts?: ReadonlyMap<string, ResourceCost>
243
- /** CPU budget reservations pack against. Defaults to `concurrency`. */
244
- cpuBudget?: number
245
- /** Memory budget (bytes). Defaults to Infinity (axis off). */
246
- memBudget?: number
226
+ admit?: (id: string, running: ReadonlySet<string>) => boolean
247
227
  }
248
228
 
249
- /**
250
- * Compute, for each task in the graph, how many OTHER tasks are
251
- * transitively blocked on it. Tasks with the highest count are the
252
- * most valuable to schedule first — finishing them unlocks the most
253
- * downstream work and minimizes worker idle time at the end of the
254
- * run. Matches Nx's `calculateReverseDeps`-driven schedule sort
255
- * (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
256
- */
257
- // Exported for the perf guard in tests/scheduler.test.ts, which times
258
- // this function in isolation — end-to-end runGraph timing drowns the
259
- // closure cost in task-promise overhead and flakes on slow CI.
260
- export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
261
- // Exact transitive-dependent COUNTS need closure SETS (diamonds
262
- // double-count under naive summing). Set-of-strings closures are
263
- // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
264
- // bitsets make the same closure O(E·N/32) time and N²/8 bits of
265
- // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
266
- const ids = [...nodes.keys()]
267
- const index = new Map<string, number>()
268
- for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
269
- const n = ids.length
270
- const words = (n + 31) >>> 5
271
-
272
- // Direct dependents as index lists + in-degree for the topo pass.
273
- const directReverse: number[][] = Array.from({ length: n }, () => [])
274
- const indegree = new Uint32Array(n)
275
- for (const node of nodes.values()) {
276
- const ni = index.get(node.id)!
277
- for (const dep of node.deps) {
278
- const di = index.get(dep)
279
- if (di === undefined) continue
280
- directReverse[di]!.push(ni)
281
- indegree[ni]!++
282
- }
283
- }
284
-
285
- // Kahn topo order over dependency edges (deps before dependents).
286
- // Insertion order is topo today, but the closure's correctness
287
- // must not hinge on an unstated property of buildTaskGraph.
288
- const topo = new Int32Array(n)
289
- let head = 0
290
- let tail = 0
291
- for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
292
- while (head < tail) {
293
- const v = topo[head++]!
294
- for (const r of directReverse[v]!) {
295
- if (--indegree[r]! === 0) topo[tail++] = r
296
- }
297
- }
298
-
299
- // Reverse-topo sweep: every direct dependent's closure is final
300
- // before its dependency folds it in. closure[i] = bitset over node
301
- // indices of i's transitive dependents.
302
- const closure = new Uint32Array(n * words)
303
- const counts = new Map<string, number>()
304
- for (let t = tail - 1; t >= 0; t--) {
305
- const i = topo[t]!
306
- const base = i * words
307
- for (const r of directReverse[i]!) {
308
- closure[base + (r >>> 5)]! |= 1 << (r & 31)
309
- const rbase = r * words
310
- for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
311
- }
312
- let count = 0
313
- for (let w = 0; w < words; w++) {
314
- let v = closure[base + w]!
315
- v = v - ((v >>> 1) & 0x55555555)
316
- v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
317
- count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
318
- }
319
- counts.set(ids[i]!, count)
320
- }
321
- // Cycle-stranded nodes (never topo-visited) can't occur — the graph
322
- // builder rejects cycles — but a missing Map entry would silently
323
- // sort as undefined, so default them defensively to 0.
324
- for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
325
- return counts
326
- }
327
-
328
- /**
329
- * Run the task graph. Independent tasks run in parallel up to `concurrency`.
330
- * If a task fails, its dependents are marked `skipped` but unrelated tasks
331
- * keep running so the user gets maximum information per invocation.
332
- *
333
- * Scheduling: when more than one task is ready, the scheduler picks the
334
- * one that blocks the most downstream work (most transitive reverse
335
- * dependents). Ties break in graph-insertion order (which is the topo
336
- * order produced by `buildTaskGraph`). Minimizes worker idle at the
337
- * end of the run.
338
- */
339
229
  /**
340
230
  * A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
341
231
  * ASC) so `pop()` returns the highest-priority task and equal-priority ties
@@ -368,8 +258,18 @@ class ReadyHeap {
368
258
  }
369
259
  /**
370
260
  * `seq` defaults to a fresh monotonic counter. A parked-then-repushed
371
- * task passes its ORIGINAL seq back in so FIFO-among-equals survives
372
- * the round trip (a fresh seq would demote it behind later arrivals).
261
+ * task passes its ORIGINAL seq back in, which makes FIFO-among-equals a
262
+ * property of the heap rather than of the tick loop's shape.
263
+ *
264
+ * It is NOT the difference between correct and incorrect order today,
265
+ * and the comment here used to claim it was ("a fresh seq would demote
266
+ * it behind later arrivals"). It would not: `parked` is a subsequence
267
+ * of the tick's pop order, so it is already in ranking order, and every
268
+ * push that follows takes a later seq — so fresh seqs handed out in
269
+ * parked order reproduce the same ranking. Measured over 800 randomized
270
+ * scenarios (random DAGs, costs, budgets, concurrency, failures, all
271
+ * three continue modes): zero order differences, item 493. Kept because
272
+ * it holds without that argument.
373
273
  */
374
274
  push(id: string, seq: number = this.next++): void {
375
275
  this.ids.push(id)
@@ -411,6 +311,17 @@ class ReadyHeap {
411
311
  }
412
312
  }
413
313
 
314
+ /**
315
+ * Run the task graph. Independent tasks run in parallel up to `concurrency`.
316
+ * If a task fails, its dependents are marked `skipped` but unrelated tasks
317
+ * keep running so the user gets maximum information per invocation.
318
+ *
319
+ * Scheduling: when more than one task is ready, the scheduler picks the
320
+ * one that blocks the most downstream work (most transitive reverse
321
+ * dependents). Ties break in graph-insertion order (which is the topo
322
+ * order produced by `buildTaskGraph`). Minimizes worker idle at the
323
+ * end of the run.
324
+ */
414
325
  export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
415
326
  const { nodes, concurrency, execute, onStart, onFinish } = options
416
327
  const continueMode = options.continueMode ?? 'deps-ok'
@@ -439,7 +350,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
439
350
  // priorities map (e.g. from history-aware critical-path estimation),
440
351
  // use those weights. Falls back to the reverse-deps-count heuristic
441
352
  // for nodes the caller didn't score, so partial coverage works.
442
- const baseline = computeReverseDepCount(nodes)
353
+ const baseline =
354
+ options.restoreTier !== undefined && options.restoreTier.size > 0
355
+ ? tieredReverseDepCount(nodes, options.restoreTier)
356
+ : computeReverseDepCount(nodes)
443
357
  const priority: ReadonlyMap<string, number> = options.priorities
444
358
  ? mergePriorities(baseline, options.priorities)
445
359
  : baseline
@@ -453,6 +367,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
453
367
  // idle capacity (or run when an exec is blocked on a restorable dep and
454
368
  // nothing else is runnable).
455
369
  const restoreTier = options.restoreTier
370
+ // Restore-tier tasks whose execute threw `RestoreDemoted`: from then on
371
+ // they are exec-tier tasks, gated on their deps like any other.
372
+ const demoted = new Set<string>()
373
+ const inRestoreTier = (id: string): boolean => restoreTier?.has(id) === true && !demoted.has(id)
456
374
  const execReady = new ReadyHeap(priority)
457
375
  const restoreReady = new ReadyHeap(priority)
458
376
  // A restore-tier task is dep-independent (a stable hit's restore needs
@@ -466,33 +384,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
466
384
  }
467
385
 
468
386
  let active = 0
387
+ // Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
388
+ // filesystem round trips and no CPU — so it gets its own lane, twice
389
+ // the exec cap: on the 1,000-project bench with every task a restore,
390
+ // 4 → 8 workers cut the run-graph stage 683–754 → 556–595 ms and 16 was
391
+ // no better (2026-09-10). Exec-tier work keeps the CPU-shaped cap; the
392
+ // two lanes never wait on each other. `--concurrency 1` stays serial.
393
+ let activeRestore = 0
394
+ const restoreConcurrency = concurrency === 1 ? 1 : 2 * concurrency
469
395
  let resolved = false
470
396
 
471
- // Resource admission (2-D bin packing over the count limit). Inactive
472
- // (no task opted in) → the tick loop short-circuits before any of this
473
- // and behaves byte-identically to the count-only scheduler.
474
- const costs = options.resourceCosts
475
- const resourcesActive = costs !== undefined && costs.size > 0
476
- const cpuBudget = options.cpuBudget ?? concurrency
477
- const memBudget = options.memBudget ?? Infinity
478
- // Reservations are FLOAT sums (fractional cpus, and percent-of-budget
479
- // resolves to non-representable values like `0.30000000000000004`), so
480
- // add/release cycles leave ~1e-17 residue instead of an exact 0. Two
481
- // guards keep that residue from corrupting admission:
482
- // - the solo-clamp gate ("is the axis idle?") reads INTEGER holder
483
- // counts, never the float sum === 0 — a residue would otherwise
484
- // park an over-budget task forever (active===0, no future tick =
485
- // a silent hang / exit-0-without-running);
486
- // - `reserved` snaps back to EXACT 0 whenever its holder count hits
487
- // 0, so residue can't accumulate across busy periods.
488
- // The within-budget comparison also carries a tiny relative epsilon so
489
- // an exact-fill (`reserved + cost == budget`) can't mis-round into a
490
- // spurious block. Admission is a hint, so the epsilon's sub-ulp
491
- // over-admission is harmless.
492
- let reservedCpu = 0
493
- let reservedMem = 0
494
- let holdersCpu = 0
495
- let holdersMem = 0
397
+ // Admission policy over the count limit (a plugin's `admit`). Inactive
398
+ // → the tick loop short-circuits before any of this and behaves
399
+ // byte-identically to the count-only scheduler.
400
+ const admitPolicy = options.admit
401
+ const admitActive = admitPolicy !== undefined
402
+ const running = new Set<string>()
403
+ // When a policy first refused each parked task — so its outcome can say
404
+ // how long the policy, not the count gate, held it. Nothing with no
405
+ // policy: the map is never touched.
406
+ const heldSince = new Map<string, number>()
496
407
 
497
408
  // A restore-tier task is a confirmed local cache hit: its "execution"
498
409
  // is a cheap tar extract, not the task's real work — it reserves ZERO
@@ -504,12 +415,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
504
415
  // against `concurrency`; pooled tasks reserve no local resources.
505
416
  const hasRoom = (id: string): boolean => {
506
417
  const pool = poolOf?.(id)
507
- if (pool === undefined) return active < concurrency
418
+ if (pool === undefined) {
419
+ return inRestoreTier(id) ? activeRestore < restoreConcurrency : active < concurrency
420
+ }
508
421
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
509
422
  }
510
423
  const admit = (id: string): (() => void) => {
511
424
  const pool = poolOf?.(id)
512
425
  if (pool === undefined) {
426
+ if (inRestoreTier(id)) {
427
+ activeRestore++
428
+ return () => {
429
+ activeRestore--
430
+ }
431
+ }
513
432
  active++
514
433
  return () => {
515
434
  active--
@@ -520,44 +439,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
520
439
  poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
521
440
  }
522
441
  }
523
- const costOf = (id: string): ResourceCost =>
524
- options.restoreTier?.has(id) || poolOf?.(id) !== undefined
525
- ? ZERO_COST
526
- : (costs?.get(id) ?? ZERO_COST)
527
-
528
- // Zero never blocks; a within-budget cost needs headroom (with an
529
- // exact-fill epsilon); an over-budget cost can never have headroom, so
530
- // it solo-clamps: admitted only when the axis is idle (no holders — an
531
- // idle pool always admits at least one ready task, no deadlock).
532
- const fitsAxis = (cost: number, reserved: number, holders: number, budget: number): boolean =>
533
- cost === 0 ? true : cost <= budget ? reserved + cost <= budget + budget * 1e-9 : holders === 0
534
-
535
- const fits = (id: string): boolean => {
536
- const c = costOf(id)
537
- return (
538
- fitsAxis(c.cpu, reservedCpu, holdersCpu, cpuBudget) &&
539
- fitsAxis(c.mem, reservedMem, holdersMem, memBudget)
540
- )
541
- }
542
-
543
- // Reserve/release keep the float sum AND the integer holder count in
544
- // lockstep; releasing the last holder on an axis snaps its sum to 0.
545
- const reserve = (c: ResourceCost): void => {
546
- if (c.cpu > 0) {
547
- reservedCpu += c.cpu
548
- holdersCpu++
549
- }
550
- if (c.mem > 0) {
551
- reservedMem += c.mem
552
- holdersMem++
442
+ // The policy sees exec-tier local tasks only; a restore is a tar
443
+ // extract and a pooled task runs on someone else's capacity.
444
+ const local = (id: string): boolean => !inRestoreTier(id) && poolOf?.(id) === undefined
445
+ const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
446
+ // With no policy nothing reads `running`, so nothing is tracked: the
447
+ // count-only dispatch allocates no closure and touches no set per task.
448
+ const untracked = (): void => {}
449
+ const track = (id: string): (() => void) => {
450
+ if (!admitActive || !local(id)) return untracked
451
+ running.add(id)
452
+ return () => {
453
+ running.delete(id)
553
454
  }
554
455
  }
555
- const release = (c: ResourceCost): void => {
556
- if (c.cpu > 0 && --holdersCpu === 0) reservedCpu = 0
557
- else reservedCpu -= c.cpu
558
- if (c.mem > 0 && --holdersMem === 0) reservedMem = 0
559
- else reservedMem -= c.mem
560
- }
561
456
 
562
457
  return new Promise<Map<string, TaskOutcome>>((resolve) => {
563
458
  const finishOne = (id: string, outcome: TaskOutcome): void => {
@@ -575,14 +470,54 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
575
470
  const m = err instanceof Error ? err.message : String(err)
576
471
  process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
577
472
  }
473
+ // A restore-tier task runs before its deps, but its DEPENDENTS keep
474
+ // the order `dependsOn` states: they are released once its own deps
475
+ // have settled too. Released at its own finish, an `e2e` ran beside
476
+ // the `db:migrate` two edges up whenever the middle task was a hit,
477
+ // and ran green after that dep failed (item 963).
478
+ if (inRestoreTier(id) && (pending.get(id) ?? 0) > 0) {
479
+ heldRelease.add(id)
480
+ return
481
+ }
482
+ release(id)
483
+ }
484
+
485
+ // A restore-tier task whose deps did not all succeed is still a
486
+ // `cache-hit` (its key is theirs, not their outcome), but it passes the
487
+ // block down: its dependents are skipped as a failed dep's would be.
488
+ // Its root: a failed or aborted dep, or the root a skipped or blocked
489
+ // one carries.
490
+ const blockedRestores = new Map<string, string | undefined>()
491
+ const heldRelease = new Set<string>()
492
+ const release = (id: string): void => {
493
+ if (inRestoreTier(id) && continueMode !== 'always') {
494
+ for (const dep of (nodes.get(id) as TaskNode).deps) {
495
+ const u = outcomes.get(dep)
496
+ if (u?.status === 'failed' || u?.status === 'aborted') {
497
+ blockedRestores.set(id, dep)
498
+ break
499
+ }
500
+ if (u?.status === 'skipped') {
501
+ blockedRestores.set(id, u.blockedBy)
502
+ break
503
+ }
504
+ if (blockedRestores.has(dep)) {
505
+ blockedRestores.set(id, blockedRestores.get(dep))
506
+ break
507
+ }
508
+ }
509
+ }
578
510
  const ds = dependents.get(id)
579
511
  if (!ds) return
580
512
  for (const d of ds) {
581
513
  const rem = (pending.get(d) ?? 0) - 1
582
514
  pending.set(d, rem)
515
+ if (rem !== 0) continue
583
516
  // Restore-tier dependents were already enqueued at startup (they
584
- // don't wait on deps); only re-enqueue an exec-tier dependent.
585
- if (rem === 0 && !restoreTier?.has(d)) execReady.push(d)
517
+ // don't wait on deps); one that finished early releases its own
518
+ // dependents now. Only an exec-tier dependent is enqueued.
519
+ if (heldRelease.delete(d)) release(d)
520
+ else if (!inRestoreTier(d)) execReady.push(d)
586
521
  }
587
522
  }
588
523
 
@@ -598,9 +533,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
598
533
  // cached output is reported `cache-hit` even if a dep failed — and
599
534
  // they're dep-independent, so they typically restore before a dep
600
535
  // could fail anyway.
536
+ const aborted = (): boolean => options.signal?.aborted === true
601
537
  const willSkip = (id: string): boolean => {
602
- if (failFastTripped) return true
603
- if (restoreTier?.has(id)) return false
538
+ if (failFastTripped || aborted()) return true
539
+ if (inRestoreTier(id)) return false
604
540
  if (continueMode === 'always') return false
605
541
  const node = nodes.get(id) as TaskNode
606
542
  return node.deps.some((d) => {
@@ -611,37 +547,54 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
611
547
  // from them — under the key a healthy run derives, since the fold
612
548
  // takes the upstream's INPUT key — so the next run replays those
613
549
  // bytes as a green hit.
614
- return u?.status === 'failed' || u?.status === 'skipped' || u?.status === 'aborted'
550
+ return (
551
+ u?.status === 'failed' ||
552
+ u?.status === 'skipped' ||
553
+ u?.status === 'aborted' ||
554
+ blockedRestores.has(d)
555
+ )
615
556
  })
616
557
  }
617
558
 
618
559
  const tick = (): void => {
619
560
  if (resolved) return
620
- // Exec-tier tasks parked THIS tick on a failed resource fit.
621
- // Within one synchronous tick `reserved` only increases (release
622
- // happens in the async completion callbacks, which run a fresh
623
- // tick), so a task that doesn't fit now cannot fit later in the
624
- // same tick — parking it for the tick's remainder is exact, and
625
- // each id pops at most once per tick (O(R log R)).
561
+ // Exec-tier tasks parked THIS tick on a refused admission. Within
562
+ // one synchronous tick `running` only grows (tasks leave it in the
563
+ // async completion callbacks, which run a fresh tick), so a task
564
+ // refused now cannot be admitted later in the same tick — parking
565
+ // it for the tick's remainder is exact, and each id pops at most
566
+ // once per tick (O(R log R)).
626
567
  const parked: Array<[string, number]> = []
627
568
 
628
569
  // Highest-priority admissible task: exec tier first (misses own
629
- // the pool), then restore tier. With no reservations declared this
570
+ // the pool), then restore tier. With no admission policy this
630
571
  // short-circuits to exactly the legacy takeReady. A would-skip
631
- // task returns without a fit check (finishing it is free); restore
632
- // tasks cost zero by construction, so they never park.
572
+ // task returns without asking (finishing it is free); restore
573
+ // tasks are never asked, so they never park.
633
574
  const takeFitting = (): string | undefined => {
634
- // No pools declared (the common case): a full local pool admits
635
- // nothing, so do not scan — this is the legacy O(1) gate.
636
- if (poolOf === undefined && active >= concurrency) return undefined
637
- while (execReady.size > 0) {
638
- const seq = execReady.peekSeq()
639
- const id = execReady.pop() as string
640
- if (willSkip(id) || ((!resourcesActive || fits(id)) && hasRoom(id))) return id
641
- parked.push([id, seq])
575
+ // No pools, no policy and a full exec lane: nothing on the
576
+ // exec queue can be admitted, so it is not scanned — scanning it
577
+ // would pop and re-park every ready exec task on every tick, and
578
+ // with a wide frontier that is O(R) per tick, O(R²) per run (the
579
+ // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
580
+ // first cut let the scan run past a full exec lane, 2026-09-10).
581
+ // This is the legacy O(1) gate, kept per lane.
582
+ const execAdmissible = poolOf !== undefined || admitActive || active < concurrency
583
+ if (execAdmissible) {
584
+ while (execReady.size > 0) {
585
+ const seq = execReady.peekSeq()
586
+ const id = execReady.pop() as string
587
+ if (willSkip(id)) return id
588
+ if (hasRoom(id)) {
589
+ if (!admitActive || admits(id)) return id
590
+ // A free worker, refused by the policy: the hold starts now.
591
+ if (!heldSince.has(id)) heldSince.set(id, Date.now())
592
+ }
593
+ parked.push([id, seq])
594
+ }
642
595
  }
643
- // Restore-tier tasks are local work (a cache restore on this disk).
644
- return active < concurrency ? restoreReady.pop() : undefined
596
+ // Restore-tier tasks are local disk work, on their own lane.
597
+ return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
645
598
  }
646
599
 
647
600
  for (;;) {
@@ -660,47 +613,98 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
660
613
  // because dependents are pushed when `pending` hits 0 regardless
661
614
  // of outcome — keeps the propagation logic in one place.
662
615
  if (willSkip(id)) {
663
- finishOne(id, { node, status: 'skipped', exitCode: 1, durationMs: 0 })
616
+ // The root of the block: a failed or aborted upstream names
617
+ // itself; a skipped one hands down its own root.
618
+ const blocker = upstream.find(
619
+ (u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
620
+ )
621
+ const viaSkip = upstream.find((u) => u !== undefined && u.status === 'skipped')
622
+ const viaRestore = node.deps.find((d) => blockedRestores.has(d))
623
+ const blockedBy =
624
+ blocker?.node.id ??
625
+ viaSkip?.blockedBy ??
626
+ (viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
627
+ finishOne(id, {
628
+ node,
629
+ status: aborted() ? 'aborted' : 'skipped',
630
+ exitCode: 1,
631
+ durationMs: 0,
632
+ ...(blockedBy !== undefined ? { blockedBy } : {}),
633
+ })
664
634
  continue
665
635
  }
666
636
 
667
637
  const leave = admit(id)
668
- // Reserve on dispatch; capture the cost so the release in the
669
- // completion callbacks is symmetric even if the maps change.
670
- const cost = costOf(id)
671
- reserve(cost)
638
+ // Listed as running on dispatch, so the policy's next ask in this
639
+ // tick sees it; the completion callbacks unlist it.
640
+ const untrack = track(id)
641
+ const since = admitActive ? heldSince.get(id) : undefined
642
+ const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
643
+ if (since !== undefined) heldSince.delete(id)
644
+ const withHold = (o: TaskOutcome): TaskOutcome =>
645
+ heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
672
646
  // Crash-isolated observer hook — a throwing onStart must not abort
673
- // the dispatch loop (it would strand the tick with reservations held).
674
- try {
675
- onStart?.(node)
676
- } catch (err) {
677
- const m = err instanceof Error ? err.message : String(err)
678
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
647
+ // the dispatch loop (it would strand the tick with the slot held).
648
+ // A demoted task's second dispatch is the same task, already started.
649
+ if (!demoted.has(id)) {
650
+ try {
651
+ onStart?.(node)
652
+ } catch (err) {
653
+ const m = err instanceof Error ? err.message : String(err)
654
+ process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
655
+ }
679
656
  }
680
657
 
681
658
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
682
659
  // rejection arm handles ONLY `execute()` rejecting; a throw from
683
- // the fulfillment arm (finishOne / onFinish / tick) must NOT also
684
- // run the rejection arm, or `active`/`reserved` release twice —
685
- // and a double release drives `reserved` negative, permanently
686
- // wedging the solo-clamp gate.
660
+ // the fulfillment arm must NOT also run the rejection arm, or
661
+ // `active` releases twice — and a double release wedges the count
662
+ // gate. NOT `onFinish`, which this comment used to cite: it is
663
+ // isolated inside `finishOne`, so it never reaches here (which is
664
+ // why the row named for it cannot tell the two shapes apart —
665
+ // item 560). What is left unisolated is the caller's
666
+ // `settledOf`, and under THIS shape a throw there strands the
667
+ // task's outcome and hangs the run rather than double-releasing.
668
+ // The two-arm form is still the correct choice; there is simply
669
+ // no reachable throw that makes the difference observable.
687
670
  execute(node, upstream).then(
688
- (outcome) => {
671
+ (raw) => {
672
+ const outcome = withHold(raw)
689
673
  leave()
690
- release(cost)
691
- finishOne(id, outcome)
674
+ untrack()
675
+ // The slot is free now; the dependents wait for what the
676
+ // outcome still owes (a save landing), if anything.
677
+ const settled = options.settledOf?.(outcome)
678
+ if (settled === undefined) {
679
+ finishOne(id, outcome)
680
+ tick()
681
+ return
682
+ }
692
683
  tick()
684
+ const done = (): void => {
685
+ finishOne(id, outcome)
686
+ tick()
687
+ }
688
+ void settled.then(done, done)
693
689
  },
694
690
  (err: unknown) => {
691
+ if (err instanceof RestoreDemoted) {
692
+ leave()
693
+ untrack()
694
+ demoted.add(id)
695
+ if (pending.get(id) === 0) execReady.push(id)
696
+ tick()
697
+ return
698
+ }
695
699
  const message = err instanceof Error ? err.message : String(err)
696
- const outcome: TaskOutcome = {
700
+ const outcome: TaskOutcome = withHold({
697
701
  node,
698
702
  status: 'failed',
699
703
  exitCode: 1,
700
704
  durationMs: 0,
701
- }
705
+ })
702
706
  leave()
703
- release(cost)
707
+ untrack()
704
708
  finishOne(id, outcome)
705
709
  // Surface the error live; the outcome itself doesn't
706
710
  // carry captured stderr (that's the logger's job). A
@@ -709,20 +713,37 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
709
713
  // plainly, never as an "internal error".
710
714
  if (isUserError(err)) {
711
715
  process.stderr.write(`[vx] ${id}: ${message}\n`)
716
+ } else if (isFsRefusal(err)) {
717
+ process.stderr.write(`[vx] ${id}: ${message} — ${fsRefusalHint(err)}\n`)
712
718
  } else {
713
719
  const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
714
- process.stderr.write(`[vx] internal error in ${id}: ${named}${message}\n`)
720
+ // A wrapped error's cause is the fact the reader needs (a
721
+ // CorruptArtifactError over an ENOENT is a race, not a bad
722
+ // archive); print it, since the stack is not printed here.
723
+ const cause =
724
+ err instanceof Error && err.cause instanceof Error ? err.cause : undefined
725
+ const because =
726
+ cause === undefined
727
+ ? ''
728
+ : ` (cause: ${(cause as NodeJS.ErrnoException).code ?? cause.name}: ${cause.message})`
729
+ process.stderr.write(`[vx] internal error in ${id}: ${named}${message}${because}\n`)
715
730
  }
716
731
  tick()
717
732
  },
718
733
  )
719
734
  }
720
735
 
721
- // Repush parked ids with their ORIGINAL seqs — FIFO-among-equals
722
- // is exactly preserved for the next tick's admission pass.
736
+ // Repush parked ids with their ORIGINAL seqs, so the next tick's
737
+ // admission pass does not depend on `parked` having been built in
738
+ // ranking order (it is, but see `push`).
723
739
  for (const [id, seq] of parked) execReady.push(id, seq)
724
740
 
725
- if (outcomes.size === nodes.size && active === 0) {
741
+ // The outcome count alone: a slot is released before its outcome
742
+ // lands (`leave()` precedes `finishOne` in both arms, and a skip
743
+ // takes none), so a task still holding one has no outcome yet, and
744
+ // the lane counters could add nothing — deleting both from this
745
+ // condition survived the whole core suite (item 644).
746
+ if (outcomes.size === nodes.size) {
726
747
  resolved = true
727
748
  resolve(outcomes)
728
749
  }
@@ -731,21 +752,3 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
731
752
  tick()
732
753
  })
733
754
  }
734
-
735
- export function mergePriorities(
736
- baseline: ReadonlyMap<string, number>,
737
- overrides: ReadonlyMap<string, number>,
738
- ): ReadonlyMap<string, number> {
739
- // The override caller scored "expected critical-path duration" in ms.
740
- // Baseline reverse-deps counts are bounded by N; scale the override so
741
- // it sorts above the baseline for any node it covers, and add the
742
- // baseline as a tie-break for parity within the override set.
743
- const SCALE = 1 << 20
744
- const out = new Map<string, number>()
745
- for (const [id, w] of baseline) out.set(id, w)
746
- for (const [id, w] of overrides) {
747
- const b = baseline.get(id) ?? 0
748
- out.set(id, w * SCALE + b)
749
- }
750
- return out
751
- }