@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -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,
@@ -141,6 +143,12 @@ export interface TaskOutcome {
141
143
  * read this one field (item 267).
142
144
  */
143
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 }
144
152
  /**
145
153
  * On a `failed` outcome: vx's own `timeout` killed the final attempt.
146
154
  * The exit is the shell's 143, and every label says "timed out" instead
@@ -165,6 +173,12 @@ export interface TaskOutcome {
165
173
  * the spawn itself failed. Every label reads this instead of the exit.
166
174
  */
167
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
168
182
  /**
169
183
  * The input that changed while the command ran, failing the task under
170
184
  * `inputStability.severity: 'error'` (workspace-relative; '' when vx
@@ -174,9 +188,13 @@ export interface TaskOutcome {
174
188
  /** Executor-reported placement label (`ExecuteResult.where`) — set only
175
189
  * when the task ran somewhere other than this host: telemetry and the task's row. */
176
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
177
193
  /** The cores and memory of the machine `where` names (`ExecuteResult.machine`). */
178
194
  whereCores?: number
179
195
  whereMemoryBytes?: number
196
+ /** `ExecuteResult.machine.cpuShare`: the share of a core its threads got there. */
197
+ cpuShare?: number
180
198
  /** `'deferred'` when the task's outputs were left in the remote store
181
199
  * (`--cache-download=none`) instead of landing on this machine. Absent for
182
200
  * every ordinary outcome. Telemetry-only. */
@@ -210,7 +228,7 @@ export interface TaskOutcome {
210
228
  failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
211
229
  /**
212
230
  * Set when this run proved the task flaky: its key failed after it had
213
- * 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.
214
232
  */
215
233
  flaky?: { passes: number; failures: number }
216
234
  /**
@@ -303,14 +321,16 @@ export interface ScheduleOptions {
303
321
  */
304
322
  settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
305
323
  /**
306
- * The outcome of a task the caller settles in place, or undefined to
307
- * dispatch it through `execute`. Asked of an exec-tier task the lane has
308
- * room for, after the skip check; its outcome lands in the same tick, it
309
- * holds no slot, and `onStart` / `onFinish` hear it as any other. An
310
- * unkeyed group runs nothing, and the dispatch, the slot and the promise
311
- * 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.
312
332
  */
313
- settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => TaskOutcome | undefined
333
+ settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
314
334
  /**
315
335
  * Optional priority override: callers pass their own per-node weight
316
336
  * (e.g. `computePredictedPriorities` from the orchestrator's history
@@ -403,7 +423,12 @@ class ReadyHeap {
403
423
  // was two per heap level on every push and pop.
404
424
  private readonly prio: number[] = []
405
425
  private next = 0
406
- 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
+ ) {}
407
432
  get size(): number {
408
433
  return this.ids.length
409
434
  }
@@ -458,6 +483,7 @@ class ReadyHeap {
458
483
  * it holds without that argument.
459
484
  */
460
485
  push(id: string, seq: number = this.next++): void {
486
+ if (this.counts?.(id) === true) this.counted++
461
487
  this.ids.push(id)
462
488
  this.seq.push(seq)
463
489
  this.prio.push(this.priority.get(id) ?? 0)
@@ -477,6 +503,7 @@ class ReadyHeap {
477
503
  const n = this.ids.length
478
504
  if (n === 0) return undefined
479
505
  const top = this.ids[0]!
506
+ if (this.counts?.(top) === true) this.counted--
480
507
  const lastId = this.ids.pop()!
481
508
  const lastSeq = this.seq.pop()!
482
509
  const lastPrio = this.prio.pop()!
@@ -588,14 +615,50 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
588
615
  // (`dormant`) until a dependant is demoted, which starts it, or until
589
616
  // every dependant has finished, which settles it unspawned. Settled
590
617
  // without an outcome: it is not part of the run, as a pruned task is not.
591
- 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)
592
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
+ }
593
652
  // Held servers not yet answered, and those of them that became ready.
594
653
  const unanswered = new Set(options.heldServers?.keys() ?? [])
595
654
  for (const id of idle) unanswered.delete(id)
596
655
  const heldReady = new Set<string>()
597
656
  let settledIdle = 0
598
- 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
+ )
599
662
  const restoreReady = new ReadyHeap(priority)
