@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -250,10 +250,12 @@ export function formatSummarySection(
250
250
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
251
251
  // The plugin's hand on the run: a task a policy held with a worker
252
252
  // free would otherwise have started; without the line nothing says
253
- // the policy acted.
253
+ // the policy acted. The duration is the waits summed, task-seconds
254
+ // — 85 held tasks read `4783s` beside a 160 s run on TanStack/router
255
+ // (2026-09-15), so the line says it is a sum.
254
256
  if (stats.held !== undefined && stats.held.count > 0) {
255
257
  info.push(
256
- `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'} ${formatDuration(stats.held.sumMs)}`,
258
+ `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatDuration(stats.held.sumMs)} in all`,
257
259
  )
258
260
  }
259
261
  lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
@@ -343,6 +345,51 @@ export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[]
343
345
  return lines
344
346
  }
345
347
 
348
+ /**
349
+ * Post-summary section naming the tasks that never started, under the
350
+ * failure that blocked them. The footer's "1 skipped" says a task was
351
+ * blocked and nothing about which task or by what; the broad flow prints
352
+ * no row for a skipped task on purpose (a wide red run would drown in
353
+ * them), so this is where the reader learns what the failure cost
354
+ * (item 266). The scheduler records the root of each block on the outcome
355
+ * (`blockedBy`, item 267); a skip without one is fail-fast's. Empty when
356
+ * nothing was skipped.
357
+ */
358
+ export function formatSkippedSection(outcomes: readonly TaskOutcome[]): string[] {
359
+ // A group (no `exec`) never starts by definition and is not a task the
360
+ // tally counts; listing a blocked one here named three "tasks" the legend
361
+ // beside it counted as none (item 281). Its members' rows say enough.
362
+ const skipped = outcomes.filter((o) => o.status === 'skipped' && !isGroupTask(o.node))
363
+ if (skipped.length === 0) return []
364
+ const byId = new Map(outcomes.map((o) => [o.node.id, o]))
365
+ const causeOf = (o: TaskOutcome): string => {
366
+ if (o.blockedBy === undefined) return 'after the run stopped (fail-fast)'
367
+ const root = byId.get(o.blockedBy)
368
+ return root?.status === 'aborted'
369
+ ? `after ${o.blockedBy} was aborted`
370
+ : `after ${o.blockedBy} failed`
371
+ }
372
+ const groups = new Map<string, string[]>()
373
+ for (const o of skipped) {
374
+ const cause = causeOf(o)
375
+ const list = groups.get(cause) ?? []
376
+ list.push(o.node.id)
377
+ groups.set(cause, list)
378
+ }
379
+ const lines = [
380
+ '',
381
+ ` Skipped: ${skipped.length} task${skipped.length === 1 ? '' : 's'} never started \u2014 blocked upstream`,
382
+ ]
383
+ const NAMES = 8
384
+ for (const [cause, ids] of [...groups].sort((a, b) => b[1].length - a[1].length)) {
385
+ ids.sort()
386
+ const shown = ids.slice(0, NAMES).join(', ')
387
+ const more = ids.length > NAMES ? ` \u2026 +${ids.length - NAMES} more` : ''
388
+ lines.push(` \u2298 ${cause}: ${shown}${more}`)
389
+ }
390
+ return lines
391
+ }
392
+
346
393
  /**
347
394
  * Post-summary section naming the tasks this run proved flaky: identical
348
395
  * inputs, both outcomes on record (or a retry within the run). A red run
@@ -1,9 +1,11 @@
1
+ import { lstatSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import type { TaskConfig, CacheConfig } from '../config.js'
3
- import type { WorkspaceFilesCache } from '../cache/index.js'
4
+ import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
4
5
  import {
5
6
  type CacheKeyInput,
6
7
  type CacheLayer,
8
+ FILE_HASH_RACY_MS,
7
9
  resolveInputs,
8
10
  type GitFilesCache,
9
11
  WORKSPACE_OUTPUT_PREFIX,
@@ -20,6 +22,9 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
20
22
  * project re-reads the same `package.json`; without this, a
21
23
  * monorepo with N projects × M tasks per project does N×M reads
22
24
  * of the same bytes.
25
+ * - `projectFiles`: keyed by the project and its input DECLARATION.
26
+ * Tasks of one project that declare the same inputs and outputs
27
+ * resolve the same file list; see `ProjectFilesCache`.
23
28
  * - `taskConfig`: keyed by the resolved-config object reference.
24
29
  * Each task's config is created once at prepareRun time; the
25
30
  * JSON.stringify + xxh3 of it is deterministic. WeakMap so
@@ -34,6 +39,7 @@ export interface HashCache {
34
39
  runtime: Map<string, Promise<string>>
35
40
  workspaceRuntime: Map<string, Promise<string>>
36
41
  workspaceFiles: WorkspaceFilesCache
42
+ projectFiles: ProjectFilesCache
37
43
  }
38
44
 
39
45
  export function createHashCache(): HashCache {
@@ -43,6 +49,7 @@ export function createHashCache(): HashCache {
43
49
  runtime: new Map(),
44
50
  workspaceRuntime: new Map(),
45
51
  workspaceFiles: new Map(),
52
+ projectFiles: new Map(),
46
53
  }
47
54
  }
48
55
 
@@ -101,6 +108,18 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
101
108
  }
102
109
  }
103
110
 
111
+ /**
112
+ * One file the key folded: the digest it folded, and since when (ms since
113
+ * the epoch) that digest is known to be the file's content — the
114
+ * enumeration's start for an index OID (`git status` vouched for it then),
115
+ * the describe's start for a digest hashed from the file.
116
+ */
117
+ export interface InputFact {
118
+ path: string
119
+ digest: string
120
+ since: number
121
+ }
122
+
104
123
  /**
105
124
  * The key AND the structured input set behind it, for the executor seam.
106
125
  * Miss path only: it re-runs the (memoized) resolution `computeTaskHash`
@@ -112,7 +131,8 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
112
131
  */
