@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
@@ -6,6 +6,7 @@ import {
6
6
  ABSENT_INPUT,
7
7
  absentOr,
8
8
  addedInput,
9
+ behindLink,
9
10
  filesToken,
10
11
  foldHead,
11
12
  type InputListing,
@@ -21,7 +22,16 @@ import {
21
22
  } from '../cache/index.js'
22
23
  import type { InputFile, TaskInputs } from '../exec/index.js'
23
24
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
24
- import { isUserError, relPosix, secretMask, span, UserError, xxh3hex } from '../util/index.js'
25
+ import { GROUP_VIA } from '../history/index.js'
26
+ import {
27
+ isUserError,
28
+ keysSorted,
29
+ relPosix,
30
+ secretMask,
31
+ span,
32
+ UserError,
33
+ xxh3hex,
34
+ } from '../util/index.js'
25
35
  import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
26
36
 
27
37
  /**
@@ -46,6 +56,11 @@ export interface HashCache {
46
56
  projectFiles: ProjectFilesCache
47
57
  /** Each task's last key by its probe, across runs (the local cache's `FoldMemo`). */
48
58
  folds?: FoldMemo | undefined
59
+ /**
60
+ * The token of the remembered graph the run's nodes are (graph-memo.ts),
61
+ * while no plugin or flag has edited them since.
62
+ */
63
+ graph?: string | undefined
49
64
  }
50
65
 
