@vzn/vx 0.0.21 → 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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -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'
@@ -60,6 +76,26 @@ export interface TaskOutcome {
60
76
  * outcomes only; never folded into any key.
61
77
  */
62
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'
63
99
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
64
100
  * when the task ran somewhere other than this host. Telemetry-only. */
65
101
  where?: string
@@ -163,7 +199,9 @@ export interface ScheduleOptions {
163
199
  * it);
164
200
  * - bypasses the `failedDep`→`skipped` check (its key is independent
165
201
  * of dep success — pure-input transitive hashing — so a valid cached
166
- * 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.
167
205
  * It still runs through `execute()` (so the logger frame is unchanged);
168
206
  * the orchestrator's execute reuses the up-front probe, so there is no
169
207
  * second cache.get. When undefined/empty, behavior is byte-identical.
@@ -188,96 +226,6 @@ export interface ScheduleOptions {
188
226
  admit?: (id: string, running: ReadonlySet<string>) => boolean
189
227
  }
190
228
 
191
- /**
192
- * Compute, for each task in the graph, how many OTHER tasks are
193
- * transitively blocked on it. Tasks with the highest count are the
194
- * most valuable to schedule first — finishing them unlocks the most
195
- * downstream work and minimizes worker idle time at the end of the
196
- * run. Matches Nx's `calculateReverseDeps`-driven schedule sort
197
- * (`packages/nx/src/tasks-runner/tasks-schedule.ts:166-207`).
198
- */
199
- // Exported for the perf guard in tests/scheduler.test.ts, which times
200
- // this function in isolation — end-to-end runGraph timing drowns the
201
- // closure cost in task-promise overhead and flakes on slow CI.
202
- export function computeReverseDepCount(nodes: Map<string, TaskNode>): Map<string, number> {
203
- // Exact transitive-dependent COUNTS need closure SETS (diamonds
204
- // double-count under naive summing). Set-of-strings closures are
205
- // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
206
- // bitsets make the same closure O(E·N/32) time and N²/8 bits of
207
- // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
208
- const ids = [...nodes.keys()]
209
- const index = new Map<string, number>()
210
- for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
211
- const n = ids.length
212
- const words = (n + 31) >>> 5
213
-
214
- // Direct dependents as index lists + in-degree for the topo pass.
215
- const directReverse: number[][] = Array.from({ length: n }, () => [])
216
- const indegree = new Uint32Array(n)
217
- for (const node of nodes.values()) {
218
- const ni = index.get(node.id)!
219
- for (const dep of node.deps) {
220
- const di = index.get(dep)
221
- if (di === undefined) continue
222
- directReverse[di]!.push(ni)
223
- indegree[ni]!++
224
- }
225
- }
226
-
227
- // Kahn topo order over dependency edges (deps before dependents).
228
- // Insertion order is topo today, but the closure's correctness
229
- // must not hinge on an unstated property of buildTaskGraph.
230
- const topo = new Int32Array(n)
231
- let head = 0
232
- let tail = 0
233
- for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
234
- while (head < tail) {
235
- const v = topo[head++]!
236
- for (const r of directReverse[v]!) {
237
- if (--indegree[r]! === 0) topo[tail++] = r
238
- }
239
- }
240
-
241
- // Reverse-topo sweep: every direct dependent's closure is final
242
- // before its dependency folds it in. closure[i] = bitset over node
243
- // indices of i's transitive dependents.
244
- const closure = new Uint32Array(n * words)
245
- const counts = new Map<string, number>()
246
- for (let t = tail - 1; t >= 0; t--) {
247
- const i = topo[t]!
248
- const base = i * words
249
- for (const r of directReverse[i]!) {
250
- closure[base + (r >>> 5)]! |= 1 << (r & 31)
251
- const rbase = r * words
252
- for (let w = 0; w < words; w++) closure[base + w]! |= closure[rbase + w]!
253
- }
254
- let count = 0
255
- for (let w = 0; w < words; w++) {
256
- let v = closure[base + w]!
257
- v = v - ((v >>> 1) & 0x55555555)
258
- v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
259
- count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
260
- }
261
- counts.set(ids[i]!, count)
262
- }
263
- // Cycle-stranded nodes (never topo-visited) can't occur — the graph
264
- // builder rejects cycles — but a missing Map entry would silently
265
- // sort as undefined, so default them defensively to 0.
266
- for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
267
- return counts
268
- }
269
-
270
- /**
271
- * Run the task graph. Independent tasks run in parallel up to `concurrency`.
272
- * If a task fails, its dependents are marked `skipped` but unrelated tasks
273
- * keep running so the user gets maximum information per invocation.
274
- *
275
- * Scheduling: when more than one task is ready, the scheduler picks the
276
- * one that blocks the most downstream work (most transitive reverse
277
- * dependents). Ties break in graph-insertion order (which is the topo
278
- * order produced by `buildTaskGraph`). Minimizes worker idle at the
279
- * end of the run.
280
- */
281
229
  /**
282
230
  * A binary max-heap of ready task ids, ordered by (priority DESC, enqueue-seq
283
231
  * ASC) so `pop()` returns the highest-priority task and equal-priority ties
@@ -310,8 +258,18 @@ class ReadyHeap {
310
258
  }
311
259
  /**
312
260
  * `seq` defaults to a fresh monotonic counter. A parked-then-repushed
313
- * task passes its ORIGINAL seq back in so FIFO-among-equals survives
314
- * 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.
315
273
  */
316
274
  push(id: string, seq: number = this.next++): void {
317
275
  this.ids.push(id)
@@ -353,6 +311,17 @@ class ReadyHeap {
353
311
  }
354
312
  }
355
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
+ */
356
325
  export async function runGraph(options: ScheduleOptions): Promise<Map<string, TaskOutcome>> {
357
326
  const { nodes, concurrency, execute, onStart, onFinish } = options
358
327
  const continueMode = options.continueMode ?? 'deps-ok'
@@ -381,7 +350,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
381
350
  // priorities map (e.g. from history-aware critical-path estimation),
382
351
  // use those weights. Falls back to the reverse-deps-count heuristic
383
352
  // for nodes the caller didn't score, so partial coverage works.
384
- const baseline = computeReverseDepCount(nodes)
353
+ const baseline =
354
+ options.restoreTier !== undefined && options.restoreTier.size > 0
355
+ ? tieredReverseDepCount(nodes, options.restoreTier)
356
+ : computeReverseDepCount(nodes)
385
357
  const priority: ReadonlyMap<string, number> = options.priorities
386
358
  ? mergePriorities(baseline, options.priorities)
387
359
  : baseline
@@ -395,6 +367,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
395
367
  // idle capacity (or run when an exec is blocked on a restorable dep and
396
368
  // nothing else is runnable).
397
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)
398
374
  const execReady = new ReadyHeap(priority)
399
375
  const restoreReady = new ReadyHeap(priority)
400
376
  // A restore-tier task is dep-independent (a stable hit's restore needs
@@ -440,16 +416,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
440
416
  const hasRoom = (id: string): boolean => {
441
417
  const pool = poolOf?.(id)
442
418
  if (pool === undefined) {
443
- return options.restoreTier?.has(id)
444
- ? activeRestore < restoreConcurrency
445
- : active < concurrency
419
+ return inRestoreTier(id) ? activeRestore < restoreConcurrency : active < concurrency
446
420
  }
447
421
  return (poolActive.get(pool.name) ?? 0) < pool.capacity
448
422
  }
449
423
  const admit = (id: string): (() => void) => {
450
424
  const pool = poolOf?.(id)
451
425
  if (pool === undefined) {
452
- if (options.restoreTier?.has(id)) {
426
+ if (inRestoreTier(id)) {
453
427
  activeRestore++
454
428
  return () => {
455
429
  activeRestore--
@@ -467,7 +441,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
467
441
  }
468
442
  // The policy sees exec-tier local tasks only; a restore is a tar
469
443
  // extract and a pooled task runs on someone else's capacity.
470
- const local = (id: string): boolean => !options.restoreTier?.has(id) && poolOf?.(id) === undefined
444
+ const local = (id: string): boolean => !inRestoreTier(id) && poolOf?.(id) === undefined
471
445
  const admits = (id: string): boolean => !local(id) || admitPolicy!(id, running)
472
446
  // With no policy nothing reads `running`, so nothing is tracked: the
473
447
  // count-only dispatch allocates no closure and touches no set per task.
@@ -496,14 +470,54 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
496
470
  const m = err instanceof Error ? err.message : String(err)
497
471
  process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
498
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
+ }
499
510
  const ds = dependents.get(id)
500
511
  if (!ds) return
501
512
  for (const d of ds) {
502
513
  const rem = (pending.get(d) ?? 0) - 1
503
514
  pending.set(d, rem)
515
+ if (rem !== 0) continue
504
516
  // Restore-tier dependents were already enqueued at startup (they
505
- // don't wait on deps); only re-enqueue an exec-tier dependent.
506
- 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)
507
521
  }
508
522
  }
509
523
 
@@ -522,7 +536,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
522
536
  const aborted = (): boolean => options.signal?.aborted === true
523
537
  const willSkip = (id: string): boolean => {
524
538
  if (failFastTripped || aborted()) return true
525
- if (restoreTier?.has(id)) return false
539
+ if (inRestoreTier(id)) return false
526
540
  if (continueMode === 'always') return false
527
541
  const node = nodes.get(id) as TaskNode
528
542
  return node.deps.some((d) => {
@@ -533,7 +547,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
533
547
  // from them — under the key a healthy run derives, since the fold
534
548
  // takes the upstream's INPUT key — so the next run replays those
535
549
  // bytes as a green hit.
536
- 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
+ )
537
556
  })
538
557
  }
539
558
 
@@ -594,11 +613,23 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
594
613
  // because dependents are pushed when `pending` hits 0 regardless
595
614
  // of outcome — keeps the propagation logic in one place.
596
615
  if (willSkip(id)) {
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))
597
627
  finishOne(id, {
598
628
  node,
599
629
  status: aborted() ? 'aborted' : 'skipped',
600
630
  exitCode: 1,
601
631
  durationMs: 0,
632
+ ...(blockedBy !== undefined ? { blockedBy } : {}),
602
633
  })
603
634
  continue
604
635
  }
