@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -33,6 +33,81 @@ export class RestoreDemoted extends Error {
33
33
  }
34
34
  }
35
35
 
36
+ /**
37
+ * Thrown by `execute` when restoring a cache hit threw (a full disk, a
38
+ * path in the way, a link out of the project). The run reports `error`
39
+ * as any throw, and the failed outcome says `restoreFailed`: a failed
40
+ * task, counted as neither a miss nor a hit.
41
+ */
42
+ export class RestoreFailed extends Error {
43
+ constructor(readonly error: unknown) {
44
+ super(thrownMessage(error))
45
+ this.name = 'RestoreFailed'
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Thrown by `execute` when deriving the task's key threw (a runtime probe
51
+ * that exited non-zero, a refused `inputs.env` value). The command never
52
+ * ran: the failed outcome is marked (`isKeyFailed`), and the line says so.
53
+ */
54
+ export class KeyFailed extends Error {
55
+ constructor(readonly error: unknown) {
56
+ super(thrownMessage(error))
57
+ this.name = 'KeyFailed'
58
+ }
59
+ }
60
+
61
+ // A mark, not a field: `TaskOutcome` and `RunPlan` are public types. A symbol
62
+ // rides a spread copy and never reaches JSON.
63
+ const KEY_FAILED = Symbol('keyFailed')
64
+
65
+ /** Mark a failed outcome (or a plan holding one) as having no derivable key. */
66
+ export function markKeyFailed<T extends object>(o: T): T {
67
+ ;(o as Record<symbol, unknown>)[KEY_FAILED] = true
68
+ return o
69
+ }
70
+
71
+ /** Whether `markKeyFailed` marked it: no key, so no command ran and no cache was asked. */
72
+ export function isKeyFailed(o: object): boolean {
73
+ return (o as Record<symbol, unknown>)[KEY_FAILED] === true
74
+ }
75
+
76
+ /**
77
+ * Thrown by `execute` when vx's own work on a miss's outputs threw: the
78
+ * clean before an attempt, or the save after an exit 0 (a name that is
79
+ * not UTF-8). The outcome keeps `exitCode` and `durationMs` (what the
80
+ * command returned and took so far) and is marked (`outputsFailedOf`),
81
+ * so no label names an exit the command never returned.
82
+ */
83
+ export class OutputsFailed extends Error {
84
+ constructor(
85
+ readonly error: unknown,
86
+ readonly step: OutputsStep,
87
+ readonly exitCode: number,
88
+ readonly durationMs: number,
89
+ ) {
90
+ super(thrownMessage(error))
91
+ this.name = 'OutputsFailed'
92
+ }
93
+ }
94
+
95
+ type OutputsStep = 'clean' | 'save'
96
+
97
+ // A mark, as KEY_FAILED: `TaskOutcome` is a public type.
98
+ const OUTPUTS_FAILED = Symbol('outputsFailed')
99
+
100
+ /** Mark a failed outcome (or its view) with the output step that failed it. */
101
+ export function markOutputsFailed<T extends object>(o: T, step: OutputsStep): T {
102
+ ;(o as Record<symbol, unknown>)[OUTPUTS_FAILED] = step
103
+ return o
104
+ }
105
+
106
+ /** The output step `markOutputsFailed` marked, if any. */
107
+ export function outputsFailedOf(o: object): OutputsStep | undefined {
108
+ return (o as Record<symbol, OutputsStep | undefined>)[OUTPUTS_FAILED]
109
+ }
110
+
36
111
  /** What moved in a missed task's key since the last entry saved for it. */
37
112
  export interface InputChanges {
38
113
  /** How many components changed, appeared or went. 0: the same key, its entry gone. */
@@ -128,6 +203,13 @@ export interface TaskOutcome {
128
203
  * so a dependant that folds `hash` saves nothing either (A-12).
129
204
  */
130
205
  unkeyed?: true
206
+ /**
207
+ * The input this task's own re-check found moved while it ran (absolute),
208
+ * or null when the key re-derived before the command already differed.
209
+ * The command read a state of it no `vx watch` judgement saw, so the
210
+ * loop counts its next settled state as a change.
211
+ */
212
+ driftedInput?: string | null
131
213
  /**
132
214
  * The task with no `inputs` this task's key folds (this task, or one
133
215
  * upstream): no run can derive that key again, so it neither read nor
@@ -148,6 +230,14 @@ export interface TaskOutcome {
148
230
  * read this one field (item 267).
149
231
  */
150
232
  blockedBy?: string
233
+ /**
234
+ * On an outcome that ran behind a failed dependency (`continueMode:
235
+ * 'always'`), one `--exclude-dependencies` dropped, or a server that died
236
+ * while it ran: the task at the root of it. Its result is not its key's, so no flaky verdict reads it and
237
+ * its run row names the root as a skip's `blockedBy` does. Set by `run()`
238
+ * before any observer (a plugin's `onTaskComplete`) hears the outcome.
239
+ */
240
+ taintedBy?: string
151
241
  /**
152
242
  * On a `skipped` outcome: its `exec.platforms` leave out this machine.
153
243
  * Its dependants are skipped with `blockedBy` naming it, and neither
@@ -186,7 +276,7 @@ export interface TaskOutcome {
186
276
  serverExit?: number
187
277
  /**
188
278
  * The input that changed while the command ran, failing the task under
189
- * `inputStability.severity: 'error'` (workspace-relative; '' when vx
279
+ * `inputs.stability.severity: 'error'` (workspace-relative; '' when vx
190
280
  * could not name it). The exit is vx's 1; the label reads this instead.
191
281
  */
192
282
  inputMoved?: string
@@ -206,6 +296,12 @@ export interface TaskOutcome {
206
296
  outputs?: 'deferred'
207
297
  /** The task ran here and its result went to the cache. The footer's `↑`. */
208
298
  saved?: true
299
+ /**
300
+ * On a `failed` outcome: a cache hit whose restore threw, so no command
301
+ * ran. It counts as failed only, in no cache column: not a miss, not
302
+ * its layer's hit.
303
+ */
304
+ restoreFailed?: true
209
305
  /**
210
306
  * v11 analytics: hrtime span relative to the parent run's t=0.
211
307
  * Lets downstream analytics reconstruct the actual parallel timeline
@@ -231,6 +327,8 @@ export interface TaskOutcome {
231
327
  attempts?: number
232
328
  /** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
233
329
  failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
330
+ /** Each of `failedAttempts`' output, as `failedOutput` encodes it: what `vx last --log` shows. */
331
+ attemptOutputs?: readonly string[]
234
332
  /**
235
333
  * Set when this run proved the task flaky: its key failed after it had
236
334
  * passed (this run's included) or it passed only on a retry. Its row carries it.
@@ -334,6 +432,8 @@ export interface ScheduleOptions {
334
432
  * dispatch, the slot and the promise around it were ~10 µs of each of a
335
433
  * warm run's 1,090 groups (X-192); a hit whose tree is current does no
336
434
  * more. A restore-tier task's `upstream` may hold undefined entries.
435
+ * The thunk's outcome is the scheduler's: its queue time is set on it,
436
+ * not on a copy (a copy per hit, 2026-11-07).
337
437
  */
338
438
  settleNow?: (node: TaskNode, upstream: TaskOutcome[]) => (() => TaskOutcome) | undefined
339
439
  /**
@@ -429,23 +529,23 @@ export interface ScheduleOptions {
429
529
  class ReadyHeap {
430
530
  private readonly ids: string[] = []
431
531
  private readonly seq: number[] = []
432
- // Each slot's priority, read once at push: a map lookup per comparison
532
+ // Each slot's priority, read once at push: a lookup per comparison
433
533
  // was two per heap level on every push and pop.
434
534
  private readonly prio: number[] = []
435
535
  private next = 0
436
536
  /** Queued ids `counts` holds true for. */
437
537
  counted = 0
438
538
  constructor(
439
- private priority: ReadonlyMap<string, number>,
539
+ private priority: (id: string) => number,
440
540
  private readonly counts?: (id: string) => boolean,
441
541
  ) {}
442
542
  get size(): number {
443
543
  return this.ids.length
444
544
  }
445
545
  /** Re-rank every queued id under `priority`, and every later push. */
446
- reprioritize(priority: ReadonlyMap<string, number>): void {
546
+ reprioritize(priority: (id: string) => number): void {
447
547
  this.priority = priority
448
- for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority.get(this.ids[i]!) ?? 0
548
+ for (let i = 0; i < this.ids.length; i++) this.prio[i] = priority(this.ids[i]!)
449
549
  for (let i = (this.ids.length >> 1) - 1; i >= 0; i--) this.down(i)
450
550
  }
451
551
  private down(i: number): void {
@@ -496,7 +596,7 @@ class ReadyHeap {
496
596
  if (this.counts?.(id) === true) this.counted++
497
597
  this.ids.push(id)
498
598
  this.seq.push(seq)
499
- this.prio.push(this.priority.get(id) ?? 0)
599
+ this.prio.push(this.priority(id))
500
600
  let i = this.ids.length - 1
501
601
  while (i > 0) {
502
602
  const parent = (i - 1) >> 1
@@ -528,11 +628,13 @@ class ReadyHeap {
528
628
  }
529
629
 
530
630
  /**
531
- * The dead server a dependency stands for: itself, or one a group reaches.
532
- * A group is a name for its deps, and it finished the moment the server
533
- * was ready, so a task behind it ran against the dead one. Walked with a
534
- * stack and a seen set: recursion threw `RangeError` on a deep chain of
535
- * groups, and re-walked a group each path reached it by.
631
+ * The dead server a dependency stands for: itself, or one a group or a
632
+ * server reaches. A group is a name for its deps, and it finished the
633
+ * moment the server was ready, so a task behind it ran against the dead
634
+ * one; a server stays up on the servers it depends on, so a task behind
635
+ * `web` runs against the `api` under it too. Walked with a stack and a
636
+ * seen set: recursion threw `RangeError` on a deep chain of groups, and
637
+ * re-walked a group each path reached it by.
536
638
  */
537
639
  export function deadServerBehind(
538
640
  nodes: ReadonlyMap<string, TaskNode>,
@@ -547,7 +649,7 @@ export function deadServerBehind(
547
649
  seen.add(at)
548
650
  if (serverDied(at)) return at
549
651
  const n = nodes.get(at)
550
- if (n === undefined || !isGroupTask(n)) continue
652
+ if (n === undefined || (!isGroupTask(n) && n.config.exec?.persistent === undefined)) continue
551
653
  // Reversed, so the first dep is walked first, as the order it names them.
552
654
  for (let i = n.deps.length - 1; i >= 0; i--) stack.push(n.deps[i]!)
553
655
  }
@@ -604,9 +706,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
604
706
  : options.restoreTier !== undefined && options.restoreTier.size > 0
605
707
  ? tieredReverseDepCount(nodes, options.restoreTier)
606
708
  : (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
607
- const priority: ReadonlyMap<string, number> = options.priorities
709
+ const priority: (id: string) => number = options.priorities
608
710
  ? mergePriorities(baseline, options.priorities)
609
- : baseline
711
+ : (id) => baseline.get(id) ?? 0
610
712
 
611
713
  // Two ready queues — exec-tier (cache misses + unstable tasks) and
612
714
  // restore-tier (confirmed stable local hits). Each is a max-heap keyed by
@@ -625,7 +727,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
625
727
  // (`dormant`) until a dependant is demoted, which starts it, or until
626
728
  // every dependant has finished, which settles it unspawned. Settled
627
729
  // without an outcome: it is not part of the run, as a pruned task is not.
628
- // A group between it and its hits is idle with it.
730
+ // A group or a server between it and its hits is idle with it.
629
731
  const idleGroups = new Set<string>()
630
732
  const idle = idleServers(nodes, dependents, restoreTier, idleGroups)
631
733
  for (const g of idleGroups) idle.add(g)
@@ -682,7 +784,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
682
784
  // complete.
683
785
  // When each queued id became ready; dispatch takes it out.
684
786
  const readyAt = new Map<string, number>()
685
- const startedAt = Date.now()
787
+ // Elapsed-time stamps (queue and hold waits), immune to wall clock steps.
788
+ const startedAt = performance.now()
686
789
  for (const node of nodes.values()) {
687
790
  if (restoreTier?.has(node.id)) restoreReady.push(node.id)
688
791
  else if (node.deps.length === 0) execReady.push(node.id)
@@ -894,7 +997,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
894
997
  if (heldRelease.delete(d)) enter(d)
895
998
  else if (!inRestoreTier(d)) {
896
999
  execReady.push(d)
897
- readyAt.set(d, Date.now())
1000
+ readyAt.set(d, performance.now())
898
1001
  }
899
1002
  }
900
1003
  }
@@ -1025,7 +1128,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1025
1128
  if (hasRoom(id)) {
1026
1129
  if (!admitActive || admits(id)) return id
1027
1130
  // A free worker, refused by the policy: the hold starts now.
1028
- if (!heldSince.has(id)) heldSince.set(id, Date.now())
1131
+ if (!heldSince.has(id)) heldSince.set(id, performance.now())
1029
1132
  }
1030
1133
  parked.push([id, seq])
1031
1134
  }
@@ -1105,7 +1208,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1105
1208
  continue
1106
1209
  }
1107
1210
 
1108
- const queuedMs = Date.now() - (readyAt.get(id) ?? Date.now())
1211
+ const readySince = readyAt.get(id)
1212
+ const queuedMs = readySince === undefined ? 0 : Math.round(performance.now() - readySince)
1109
1213
  readyAt.delete(id)
1110
1214
  const settle = settleInPlace(node, upstream)
1111
1215
  let now: TaskOutcome | undefined
@@ -1118,7 +1222,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1118
1222
  }
1119
1223
  }
1120
1224
  if (now !== undefined) {
1121
- finishOne(id, queuedMs === 0 ? now : { ...now, queuedMs })
1225
+ if (queuedMs !== 0) now.queuedMs = queuedMs
1226
+ finishOne(id, now)
1122
1227
  continue
1123
1228
  }
1124
1229
  const leave = admit(id)
@@ -1126,7 +1231,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1126
1231
  // tick sees it; the completion callbacks unlist it.
1127
1232
  const untrack = track(id)
1128
1233
  const since = admitActive ? heldSince.get(id) : undefined
1129
- const heldMs = since === undefined ? 0 : Math.max(1, Date.now() - since)
1234
+ const heldMs = since === undefined ? 0 : Math.max(1, Math.round(performance.now() - since))
1130
1235
  if (since !== undefined) heldSince.delete(id)
1131
1236
  const withHold = (o: TaskOutcome): TaskOutcome =>
1132
1237
  heldMs === 0 && queuedMs === 0
@@ -1175,8 +1280,8 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1175
1280
  }
1176
1281
  void settled.then(done, () => done())
1177
1282
  },
1178
- (err: unknown) => {
1179
- if (err instanceof RestoreDemoted) {
1283
+ (thrown: unknown) => {
1284
+ if (thrown instanceof RestoreDemoted) {
1180
1285
  leave()
1181
1286
  untrack()
1182
1287
  demoted.add(id)
@@ -1184,16 +1289,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1184
1289
  idle.delete(p)
1185
1290
  if (dormant.delete(p)) {
1186
1291
  execReady.push(p)
1187
- readyAt.set(p, Date.now())
1292
+ readyAt.set(p, performance.now())
1188
1293
  }
1189
1294
  })
1190
1295
  if (pending.get(id) === 0) {
1191
1296
  execReady.push(id)
1192
- readyAt.set(id, Date.now())
1297
+ readyAt.set(id, performance.now())
1193
1298
  }
1194
1299
  tick()
1195
1300
  return
1196
1301
  }
1302
+ const err =
1303
+ thrown instanceof RestoreFailed ||
1304
+ thrown instanceof KeyFailed ||
1305
+ thrown instanceof OutputsFailed
1306
+ ? thrown.error
1307
+ : thrown
1197
1308
  const message = thrownMessage(err)
1198
1309
  // After the run's stop a rejection is the stop's doing (a probe
1199
1310
  // it killed, C-65): aborted, as any task it kills, and unsaid.
@@ -1201,9 +1312,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1201
1312
  const outcome: TaskOutcome = withHold({
1202
1313
  node,
1203
1314
  status: stopped ? 'aborted' : 'failed',
1204
- exitCode: 1,
1205
- durationMs: 0,
1315
+ exitCode: thrown instanceof OutputsFailed ? thrown.exitCode : 1,
1316
+ durationMs: thrown instanceof OutputsFailed ? thrown.durationMs : 0,
1317
+ ...(thrown instanceof RestoreFailed && !stopped ? { restoreFailed: true } : {}),
1206
1318
  })
1319
+ if (thrown instanceof KeyFailed && !stopped) markKeyFailed(outcome)
1320
+ if (thrown instanceof OutputsFailed && !stopped) markOutputsFailed(outcome, thrown.step)
1207
1321
  leave()
1208
1322
  untrack()
1209
1323
  if (stopped) {
@@ -1218,9 +1332,10 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1218
1332
  // Out of descriptors is the environment's limit too: a stack
1219
1333
  // naming a file that was fine sent the user after the file.
1220
1334
  if (isUserError(err) || isFsRefusal(err) || isOutOfFds(err)) {
1221
- const text = isUserError(err)
1335
+ const said = isUserError(err)
1222
1336
  ? message
1223
1337
  : `${message} — ${isOutOfFds(err) ? OUT_OF_FDS_HINT : fsRefusalHint(err)}`
1338
+ const text = thrown instanceof KeyFailed ? `cache key not derived: ${said}` : said
1224
1339
  // In a task's own output block, in full: that block is read
1225
1340
  // alone, and "as <task> above" named one printed elsewhere or
1226
1341
  // not at all (2026-10-22). On bare stderr the first is above.
@@ -1288,9 +1403,25 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1288
1403
  continue
1289
1404
  promoted.add(d)
1290
1405
  restoreReady.push(d)
1291
- readyAt.set(d, Date.now())
1406
+ readyAt.set(d, performance.now())
1407
+ }
1408
+ // Idle as `idleServers` finds it: through the groups and servers
1409
+ // nobody asked for above it. The servers beneath it are held on the
1410
+ // same keys, so the walk idles them with it; a server already
1411
+ // answered, or never held, keeps its course.
1412
+ if (found.length > 0) {
1413
+ const tier =
1414
+ demoted.size === 0 ? restoreTier : new Set([...restoreTier!].filter(inRestoreTier))
1415
+ const between = new Set<string>()
1416
+ const holdsHere = (s: string): boolean =>
1417
+ s === id || unanswered.has(s) || isGroupTask(nodes.get(s) as TaskNode)
1418
+ for (const s of idleServers(nodes, dependents, tier, between)) if (holdsHere(s)) idle.add(s)
1419
+ for (const g of between)
1420
+ if (holdsHere(g) && !idleGroups.has(g)) {
1421
+ idleGroups.add(g)
1422
+ idle.add(g)
1423
+ }
1292
1424
  }
1293
- if (dependents.get(id)?.every((d) => inRestoreTier(d)) === true) idle.add(id)
1294
1425
  if (heldReady.delete(id)) execReady.push(id)
1295
1426
  tick()
1296
1427
  }
@@ -1318,10 +1449,11 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1318
1449
 
1319
1450
  /**
1320
1451
  * Persistent tasks nobody asked for whose every dependant is a restore-tier
1321
- * hit, or a group nobody asked for that only such dependants stand behind:
1322
- * a group runs nothing, so `env: { dependsOn: ['dev'] }` between the server
1323
- * and its hits booted the server on every warm run. Those groups go in
1324
- * `groups`.
1452
+ * hit, or a group or server nobody asked for that only such dependants
1453
+ * stand behind: a group runs nothing, so `env: { dependsOn: ['dev'] }`
1454
+ * between the server and its hits booted the server on every warm run, and
1455
+ * `api` under an idle `web` serves nobody either. Those groups and servers
1456
+ * go in `groups`: the scheduler settles them as it settles a group.
1325
1457
  */
1326
1458
  export function idleServers(
1327
1459
  nodes: ReadonlyMap<string, TaskNode>,
@@ -1332,10 +1464,10 @@ export function idleServers(
1332
1464
  const out = new Set<string>()
1333
1465
  if (restoreTier === undefined || restoreTier.size === 0) return out
1334
1466
  const unasked = (n: TaskNode): boolean => !n.requested && n.surfaced !== true
1335
- // Per group: whether only hits stand behind it. Post-order on a stack, as
1336
- // groups nest as deep as the graph.
1467
+ // Per group or server: whether only hits stand behind it. Post-order on a
1468
+ // stack, as groups and servers nest as deep as the graph.
1337
1469
  const spare = new Map<string, boolean>()
1338
- const spareGroup = (root: string): boolean => {
1470
+ const spareNode = (root: string): boolean => {
1339
1471
  const stack = [root]
1340
1472
  while (stack.length > 0) {
1341
1473
  const id = stack[stack.length - 1]!
@@ -1345,7 +1477,12 @@ export function idleServers(
1345
1477
  }
1346
1478
  const n = nodes.get(id)
1347
1479
  const ds = dependents.get(id)
1348
- if (n === undefined || !isGroupTask(n) || !unasked(n) || ds === undefined) {
1480
+ if (
1481
+ n === undefined ||
1482
+ (!isGroupTask(n) && n.config.exec?.persistent === undefined) ||
1483
+ !unasked(n) ||
1484
+ ds === undefined
1485
+ ) {
1349
1486
  spare.set(id, false)
1350
1487
  stack.pop()
1351
1488
  continue
@@ -1366,7 +1503,7 @@ export function idleServers(
1366
1503
  for (const n of nodes.values()) {
1367
1504
  if (n.config.exec?.persistent === undefined || !unasked(n)) continue
1368
1505
  const ds = dependents.get(n.id)
1369
- if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareGroup(d))) continue
1506
+ if (ds === undefined || !ds.every((d) => restoreTier.has(d) || spareNode(d))) continue
1370
1507
  out.add(n.id)
1371
1508
  if (groups === undefined) continue
1372
1509
  const stack = ds.filter((d) => !restoreTier.has(d))
@@ -0,0 +1,36 @@
1
+ // What an edge past a default build on a package cycle stands in for. Such
2
+ // a build folds nothing of its cycle, so the walk takes edges past it
3
+ // (`goPast`, `walkHolders` in task-graph.ts), and `inputs.tasks` selecting
4
+ // it selects them (`selectFoldedDeps`).
5
+ //
6
+ // Kept off the public `TaskNode` shape under a symbol key: a spread copy
7
+ // (the scheduler's restore view, an executor's `accepts` copy) carries it,
8
+ // and `applyGraphStage` re-attaches it by id to a node a hook rebuilt.
9
+
10
+ import type { TaskNode } from './task-graph.js'
11
+
12
+ /** Each edge's id, to the ids of the edges it went past. */
13
+ export type StandIns = Readonly<Record<string, readonly string[]>>
14
+
15
+ const STAND_INS = Symbol('vx.standIns')
16
+ type Carrier = TaskNode & { [STAND_INS]?: Record<string, string[]> }
17
+
18
+ /** The node's stand-ins; undefined off a package cycle. */
19
+ export function standInsOf(node: TaskNode): StandIns | undefined {
20
+ return (node as Carrier)[STAND_INS]
21
+ }
22
+
23
+ /** Give `node` its stand-ins: a graph read back, or a node a hook rebuilt. */
24
+ export function setStandIns(node: TaskNode, standIns: StandIns): void {
25
+ const own: Record<string, string[]> = {}
26
+ for (const [edge, past] of Object.entries(standIns)) own[edge] = [...past]
27
+ ;(node as Carrier)[STAND_INS] = own
28
+ }
29
+
30
+ /** Record that `node`'s edge to `id` stands in for the edges in `past`. */
31
+ export function addStandIn(node: TaskNode, id: string, past: readonly string[]): void {
32
+ const map = ((node as Carrier)[STAND_INS] ??= {})
33
+ const known = map[id]
34
+ if (known === undefined) map[id] = [...past]
35
+ else for (const p of past) if (!known.includes(p)) known.push(p)
36
+ }