113
132
  export async function describeTaskInputs(
114
133
  args: ComputeHashArgs,
115
- ): Promise<{ hash: string; inputs: TaskInputs }> {
134
+ ): Promise<{ hash: string; inputs: TaskInputs; facts: InputFact[]; describedAt: number }> {
135
+ const hashedAt = Date.now()
116
136
  const input = await resolveKeyInput(args)
117
137
  const hash = await args.cache.key(input)
118
138
  const sorted = [...input.inputFiles].sort()
@@ -123,6 +143,22 @@ export async function describeTaskInputs(
123
143
  path: relPosix(input.workspaceRoot, f),
124
144
  digest: digests[i]!,
125
145
  }))
146
+ // The package.json digest is a per-run memo that may predate this
147
+ // describe, so it is dated from the enumeration, the earliest a run
148
+ // learns anything.
149
+ const indexedAt = args.gitFilesCache?.enumeratedAtMs ?? 0
150
+ const facts: InputFact[] = sorted.map((f, i) => ({
151
+ path: f,
152
+ digest: digests[i]!,
153
+ since: input.fileHashes?.has(f) === true ? indexedAt : hashedAt,
154
+ }))
155
+ if (input.projectPackageJsonHash !== '') {
156
+ facts.push({
157
+ path: path.join(args.node.projectDir, 'package.json'),
158
+ digest: input.projectPackageJsonHash,
159
+ since: indexedAt,
160
+ })
161
+ }
126
162
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
127
163
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
128
164
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -143,6 +179,8 @@ export async function describeTaskInputs(
143
179
  })