600
663
  options.reprioritize?.((weights) => {
601
664
  const merged = mergePriorities(baseline, weights)
@@ -634,6 +697,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
634
697
  const restoreRoom = (): boolean =>
635
698
  serial ? active + activeRestore < 1 : activeRestore < restoreConcurrency
636
699
  let resolved = false
700
+ let stopListening = (): void => {}
637
701
  // A refusal's text → the first task that said it. A corrupt cache index
638
702
  // reaches every lookup as one UserError; on bare stderr a repeat names the first.
639
703
  const refusedBy = new Map<string, string>()
@@ -655,6 +719,18 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
655
719
  // against a real executor (and never parks).
656
720
  const poolOf = options.poolOf
657
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
+ }
658
734
  // A pooled task is admitted against ITS pool's capacity, a local one
659
735
  // against `concurrency`; pooled tasks reserve no local resources. A
660
736
  // restore is local disk work wherever its task was placed: on the pool's
@@ -723,7 +799,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
723
799
  // A dormant server is asked again: the dispatch settles it once its
724
800
  // last dependant is in.
725
801
  if (dormant.size > 0) {
726
- 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
+ })
727
805
  }
728
806
  // Observer hook (the logger's taskComplete). Crash-isolated: a
729
807
  // throwing observer must NOT break scheduling — it would otherwise
