@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -4,6 +4,8 @@ import {
4
4
  isOutOfFds,
5
5
  isUserError,
6
6
  OUT_OF_FDS_HINT,
7
+ thrownMessage,
8
+ ui,
7
9
  } from '../util/index.js'
8
10
  import {
9
11
  computeReverseDepCount,
@@ -98,6 +100,12 @@ export interface TaskOutcome {
98
100
  /** v11 analytics: CPU time + peak RSS for this task's child process. */
99
101
  cpuMs?: number
100
102
  peakRssBytes?: number
103
+ /**
104
+ * The most cores one sampling interval burned and the mean resident
105
+ * memory over the looks; only when a sink samples and a look landed.
106
+ */
107
+ peakCores?: number
108
+ avgRssBytes?: number
101
109
  /**
102
110
  * A GROUP task's own dependency outcomes — the tasks it stands for.
103
111
  * A group has no `exec` and therefore no outputs and no cache entry: its
@@ -116,11 +124,11 @@ export interface TaskOutcome {
116
124
  */
117
125
  unkeyed?: true
118
126
  /**
119
- * This task's key folds a task with no `inputs` (or it is one): no run
120
- * can derive that key again, so it neither read nor saved an entry, and a
121
- * dependant that folds `hash` does neither.
127
+ * The task with no `inputs` this task's key folds (this task, or one
128
+ * upstream): no run can derive that key again, so it neither read nor
129
+ * saved an entry, and a dependant that folds `hash` does neither.
122
130
  */
123
- keyless?: true
131
+ keylessBy?: string
124
132
  /**
125
133
  * The run's cache policy read and wrote nothing (`--no-cache`,
126
134
  * `--cache-policy=local:`): no cache answered for this task, so a run of it is
@@ -135,6 +143,12 @@ export interface TaskOutcome {
135
143
  * read this one field (item 267).
136
144
  */
137
145
  blockedBy?: string
146
+ /**
147
+ * On a `skipped` outcome: its `exec.platforms` leave out this machine.
148
+ * Its dependants are skipped with `blockedBy` naming it, and neither
149
+ * fails the run.
150
+ */
151
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
138
152
  /**
139
153
  * On a `failed` outcome: vx's own `timeout` killed the final attempt.
140
154
  * The exit is the shell's 143, and every label says "timed out" instead
@@ -159,6 +173,12 @@ export interface TaskOutcome {
159
173
  * the spawn itself failed. Every label reads this instead of the exit.
160
174
  */
161
175
  notReady?: 'timeout' | 'exited' | 'spawn'
176
+ /**
177
+ * On a ready persistent task: its child exited on its own while the run
178
+ * went on — a setup step's 0 at ready, a crash's code after it — so no
179
+ * label calls it running.
180
+ */
181
+ serverExit?: number
162
182
  /**
163
183
  * The input that changed while the command ran, failing the task under
164
184
  * `inputStability.severity: 'error'` (workspace-relative; '' when vx
@@ -166,8 +186,15 @@ export interface TaskOutcome {
166
186
  */
167
187
  inputMoved?: string
168
188
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
169
- * when the task ran somewhere other than this host. Telemetry-only. */
189
+ * when the task ran somewhere other than this host: telemetry and the task's row. */
170
190
  where?: string
191
+ /** A persistent task each dependant's executor starts where it runs: nothing ran here, and it is no execution of its own. */
192
+ carried?: true
193
+ /** The cores and memory of the machine `where` names (`ExecuteResult.machine`). */
194
+ whereCores?: number
195
+ whereMemoryBytes?: number
196
+ /** `ExecuteResult.machine.cpuShare`: the share of a core its threads got there. */
197
+ cpuShare?: number
171
198
  /** `'deferred'` when the task's outputs were left in the remote store
172
199
  * (`--cache-download=none`) instead of landing on this machine. Absent for
173
200
  * every ordinary outcome. Telemetry-only. */
@@ -201,7 +228,7 @@ export interface TaskOutcome {
201
228
  failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
202
229
  /**
203
230
  * Set when this run proved the task flaky: its key failed after it had
204
- * passed (this run's included) or it needed a retry. Its row carries it.
231
+ * passed (this run's included) or it passed only on a retry. Its row carries it.
205
232
  */
206
233
  flaky?: { passes: number; failures: number }
207
234
  /**
@@ -294,14 +321,16 @@ export interface ScheduleOptions {
294
321
  */
295
322
  settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
296
323
  /**
297
- * The outcome of a task the caller settles in place, or undefined to
298
- * dispatch it through `execute`. Asked of an exec-tier task the lane has
299
- * room for, after the skip check; its outcome lands in the same tick, it
300
- * holds no slot, and `onStart` / `onFinish` hear it as any other. An
301
- * unkeyed group runs nothing, and the dispatch, the slot and the promise
302
- * around it were ~10 µs of each of a warm run's 1,090 groups (X-192).
324
+ * A task the caller settles in place: a thunk returning its outcome, or
325
+ * undefined to dispatch it through `execute`. Asked of a task its lane
326
+ * has room for, after the skip check; `onStart` hears it, then the thunk
327
+ * runs (a hit replays its output there), and its outcome lands in the
328
+ * same tick, holding no slot. An unkeyed group runs nothing, and the
329
+ * dispatch, the slot and the promise around it were ~10 µs of each of a
330
+ * warm run's 1,090 groups (X-192); a hit whose tree is current does no
331
+ * more. A restore-tier task's `upstream` may hold undefined entries.
303
332
  */
304
- settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
333
+ settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
305
334
  /**
306
335
  * Optional priority override: callers pass their own per-node weight
307
336
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -344,6 +373,14 @@ export interface ScheduleOptions {
344
373
  * second cache.get. When undefined/empty, behavior is byte-identical.
345
374
  */
346
375
  restoreTier?: ReadonlySet<string>
376
+ /**
377
+ * Servers held back until the caller knows whether their dependants are
378
+ * hits a remote holds. Each resolves to the dependants it has added to
379
+ * `restoreTier` since the run began (the caller's set, read live); a
380
+ * server whose every dependant is then in the tier is idle, as above,
381
+ * and any other is dispatched as usual.
382
+ */
383
+ heldServers?: ReadonlyMap<string, Promise<readonly string[]>>
347
384
  /**
348
385
  * Pool assignment for tasks placed on an executor with its own capacity
349
386
  * (a remote pool). Such a task occupies one of the pool's `capacity` slots
@@ -386,7 +423,12 @@ class ReadyHeap {
386
423
  // was two per heap level on every push and pop.
387
424
  private readonly prio: number[] = []
388
425
  private next = 0
389
- constructor(private priority: ReadonlyMap<string, number>) {}
426
+ /** Queued ids `counts` holds true for. */
427
+ counted = 0
428
+ constructor(
429
+ private priority: ReadonlyMap<string, number>,
430
+ private readonly counts?: (id: string) => boolean,
431
+ ) {}
390
432
  get size(): number {
391
433
  return this.ids.length
392
434
  }
@@ -441,6 +483,7 @@ class ReadyHeap {
441
483
  * it holds without that argument.
442
484
  */
443
485
  push(id: string, seq: number = this.next++): void {
486
+ if (this.counts?.(id) === true) this.counted++
444
487
  this.ids.push(id)
445
488
  this.seq.push(seq)
446
489
  this.prio.push(this.priority.get(id) ?? 0)
@@ -460,6 +503,7 @@ class ReadyHeap {
460
503
  const n = this.ids.length
461
504
  if (n === 0) return undefined
462
505
  const top = this.ids[0]!
506
+ if (this.counts?.(top) === true) this.counted--
463
507
  const lastId = this.ids.pop()!
464
508
  const lastSeq = this.seq.pop()!
465
509
  const lastPrio = this.prio.pop()!
@@ -571,10 +615,50 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
571
615
  // (`dormant`) until a dependant is demoted, which starts it, or until
572
616
  // every dependant has finished, which settles it unspawned. Settled
573
617
  // without an outcome: it is not part of the run, as a pruned task is not.
574
- const idle = idleServers(nodes, dependents, restoreTier)
618
+ // A group between it and its hits is idle with it.
619
+ const idleGroups = new Set<string>()
620
+ const idle = idleServers(nodes, dependents, restoreTier, idleGroups)
621
+ for (const g of idleGroups) idle.add(g)
575
622
  const dormant = new Set<string>()
623
+ // The idle tasks below `id`, through idle groups: those its outcome may
624
+ // let settle, and those its demotion wakes.
625
+ const idleBelow = (id: string, each: (p: string) => void): void => {
626
+ const stack = [...(nodes.get(id) as TaskNode).deps]
627
+ const seen = new Set<string>()
628
+ while (stack.length > 0) {
629
+ const p = stack.pop()!
630
+ if (seen.has(p) || !idle.has(p)) continue
631
+ seen.add(p)
632
+ each(p)
633
+ if (idleGroups.has(p)) stack.push(...(nodes.get(p) as TaskNode).deps)
634
+ }
635
+ }
636
+ // Every task above an idle one, through idle groups, has finished.
637
+ const settledAbove = (id: string): boolean => {
638
+ const stack = [id]
639
+ const seen = new Set<string>()
640
+ while (stack.length > 0) {
641
+ for (const d of dependents.get(stack.pop()!) ?? []) {
642
+ if (outcomes.has(d)) continue
643
+ if (!idleGroups.has(d) || !idle.has(d)) return false
644
+ if (!seen.has(d)) {
645
+ seen.add(d)
646
+ stack.push(d)
647
+ }
648
+ }
649
+ }
650
+ return true
651
+ }
652
+ // Held servers not yet answered, and those of them that became ready.
653
+ const unanswered = new Set(options.heldServers?.keys() ?? [])
654
+ for (const id of idle) unanswered.delete(id)
655
+ const heldReady = new Set<string>()
576
656
  let settledIdle = 0
577
- const execReady = new ReadyHeap(priority)
657
+ // Counting those no pool takes: with every pool full, only one of them can start.
658
+ const execReady = new ReadyHeap(
659
+ priority,
660
+ options.poolOf === undefined ? undefined : (id) => options.poolOf!(id) === undefined,
661
+ )
578
662
  const restoreReady = new ReadyHeap(priority)
579
663
  options.reprioritize?.((weights) => {
580
664
  const merged = mergePriorities(baseline, weights)
@@ -595,6 +679,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
595
679
  else continue
596
680
  readyAt.set(node.id, startedAt)
597
681
  }
682
+ const promoted = new Set<string>()
598
683
 
599
684
  let active = 0
600
685
  // Restore-tier work is disk I/O — a confirmed hit's tar extract is ~8
@@ -612,8 +697,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
612
697
  const restoreRoom = (): boolean =>
613
698
  serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
614
699
  let resolved = false
700
+ let stopListening = (): void => {}
615
701
  // A refusal's text → the first task that said it. A corrupt cache index
616
- // reaches every lookup as one UserError; a repeat names the first.
702
+ // reaches every lookup as one UserError; on bare stderr a repeat names the first.
617
703
  const refusedBy = new Map<string, string>()
618
704
 
619
705
  // Admission policy over the count limit (a plugin's `admit`). Inactive
@@ -633,6 +719,18 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
633
719
  // against a real executor (and never parks).
634
720
  const poolOf = options.poolOf
635
721
  const poolActive = new Map<string, number>()
722
+ // Every pool a task of the run is placed in, by name: a task's pool is
723
+ // fixed for the run, so read once here.
724
+ const pools = new Map<string, number>()
725
+ if (poolOf !== undefined)
726
+ for (const id of nodes.keys()) {
727
+ const p = poolOf(id)
728
+ if (p !== undefined) pools.set(p.name, p.capacity)
729
+ }
730
+ const poolRoom = (): boolean => {
731
+ for (const [name, capacity] of pools) if ((poolActive.get(name) ?? 0) < capacity) return true
732
+ return false
733
+ }
636
734
  // A pooled task is admitted against ITS pool's capacity, a local one
637
735
  // against `concurrency`; pooled tasks reserve no local resources. A
638
736
  // restore is local disk work wherever its task was placed: on the pool's
@@ -701,7 +799,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
701
799
  // A dormant server is asked again: the dispatch settles it once its
702
800
  // last dependant is in.
703
801
  if (dormant.size > 0) {
704
- for (const p of (nodes.get(id) as TaskNode).deps) if (dormant.delete(p)) execReady.push(p)
802
+ idleBelow(id, (p) => {
803
+ if (dormant.delete(p)) execReady.push(p)
804
+ })
705
805
  }
706
806
  // Observer hook (the logger's taskComplete). Crash-isolated: a
707
807
  // throwing observer must NOT break scheduling — it would otherwise
@@ -712,8 +812,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
712
812
  try {
713
813
  onFinish?.(outcome)
714
814
  } catch (err) {
715
- const m = err instanceof Error ? err.message : String(err)
716
- process.stderr.write(`[vx] onFinish observer threw for ${id}: ${m}\n`)
815
+ const m = thrownMessage(err)
816
+ ui.say('warn', `[vx] onFinish observer threw for ${id}: ${m}\n`)
717
817
  }
718
818
  // A restore-tier task runs before its deps, but its DEPENDENTS keep
719
819
  // the order `dependsOn` states: they are released once its own deps
@@ -734,16 +834,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
734
834
  // one carries.
735
835
  const blockedRestores = new Map<string, string | undefined>()
736
836
  const heldRelease = new Set<string>()
737
- const release = (id: string): void => {
738
- if (inRestoreTier(id) && continueMode !== 'always') {
837
+ const blockOf = (id: string): void => {
838
+ if (inRestoreTier(id)) {
739
839
  for (const dep of (nodes.get(id) as TaskNode).deps) {
740
840
  const u = outcomes.get(dep)
741
- if (u?.status === 'failed' || u?.status === 'aborted') {
841
+ if (continueMode !== 'always' && (u?.status === 'failed' || u?.status === 'aborted')) {
742
842
  blockedRestores.set(id, dep)
743
843
  break
744
844
  }
745
845
  if (u?.status === 'skipped') {
746
- blockedRestores.set(id, u.blockedBy)
846
+ blockedRestores.set(
847
+ id,
848
+ u.blockedBy ?? (u.otherPlatform !== undefined ? dep : undefined),
849
+ )
747
850
  break
748
851
  }
749
852
  if (blockedRestores.has(dep)) {
@@ -752,16 +855,33 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
752
855
  }
753
856
  }
754
857
  }
755
- const ds = dependents.get(id)
756
- if (!ds) return
757
- for (const d of ds) {
858
+ }
859
+ // Its block, then its dependents: one whose pending hits 0 is enqueued,
860
+ // or, when it is a held hit, released in turn before the next sibling.
861
+ // A frame stack, not recursion: a chain of held hits is released in one
862
+ // walk, and recursion threw RangeError on a 30,000-deep one.
863
+ const release = (root: string): void => {
864
+ const frames: { ds: readonly string[]; i: number }[] = []
865
+ const enter = (id: string): void => {
866
+ blockOf(id)
867
+ const ds = dependents.get(id)
868
+ if (ds !== undefined) frames.push({ ds, i: 0 })
869
+ }
870
+ enter(root)
871
+ while (frames.length > 0) {
872
+ const f = frames[frames.length - 1]!
873
+ if (f.i === f.ds.length) {
874
+ frames.pop()
875
+ continue
876
+ }
877
+ const d = f.ds[f.i++]!
758
878
  const rem = (pending.get(d) ?? 0) - 1
759
879
  pending.set(d, rem)
760
880
  if (rem !== 0) continue
761
881
  // Restore-tier dependents were already enqueued at startup (they
762
882
  // don't wait on deps); one that finished early releases its own
763
883
  // dependents now. Only an exec-tier dependent is enqueued.
764
- if (heldRelease.delete(d)) release(d)
884
+ if (heldRelease.delete(d)) enter(d)
765
885
  else if (!inRestoreTier(d)) {
766
886
  execReady.push(d)
767
887
  readyAt.set(d, Date.now())
@@ -802,8 +922,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
802
922
  return true
803
923
  }
804
924
  if (inRestoreTier(id)) return false
805
- if (continueMode === 'always') return false
806
925
  const node = nodes.get(id) as TaskNode
926
+ // `always` runs past a failed upstream, never past one that did not
927
+ // run on this platform: under it, a skip has no other cause.
928
+ if (continueMode === 'always')
929
+ return node.deps.some(
930
+ (d) => outcomes.get(d)?.status === 'skipped' || blockedRestores.has(d),
931
+ )
807
932
  return node.deps.some((d) => {
808
933
  const u = outcomes.get(d)
809
934
  // `aborted` propagates even though it is counted nowhere: the
@@ -823,7 +948,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
823
948
  }
824
949
 
825
950
  // A throw falls back to the dispatch, whose `execute` reports it.
826
- const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
951
+ const settleInPlace = (
952
+ node: TaskNode,
953
+ upstream: TaskOutcome[],
954
+ ): (() => TaskOutcome) | undefined => {
827
955
  if (options.settleNow === undefined) return undefined
828
956
  try {
829
957
  return options.settleNow(node, upstream)
@@ -831,6 +959,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
831
959
  return undefined
832
960
  }
833
961
  }
962
+ const started = (node: TaskNode): void => {
963
+ try {
964
+ onStart?.(node)
965
+ } catch (err) {
966
+ const m = thrownMessage(err)
967
+ ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
968
+ }
969
+ }
834
970
 
835
971
  const tick = (): void => {
836
972
  if (resolved) return
@@ -857,12 +993,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
857
993
  // with a wide frontier that is O(R) per tick, O(R²) per run (the
858
994
  // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
859
995
  // first cut let the scan run past a full exec lane, 2026-09-10).
860
- // This is the legacy O(1) gate, kept per lane.
861
- const execAdmissible = poolOf !== undefined || execRoom()
996
+ // This is the legacy O(1) gate, kept per lane. Every pool full is
997
+ // the same: a remote pool of 6 slots before 2000 ready tasks
998
+ // re-popped them all on each end (DTE, 2026-10-25).
999
+ const execAdmissible =
1000
+ poolOf === undefined ? execRoom() : (execRoom() && execReady.counted > 0) || poolRoom()
862
1001
  if (execAdmissible) {
863
1002
  while (execReady.size > 0) {
864
1003
  const seq = execReady.peekSeq()
865
1004
  const id = execReady.pop() as string
1005
+ if (unanswered.has(id) && !willSkip(id)) {
1006
+ heldReady.add(id)
1007
+ continue
1008
+ }
866
1009
  if (willSkip(id) || idle.has(id)) return id
867
1010
  if (exclusive?.has(id) === true) {
868
1011
  if (!busy() && (!admitActive || admits(id))) return id
@@ -892,35 +1035,60 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
892
1035
  // nothing on the restore path may.
893
1036
  const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
894
1037
 
1038
+ // An idle task the stop or fail-fast would skip settles with no
1039
+ // outcome, as it would have run: the run never meant to start it.
1040
+ // Below a failed dep it is skipped as before, so the hits above it
1041
+ // pass the block down.
1042
+ const settlesIdle =
1043
+ idle.has(id) &&
1044
+ (!willSkip(id) ||
1045
+ aborted() ||
1046
+ (failFastTripped &&
1047
+ !node.deps.some((d) => {
1048
+ const s = outcomes.get(d)?.status
1049
+ return (
1050
+ s === 'failed' || s === 'skipped' || s === 'aborted' || blockedRestores.has(d)
1051
+ )
1052
+ })))
1053
+
895
1054
  // If any upstream failed/skipped, propagate skip synchronously
896
1055
  // without running. Skipped tasks still flow through this queue
897
1056
  // because dependents are pushed when `pending` hits 0 regardless
898
1057
  // of outcome — keeps the propagation logic in one place.
899
- if (willSkip(id)) {
1058
+ if (!settlesIdle && willSkip(id)) {
900
1059
  // The root of the block: a failed or aborted upstream names
901
1060
  // itself; a skipped one hands down its own root.
902
- const blocker = upstream.find(
903
- (u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
904
- )
1061
+ const blocker =
1062
+ continueMode === 'always'
1063
+ ? undefined
1064
+ : upstream.find(
1065
+ (u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
1066
+ )
905
1067
  const viaSkip = upstream.find((u) => u !== undefined && u.status === 'skipped')
906
1068
  const viaRestore = node.deps.find((d) => blockedRestores.has(d))
907
1069
  const blockedBy =
908
1070
  blocker?.node.id ??
909
1071
  node.deps.map(deadServerVia).find((d) => d !== undefined) ??
910
1072
  viaSkip?.blockedBy ??
1073
+ (viaSkip?.otherPlatform !== undefined ? viaSkip.node.id : undefined) ??
911
1074
  (viaRestore === undefined ? undefined : blockedRestores.get(viaRestore))
1075
+ // Behind a platform skip is no failure: the run passes, so the exit does.
1076
+ const notHere =
1077
+ !aborted() &&
1078
+ blockedBy !== undefined &&
1079
+ outcomes.get(blockedBy)?.otherPlatform !== undefined
912
1080
  finishOne(id, {
913
1081
  node,
914
1082
  status: aborted() ? 'aborted' : 'skipped',
915
- exitCode: 1,
1083
+ exitCode: notHere ? 0 : 1,
916
1084
  durationMs: 0,
917
1085
  ...(blockedBy !== undefined ? { blockedBy } : {}),
918
1086
  })
919
1087
  continue
920
1088
  }
921
- if (idle.has(id)) {
1089
+ if (settlesIdle) {
922
1090
  readyAt.delete(id)
923
- if (dependents.get(id)!.every((d) => outcomes.has(d))) {
1091
+ if (settledAbove(id)) {
924
1092
  settledIdle++
925
1093
  release(id)
926
1094
  } else dormant.add(id)
@@ -929,14 +1097,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
929
1097
 
930
1098
  const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
931
1099
  readyAt.delete(id)
932
- const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
933
- if (now !== undefined) {
1100
+ const settle = settleInPlace(node, upstream)
1101
+ let now: TaskOutcome | undefined
1102
+ if (settle !== undefined) {
1103
+ if (!demoted.has(id)) started(node)
934
1104
  try {
935
- onStart?.(node)
936
- } catch (err) {
937
- const m = err instanceof Error ? err.message : String(err)
938
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
1105
+ now = settle()
1106
+ } catch {
1107
+ // Dispatched below, already started: `execute` reports it.
939
1108
  }
1109
+ }
1110
+ if (now !== undefined) {
940
1111
  finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
941
1112
  continue
942
1113
  }
@@ -957,15 +1128,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
957
1128
  }
958
1129
  // Crash-isolated observer hook — a throwing onStart must not abort
959
1130
  // the dispatch loop (it would strand the tick with the slot held).
960
- // A demoted task's second dispatch is the same task, already started.
961
- if (!demoted.has(id)) {
962
- try {
963
- onStart?.(node)
964
- } catch (err) {
965
- const m = err instanceof Error ? err.message : String(err)
966
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
967
- }
968
- }
1131
+ // A demoted task's second dispatch is the same task, already started,
1132
+ // as is one whose settle threw.
1133
+ if (!demoted.has(id) && settle === undefined) started(node)
969
1134
 
970
1135
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
971
1136
  // rejection arm handles ONLY `execute()` rejecting; a throw from
@@ -1005,12 +1170,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1005
1170
  leave()
1006
1171
  untrack()
1007
1172
  demoted.add(id)
1008
- for (const p of node.deps) {
1009
- if (idle.delete(p) && dormant.delete(p)) {
1173
+ idleBelow(id, (p) => {
1174
+ idle.delete(p)
1175
+ if (dormant.delete(p)) {
1010
1176
  execReady.push(p)
1011
1177
  readyAt.set(p, Date.now())
1012
1178
  }
1013
- }
1179
+ })
1014
1180
  if (pending.get(id) === 0) {
1015
1181
  execReady.push(id)
1016
1182
  readyAt.set(id, Date.now())
@@ -1018,7 +1184,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1018
1184
  tick()
1019
1185
  return
1020
1186
  }
1021
- const message = err instanceof Error ? err.message : String(err)
1187
+ const message = thrownMessage(err)
1022
1188
  // After the run's stop a rejection is the stop's doing (a probe
1023
1189
  // it killed, C-65): aborted, as any task it kills, and unsaid.
1024
1190
  const stopped = aborted()
@@ -1045,8 +1211,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1045
1211
  const text = isUserError(err)
1046
1212
  ? message
1047
1213
  : `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
1048
- const first = refusedBy.get(text)
1049
- if (first === undefined) refusedBy.set(text, id)
1214
+ // In a task's own output block, in full: that block is read
1215
+ // alone, and "as <task> above" named one printed elsewhere or
1216
+ // not at all (2026-10-22). On bare stderr the first is above.
1217
+ const first = options.onError === undefined ? refusedBy.get(text) : undefined
1218
+ if (!refusedBy.has(text)) refusedBy.set(text, id)
1050
1219
  line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
1051
1220
  } else {
1052
1221
  const named = err instanceof Error && err.name !== 'Error' ? `${err.name}: ` : ''
@@ -1062,10 +1231,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1062
1231
  line = `[vx] internal error in ${id}: ${named}${message}${because}\n`
1063
1232
  }
1064
1233
  try {
1065
- if (options.onError === undefined) process.stderr.write(line)
1234
+ if (options.onError === undefined) ui.say('error', line)
1066
1235
  else options.onError(node, line, err)
1067
1236
  } catch {
1068
- process.stderr.write(line)
1237
+ ui.say('error', line)
1069
1238
  }
1070
1239
  finishOne(id, outcome)
1071
1240
  tick()
@@ -1085,26 +1254,118 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1085
1254
  // condition survived the whole core suite (item 644).
1086
1255
  if (outcomes.size + settledIdle === nodes.size) {
1087
1256
  resolved = true
1257
+ stopListening()
1088
1258
  resolve(outcomes)
1089
1259
  }
1090
1260
  }
1091
1261
 
1262
+ // A held server's answer: the dependants it found go on the restore
1263
+ // lane, and the server is idle when they were all of them. One whose
1264
+ // deps all settled is already on the exec queue, running, or done:
1265
+ // pushed again it ran twice (its server skipped, the exec lane full).
1266
+ const answer = (id: string, found: readonly string[]): void => {
1267
+ if (resolved || !unanswered.delete(id)) return
1268
+ for (const d of found) {
1269
+ // In `readyAt`: already queued, released to the exec queue when its
1270
+ // server was skipped before this answer. On both lanes it ran twice.
1271
+ if (
1272
+ promoted.has(d) ||
1273
+ outcomes.has(d) ||
1274
+ readyAt.has(d) ||
1275
+ pending.get(d) === 0 ||
1276
+ !inRestoreTier(d)
1277
+ )
1278
+ continue
1279
+ promoted.add(d)
1280
+ restoreReady.push(d)
1281
+ readyAt.set(d, Date.now())
1282
+ }
1283
+ if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
1284
+ if (heldReady.delete(id)) execReady.push(id)
1285
+ tick()
1286
+ }
1287
+ for (const [id, found] of options.heldServers ?? []) {
1288
+ if (unanswered.has(id))
1289
+ void found.then(
1290
+ (ids) => answer(id, ids),
1291
+ () => answer(id, []),
1292
+ )
1293
+ }
1294
+ // A stop reaches the scheduler through a completion, and a held server
1295
+ // waiting on the remote has none: the run waited out the remote's answer.
1296
+ const signal = options.signal
1297
+ if (unanswered.size > 0 && signal !== undefined) {
1298
+ const stop = (): void => {
1299
+ for (const id of [...unanswered]) answer(id, [])
1300
+ }
1301
+ signal.addEventListener('abort', stop, { once: true })
1302
+ stopListening = () => signal.removeEventListener('abort', stop)
1303
+ }
1304
+
1092
1305
  tick()
1093
1306
  })
1094
1307
  }
1095
1308
 
1096
- /** Persistent tasks nobody asked for whose every dependant is a restore-tier hit. */
1309
+ /**
1310
+ * Persistent tasks nobody asked for whose every dependant is a restore-tier
1311
+ * hit, or a group nobody asked for that only such dependants stand behind:
1312
+ * a group runs nothing, so `env: { dependsOn: ['dev'] }` between the server
1313
+ * and its hits booted the server on every warm run. Those groups go in
1314
+ * `groups`.
1315
+ */
1097
1316
  export function idleServers(
1098
1317
  nodes: ReadonlyMap<string, TaskNode>,
1099
1318
  dependents: ReadonlyMap<string, string[]>,
1100
1319
  restoreTier: ReadonlySet<string> | undefined,
1320
+ groups?: Set<string>,
1101
1321
  ): Set<string> {
1102
1322
  const out = new Set<string>()
1103
1323
  if (restoreTier === undefined || restoreTier.size === 0) return out
1324
+ const unasked = (n: TaskNode): boolean => !n.requested && n.surfaced !== true
1325
+ // Per group: whether only hits stand behind it. Post-order on a stack, as
1326
+ // groups nest as deep as the graph.
1327
+ const spare = new Map<string, boolean>()
1328
+ const spareGroup = (root: string): boolean => {
1329
+ const stack = [root]
1330
+ while (stack.length > 0) {
1331
+ const id = stack[stack.length - 1]!
1332
+ if (spare.has(id)) {
1333
+ stack.pop()
1334
+ continue
1335
+ }
1336
+ const n = nodes.get(id)
1337
+ const ds = dependents.get(id)
1338
+ if (n === undefined || !isGroupTask(n) || !unasked(n) || ds === undefined) {
1339
+ spare.set(id, false)
1340
+ stack.pop()
1341
+ continue
1342
+ }
1343
+ const next = ds.find((d) => !restoreTier.has(d) && !spare.has(d))
1344
+ if (next !== undefined) {
1345
+ stack.push(next)
1346
+ continue
1347
+ }
1348
+ stack.pop()
1349
+ spare.set(
1350
+ id,
1351
+ ds.every((d) => restoreTier.has(d) || spare.get(d) === true),
1352
+ )
1353
+ }
1354
+ return spare.get(root)!
1355
+ }
1104
1356
  for (const n of nodes.values()) {
1105
- if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
1357
+ if (n.config.exec?.persistent === undefined || !unasked(n)) continue
1106
1358
  const ds = dependents.get(n.id)
1107
- if (ds !== undefined && ds.every((d) => restoreTier.has(d))) out.add(n.id)
1359
+ if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareGroup(d))) continue
1360
+ out.add(n.id)
1361
+ if (groups === undefined) continue
1362
+ const stack = ds.filter((d) => !restoreTier.has(d))
1363
+ while (stack.length > 0) {
1364
+ const g = stack.pop()!
1365
+ if (groups.has(g)) continue
1366
+ groups.add(g)
1367
+ for (const d of dependents.get(g)!) if (!restoreTier.has(d)) stack.push(d)
1368
+ }
1108
1369
  }
1109
1370
  return out
1110
1371
  }