144
180
  return {
145
181
  hash,
182
+ facts,
183
+ describedAt: hashedAt,
146
184
  inputs: {
147
185
  files,
148
186
  env: input.envValues.map(([name, value]) => ({ name, value })),
@@ -159,6 +197,41 @@ export async function describeTaskInputs(
159
197
  }
160
198
  }
161
199
 
200
+ /**
201
+ * The first input whose content may no longer be the digest the key folded
202
+ * (item 743), or undefined when every one still is. One `lstat` per file:
203
+ * a file whose ctime is older than its fact (by the racy window, as git
204
+ * judges its index) has not been written since, and one that is not gets
205
+ * hashed again and compared. ctime, because no writer can set it back. A
206
+ * file that is gone has moved. One written at or after `commandFrom` (the
207
+ * describe just before the command) has moved whatever it holds now: an
208
+ * input changed and changed BACK while the command ran matched its digest
209
+ * again, and the entry saved the other content's output under the key (item
210
+ * 1015). A write before it — an upstream's — is judged by content, as before.
211
+ */
212
+ export async function movedInput(
213
+ facts: readonly InputFact[],
214
+ cache: CacheLayer,
215
+ commandFrom?: number,
216
+ ): Promise<string | undefined> {
217
+ const suspects: InputFact[] = []
218
+ for (const f of facts) {
219
+ let ctimeMs: number
220
+ try {
221
+ ctimeMs = lstatSync(f.path).ctimeMs
222
+ } catch {
223
+ return f.path
224
+ }
225
+ if (commandFrom !== undefined && ctimeMs >= commandFrom) return f.path
226
+ if (ctimeMs >= f.since - FILE_HASH_RACY_MS) suspects.push(f)
227
+ }
228
+ for (const f of suspects) {
229
+ const now = await cache.hashFile(f.path).catch(() => undefined)
230
+ if (now !== f.digest) return f.path
231
+ }
232
+ return undefined
233
+ }
234
+
162
235
  async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
163
236
  const cfg = args.node.config
164
237
  const cacheCfg: CacheConfig | undefined = cfg.cache
@@ -178,6 +251,7 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
178
251
  runtimeCache: args.hashCache.runtime,
179
252
  workspaceRuntimeCache: args.hashCache.workspaceRuntime,
180
253
  workspaceFilesCache: args.hashCache.workspaceFiles,
254
+ projectFilesCache: args.hashCache.projectFiles,
181
255
  }
182
256
  : {}),
183
257
  })
@@ -251,8 +325,9 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
251
325
  * participate because the loader has already baked them into the
252
326
  * resolved object before we serialize.
253
327
  *
254
- * The schema is JSON-serializable by construction (no functions in
255
- * fields). The `hashCache.taskConfig` WeakMap is consulted first —
328
+ * A loaded config is JSON data: every load path refuses a value JSON
329
+ * cannot carry (workspace/json-data.ts, item 701), so this JSON is the
330
+ * config as vx reads it. The `hashCache.taskConfig` WeakMap is consulted first —
256
331
  * each task's config object is created once per run, so a hit there
257
332
  * skips the JSON.stringify + xxh3 entirely.
258
333
  */
@@ -276,9 +351,14 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
276
351
  * laptop from a worker pool over nothing. (`exec.resources` rode the same
277
352
  * rule until 2026-09-12, when reservations left the config for the
278
353
  * schedule plugin that learns them; a config never declares them now.) A
279
- * config that declares no `remote` takes the fast path and stringifies
280
- * byte-identically to before the field existed (why this needs no
281
- * CACHE_VERSION bump). `timeout`/`retries` stay folded — their keys are
354
+ * config that declares no `remote` takes the fast path. That is a
355
+ * SHORTCUT, not the reason the field needed no CACHE_VERSION bump:
356
+ * measured (item 505), the spread path stringifies byte-identically for
357
+ * every config that HAS an `exec`, since a spread keeps an existing key
358
+ * in place. Its one behavioural case is a config with NO `exec`, where
359
+ * the spread would add `exec: {}` — and nothing reaches here that way,
360
+ * proven by throwing on a no-exec config and running the whole suite:
361
+ * a group takes `computeGroupHash` instead. `timeout`/`retries` stay folded — their keys are
282
362
  * distinct by design (see the decision log); stripping them retroactively