@@ -734,8 +812,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
734
812
  try {
735
813
  onFinish?.(outcome)
736
814
  } catch (err) {
737
- const m = err instanceof Error ? err.message : String(err)
738
- 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`)
739
817
  }
740
818
  // A restore-tier task runs before its deps, but its DEPENDENTS keep
741
819
  // the order `dependsOn` states: they are released once its own deps
@@ -756,16 +834,19 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
756
834
  // one carries.
757
835
  const blockedRestores = new Map<string, string | undefined>()
758
836
  const heldRelease = new Set<string>()
759
- const release = (id: string): void => {
760
- if (inRestoreTier(id) && continueMode !== 'always') {
837
+ const blockOf = (id: string): void => {
838
+ if (inRestoreTier(id)) {
761
839
  for (const dep of (nodes.get(id) as TaskNode).deps) {
762
840
  const u = outcomes.get(dep)
763
- if (u?.status === 'failed' || u?.status === 'aborted') {
841
+ if (continueMode !== 'always' && (u?.status === 'failed' || u?.status === 'aborted')) {
764
842
  blockedRestores.set(id, dep)
765
843
  break
766
844
  }
767
845
  if (u?.status === 'skipped') {
768
- blockedRestores.set(id, u.blockedBy)
846
+ blockedRestores.set(
847
+ id,
848
+ u.blockedBy ?? (u.otherPlatform !== undefined ? dep : undefined),
849
+ )
769
850
  break
770
851
  }
771
852
  if (blockedRestores.has(dep)) {
@@ -774,16 +855,33 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
774
855
  }
775
856
  }
776
857
  }
777
- const ds = dependents.get(id)
778
- if (!ds) return
779
- 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++]!
780
878
  const rem = (pending.get(d) ?? 0) - 1
781
879
  pending.set(d, rem)
782
880
  if (rem !== 0) continue
783
881
  // Restore-tier dependents were already enqueued at startup (they
784
882
  // don't wait on deps); one that finished early releases its own
785
883
  // dependents now. Only an exec-tier dependent is enqueued.
786
- if (heldRelease.delete(d)) release(d)
884
+ if (heldRelease.delete(d)) enter(d)
787
885
  else if (!inRestoreTier(d)) {
788
886
  execReady.push(d)
789
887
  readyAt.set(d, Date.now())
@@ -824,8 +922,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
824
922
  return true
825
923
  }
826
924
  if (inRestoreTier(id)) return false
827
- if (continueMode === 'always') return false
828
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
+ )
829
932
  return node.deps.some((d) => {
830
933
  const u = outcomes.get(d)
831
934
  // `aborted` propagates even though it is counted nowhere: the
@@ -845,7 +948,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
845
948
  }
846
949
 
847
950
  // A throw falls back to the dispatch, whose `execute` reports it.
848
- const settleInPlace = (node: TaskNode, upstream: TaskOutcome[]): TaskOutcome | undefined => {
951
+ const settleInPlace = (
952
+ node: TaskNode,
953
+ upstream: TaskOutcome[],
954
+ ): (() => TaskOutcome) | undefined => {
849
955
  if (options.settleNow === undefined) return undefined
850
956
  try {
851
957
  return options.settleNow(node, upstream)
@@ -853,6 +959,14 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
853
959
  return undefined
854
960
  }
855
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
+ }
856
970
 
857
971
  const tick = (): void => {
858
972
  if (resolved) return
@@ -879,8 +993,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
879
993
  // with a wide frontier that is O(R) per tick, O(R²) per run (the
880
994
  // 6,000-task scale pin went 0.5 s → 28 s when the restore lane's
881
995
  // first cut let the scan run past a full exec lane, 2026-09-10).
882
- // This is the legacy O(1) gate, kept per lane.
883
- 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()
884
1001
  if (execAdmissible) {
885
1002
  while (execReady.size > 0) {
886
1003
  const seq = execReady.peekSeq()
@@ -918,35 +1035,60 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
918
1035
  // nothing on the restore path may.
919
1036
  const upstream = node.deps.map((d) => outcomes.get(d) as TaskOutcome)
920
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
+
921
1054
  // If any upstream failed/skipped, propagate skip synchronously
922
1055
  // without running. Skipped tasks still flow through this queue
923
1056
  // because dependents are pushed when `pending` hits 0 regardless
924
1057
  // of outcome — keeps the propagation logic in one place.
925
- if (willSkip(id)) {
1058
+ if (!settlesIdle && willSkip(id)) {
926
1059
  // The root of the block: a failed or aborted upstream names
927
1060
  // itself; a skipped one hands down its own root.
928
- const blocker = upstream.find(
929
- (u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
930
- )
1061
+ const blocker =
1062
+ continueMode === 'always'
1063
+ ? undefined
1064
+ : upstream.find(
1065
+ (u) => u !== undefined && (u.status === 'failed' || u.status === 'aborted'),
1066
+ )
931
1067
  const viaSkip = upstream.find((u) => u !== undefined && u.status === 'skipped')
932
1068
  const viaRestore = node.deps.find((d) => blockedRestores.has(d))
933
1069
  const blockedBy =
934
1070
  blocker?.node.id ??
935
1071
  node.deps.map(deadServerVia).find((d) => d !== undefined) ??
936
1072
  viaSkip?.blockedBy ??
1073
+ (viaSkip?.otherPlatform !== undefined ? viaSkip.node.id : undefined) ??
937
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
938
1080
  finishOne(id, {
939
1081
  node,
940
1082
  status: aborted() ? 'aborted' : 'skipped',
941
- exitCode: 1,
1083
+ exitCode: notHere ? 0 : 1,
942
1084
  durationMs: 0,
943
1085
  ...(blockedBy !== undefined ? { blockedBy } : {}),
944
1086
  })
945
1087
  continue
946
1088
  }
947
- if (idle.has(id)) {
1089
+ if (settlesIdle) {
948
1090
  readyAt.delete(id)
949
- if (dependents.get(id)!.every((d) => outcomes.has(d))) {
1091
+ if (settledAbove(id)) {
950
1092
  settledIdle++
951
1093
  release(id)
952
1094
  } else dormant.add(id)
@@ -955,14 +1097,17 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
955
1097
 
956
1098
  const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
957
1099
  readyAt.delete(id)
958
- const now = inRestoreTier(id) ? undefined : settleInPlace(node, upstream)
959
- 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)
960
1104
  try {
961
- onStart?.(node)
962
- } catch (err) {
963
- const m = err instanceof Error ? err.message : String(err)
964
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
1105
+ now = settle()
1106
+ } catch {
1107
+ // Dispatched below, already started: `execute` reports it.
965
1108
  }
1109
+ }
1110
+ if (now !== undefined) {
966
1111
  finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
967
1112
  continue
968
1113
  }
@@ -983,15 +1128,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
983
1128
  }
984
1129
  // Crash-isolated observer hook — a throwing onStart must not abort
985
1130
  // the dispatch loop (it would strand the tick with the slot held).
986
- // A demoted task's second dispatch is the same task, already started.
987
- if (!demoted.has(id)) {
988
- try {
989
- onStart?.(node)
990
- } catch (err) {
991
- const m = err instanceof Error ? err.message : String(err)
992
- process.stderr.write(`[vx] onStart observer threw for ${id}: ${m}\n`)
993
- }
994
- }
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)
995
1134
 
996
1135
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
997
1136
  // rejection arm handles ONLY `execute()` rejecting; a throw from
@@ -1031,12 +1170,13 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1031
1170
  leave()
1032
1171
  untrack()
1033
1172
  demoted.add(id)
1034
- for (const p of node.deps) {
1035
- if (idle.delete(p) && dormant.delete(p)) {
1173
+ idleBelow(id, (p) => {
1174
+ idle.delete(p)
1175
+ if (dormant.delete(p)) {
1036
1176
  execReady.push(p)
1037
1177
  readyAt.set(p, Date.now())
1038
1178
  }
1039
- }
1179
+ })
1040
1180
  if (pending.get(id) === 0) {
1041
1181
  execReady.push(id)
1042
1182
  readyAt.set(id, Date.now())
@@ -1044,7 +1184,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1044
1184
  tick()
1045
1185
  return
1046
1186
  }
1047
- const message = err instanceof Error ? err.message : String(err)
1187
+ const message = thrownMessage(err)
1048
1188
  // After the run's stop a rejection is the stop's doing (a probe
1049
1189
  // it killed, C-65): aborted, as any task it kills, and unsaid.
1050
1190
  const stopped = aborted()
@@ -1073,7 +1213,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1073
1213
  : `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
1074
1214
  // In a task's own output block, in full: that block is read
1075
1215
  // alone, and "as <task> above" named one printed elsewhere or
1076
- // not at all (M, 2026-10-22). On bare stderr the first is above.
1216
+ // not at all (2026-10-22). On bare stderr the first is above.
1077
1217
  const first = options.onError === undefined ? refusedBy.get(text) : undefined
1078
1218
  if (!refusedBy.has(text)) refusedBy.set(text, id)
1079
1219
  line = `[vx] ${id}: ${first === undefined ? text : `as ${first} above`}\n`
@@ -1091,10 +1231,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1091
1231
  line = `[vx] internal error in ${id}: ${named}${message}${because}\n`
1092
1232
  }