51
66
  export function createHashCache(folds?: FoldMemo): HashCache {
@@ -131,15 +146,15 @@ const FOLD_MEMO_VERSION = 'fold-memo-v1'
131
146
  /**
132
147
  * A task that runs a command and declares no `inputs`: nothing says what
133
148
  * it read, so no key can stand for its outputs, and a key that folds it
134
- * would replay a stale entry (owner, 2026-10-16).
149
+ * would replay a stale entry (2026-10-16).
135
150
  */
136
151
  export function isKeyless(task: TaskConfig): boolean {
137
152
  return task.exec !== undefined && task.inputs === undefined
138
153
  }
139
154
 
140
155
  /**
141
- * Drawn once per process, so no run derives another run's keyless keys;
142
- * on first use, as the browser playground loads this module with no `crypto`.
156
+ * Drawn once per process, on first use, so no run derives another run's
157
+ * keyless keys.
143
158
  */
144
159
  let keylessSalt: string | undefined
145
160
 
@@ -159,6 +174,12 @@ export interface InputFact {
159
174
  path: string
160
175
  digest: string
161
176
  since: number
177
+ /**
178
+ * For a link, the content behind it (`behindLink`), which the key folds
179
+ * after the link's own target string. An edit there leaves the link
180
+ * untouched, so the re-check compares this rather than the link's ctime.
181
+ */
182
+ behind?: string
162
183
  }
163
184
 
164
185
  /**
@@ -180,11 +201,21 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
180
201
  }> {
181
202
  const hashedAt = Date.now()
182
203
  const { input, listings } = await resolveKeyInput(args)
183
- const hash = await args.cache.key(input)
184
204
  const sorted = [...input.inputFiles].sort()
185
205
  const digests = await Promise.all(
186
206
  sorted.map((f) => input.fileHashes?.get(f) ?? args.cache.hashFile(f).catch(absentOr)),
187
207
  )
208
+ // Learned BEFORE the key folds it: an edit in between then reads as a
209
+ // move at the re-check (a withheld save), never as content the key
210
+ // did not fold matching it (a stale entry).
211
+ const hashFile = (f: string): Promise<string> => args.cache.hashFile(f)
212
+ const behind = new Map<string, string>()
213
+ await Promise.all(
214
+ sorted.map(async (f, i) => {
215
+ if (digests[i]!.startsWith('120000:')) behind.set(f, await behindLink(f, hashFile))
216
+ }),
217
+ )
218
+ const hash = await args.cache.key(input)
188
219
  // A file gone since the enumeration is no input: the task will not read it.
189
220
  const files: InputFile[] = sorted.flatMap((f, i) =>
190
221
  digests[i] === ABSENT_INPUT
@@ -192,11 +223,16 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
192
223
  : [{ path: relPosix(input.workspaceRoot, f), digest: digests[i]! }],
193
224
  )
194
225
  const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
195
- const facts: InputFact[] = sorted.map((f, i) => ({
196
- path: f,
197
- digest: digests[i]!,
198
- since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
199
- }))
226
+ const facts: InputFact[] = sorted.map((f, i) => {
227
+ const fact: InputFact = {
228
+ path: f,
229
+ digest: digests[i]!,
230
+ since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
231
+ }
232
+ const linked = behind.get(f)
233
+ if (linked !== undefined) fact.behind = linked
234
+ return fact
235
+ })
200
236
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
201
237
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
202
238
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -260,7 +296,8 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
260
296
  * the command by content too, when it answers true for that file and its
261
297
  * ctime: the task says the same bytes are its own identical rewrite
262
298
  * (tsdown's package.json), not an edit undone, unless another command that
263
- * may write the file ran until then (command-windows.ts).
299
+ * may write the file ran until then (command-windows.ts). A link's content
300
+ * behind it (`InputFact.behind`) is followed again and compared.
264
301
  */
265
302
  export async function movedInput(
266
303
  facts: readonly InputFact[],
@@ -294,6 +331,12 @@ export async function movedInput(
294
331
  const now = await cache.hashFile(f.path).catch(() => undefined)
295
332
  if (now !== f.digest) return f.path
296
333
  }
334
+ // What a link points to changes without touching the link: each is
335
+ // followed again (links are rare, so a task without one pays nothing).
336
+ const hashFile = (file: string): Promise<string> => cache.hashFile(file)
337
+ for (const f of facts) {
338
+ if (f.behind !== undefined && (await behindLink(f.path, hashFile)) !== f.behind) return f.path
339
+ }
297
340
  return undefined
298
341
  }
299
342
 
@@ -358,6 +401,24 @@ async function resolveKeyInput(
358
401
  const upstreamHashes = upstreamPairs.map(([, hash]) => hash)
359
402
  // hash → upstream task id, for capture-row naming only (not folded).
360
403
  const upstreamIds = new Map(upstreamPairs.map(([id, hash]) => [hash, id]))
404
+ // A group records no run and keeps no fingerprints, so `vx why` could not
405
+ // see past one: each folded group's members are captured as rows of their
406
+ // own, named through it. Rows only, never folded: the group's hash already
407
+ // folds theirs.
408
+ if (args.captureInto !== undefined) {
409
+ const folded = new Set(upstreamPairs.map(([id]) => id))
410
+ for (const g of args.upstream) {
411
+ if (g.groupUpstream === undefined || !folded.has(g.node.id)) continue
412
+ for (const m of expandGroupUpstream(g.groupUpstream)) {
413
+ if (m.hash === undefined) continue
414
+ args.captureInto.push({
415
+ kind: 'upstream',
416
+ name: `${g.node.id}${GROUP_VIA}${m.node.id}`,
417
+ hash: m.hash,
418
+ })
419
+ }
420
+ }
421
+ }
361
422
  // The DEPENDENCY closure, groups expanded — for the executor's input root.
362
423
  // Deliberately NOT the filtered set above: `inputs.tasks` says which
363
424
  // upstream KEYS this task's key folds, and what a task may READ is
@@ -403,6 +464,7 @@ async function resolveKeyInput(
403
464
  forwardArgs: effectiveForwardArgs,
404
465
  ...(fileHashes !== undefined ? { fileHashes } : {}),
405
466
  ...(args.node.keyParts !== undefined ? { pluginParts: args.node.keyParts } : {}),
467
+ ...(args.node.platform !== undefined ? { platform: args.node.platform } : {}),
406
468
  ...(args.captureInto !== undefined ? { captureInto: args.captureInto } : {}),
407
469
  }
408
470
  return { input, listings: resolved.listings }
@@ -454,9 +516,12 @@ export function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
454
516
  * would bump CACHE_VERSION.
455
517
  */
456
518
  function hashableConfig(cfg: TaskConfig): unknown {
457
- if (cfg.exec?.remote === undefined) return cfg
519
+ // Keys sorted: moving `cache` above `exec`, or a `define` in another
520
+ // order, missed every key of the task, and a `project` or `graph` plugin
521
+ // writes a config the lock never sorted (2026-10-25).
522
+ if (cfg.exec?.remote === undefined) return keysSorted(cfg)
458
523
  const { remote: _remote, ...execRest } = cfg.exec
459
- return { ...cfg, exec: execRest }
524
+ return keysSorted({ ...cfg, exec: execRest })
460
525
  }
461
526
 
462
527
  /**
@@ -105,7 +105,7 @@ function budgetCost(chars: number, chunkCount: number): number {
105
105
 
106
106
  /**
107
107
  * Bounded per-run capture. `append` keeps a chunk LIST + running char count per
108
- * task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
108
+ * task, evicting from the head past `TASK_LOG_TAIL_CHARS` — no
109
109
  * string concatenation until `drain`, so a cache-hit replay (one big chunk) is
110
110
  * one array push, zero copies. `finish` decides retention; `drain` emits the
111
111
  * bundle, failures first.
@@ -115,6 +115,16 @@ export class TaskLogBuffer {
115
115
  private readonly retained = new Map<string, Retained>()
116
116
  private retainedChars = 0
117
117
  private seq = 0
118
+ /**
119
+ * Retained entries in eviction order, kept as they arrive: successes are
120
+ * stubbed from `successHead` on (oldest first), failures from the end
121
+ * (newest first). An entry since stubbed, taken or replaced is skipped
122
+ * where it lies. A sort of every entry per finish made a run past the
123
+ * budget quadratic: 40,000 tasks took 17 s in a sink's `onRecord`.
124
+ */
125
+ private readonly successes: Retained[] = []
126
+ private successHead = 0
127
+ private failures: Retained[] = []
118
128
 
119
129
  append(taskId: string, chunk: string): void {
120
130
  if (chunk.length === 0) return
@@ -126,24 +136,27 @@ export class TaskLogBuffer {
126
136
  acc.chunks.push(chunk)
127
137
  acc.chars += chunk.length
128
138
  acc.charsFull += chunk.length
129
- // Evict whole chunks from the head until under the per-task tail cap. A
130
- // single over-cap chunk is sliced to its tail (the only place we copy).
131
- while (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length > 1) {
132
- const dropped = acc.chunks.shift()!
133
- acc.chars -= dropped.length
134
- acc.truncatedHeadChars += dropped.length
135
- }
136
- if (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length === 1) {
137
- const only = acc.chunks[0]!
138
- let start = only.length - TASK_LOG_TAIL_CHARS
139
+ // Evict whole chunks from the head until under the per-task tail cap;
140
+ // the one that straddles the cap is sliced to what still fits (the only
141
+ // place we copy). Dropped whole, a 128 KiB chunk and a 7-char one after
142
+ // it kept the 7 chars alone.
143
+ while (acc.chars > TASK_LOG_TAIL_CHARS) {
144
+ const head = acc.chunks[0]!
145
+ const excess = acc.chars - TASK_LOG_TAIL_CHARS
146
+ if (head.length <= excess) {
147
+ acc.chunks.shift()
148
+ acc.chars -= head.length
149
+ acc.truncatedHeadChars += head.length
150
+ continue
151
+ }
152
+ let start = excess
139
153
  // A cut between a surrogate pair would open the tail on a lone low
140
154
  // surrogate, which a sink's UTF-8 encode turns into U+FFFD.
141
- const first = only.charCodeAt(start)
155
+ const first = head.charCodeAt(start)
142
156
  if (first >= 0xdc00 && first <= 0xdfff) start++
143
- const keep = only.slice(start)
144
- acc.truncatedHeadChars += only.length - keep.length
145
- acc.chunks[0] = keep
146
- acc.chars = keep.length
157
+ acc.chunks[0] = head.slice(start)
158
+ acc.chars -= start
159
+ acc.truncatedHeadChars += start
147
160
  }
148
161
  }
149
162
 
@@ -180,9 +193,32 @@ export class TaskLogBuffer {
180
193
  if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
181
194
  this.retained.set(taskId, entry)
182
195
  this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
196
+ ;(status === 'success' ? this.successes : this.failures).push(entry)
197
+ this.compact()
183
198
  this.evictToBudget()
184
199
  }
185
200
 
201
+ /** Whether `e` may still be stubbed: retained as it is, content and all. */
202
+ private evictable(e: Retained): boolean {
203
+ return e.chars > 0 && this.retained.get(e.taskId) === e
204
+ }
205
+
206
+ /**
207
+ * Drop what eviction would only skip, once the queues hold twice what is
208
+ * retained: a sink that takes every entry (`takeEntry`) never evicts, and
209
+ * its queues would grow by a task each. Amortized constant per finish.
210
+ */
211
+ private compact(): void {
212
+ const room = 2 * this.retained.size + 1024
213
+ if (this.successes.length - this.successHead > room || this.successHead > room) {
214
+ const live = this.successes.slice(this.successHead).filter((e) => this.evictable(e))
215
+ this.successes.length = 0
216
+ this.successes.push(...live)
217
+ this.successHead = 0
218
+ }
219
+ if (this.failures.length > room) this.failures = this.failures.filter((e) => this.evictable(e))
220
+ }
221
+
186
222
  /**
187
223
  * Take ONE finished task's retained tail as a wire entry, removing it from
188
224
  * the buffer (so a later `drain` won't re-ship it). Used by per-task
@@ -249,7 +285,7 @@ export class TaskLogBuffer {
249
285
  /**
250
286
  * Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
251
287
  * (oldest by seq); only when failures ALONE still exceed the budget do the
252
- * oldest failures go — a failure is never dropped to keep a success.
288
+ * newest failures go — a failure is never dropped to keep a success.
253
289
  *
254
290
  * An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
255
291
  * = everything it emitted) rather than removed. Dropping the entry makes a
@@ -269,25 +305,25 @@ export class TaskLogBuffer {
269
305
  * sees it.
270
306
  */
271
307
  private evictToBudget(): void {
272
- if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
273
- const order = [...this.retained.values()].sort((a, b) => {
274
- const sa = a.status === 'success' ? 0 : 1
275
- const sb = b.status === 'success' ? 0 : 1
276
- if (sa !== sb) return sa - sb // successes are dropped first, always
277
- // Within a tier the tiebreak DIFFERS by status, and the failed one is
278
- // the point: when failures alone blow the budget, the FIRST failure is
279
- // usually the root cause and the later ones its cascade, so it must be
280
- // the last thing stubbed. Successes keep oldest-first — none of them
281
- // is more interesting than another, and recency is the better guess.
282
- return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
283
- })
284
- for (const e of order) {
285
- if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
286
- if (e.chars === 0) continue // already a stub — frees nothing
287
- this.retainedChars -= budgetCost(e.chars, e.chunks.length)
288
- e.truncatedHeadChars += e.chars
289
- e.chunks = []
290
- e.chars = 0
308
+ // Successes are dropped first, always, and keep oldest-first: none of
309
+ // them is more interesting than another, and recency is the better
310
+ // guess. Failures go newest-first: when failures alone blow the budget,
311
+ // the FIRST failure is usually the root cause and the later ones its
312
+ // cascade, so it must be the last thing stubbed.
313
+ while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.successHead < this.successes.length) {
314
+ const e = this.successes[this.successHead++]!
315
+ if (this.evictable(e)) this.stub(e)
316
+ }
317
+ while (this.retainedChars > RUN_LOG_BUDGET_CHARS && this.failures.length > 0) {
318
+ const e = this.failures.pop()!
319
+ if (this.evictable(e)) this.stub(e)
291
320
  }
292
321
  }
322
+
323
+ private stub(e: Retained): void {
324
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
325
+ e.truncatedHeadChars += e.chars
326
+ e.chunks = []
327
+ e.chars = 0
328
+ }
293
329
  }
@@ -74,6 +74,7 @@ export function taskEnv(
74
74
  define: step.env?.define ?? {},
75
75
  source: process.env,
76
76
  binPaths: bins,
77
+ workspaceRoot,
77
78
  })
78
79
  env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
79
80
  env[VX_RUN_TASK_ENV] = node.id
@@ -91,7 +92,11 @@ export function taskTimeoutMs(step: ExecConfig): number | undefined {
91
92
  }
92
93
 
93
94
  /** Persistent task `node` as a `servers` executor starts it: no forwarded args, which a dependency never gets. */
94
- export function serverSpec(node: TaskNode, workspaceRoot: string): ServerSpec {
95
+ export function serverSpec(
96
+ node: TaskNode,
97
+ workspaceRoot: string,
98
+ nested: readonly string[],
99
+ ): ServerSpec {
95
100
  const step = node.config.exec!
96
101
  const timeoutMs = taskTimeoutMs(step)
97
102
  const readyWhen = step.persistent?.readyWhen
@@ -102,6 +107,7 @@ export function serverSpec(node: TaskNode, workspaceRoot: string): ServerSpec {
102
107
  env: taskEnv(node, step, workspaceRoot),
103
108
  ...(readyWhen === undefined ? {} : { readyWhen }),
104
109
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
110
+ ...(step.sandbox === undefined ? {} : { sandbox: { config: step.sandbox, nested } }),
105
111
  }
106
112
  }
107
113
 
@@ -17,10 +17,11 @@ import type {
17
17
  RunContextRecord,
18
18
  RunSummaryRecord,
19
19
  TelemetryContext,
20
+ TelemetryRecord,
20
21
  TelemetrySink,
21
22
  TreeLoad,
22
23
  } from './telemetry.js'
23
- import { settleWithin, teardownTimeoutMs } from '../util/index.js'
24
+ import { settleWithin, teardownTimeoutMs, thrownMessage } from '../util/index.js'
24
25
  import { createTelemetrySource } from './telemetry.js'
25
26
  import type { VxPlugin } from './plugin.js'
26
27
 
@@ -36,6 +37,16 @@ export interface TelemetryHandle {
36
37
  readonly track?: (taskId: string, pid: number) => () => TreeLoad | undefined
37
38
  }
38
39
 
40
+ // Every kind once: a kind added to `TelemetryRecord` fails to compile here.
41
+ const RECORD_KINDS: Record<TelemetryRecord['kind'], true> = {
42
+ 'run.start': true,
43
+ 'task.start': true,
44
+ 'task.log': true,
45
+ 'task.sample': true,
46
+ 'task.end': true,
47
+ 'run.end': true,
48
+ }
49
+
39
50
  /** Reject anything that is not sink-shaped, naming what arrived. */
40
51
  function checkSink(sink: unknown): TelemetrySink {
41
52
  if (typeof sink !== 'object' || sink === null) {
@@ -45,6 +56,15 @@ function checkSink(sink: unknown): TelemetrySink {
45
56
  if (wants !== undefined && !Array.isArray(wants)) {
46
57
  throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
47
58
  }
59
+ // A misspelt kind (`task.logs`) was a sink that never heard it, with no word.
60
+ const unknown = (wants as readonly unknown[] | undefined)?.find(
61
+ (k) => typeof k !== 'string' || !Object.hasOwn(RECORD_KINDS, k),
62
+ )
63
+ if (unknown !== undefined) {
64
+ throw new Error(
65
+ `telemetry sink 'wants' names ${typeof unknown === 'string' ? JSON.stringify(unknown) : typeof unknown}, which is no record kind (${Object.keys(RECORD_KINDS).join(', ')})`,
66
+ )
67
+ }
48
68
  // Every handler is optional, so `{ nope: true }` was a valid sink that
49
69
  // subscribed to the bus and heard nothing — the plugin was "on" and did
50
70
  // nothing. Refused here, which the caller turns into the same warning
@@ -102,7 +122,7 @@ export async function subscribeTelemetry(
102
122
  accepted = list.map((sink) => checkSink(sink))
103
123
  } catch (err) {
104
124
  ctx.warn(
105
- `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
125
+ `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${thrownMessage(err)}`,
106
126
  )
107
127
  continue
108
128
  }
@@ -27,7 +27,13 @@ import {
27
27
  type TaskOutcome,
28
28
  type TaskStatus,
29
29
  } from '../graph/index.js'
30
- import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
30
+ import {
31
+ maskedCommand,
32
+ settleWithin,
33
+ stageTimes,
34
+ teardownTimeoutMs,
35
+ thrownMessage,
36
+ } from '../util/index.js'
31
37
  import type { RunEvent, RunEventSubscriber } from '../report/index.js'
32
38
 
33
39
  /** Bumped when the record shape changes. Readers MUST check `v`. */
@@ -114,6 +120,10 @@ export interface TaskTelemetry {
114
120
  /** Executor-reported placement (a worker id) — absent for local runs.
115
121
  * Additive-optional, no schema bump (the `attempts` precedent). */
116
122
  where?: string
123
+ /** A persistent task each dependant's executor started where that ran:
124
+ * part of those runs, never an execution of its own (2026-10-23:
125
+ * nothing runs on a distributed run's orchestrator). Additive-optional. */
126
+ carried?: true
117
127
  /** `'deferred'` when the outputs stayed remote (`--cache-download=none`).
118
128
  * Additive-optional, same no-bump precedent as `where`. */
119
129
  outputs?: 'deferred'
@@ -127,6 +137,8 @@ export interface TaskTelemetry {
127
137
  * fail-fast skip and on every other status. Additive (schema stays 2).
128
138
  */
129
139
  blockedBy?: string
140
+ /** On a `skipped` task: its `exec.platforms` leave out the machine that ran the run. Additive. */
141
+ otherPlatform?: { readonly needs: readonly string[]; readonly host: string }
130
142
  /** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
131
143
  timedOut?: true
132
144
  /** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
@@ -188,6 +200,10 @@ export type TelemetryRecord =
188
200
  /** The run's canonical start (epoch ms) — equals the summary's
189
201
  * `startedAt`; a sink derives per-task timing from it during the run. */
190
202
  startedAt: number
203
+ /** Where the run's stage table begins (epoch ms), the first stage's
204
+ * start: at or before `startedAt`. A sink that draws the stages
205
+ * starts the run here. Additive. */
206
+ originAt?: number
191
207
  }
192
208
  | {
193
209
  v: number
@@ -280,6 +296,11 @@ export interface RunSummaryRecord {
280
296
  * executor reports any. Additive.
281
297
  */
282
298
  machines?: readonly MachineReport[]
299
+ /**
300
+ * The run's end had an executor placed its tasks perfectly
301
+ * (`TaskExecutor.idealMs`), beside `totalDurationMs`. Additive.
302
+ */
303
+ idealMs?: number
283
304
  }
284
305
 
285
306
  /** One stage of a run, a wall window in epoch ms. */
@@ -312,6 +333,7 @@ export function assembleRunSummary(
312
333
  stages?: readonly RunStage[]
313
334
  uploads?: { count: number; bytes: number; ms: number; failed: number }
314
335
  machines?: readonly MachineReport[]
336
+ idealMs?: number | undefined
315
337
  },
316
338
  ): RunSummaryRecord {
317
339
  let failedCount = 0
@@ -353,6 +375,7 @@ export function assembleRunSummary(
353
375
  ...(timing.machines !== undefined && timing.machines.length > 0
354
376
  ? { machines: timing.machines }
355
377
  : {}),
378
+ ...(timing.idealMs !== undefined ? { idealMs: timing.idealMs } : {}),
356
379
  }
357
380
  }
358
381
 
@@ -427,6 +450,8 @@ export interface TreeLoad {
427
450
 
428
451
  /** How often a running task's process tree is sampled. */
429
452
  const SAMPLE_MS = 1000
453
+ /** How long an aborted flush may take to end its own requests. */
454
+ const ABORT_GRACE_MS = 250
430
455
 
431
456
  /** Call `fn` every `ms` until the returned stop is called. */
432
457
  type Every = (ms: number, fn: () => Promise<void>) => () => void
@@ -489,7 +514,7 @@ export function createTelemetrySource(args: {
489
514
  if (disabled.has(sink)) return
490
515
  disabled.add(sink)
491
516
  warn?.(
492
- `[vx] telemetry sink '${label(sink)}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
517
+ `[vx] telemetry sink '${label(sink)}' threw in ${hook}; disabled for this run: ${thrownMessage(err)}`,
493
518
  )
494
519
  }
495
520
  // Precompute which sinks want each kind, so per-event fan-out is a plain
@@ -599,6 +624,7 @@ export function createTelemetrySource(args: {
599
624
  total: event.info.total,
600
625
  ts,
601
626
  startedAt: event.info.startedAtMs ?? ts,
627
+ ...originOf(),
602
628
  })
603
629
  return
604
630
  case 'task:start': {
@@ -682,28 +708,26 @@ export function createTelemetrySource(args: {
682
708
  // accessed_at bumps and every later plugin's teardown lost with it.
683
709
  // Sinks race concurrently, so each still gets the whole budget.
684
710
  const deadline = new AbortController()
685
- const settled = await settleWithin(
686
- Promise.all(
687
- sinks.map(async (sink) => {
688
- if (disabled.has(sink) || sink.flush === undefined) return
689
- try {
690
- await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
691
- sink.flush!(deadline.signal),
692
- )
693
- } catch (err) {
694
- // A flush failure can never break the run — but it still costs
695
- // this sink its export, so say so rather than dropping it.
696
- warn?.(
697
- `[vx] telemetry sink '${label(sink)}' failed to flush: ${err instanceof Error ? err.message : String(err)}`,
698
- )
699
- }
700
- }),
701
- ),
702
- ms,
711
+ const all = Promise.all(
712
+ sinks.map(async (sink) => {
713
+ if (disabled.has(sink) || sink.flush === undefined) return
714
+ try {
715
+ await step(`${args.owners?.get(sink) ?? label(sink)}:telemetry`, () =>
716
+ sink.flush!(deadline.signal),
717
+ )
718
+ } catch (err) {
719
+ // A flush failure can never break the run — but it still costs
720
+ // this sink its export, so say so rather than dropping it.
721
+ warn?.(`[vx] telemetry sink '${label(sink)}' failed to flush: ${thrownMessage(err)}`)
722
+ }
723
+ }),
703
724
  )
704
- if (!settled) {
725
+ if (!(await settleWithin(all, ms))) {
705
726
  deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
706
727
  warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
728
+ // A sink told of the abort says what it lost as its request ends; the
729
+ // run ended first and that line was lost (vx-otel, on CI). Briefly.
730
+ await settleWithin(all, ABORT_GRACE_MS)
707
731
  }
708
732
  },
709
733
  }
@@ -747,9 +771,11 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
747
771
  if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
748
772
  if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
749
773
  if (o.where !== undefined) t.where = o.where
774
+ if (o.carried === true) t.carried = true
750
775
  if (o.outputs !== undefined) t.outputs = o.outputs
751
776
  if (o.attempts !== undefined) t.attempts = o.attempts
752
777
  if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
778
+ if (o.otherPlatform !== undefined) t.otherPlatform = o.otherPlatform
753
779
  if (o.timedOut === true) t.timedOut = true
754
780
  if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
755
781
  if (o.notReady !== undefined) t.notReady = o.notReady
@@ -773,3 +799,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
773
799
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
774
800
  return t
775
801
  }
802
+
803
+ /** The stage table's start, when a stage was marked. */
804
+ function originOf(): { originAt?: number } {
805
+ const first = stageTimes()[0]
806
+ return first === undefined ? {} : { originAt: first.startedAt }
807
+ }
@@ -24,6 +24,21 @@ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcom
24
24
  return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
25
25
  }
26
26
 
27
+ /** The ids some task of the run depends on; the rest run with `noDependants`. */
28
+ export function dependedOn(nodes: Iterable<TaskNode>): Set<string> {
29
+ const ids = new Set<string>()
30
+ for (const n of nodes) for (const d of n.deps) ids.add(d)
31
+ return ids
32
+ }
33
+
34
+ /**
35
+ * A server's key is read only by a dependant (A-17), so with none in the
36
+ * run it derives none (`noDependants`), and the plan must not show one.
37
+ */
38
+ export function keylessServer(node: TaskNode, depended: ReadonlySet<string>): boolean {
39
+ return node.config.exec?.persistent !== undefined && !depended.has(node.id)
40
+ }
41
+
27
42
  /** A dependency as a key reads it: only `node` and `hash` matter. */
28
43
  export function keyedOutcome(node: TaskNode, hash: string | undefined): TaskOutcome {
29
44
  const o: TaskOutcome = { node, status: 'success', exitCode: 0, durationMs: 0 }