@vzn/vx 0.0.18 → 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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -14,65 +14,6 @@ export type TaskStatus =
14
14
  // are partial.
15
15
  | 'aborted'
16
16
 
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
17
  export interface TaskOutcome {
77
18
  node: TaskNode
78
19
  status: TaskStatus
@@ -90,6 +31,22 @@ export interface TaskOutcome {
90
31
  * Undefined on every non-hit outcome — nothing was skipped.
91
32
  */
92
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
93
50
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
94
51
  cpuMs?: number
95
52
  peakRssBytes?: number
@@ -134,10 +91,10 @@ export interface TaskOutcome {
134
91
  */
135
92
  attempts?: number
136
93
  /**
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.
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.
141
98
  */
142
99
  sandboxViolations?: number
143
100
  /**
@@ -146,38 +103,19 @@ export interface TaskOutcome {
146
103
  * inline in the task's block instead of as loose status output.
147
104
  */
148
105
  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
106
  }
161
107
 
162
108
  export type ContinueMode = 'never' | 'deps-ok' | 'always'
163
109
 
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
110
  export interface ScheduleOptions {
179
111
  nodes: Map<string, TaskNode>
180
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
181
119
  /**
182
120
  * Failure propagation (default 'deps-ok' — the historical behavior):
183
121
  * - 'deps-ok': a failed/skipped/aborted upstream skips its
@@ -186,18 +124,25 @@ export interface ScheduleOptions {
186
124
  * finish naturally, everything not yet started completes as
187
125
  * skipped (restore-tier included: a fail-fast run stops
188
126
  * 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.
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`).
196
133
  */
197
134
  continueMode?: ContinueMode
198
135
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
199
136
  onStart?: (node: TaskNode) => void
200
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
201
146
  /**
202
147
  * Optional priority override: callers pass their own per-node weight
203
148
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -233,17 +178,14 @@ export interface ScheduleOptions {
233
178
  */
234
179
  poolOf?: (id: string) => { name: string; capacity: number } | undefined
235
180
  /**
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.
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.
241
187
  */
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
188
+ admit?: (id: string, running: ReadonlySet<string>) => boolean
247
189
  }
248
190
 
249
191
  /**
@@ -466,33 +408,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
466
408
  }
467
409
 
468
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
469
419
  let resolved = false
470
420
 
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
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>()
496
431
 
497
432
  // A restore-tier task is a confirmed local cache hit: its "execution"
498
433
  // is a cheap tar extract, not the task's real work — it reserves ZERO
@@ -504,12 +439,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
504
439
  // against `concurrency`; pooled tasks reserve no local resources.
505
440
  const hasRoom = (id: string): boolean => {
506
441
  const pool = poolOf?.(id)
507
- if (pool === undefined) return active < concurrency
442
+ if (pool === undefined) {
443
+ return options.restoreTier?.has(id)
444
+ ? activeRestore < restoreConcurrency
445
+ : active < concurrency
446
+ }
508
447
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
509
448
  }
510
449
  const admit = (id: string): (() => void) => {
511
450
  const pool = poolOf?.(id)
512
451
  if (pool === undefined) {
452
+ if (options.restoreTier?.has(id)) {
453
+ activeRestore++
454
+ return () => {
455
+ activeRestore--
456
+ }
457
+ }
513
458
  active++
514
459
  return () => {
515
460
  active--
@@ -520,44 +465,20 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
520
465
  poolActive.set(pool.name, (poolActive.get(pool.name) ?? 1) - 1)
521
466
  }
522
467
  }
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++
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)
553
480
  }
554
481
  }
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
482
 
562
483
  return new Promise<Map<string, TaskOutcome>>((resolve) => {
563
484
  const finishOne = (id: string, outcome: TaskOutcome): void => {
@@ -598,8 +519,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
598
519
  // cached output is reported `cache-hit` even if a dep failed — and
599
520
  // they're dep-independent, so they typically restore before a dep
600
521
  // could fail anyway.
522
+ const aborted = (): boolean => options.signal?.aborted === true
601
523
  const willSkip = (id: string): boolean => {
602
- if (failFastTripped) return true
524
+ if (failFastTripped || aborted()) return true
603
525
  if (restoreTier?.has(id)) return false
604
526
  if (continueMode === 'always') return false
605
527
  const node = nodes.get(id) as TaskNode
@@ -617,31 +539,43 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
617
539
 
618
540
  const tick = (): void => {
619
541
  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)).
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)).
626
548
  const parked: Array<[string, number]> = []
627
549
 
628
550
  // Highest-priority admissible task: exec tier first (misses own
629
- // the pool), then restore tier. With no reservations declared this
551
+ // the pool), then restore tier. With no admission policy this
630
552
  // 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.
553
+ // task returns without asking (finishing it is free); restore
554
+ // tasks are never asked, so they never park.
633
555
  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])
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
+ }
642
576
  }
643
- // Restore-tier tasks are local work (a cache restore on this disk).
644
- return active < concurrency ? restoreReady.pop() : undefined
577
+ // Restore-tier tasks are local disk work, on their own lane.
578
+ return activeRestore < restoreConcurrency ? restoreReady.pop() : undefined
645
579
  }
646
580
 
647
581
  for (;;) {
@@ -660,17 +594,26 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
660
594
  // because dependents are pushed when `pending` hits 0 regardless
661
595
  // of outcome — keeps the propagation logic in one place.
662
596
  if (willSkip(id)) {
663
- 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
+ })
664
603
  continue
665
604
  }
666
605
 
667
606
  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)
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
672
615
  // Crash-isolated observer hook — a throwing onStart must not abort
673
- // the dispatch loop (it would strand the tick with reservations held).
616
+ // the dispatch loop (it would strand the tick with the slot held).
674
617
  try {
675
618
  onStart?.(node)
676
619
  } catch (err) {
@@ -681,26 +624,38 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
681
624
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
682
625
  // rejection arm handles ONLY `execute()` rejecting; a throw from
683
626
  // 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.
627
+ // run the rejection arm, or `active` releases twice — and a
628
+ // double release wedges the count gate.
687
629
  execute(node, upstream).then(
688
- (outcome) => {
630
+ (raw) => {
631
+ const outcome = withHold(raw)
689
632
  leave()
690
- release(cost)
691
- finishOne(id, outcome)
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
+ }
692
642
  tick()
643
+ const done = (): void => {
644
+ finishOne(id, outcome)
645
+ tick()
646
+ }
647
+ void settled.then(done, done)
693
648
  },
694
649
  (err: unknown) => {
695
650
  const message = err instanceof Error ? err.message : String(err)
696
- const outcome: TaskOutcome = {
651
+ const outcome: TaskOutcome = withHold({
697
652
  node,
698
653
  status: 'failed',
699
654
  exitCode: 1,
700
655
  durationMs: 0,
701
- }
656
+ })
702
657
  leave()
703
- release(cost)
658
+ untrack()
704
659
  finishOne(id, outcome)
705
660
  // Surface the error live; the outcome itself doesn't
706
661
  // carry captured stderr (that's the logger's job). A
@@ -722,7 +677,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
722
677
  // is exactly preserved for the next tick's admission pass.
723
678
  for (const [id, seq] of parked) execReady.push(id, seq)
724
679
 
725
- if (outcomes.size === nodes.size && active === 0) {
680
+ if (outcomes.size === nodes.size && active === 0 && activeRestore === 0) {
726
681
  resolved = true
727
682
  resolve(outcomes)
728
683
  }
@@ -289,7 +289,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
289
289
  `Task ${id}: dependsOn patterns are not supported in the "pkg#task" form (got "${raw}")`,
290
290
  )
291
291
  }
292
- // CLI `--excludeDependencies=name1,name2` drops edges whose target
292
+ // CLI `--exclude-dependencies=name1,name2` drops edges whose target
293
293
  // task name matches, regardless of bucket (self / deps / cross).
294
294
  // Pattern specs re-apply the filter per EXPANDED name below.
295
295
  if (skipNames?.has(spec.task)) continue
@@ -331,7 +331,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
331
331
  // With a pattern (`^build.*`), a holder is a package declaring
332
332
  // AT LEAST ONE matching task and it receives edges to ALL its
333
333
  // matches — holder-ness is about declaration, so a holder still
334
- // stops the walk even when every match is --excludeDependencies'd.
334
+ // stops the walk even when every match is --exclude-dependencies'd.
335
335
  //
336
336
  // The declaring project seeds `visited`: package graphs may legally
337
337
  // contain cycles (the common "b devDepends on a for its tests"