@@ -614,18 +645,28 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
614
645
  heldMs > 0 ? { ...o, admissionHeldMs: heldMs } : o
615
646
  // Crash-isolated observer hook — a throwing onStart must not abort
616
647
  // the dispatch loop (it would strand the tick with the slot held).
617
- try {
618
- onStart?.(node)
619
- } catch (err) {
620
- const m = err instanceof Error ? err.message : String(err)
621
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
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
+ }
622
656
  }
623
657
 
624
658
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
625
659
  // rejection arm handles ONLY `execute()` rejecting; a throw from
626
- // the fulfillment arm (finishOne / onFinish / tick) must NOT also
627
- // run the rejection arm, or `active` releases twice — and a
628
- // double release wedges the count 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.
629
670
  execute(node, upstream).then(
630
671
  (raw) => {
631
672
  const outcome = withHold(raw)
@@ -647,6 +688,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
647
688
  void settled.then(done, done)
648
689
  },
649
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
+ }
650
699
  const message = err instanceof Error ? err.message : String(err)
651
700
  const outcome: TaskOutcome = withHold({
652
701
  node,
@@ -664,20 +713,37 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
664
713
  // plainly, never as an "internal error".
665
714
  if (isUserError(err)) {
666
715
  process.stderr.write(`[vx] ${id}: ${message}\n`)
716
+ } else if (isFsRefusal(err)) {
717
+ process.stderr.write(`[vx] ${id}: ${message} — ${fsRefusalHint(err)}\n`)
667
718
  } else {
668
719
  const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
669
- 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`)
670
730
  }
671
731
  tick()
672
732
  },
673
733
  )
674
734
  }
675
735
 
676
- // Repush parked ids with their ORIGINAL seqs — FIFO-among-equals
677
- // 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`).
678
739
  for (const [id, seq] of parked) execReady.push(id, seq)
679
740
 
680
- if (outcomes.size === nodes.size && active === 0 && activeRestore === 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) {
681
747
  resolved = true
682
748
  resolve(outcomes)
683
749
  }
@@ -686,21 +752,3 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
686
752
  tick()
687
753
  })
688
754
  }
689
-
690
- export function mergePriorities(
691
- baseline: ReadonlyMap<string, number>,
692
- overrides: ReadonlyMap<string, number>,
693
- ): ReadonlyMap<string, number> {
694
- // The override caller scored "expected critical-path duration" in ms.
695
- // Baseline reverse-deps counts are bounded by N; scale the override so
696
- // it sorts above the baseline for any node it covers, and add the
697
- // baseline as a tie-break for parity within the override set.
698
- const SCALE = 1 << 20
699
- const out = new Map<string, number>()
700
- for (const [id, w] of baseline) out.set(id, w)
701
- for (const [id, w] of overrides) {
702
- const b = baseline.get(id) ?? 0
703
- out.set(id, w * SCALE + b)
704
- }
705
- return out
706
- }