283
363
  * would bump CACHE_VERSION.
284
364
  */
@@ -79,13 +79,6 @@ interface InFlight {
79
79
  truncatedHeadChars: number
80
80
  }
81
81
 
82
- /**
83
- * Bounded per-run capture. `append` keeps a chunk LIST + running char count per
84
- * task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
85
- * string concatenation until `drain`, so a cache-hit replay (one big chunk) is
86
- * one array push, zero copies. `finish` decides retention; `drain` emits the
87
- * bundle, failures first.
88
- */
89
82
  /**
90
83
  * Per-retained-chunk overhead, in char-equivalents, charged against the RUN
91
84
  * budget on top of the characters themselves.
@@ -110,6 +103,13 @@ function budgetCost(chars: number, chunkCount: number): number {
110
103
  return chars + chunkCount * CHUNK_OVERHEAD_CHARS
111
104
  }
112
105
 
106
+ /**
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
109
+ * string concatenation until `drain`, so a cache-hit replay (one big chunk) is
110
+ * one array push, zero copies. `finish` decides retention; `drain` emits the
111
+ * bundle, failures first.
112
+ */
113
113
  export class TaskLogBuffer {
114
114
  private readonly inFlight = new Map<string, InFlight>()
115
115
  private readonly retained = new Map<string, Retained>()
@@ -18,6 +18,7 @@ import type {
18
18
  TelemetryContext,
19
19
  TelemetrySink,
20
20
  } from './telemetry.js'
21
+ import { settleWithin, teardownTimeoutMs } from '../util/index.js'
21
22
  import { createTelemetrySource } from './telemetry.js'
22
23
  import type { VxPlugin } from './plugin.js'
23
24
 
@@ -79,7 +80,18 @@ export async function subscribeTelemetry(
79
80
  // check sits INSIDE the try so a throwing `wants` getter is caught too.
80
81
  let accepted: TelemetrySink[]
81
82
  try {
82
- const result = await plugin.telemetry(ctx)
83
+ // Bounded as flush and teardown are: a hook that never settles held
84
+ // the run before its first task, forever or until the loop drained
85
+ // and Bun exited 0 (item 921).
86
+ let result: Awaited<ReturnType<NonNullable<VxPlugin['telemetry']>>> | undefined
87
+ const ms = teardownTimeoutMs()
88
+ const arrived = await settleWithin(
89
+ Promise.resolve(plugin.telemetry(ctx)).then((r) => {
90
+ result = r
91
+ }),
92
+ ms,
93
+ )
94
+ if (!arrived) throw new Error(`did not initialize within ${ms} ms`)
83
95
  const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
84
96
  accepted = list.map((sink) => checkSink(sink))
85
97
  } catch (err) {
@@ -94,15 +106,12 @@ export async function subscribeTelemetry(
94
106
  if (sinks.length === 0) return undefined
95
107
 
96
108
  const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
109
+ // The bus's unsubscribe is idempotent, so it is the handle's dispose as
110
+ // is (a once-flag here survived item 654).
97
111
  const dispose = bus.subscribe(source.subscriber)
98
- let disposed = false
99
112
  return {
100
113
  emitSummary: (summary) => source.emitSummary(summary),
101
114
  flush: () => source.flush(),
102
- dispose() {
103
- if (disposed) return
104
- disposed = true
105
- dispose()
106
- },
115
+ dispose,
107
116
  }
108
117
  }
@@ -16,12 +16,12 @@
16
16
  // what or how tasks run. Contrast `cache`/`executor`, which return objects
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
- import type { TaskStatus } from '../graph/index.js'
19
+ import type { TaskOutcome, TaskStatus } from '../graph/index.js'
20
20
  import { settleWithin, teardownTimeoutMs } from '../util/index.js'
21
21
  import type { RunEvent, RunEventSubscriber } from './events.js'
22
22
 
23
23
  /** Bumped when the record shape changes. Readers MUST check `v`. */
24
- export const TELEMETRY_SCHEMA_VERSION = 2
24
+ export const TELEMETRY_SCHEMA_VERSION = 3
25
25
 
26
26
  /** Where a task's result came from, derived ONCE in core from the status. */
27
27
  export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
@@ -75,8 +75,6 @@ const PASSES: Record<TaskStatus, boolean> = {
75
75
  */
76
76
  export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
77
77
 
78
- const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
79
-
80
78
  /**
81
79
  * Did the task pass? A cache hit counts — it produced the same result without
82
80
  * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
@@ -97,7 +95,8 @@ export function isPassStatus(status: string): boolean {
97
95
  * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
98
96
  */
99
97
  export function isCacheHit(status: string): boolean {
100
- if (!KNOWN_STATUSES.has(status)) return false
98
+ // No known-status guard: an unknown string falls through the switch to
99
+ // undefined, which is neither hit source (the guard survived item 654).
101
100
  const source = deriveCacheSource(status as TaskStatus)
102
101
  return source === 'local' || source === 'remote'
103
102
  }
@@ -169,6 +168,18 @@ export interface TaskTelemetry {
169
168
  * / `--retry` produced more than one attempt. A retried-then-passed task is
170
169
  * flaky by definition; this is the telemetry-side flaky signal. */
171
170
  attempts?: number
171
+ /**
172
+ * On a `skipped` task: the id of the failed (or aborted) task at the
173
+ * root of what blocked it, through any chain of skips. Absent on a
174
+ * fail-fast skip and on every other status. Additive (schema stays 2).
175
+ */
176
+ blockedBy?: string
177
+ /** On a `failed` task: vx's own `timeout` killed it (exit 143 is the deadline, not a signal). Additive. */
178
+ timedOut?: true
179
+ /** On a sandboxed task: how many declared-boundary violations the sandbox recorded (a failure on its own). Additive. */
180
+ sandboxViolations?: number
181
+ /** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
182
+ notReady?: 'timeout' | 'exited' | 'spawn'
172
183
  /** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
173
184
  wallclockStartNs?: string
174
185
  wallclockEndNs?: string
@@ -227,6 +238,12 @@ export interface RunSummaryRecord {
227
238
  totalDurationMs: number
228
239
  taskCount: number
229
240
  failedCount: number
241
+ /**
242
+ * Tasks a shutdown signal or an embedder's abort killed (v3). They are
243
+ * not in `tasks`, which holds real runs only, so without this a stopped
244
+ * run read as a failure with nothing failed (item 851).
245
+ */
246
+ abortedCount: number
230
247
  hitCount: number
231
248
  hitLocalCount: number
232
249
  hitRemoteCount: number
@@ -248,7 +265,13 @@ export interface RunSummaryRecord {
248
265
  export function assembleRunSummary(
249
266
  run: RunContextRecord,
250
267
  tasks: readonly TaskTelemetry[],
251
- timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
268
+ timing: {
269
+ startedAt: number
270
+ endedAt: number
271
+ totalDurationMs: number
272
+ exitOk: boolean
273
+ abortedCount: number
274
+ },
252
275
  ): RunSummaryRecord {
253
276
  let failedCount = 0
254
277
  let hitLocalCount = 0
@@ -266,6 +289,7 @@ export function assembleRunSummary(
266
289
  totalDurationMs: timing.totalDurationMs,
267
290
  taskCount: tasks.length,
268
291
  failedCount,
292
+ abortedCount: timing.abortedCount,
269
293
  hitCount: hitLocalCount + hitRemoteCount,
270
294
  hitLocalCount,
271
295
  hitRemoteCount,
@@ -352,8 +376,9 @@ export function createTelemetrySource(args: {
352
376
  // Never silently. The standing rule is that a never-fail path must still
353
377
  // WARN — telemetry that vanishes without a word is indistinguishable from
354
378
  // telemetry nobody configured.
379
+ // Every hook call site skips a disabled sink first, so this runs at most
380
+ // once per sink without a guard of its own (item 654).
355
381
  const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
356
- if (disabled.has(sink)) return
357
382
  disabled.add(sink)
358
383
  warn?.(
359
384
  `[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
@@ -423,30 +448,13 @@ export function createTelemetrySource(args: {
423
448
  case 'task:complete': {
424
449
  const { node, outcome } = event
425
450
  if (node.config.exec === undefined) return // group task
426
- const rec: TelemetryRecord = {
451
+ deliver({
427
452
  v: TELEMETRY_SCHEMA_VERSION,
428
453
  kind: 'task.end',
429
454
  runId,
430
455
  ts,
431
- taskId: node.id,
432
- project: node.projectName,
433
- task: node.taskName,
434
- status: outcome.status,
435
- cacheSource: deriveCacheSource(outcome.status),
436
- exitCode: outcome.exitCode,
437
- durationMs: outcome.durationMs,
438
- }
439
- if (outcome.hash !== undefined) rec.hash = outcome.hash
440
- if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
441
- if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
442
- if (outcome.where !== undefined) rec.where = outcome.where
443
- if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
444
- if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
445
- if (outcome.wallclockStartNs !== undefined)
446
- rec.wallclockStartNs = outcome.wallclockStartNs.toString()
447
- if (outcome.wallclockEndNs !== undefined)
448
- rec.wallclockEndNs = outcome.wallclockEndNs.toString()
449
- deliver(rec)
456
+ ...taskTelemetryOf(outcome),
457
+ })
450
458
  return
451
459
  }
452
460
  case 'run:status':
@@ -476,7 +484,8 @@ export function createTelemetrySource(args: {
476
484
  const ms = teardownTimeoutMs()
477
485
  // Bounded, for the same reason plugin teardown is bounded in
478
486
  // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
479
- // returns, and bin.ts is `process.exit(await run(...))`. A sink whose
487
+ // returns, and bin.ts exits with run()'s code once it returns (through
488
+ // stdout's end callback since 2026-09-15; the code is still that). A sink whose
480
489
  // flush never settles therefore drains the event loop with no exit code
481
490
  // pending — Bun exits 0 and a FAILED run reports green, with the cache's
482
491
  // accessed_at bumps and every later plugin's teardown lost with it.
@@ -502,3 +511,35 @@ export function createTelemetrySource(args: {
502
511
  },
503
512
  }
504
513
  }
514
+
515
+ /**
516
+ * The one projection of an outcome into `TaskTelemetry`, for the streaming
517
+ * `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
518
+ * `task.end` dropped `blockedBy`, `timedOut`, `sandboxViolations` and
519
+ * `notReady`, so a streaming sink saw a timed-out or blocked task as a plain
520
+ * failure or skip (item 660).
521
+ */
522
+ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
523
+ const t: TaskTelemetry = {
524
+ taskId: o.node.id,
525
+ project: o.node.projectName,
526
+ task: o.node.taskName,
527
+ status: o.status,
528
+ cacheSource: deriveCacheSource(o.status),
529
+ exitCode: o.exitCode,
530
+ durationMs: o.durationMs,
531
+ }
532
+ if (o.hash !== undefined) t.hash = o.hash
533
+ if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
534
+ if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
535
+ if (o.where !== undefined) t.where = o.where
536
+ if (o.outputs !== undefined) t.outputs = o.outputs
537
+ if (o.attempts !== undefined) t.attempts = o.attempts
538
+ if (o.blockedBy !== undefined) t.blockedBy = o.blockedBy
539
+ if (o.timedOut === true) t.timedOut = true
540
+ if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
541
+ if (o.notReady !== undefined) t.notReady = o.notReady
542
+ if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
543
+ if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
544
+ return t
545
+ }