1093
1233
  try {
1094
- if (options.onError === undefined) process.stderr.write(line)
1234
+ if (options.onError === undefined) ui.say('error', line)
1095
1235
  else options.onError(node, line, err)
1096
1236
  } catch {
1097
- process.stderr.write(line)
1237
+ ui.say('error', line)
1098
1238
  }
1099
1239
  finishOne(id, outcome)
1100
1240
  tick()
@@ -1114,16 +1254,28 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1114
1254
  // condition survived the whole core suite (item 644).
1115
1255
  if (outcomes.size + settledIdle === nodes.size) {
1116
1256
  resolved = true
1257
+ stopListening()
1117
1258
  resolve(outcomes)
1118
1259
  }
1119
1260
  }
1120
1261
 
1121
1262
  // A held server's answer: the dependants it found go on the restore
1122
- // lane, and the server is idle when they were all of them.
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).
1123
1266
  const answer = (id: string, found: readonly string[]): void => {
1124
1267
  if (resolved || !unanswered.delete(id)) return
1125
1268
  for (const d of found) {
1126
- if (promoted.has(d) || outcomes.has(d) || !inRestoreTier(d)) continue
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
1127
1279
  promoted.add(d)
1128
1280
  restoreReady.push(d)
1129
1281
  readyAt.set(d, Date.now())
@@ -1139,23 +1291,81 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1139
1291
  () => answer(id, []),
1140
1292
  )
1141
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
+ }
1142
1304
 
1143
1305
  tick()
1144
1306
  })
1145
1307
  }
1146
1308
 
1147
- /** 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
+ */
1148
1316
  export function idleServers(
1149
1317
  nodes: ReadonlyMap<string, TaskNode>,
1150
1318
  dependents: ReadonlyMap<string, string[]>,
1151
1319
  restoreTier: ReadonlySet<string> | undefined,
1320
+ groups?: Set<string>,
1152
1321
  ): Set<string> {
1153
1322
  const out = new Set<string>()
1154
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
+ }
1155
1356
  for (const n of nodes.values()) {
1156
- if (n.config.exec?.persistent === undefined || n.requested || n.surfaced === true) continue
1357
+ if (n.config.exec?.persistent === undefined || !unasked(n)) continue
1157
1358
  const ds = dependents.get(n.id)
1158
- 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
+ }
1159
1369
  }
1160
1370
  return out
1161
1371
  }