@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -156,29 +156,47 @@ export function computeReverseDepCount(
156
156
  /** Count only these dependents (the exec tier); every one when absent. */
157
157
  counted?: ReadonlyMap<string, unknown>,
158
158
  ): Map<string, number> {
159
+ return reverseDepCountsOver(nodes, [counted])[0]!
160
+ }
161
+
162
+ /**
163
+ * `computeReverseDepCount` once per entry of `counted`, from ONE closure:
164
+ * the closure is the cost (~100 ms at 11k nodes, 2026-10-29), each count
165
+ * one popcount pass over it.
166
+ */
167
+ function reverseDepCountsOver(
168
+ nodes: ReadonlyMap<string, TaskNode>,
169
+ counted: ReadonlyArray<ReadonlyMap<string, unknown> | undefined>,
170
+ ): Map<string, number>[] {
159
171
  // Exact transitive-dependent COUNTS need closure SETS (diamonds
160
172
  // double-count under naive summing). Set-of-strings closures are
161
173
  // O(N²) entries and took 8.5s on a 1090-package, 100-layer repo;
162
- // bitsets make the same closure O(E·N/32) time and N²/8 bits of
163
- // memory (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
174
+ // bitsets make the same closure O(E·N/32) time over N²/8 bits
175
+ // (3270 tasks ≈ 1.3 MB) — single-digit ms at that scale.
164
176
  const ids = [...nodes.keys()]
165
- const index = new Map<string, number>()
166
- for (let i = 0; i < ids.length; i++) index.set(ids[i]!, i)
167
177
  const n = ids.length
168
- const words = (n + 31) >>> 5
178
+ const index = new Map<string, number>()
179
+ for (let i = 0; i < n; i++) index.set(ids[i]!, i)
169
180
 
170
- // Direct dependents as index lists + in-degree for the topo pass.
171
- const directReverse: number[][] = Array.from({ length: n }, () => [])
181
+ // Direct dependents as one flat list (offsets per node) + in-degree.
182
+ const from: number[] = []
183
+ const to: number[] = []
184
+ const offset = new Int32Array(n + 1)
172
185
  const indegree = new Uint32Array(n)
173
- for (const node of nodes.values()) {
174
- const ni = index.get(node.id)!
175
- for (const dep of node.deps) {
186
+ for (let ni = 0; ni < n; ni++) {
187
+ for (const dep of nodes.get(ids[ni]!)!.deps) {
176
188
  const di = index.get(dep)
177
189
  if (di === undefined) continue
178
- directReverse[di]!.push(ni)
190
+ from.push(di)
191
+ to.push(ni)
192
+ offset[di + 1]!++
179
193
  indegree[ni]!++
180
194
  }
181
195
  }
196
+ for (let i = 0; i < n; i++) offset[i + 1]! += offset[i]!
197
+ const next = offset.slice(0, n)
198
+ const dependents = new Int32Array(from.length)
199
+ for (let e = 0; e < from.length; e++) dependents[next[from[e]!]!++] = to[e]!
182
200
 
183
201
  // Kahn topo order over dependency edges (deps before dependents).
184
202
  // Insertion order is topo today, but the closure's correctness
@@ -189,48 +207,84 @@ export function computeReverseDepCount(
189
207
  for (let i = 0; i < n; i++) if (indegree[i] === 0) topo[tail++] = i
190
208
  while (head < tail) {
191
209
  const v = topo[head++]!
192
- for (const r of directReverse[v]!) {
210
+ for (let k = offset[v]!; k < offset[v + 1]!; k++) {
211
+ const r = dependents[k]!
193
212
  if (--indegree[r]! === 0) topo[tail++] = r
194
213
  }
195
214
  }
196
215
 
197
- // Reverse-topo sweep: every direct dependent's closure is final
198
- // before its dependency folds it in. closure[t] = bitset over topo
199
- // POSITIONS of the node at position t's transitive dependents. A
200
- // dependent sits after its dependency, so a closure holds only bits
201
- // past its own position, and each OR starts at the dependent's word:
202
- // half the words of indexing by insertion order on a layered graph.
216
+ // The same lists by topo POSITION. A dependent sits after its
217
+ // dependency, so a closure holds only bits past its own position.
203
218
  const pos = new Int32Array(n)
204
219
  for (let t = 0; t < tail; t++) pos[topo[t]!] = t
205
- const mask = new Uint32Array(words)
206
- if (counted === undefined) mask.fill(0xffffffff)
207
- else
208
- for (let t = 0; t < tail; t++) if (counted.has(ids[topo[t]!]!)) mask[t >>> 5]! |= 1 << (t & 31)
209
- const closure = new Uint32Array(n * words)
210
- const counts = new Map<string, number>()
211
- for (let t = tail - 1; t >= 0; t--) {
220
+ const start = new Int32Array(tail + 1)
221
+ for (let t = 0; t < tail; t++) {
212
222
  const i = topo[t]!
213
- const base = t * words
214
- for (const ri of directReverse[i]!) {
215
- const r = pos[ri]!
216
- closure[base + (r >>> 5)]! |= 1 << (r & 31)
217
- const rbase = r * words
218
- for (let w = r >>> 5; w < words; w++) closure[base + w]! |= closure[rbase + w]!
219
- }
220
- let count = 0
221
- for (let w = t >>> 5; w < words; w++) {
222
- let v = closure[base + w]! & mask[w]!
223
- v = v - ((v >>> 1) & 0x55555555)
224
- v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
225
- count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
223
+ start[t + 1] = start[t]! + offset[i + 1]! - offset[i]!
224
+ }
225
+ const rev = new Int32Array(start[tail]!)
226
+ for (let t = 0; t < tail; t++) {
227
+ const i = topo[t]!
228
+ let k = start[t]!
229
+ for (let j = offset[i]!; j < offset[i + 1]!; j++) rev[k++] = pos[dependents[j]!]!
230
+ }
231
+ const words = (tail + 31) >>> 5
232
+ const masks = counted.map((only) => {
233
+ const mask = new Uint32Array(words)
234
+ if (only === undefined) mask.fill(0xffffffff)
235
+ else
236
+ for (let t = 0; t < tail; t++) if (only.has(ids[topo[t]!]!)) mask[t >>> 5]! |= 1 << (t & 31)
237
+ return mask
238
+ })
239
+
240
+ // Reverse-topo sweeps, one per block of `span` words: every node's
241
+ // closure restricted to the block's positions, its count summed over
242
+ // the blocks. All N rows of all words (9,603 tasks: 11.5 MB) missed
243
+ // the cache on nearly every OR, and the first call, the only one a run
244
+ // makes, took 77 ms; a block of ~256 KB took 37 (2026-10-29). A
245
+ // dependent past the block holds no bit in it, so its edge is skipped.
246
+ const span = Math.max(1, Math.min(words, Math.floor(65536 / Math.max(1, tail))))
247
+ const totals = masks.map(() => new Int32Array(tail))
248
+ const closure = new Uint32Array(tail * span)
249
+ for (let w0 = 0; w0 < words; w0 += span) {
250
+ const width = Math.min(words, w0 + span) - w0
251
+ const end = Math.min(tail, (w0 + width) << 5)
252
+ if (w0 > 0) closure.fill(0, 0, end * span)
253
+ for (let t = end - 1; t >= 0; t--) {
254
+ const base = t * span
255
+ for (let k = start[t]!; k < start[t + 1]!; k++) {
256
+ const r = rev[k]!
257
+ if (r >= end) continue
258
+ const rw = r >>> 5
259
+ if (rw >= w0) closure[base + rw - w0]! |= 1 << (r & 31)
260
+ const rbase = r * span
261
+ for (let w = rw > w0 ? rw - w0 : 0; w < width; w++)
262
+ closure[base + w]! |= closure[rbase + w]!
263
+ }
264
+ for (let m = 0; m < masks.length; m++) {
265
+ const mask = masks[m]!
266
+ let count = 0
267
+ for (let w = t >>> 5 > w0 ? (t >>> 5) - w0 : 0; w < width; w++) {
268
+ let v = closure[base + w]! & mask[w0 + w]!
269
+ if (v === 0) continue
270
+ v = v - ((v >>> 1) & 0x55555555)
271
+ v = (v & 0x33333333) + ((v >>> 2) & 0x33333333)
272
+ count += (((v + (v >>> 4)) & 0x0f0f0f0f) * 0x01010101) >>> 24
273
+ }
274
+ totals[m]![t]! += count
275
+ }
226
276
  }
227
- counts.set(ids[i]!, count)
228
277
  }
229
- // Cycle-stranded nodes (never topo-visited) can't occur — the graph
230
- // builder rejects cycles — but a missing Map entry would silently
231
- // sort as undefined, so default them defensively to 0.
232
- for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
233
- return counts
278
+ const all = totals.map((total) => {
279
+ const counts = new Map<string, number>()
280
+ for (let t = 0; t < tail; t++) counts.set(ids[topo[t]!]!, total[t]!)
281
+ // Cycle-stranded nodes (never topo-visited) can't occur — the graph
282
+ // builder rejects cycles — but a missing Map entry would silently
283
+ // sort as undefined, so default them defensively to 0.
284
+ for (const id of ids) if (!counts.has(id)) counts.set(id, 0)
285
+ return counts
286
+ })
287
+ return all
234
288
  }
235
289
 
236
290
  /**
@@ -5,8 +5,8 @@ import {
5
5
  isUserError,
6
6
  OUT_OF_FDS_HINT,
7
7
  thrownMessage,
8
- ui,
9
8
  } from '../util/index.js'
9
+ import * as ui from '../ui/index.js'
10
10
  import {
11
11
  computeReverseDepCount,
12
12
  mergePriorities,
@@ -89,7 +89,8 @@ export interface TaskOutcome {
89
89
  */
90
90
  artifactBytes?: number
91
91
  /**
92
- * The raw bytes of a hit's outputs: the entry's files as restored.
92
+ * The raw bytes of the task's outputs: on a hit, the entry's files as
93
+ * restored; on a miss, the files its save stored (the row waits for it).
93
94
  */
94
95
  outputBytes?: number
95
96
  /** On a remote hit this run pulled: the download and its ingest. */
@@ -185,7 +186,7 @@ export interface TaskOutcome {
185
186
  serverExit?: number
186
187
  /**
187
188
  * The input that changed while the command ran, failing the task under
188
- * `inputStability.severity: 'error'` (workspace-relative; '' when vx
189
+ * `inputs.stability.severity: 'error'` (workspace-relative; '' when vx
189
190
  * could not name it). The exit is vx's 1; the label reads this instead.
190
191
  */
191
192
  inputMoved?: string
@@ -230,6 +231,8 @@ export interface TaskOutcome {
230
231
  attempts?: number
231
232
  /** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
232
233
  failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
234
+ /** Each of `failedAttempts`' output, as `failedOutput` encodes it: what `vx last --log` shows. */
235
+ attemptOutputs?: readonly string[]
233
236
  /**
234
237
  * Set when this run proved the task flaky: its key failed after it had
235
238
  * passed (this run's included) or it passed only on a retry. Its row carries it.
@@ -308,8 +311,7 @@ export interface ScheduleOptions {
308
311
  */
309
312
  onFailFast?: () => void
310
313
  execute: (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome>
311
- /** `queuedMs`: how long the task waited ready for a slot, when it did. */
312
- onStart?: (node: TaskNode, queuedMs?: number) => void
314
+ onStart?: (node: TaskNode) => void
313
315
  onFinish?: (outcome: TaskOutcome) => void
314
316
  /**
315
317
  * Where the line naming a rejected `execute` goes, before its failed
@@ -355,6 +357,11 @@ export interface ScheduleOptions {
355
357
  * Undefined or `most-work` is the reverse-deps count above.
356
358
  */
357
359
  strategy?: ScheduleStrategy
360
+ /**
361
+ * The `most-work` baseline already counted (`computeReverseDepCount`, read
362
+ * back with the graph): asked only when that baseline is the one used.
363
+ */
364
+ reverseDeps?: () => ReadonlyMap<string, number>
358
365
  /**
359
366
  * Restore-tier task ids: confirmed stable-key LOCAL cache hits (the
360
367
  * local short-circuit). A restore-tier task:
@@ -598,7 +605,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
598
605
  ? strategyPriorities(nodes, options.strategy)
599
606
  : options.restoreTier !== undefined && options.restoreTier.size > 0
600
607
  ? tieredReverseDepCount(nodes, options.restoreTier)
601
- : computeReverseDepCount(nodes)
608
+ : (options.reverseDeps?.() ?? computeReverseDepCount(nodes))
602
609
  const priority: ReadonlyMap<string, number> = options.priorities
603
610
  ? mergePriorities(baseline, options.priorities)
604
611
  : baseline
@@ -964,10 +971,9 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
964
971
  return undefined
965
972
  }
966
973
  }
967
- const started = (node: TaskNode, queuedMs = 0): void => {
974
+ const started = (node: TaskNode): void => {
968
975
  try {
969
- if (queuedMs > 0) onStart?.(node, queuedMs)
970
- else onStart?.(node)
976
+ onStart?.(node)
971
977
  } catch (err) {
972
978
  const m = thrownMessage(err)
973
979
  ui.say('warn', `[vx] onStart observer threw for ${node.id}: ${m}\n`)
@@ -1106,7 +1112,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1106
1112
  const settle = settleInPlace(node, upstream)
1107
1113
  let now: TaskOutcome | undefined
1108
1114
  if (settle !== undefined) {
1109
- if (!demoted.has(id)) started(node, queuedMs)
1115
+ if (!demoted.has(id)) started(node)
1110
1116
  try {
1111
1117
  now = settle()
1112
1118
  } catch {
@@ -1136,7 +1142,7 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
1136
1142
  // the dispatch loop (it would strand the tick with the slot held).
1137
1143
  // A demoted task's second dispatch is the same task, already started,
1138
1144
  // as is one whose settle threw.
1139
- if (!demoted.has(id) && settle === undefined) started(node, queuedMs)
1145
+ if (!demoted.has(id) && settle === undefined) started(node)
1140
1146
 
1141
1147
  // `.then(onFulfilled, onRejected)` — NOT `.then(f).catch(g)`. The
1142
1148
  // rejection arm handles ONLY `execute()` rejecting; a throw from
@@ -294,7 +294,7 @@ export interface BuildGraphOptions {
294
294
  */
295
295
  undeclaredDeps?: (taskId: string, name: string) => void
296
296
  /**
297
- * The workspace root, where `cache.outputs.workspaceFiles` is anchored.
297
+ * The workspace root, where `outputs.workspaceFiles` is anchored.
298
298
  * Given, a root-anchored output is also compared with every project's
299
299
  * `files` outputs (item 1088); absent (a graph built for a unit row), only
300
300
  * with other root-anchored ones.
@@ -335,6 +335,31 @@ export function undeclaredDepsError(
335
335
  )
336
336
  }
337
337
 
338
+ /**
339
+ * The `pkg#task` to offer for a cross-project miss: the task nearest
340
+ * `task` in the project named, or in the one nearest `project` — or the
341
+ * one whose path ends in it, since a project is named by its path unless
342
+ * named, and `a#build` is how a `packages/a` is first typed.
343
+ */
344
+ function nearestTarget(
345
+ projects: ReadonlyMap<string, ProjectEntry>,
346
+ project: string,
347
+ task: string,
348
+ ): string | undefined {
349
+ const holder =
350
+ projects.get(project) ??
351
+ projects.get(nearest(project, projects.keys()) ?? '') ??
352
+ onlyOne([...projects.values()].filter((p) => p.name.endsWith(`/${project}`)))
353
+ if (holder === undefined) return undefined
354
+ const tasks = Object.keys(holder.config.tasks ?? {})
355
+ const name = tasks.includes(task) ? task : nearest(task, tasks)
356
+ return name === undefined ? undefined : taskId(holder.name, name)
357
+ }
358
+
359
+ function onlyOne<T>(list: readonly T[]): T | undefined {
360
+ return list.length === 1 ? list[0] : undefined
361
+ }
362
+
338
363
  /** One task mid-expansion in `buildTaskGraph`'s walk. */
339
364
  interface Frame {
340
365
  node: TaskNode
@@ -656,8 +681,9 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
656
681
  `${id}: dependsOn //#${spec.task}, the workspace root's task, but the root declares no such task: add a vx.config at the root declaring it and run \`vx lock\``,
657
682
  'VX_E_DEPENDS_ON',
658
683
  )
684
+ const meant = nearestTarget(projects, spec.project, spec.task)
659
685
  throw new UserError(
660
- `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared`,
686
+ `${id}: dependsOn ${taskId(spec.project, spec.task)} but no such project or task is declared${meant === undefined ? '' : `. Did you mean ${meant}?`}`,
661
687
  'VX_E_DEPENDS_ON',
662
688
  )
663
689
  }
@@ -1248,7 +1274,7 @@ function detectOutputCollisions(
1248
1274
  const byProject = new Map<string, TaskNode[]>()
1249
1275
  const wsDeclarers: TaskNode[] = []
1250
1276
  for (const n of nodes.values()) {
1251
- const outs = n.config.cache?.outputs
1277
+ const outs = n.config.outputs
1252
1278
  if ((outs?.files?.length ?? 0) > 0) {
1253
1279
  const bucket = byProject.get(n.projectName)
1254
1280
  if (bucket) bucket.push(n)
@@ -1264,9 +1290,9 @@ function detectOutputCollisions(
1264
1290
  const positiveOf = (globs: readonly string[] | undefined): readonly string[] | undefined =>
1265
1291
  globs === undefined ? undefined : splitNegations(globs).positive
1266
1292
  const filesOf = (n: TaskNode): readonly string[] | undefined =>
1267
- positiveOf(n.config.cache?.outputs.files)
1293
+ positiveOf(n.config.outputs?.files)
1268
1294
  const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
1269
- positiveOf(n.config.cache?.outputs.workspaceFiles)
1295
+ positiveOf(n.config.outputs?.workspaceFiles)
1270
1296
  for (const bucket of byProject.values()) {
1271
1297
  // Most projects hold one task with outputs, and a call per project
1272
1298
  // cost the cold build a measured 0.4 ms at 1,000 projects.
@@ -1320,7 +1346,7 @@ function detectOutputCollisions(
1320
1346
  * captures, so the pair cannot collide there.
1321
1347
  */
1322
1348
  function takenBackOf(n: TaskNode, field: 'files' | 'workspaceFiles'): string[] {
1323
- return splitNegations(n.config.cache?.outputs[field] ?? []).negative
1349
+ return splitNegations(n.config.outputs?.[field] ?? []).negative
1324
1350
  }
1325
1351
 
1326
1352
  /**
@@ -1348,9 +1374,9 @@ function collideAcross(
1348
1374
  if (!outputsOverlap(ga, gb) || outputTakenBack(gb, wsBack) || outputTakenBack(ga, filesBack))
1349
1375
  continue
1350
1376
  const head =
1351
- `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
1377
+ `${ws.id} declares the output ${JSON.stringify(ga)} in outputs.workspaceFiles, ` +
1352
1378
  `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
1353
- `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
1379
+ `outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
1354
1380
  `cache-hit restore, so whichever of these runs second DELETES the other's output`
1355
1381
  if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
1356
1382
  throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`, 'VX_E_OUTPUT_OVERLAP')
@@ -1374,7 +1400,7 @@ function collideAcross(
1374
1400
  const outside = wsGlobs[own.indexOf(null)]!
1375
1401
  throw new UserError(
1376
1402
  `${head}. ${ws.id} depends on ${files.id}, but adding to its output needs every ` +
1377
- `cache.outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
1403
+ `outputs.workspaceFiles entry inside ${rel === '' ? 'the workspace root' : rel}, ` +
1378
1404
  `and ${JSON.stringify(outside)} is not. Give each task its own output path.`,
1379
1405
  'VX_E_OUTPUT_OVERLAP',
1380
1406
  )
@@ -1614,7 +1640,7 @@ function collide(
1614
1640
  const head =
1615
1641
  `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1616
1642
  (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
1617
- ` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1643
+ ` in outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1618
1644
  `before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
1619
1645
  `output`
1620
1646
  // An overlap WITH an edge is the addition shape (item 588): the
@@ -1626,7 +1652,7 @@ function collide(
1626
1652
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1627
1653
  const parts = {
1628
1654
  headline: `${a.id} and ${b.id} both write ${ga === gb ? ga : `${ga} / ${gb}`}`,
1629
- ...(field === 'files' ? {} : { what: ['in cache.outputs.workspaceFiles'] }),
1655
+ ...(field === 'files' ? {} : { what: ['in outputs.workspaceFiles'] }),
1630
1656
  why: "vx clears a task's outputs before it runs, so one deletes the other's",
1631
1657
  }
1632
1658
  if (up !== undefined && exclusive)
@@ -1639,9 +1665,8 @@ function collide(
1639
1665
  )
1640
1666
  if (up !== undefined && down !== undefined) {
1641
1667
  const downGlobs = splitNegations(
1642
- (field === 'files'
1643
- ? down.config.cache?.outputs.files
1644
- : down.config.cache?.outputs.workspaceFiles) ?? [],
1668
+ (field === 'files' ? down.config.outputs?.files : down.config.outputs?.workspaceFiles) ??
1669
+ [],
1645
1670
  ).positive
1646
1671
  ;(down.addsToOutputsOf ??= []).push(up.id)
1647
1672
  ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
@@ -1680,13 +1705,13 @@ export function outputTakenBack(output: string, negatives: readonly string[]): b
1680
1705
  * and its key read nothing (hunt 8). Always refused, whatever the rules.
1681
1706
  */
1682
1707
  function refuseSelfClean(node: TaskNode): void {
1683
- const cache = node.config.cache
1708
+ const declaredOutputs = node.config.outputs
1684
1709
  const declared = node.config.inputs
1685
- if (cache === undefined || declared === undefined) return
1710
+ if (declaredOutputs === undefined || declared === undefined) return
1686
1711
  for (const field of ['files', 'workspaceFiles'] as const) {
1687
- if (!coversAny(declared[field], cache.outputs[field])) continue
1712
+ if (!coversAny(declared[field], declaredOutputs[field])) continue
1688
1713
  const inputs = splitNegations(declared[field] ?? [])
1689
- const out = splitNegations(cache.outputs[field] ?? [])
1714
+ const out = splitNegations(declaredOutputs[field] ?? [])
1690
1715
  const outputs = out.positive
1691
1716
  if (outputs.length === 0) continue
1692
1717
  const roots = outputs.map((g) => rootless(staticPrefix(g)))
@@ -1797,11 +1822,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1797
1822
  return list
1798
1823
  }
1799
1824
  for (const n of nodes.values()) {
1800
- const cache = n.config.cache
1825
+ const declaredOutputs = n.config.outputs
1801
1826
  const inputs = n.config.inputs
1802
1827
  if (inputs === undefined) continue
1803
- const outFiles = positives(cache?.outputs.files ?? [])
1804
- const outWs = positives(cache?.outputs.workspaceFiles ?? [])
1828
+ const outFiles = positives(declaredOutputs?.files ?? [])
1829
+ const outWs = positives(declaredOutputs?.workspaceFiles ?? [])
1805
1830
  if (outFiles.length > 0) {
1806
1831
  const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1807
1832
  bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
@@ -1869,7 +1894,7 @@ function readsRootedOutputs(
1869
1894
  if (workspaceRoot !== undefined) {
1870
1895
  for (const node of filesWriters) {
1871
1896
  const rel = relPosix(workspaceRoot, node.projectDir)
1872
- const globs = positives(node.config.cache!.outputs.files).map((g) =>
1897
+ const globs = positives(node.config.outputs!.files).map((g) =>
1873
1898
  rel === '' ? g : `${rel}/${g}`,
1874
1899
  )
1875
1900
  rooted.push({ node, globs, reads: false, rel })
@@ -1999,14 +2024,16 @@ function takenBack(
1999
2024
  let tb = memo.bySide.get(reader)
2000
2025
  if (tb === undefined) {
2001
2026
  const inputs = reader.node.config.inputs!
2002
- const cache = reader.node.config.cache
2027
+ const declaredOutputs = reader.node.config.outputs
2003
2028
  // A reader rebased to the root (X-135) speaks in its project's terms.
2004
2029
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
2005
2030
  // What the reader's key never reads: its `!` entries and its own outputs.
2006
2031
  const list = [
2007
2032
  ...splitNegations(field === 'files' ? inputs.files : (inputs.workspaceFiles ?? [])).negative,
2008
2033
  ...splitNegations(
2009
- field === 'files' ? (cache?.outputs.files ?? []) : (cache?.outputs.workspaceFiles ?? []),
2034
+ field === 'files'
2035
+ ? (declaredOutputs?.files ?? [])
2036
+ : (declaredOutputs?.workspaceFiles ?? []),
2010
2037
  ).positive,
2011
2038
  ].map((g) => at + g)
2012
2039
  const key = list.join('\0')
package/src/handoff.ts CHANGED
@@ -223,15 +223,19 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
223
223
  // hides the env on every verb, is never reached here. Imported here:
224
224
  // a start that hands off nothing pays nothing.
225
225
  await (await import('./util/index.js')).hideEnvFromTasks()
226
- const child = Bun.spawn([bin, ...args], {
227
- stdio: ['inherit', 'inherit', 'inherit'],
228
- env: { ...process.env, VX_HANDED_OFF: process.execPath },
229
- })
226
+ // The handlers come first: a signal landing between the spawn and them
227
+ // killed this vx and left the local one running, orphaned (main
228
+ // dc9d49c). A handler runs on the event loop, after the spawn returned.
229
+ let child: Bun.Subprocess | undefined
230
230
  for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
231
231
  process.on(sig, () => {
232
- if (!inForeground()) child.kill(sig)
232
+ if (!inForeground()) child?.kill(sig)
233
233
  })
234
234
  }
235
+ child = Bun.spawn([bin, ...args], {
236
+ stdio: ['inherit', 'inherit', 'inherit'],
237
+ env: { ...process.env, VX_HANDED_OFF: process.execPath },
238
+ })
235
239
  const code = await child.exited
236
240
  const signal = child.signalCode
237
241
  if (signal === null) process.exit(code)
@@ -240,8 +244,7 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
240
244
  // SIGINT, and an exit 130 ran the script's next line. The code below is
241
245
  // the rest, and the fallback should the signal not land.
242
246
  if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') {
243
- process.removeAllListeners(signal)
244
- process.kill(process.pid, signal)
247
+ ;(await import('./util/index.js')).dieOf(signal)
245
248
  }
246
249
  if (typeof signal === 'number') process.exit(128 + signal)
247
250
  const { constants } = await import('node:os')
@@ -30,7 +30,7 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
30
30
  * `status`, `cache_hit`, `cached` columns) with how often it failed, how often it
31
31
  * passed — an executed success or a cache hit, which replays a success —
32
32
  * and how often it failed AFTER its first pass (`relapses`). A task with
33
- * no cache block keys on its config alone and runs every time, so its key
33
+ * no `cache: true` keys on its config alone and runs every time, so its key
34
34
  * says nothing about its inputs: no reader counts it, as no run's row
35
35
  * calls it flaky. Nor is a row that names a `blocked_by`: it ran behind a
36
36
  * failure (`--continue=always`), on bytes its key never named, so its
@@ -198,6 +198,29 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
198
198
  return out
199
199
  }
200
200
 
201
+ /**
202
+ * Whether a task that just failed on `hash` is known to be flaky: this key
203
+ * passed before (the failure itself is the flake), or the retained history
204
+ * holds a key of the task that failed after it passed, or a pass that took
205
+ * a retry. Asked only after a failure, so a green run never reads it.
206
+ */
207
+ export function knownFlaky(db: Database, project: string, task: string, hash: string): boolean {
208
+ const passed = db
209
+ .query(
210
+ `SELECT 1 FROM ${OUTCOMES} WHERE hash = ? AND project = ? AND task = ?
211
+ AND (status = 'success' OR status IN ${HIT_STATUSES}) LIMIT 1`,
212
+ )
213
+ .get(hash, project, task)
214
+ if (passed !== null) return true
215
+ const flaked = db
216
+ .query(
217
+ `SELECT 1 FROM (${keyOutcomesSql(OUTCOMES, ' AND project = ? AND task = ?')})
218
+ WHERE relapses > 0 OR retried_failures > 0 LIMIT 1`,
219
+ )
220
+ .get(project, task)
221
+ return flaked !== null
222
+ }
223
+
201
224
  /** One task the recorded history shows failing on a key that had passed. */
202
225
  export interface FlakyTask {
203
226
  /** `project#task`. */
@@ -311,6 +311,11 @@ export class LocalHistoryProvider implements LocalHistory {
311
311
  * against 4 for the executed successes' durations alone.
312
312
  */
313
313
  async p50sFor(taskIds: readonly string[]): Promise<ReadonlyMap<string, number>> {
314
+ return this.p50sNow(taskIds)
315
+ }
316
+
317
+ /** `p50sFor`, at once: a row drawn now reads it (the rows' `usual`). */
318
+ p50sNow(taskIds: readonly string[]): ReadonlyMap<string, number> {
314
319
  if (taskIds.length === 0) return new Map()
315
320
  return this.forecasts(new Set(taskIds))
316
321
  }
@@ -16,6 +16,7 @@ export {
16
16
  } from './history.js'
17
17
  export {
18
18
  detectFlaky,
19
+ knownFlaky,
19
20
  type FailureMode,
20
21
  type FlakyCandidate,
21
22
  type FlakyFinding,
@@ -39,7 +39,7 @@ export interface RunSummaryRow {
39
39
  cacheHit: boolean | null
40
40
  /** On a hit: true when outputs were restored, false when up-to-date (v31); null otherwise. */
41
41
  restored: boolean | null
42
- /** Whether the task declared a `cache` block; null on rows older than the column. */
42
+ /** Whether the task declared `cache: true`; null on rows older than the column. */
43
43
  cached: boolean | null
44
44
  hash: string
45
45
  cpuMs: number | null
@@ -50,6 +50,10 @@ export interface RunSummaryRow {
50
50
  avgRssBytes: number | null
51
51
  /** Where an executor ran it (`small#1`, v44); null = here. */
52
52
  placement: string | null
53
+ /** The bytes of its outputs: what its save stored, or its hit restored (v51). */
54
+ outputBytes: number | null
55
+ /** How many tasks were downstream of it (v51). */
56
+ blocks: number | null
53
57
  // High-precision spans relative to run start. Decimal strings on the wire
54
58
  // (bigints aren't JSON-safe); use BigInt or Number on the client.
55
59
  wallclockStartNs: string | null
@@ -65,6 +69,8 @@ export interface RunSummaryRow {
65
69
  remoteError: string | null
66
70
  /** The attempts a retried task took (>1, v23); null when it ran once. */
67
71
  attempts: number | null
72
+ /** Milliseconds it waited, ready, for a slot (v51); null when it never queued. */
73
+ queuedMs: number | null
68
74
  }
69
75
 
70
76
  export interface ListRunsArgs {
@@ -123,10 +129,12 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
123
129
  cache_hit AS cacheHit, restored, cached, hash,
124
130
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
125
131
  peak_cores AS peakCores, avg_rss_bytes AS avgRssBytes, placement,
132
+ output_bytes AS outputBytes, blocks,
126
133
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs,
127
134
  blocked_by AS blockedBy, timed_out AS timedOut,
128
135
  sandbox_violations AS sandboxViolations, not_ready AS notReady,
129
- forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts
136
+ forward_args IS NOT NULL AS forwarded, remote_error AS remoteError, attempts,
137
+ queued_ms AS queuedMs
130
138
  FROM runs ${clause} ORDER BY id DESC LIMIT ?`,
131
139
  )
132
140
  .all(...params, limit) as RawRow[]
@@ -374,7 +382,7 @@ export interface WhyDidThisRerun {
374
382
  cacheHit: boolean | null
375
383
  /** On a hit: outputs restored (true) or up-to-date (false); null otherwise. */
376
384
  restored: boolean | null
377
- /** Whether the task declared a `cache` block; null on rows older than the column. */
385
+ /** Whether the task declared `cache: true`; null on rows older than the column. */
378
386
  cached: boolean | null
379
387
  startedAt: number
380
388
  }
@@ -606,9 +614,9 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
606
614
  // one's is new each run, so every dependent misses: "folded by
607
615
  // dependents" read as if they could hit.
608
616
  this_.keylessBy === null
609
- ? 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
617
+ ? 'this task declares no `cache: true` — it runs on every invocation; its key is folded by dependents only'
610
618
  : this_.keylessBy === taskId
611
- ? 'this task declares no `cache` block — it runs on every invocation; it declares no `inputs` either, so each run derives a new key and every task that depends on it misses; declare its `inputs`'
619
+ ? 'this task declares no `cache: true` — it runs on every invocation; it declares no `inputs` either, so each run derives a new key and every task that depends on it misses; declare its `inputs`'
612
620
  : `this task declares no \`cache\` block — it runs on every invocation; its key includes ${this_.keylessBy}, which declares no \`inputs\`, so each run derives a new key and every task that depends on it misses; declare inputs on ${this_.keylessBy}`
613
621
  : this_.keylessBy !== null
614
622
  ? keylessNote(taskId, this_.keylessBy)
@@ -881,7 +889,7 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
881
889
  this_.cached === 0
882
890
  ? // The verdict already says the task is uncached; this line says
883
891
  // why no input can be named.
884
- 'with no `cache` block it saves no entry, so no input fingerprints are kept to say which of its `inputs` moved its key; with no `inputs`, every run gives it a new key'
892
+ 'without `cache: true` it saves no entry, so no input fingerprints are kept to say which of its `inputs` moved its key; with no `inputs`, every run gives it a new key'
885
893
  : unsaved !== null
886
894
  ? `cache key changed but input fingerprints are unavailable — ${unsaved} and saved no entry; only the key change is known`
887
895
  : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',