@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,288 @@
1
+ // The shared per-task log capture primitive (task-logs-2026-07 §1). ONE
2
+ // bounded-tail buffer every telemetry sink uses — today `@vzn/vx-otel`'s
3
+ // sink — so the capping rules can't drift between capture sites.
4
+ //
5
+ // It lives in CORE, beside the telemetry contract, for the same reason
6
+ // `assembleRunSummary` does: two sinks that each roll their own bounded buffer
7
+ // is how the retention rule forks, and a forked rule means two sinks disagree
8
+ // about which task's output survived. A sink is the only consumer — core
9
+ // itself never captures logs — but the vocabulary it decides in (`TaskStatus`,
10
+ // `CacheSource`, "a hit's bytes belong to the run that executed") is core's.
11
+ //
12
+ // The law: a log-spewing task can never OOM the capturer. Two caps enforce it:
13
+ // - per task: only the last TASK_LOG_TAIL_CHARS of merged stdout+stderr is
14
+ // retained (chunks evicted from the head, no concatenation until drain);
15
+ // - per run: only RUN_LOG_BUDGET_CHARS of RETAINED completed tails ship, and
16
+ // failed tails are never evicted by successes — a failure's output is the
17
+ // one thing you always want to read.
18
+ //
19
+ // Both caps report themselves: an evicted or head-trimmed task still ships,
20
+ // carrying how much was dropped, so "we truncated this" never reads as "this
21
+ // task printed nothing".
22
+
23
+ import type { TaskStatus } from '../graph/index.js'
24
+ import type { CacheSource } from './telemetry.js'
25
+
26
+ /** Version of the drained-bundle shape below. It is the canonical drained
27
+ * logs format, not one transport's: every sink ships the same object. */
28
+ export const LOG_WIRE_VERSION = 1
29
+
30
+ /**
31
+ * Per-task tail retained (UTF-16 units — a cheap capture-side proxy; the store
32
+ * records true stored bytes). Merged streams: what a terminal actually shows.
33
+ */
34
+ export const TASK_LOG_TAIL_CHARS = 128 * 1024
35
+
36
+ /** Total RETAINED completed-task chars a single run ships. */
37
+ export const RUN_LOG_BUDGET_CHARS = 4 * 1024 * 1024
38
+
39
+ export interface TaskLogEntry {
40
+ taskId: string
41
+ /** The task's cache key when known — the hit→executed-run resolution key. */
42
+ hash?: string
43
+ status: 'success' | 'failed'
44
+ /** Merged stdout+stderr in arrival order, tail-capped, ANSI preserved. */
45
+ content: string
46
+ /** Chars emitted before capping. */
47
+ charsFull: number
48
+ /** Chars dropped from the head; 0 = complete. */
49
+ truncatedHeadChars: number
50
+ }
51
+
52
+ export interface TaskLogBundle {
53
+ v: typeof LOG_WIRE_VERSION
54
+ runId: string
55
+ workspaceId: string
56
+ tasks: TaskLogEntry[]
57
+ }
58
+
59
+ /** Retained-until-drain state for one completed task. */
60
+ interface Retained {
61
+ taskId: string
62
+ hash?: string
63
+ status: 'success' | 'failed'
64
+ /** Emptied when the run budget evicts this task; `chars` goes to 0 and the
65
+ * dropped count moves to `truncatedHeadChars` (see `evictToBudget`). */
66
+ chunks: string[]
67
+ chars: number
68
+ charsFull: number
69
+ truncatedHeadChars: number
70
+ /** Insertion order — the eviction tiebreak (oldest first). */
71
+ seq: number
72
+ }
73
+
74
+ /** In-flight (not yet ended) accumulation for one task. */
75
+ interface InFlight {
76
+ chunks: string[]
77
+ chars: number
78
+ charsFull: number
79
+ truncatedHeadChars: number
80
+ }
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
+ /**
90
+ * Per-retained-chunk overhead, in char-equivalents, charged against the RUN
91
+ * budget on top of the characters themselves.
92
+ *
93
+ * The budget exists to bound MEMORY, but a char count is not one: a task that
94
+ * emits a byte at a time (unbuffered progress output) stores the same content
95
+ * as thousands of separate strings, each with its own header and allocation.
96
+ * MEASURED on Bun 1.4: 1 000 000 chars arrive as one chunk for ~1.4 MB of
97
+ * marginal RSS, and as 1 000 000 one-char chunks for ~30 MB — so a 4 MiB
98
+ * char budget could hold ~80 MB of actual memory. Charging each chunk ~24
99
+ * char-equivalents makes the budget track what it is trying to limit; chunky
100
+ * output is unaffected (one 128 KiB chunk pays 24 on 131 072).
101
+ *
102
+ * The per-task TAIL cap deliberately stays a pure char count — it bounds how
103
+ * much a reader is shown, which is a different question from how much the
104
+ * process holds.
105
+ */
106
+ const CHUNK_OVERHEAD_CHARS = 24
107
+
108
+ /** What one retained entry costs the run budget: content + per-chunk overhead. */
109
+ function budgetCost(chars: number, chunkCount: number): number {
110
+ return chars + chunkCount * CHUNK_OVERHEAD_CHARS
111
+ }
112
+
113
+ export class TaskLogBuffer {
114
+ private readonly inFlight = new Map<string, InFlight>()
115
+ private readonly retained = new Map<string, Retained>()
116
+ private retainedChars = 0
117
+ private seq = 0
118
+
119
+ append(taskId: string, chunk: string): void {
120
+ if (chunk.length === 0) return
121
+ let acc = this.inFlight.get(taskId)
122
+ if (acc === undefined) {
123
+ acc = { chunks: [], chars: 0, charsFull: 0, truncatedHeadChars: 0 }
124
+ this.inFlight.set(taskId, acc)
125
+ }
126
+ acc.chunks.push(chunk)
127
+ acc.chars += chunk.length
128
+ 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
+ const keep = only.slice(only.length - TASK_LOG_TAIL_CHARS)
139
+ acc.truncatedHeadChars += only.length - keep.length
140
+ acc.chunks[0] = keep
141
+ acc.chars = keep.length
142
+ }
143
+ }
144
+
145
+ /**
146
+ * task.end: decide retention.
147
+ * - cacheSource !== 'miss' (a hit) → DROP: the executed run already stored
148
+ * these bytes; a hit resolves by hash to that run.
149
+ * - 'skipped' / 'aborted' → DROP (no meaningful output; a tearing-down run).
150
+ * - a success/failed MISS → RETAIN the tail, under the run budget: adding a
151
+ * task that would exceed `RUN_LOG_BUDGET_CHARS` evicts oldest retained
152
+ * SUCCESS tails first; failed tails are evicted only by newer failed tails
153
+ * (oldest first) once failures alone exceed the budget.
154
+ */
155
+ finish(taskId: string, status: TaskStatus, cacheSource: CacheSource, hash?: string): void {
156
+ const acc = this.inFlight.get(taskId)
157
+ this.inFlight.delete(taskId)
158
+ if (cacheSource !== 'miss') return
159
+ if (status !== 'success' && status !== 'failed') return
160
+ if (acc === undefined) return
161
+
162
+ const entry: Retained = {
163
+ taskId,
164
+ ...(hash !== undefined ? { hash } : {}),
165
+ status,
166
+ chunks: acc.chunks,
167
+ chars: acc.chars,
168
+ charsFull: acc.charsFull,
169
+ truncatedHeadChars: acc.truncatedHeadChars,
170
+ seq: this.seq++,
171
+ }
172
+ // Replace an earlier retention for the same task id (a retried task ends
173
+ // once per attempt only via the winning outcome, but be defensive).
174
+ const prior = this.retained.get(taskId)
175
+ if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
176
+ this.retained.set(taskId, entry)
177
+ this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
178
+ this.evictToBudget()
179
+ }
180
+
181
+ /**
182
+ * Take ONE finished task's retained tail as a wire entry, removing it from
183
+ * the buffer (so a later `drain` won't re-ship it). Used by per-task
184
+ * incremental delivery: `finish()` retains, then the sink `takeEntry()`s and
185
+ * ships that one task. Returns undefined for a task that wasn't retained (a
186
+ * cache hit / skipped / still in-flight / unknown id) — the task result still
187
+ * ships; there is just no log tail to send.
188
+ */
189
+ takeEntry(taskId: string): TaskLogEntry | undefined {
190
+ const e = this.retained.get(taskId)
191
+ if (e === undefined) return undefined
192
+ this.retained.delete(taskId)
193
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
194
+ return {
195
+ taskId: e.taskId,
196
+ ...(e.hash !== undefined ? { hash: e.hash } : {}),
197
+ status: e.status,
198
+ content: e.chunks.join(''),
199
+ charsFull: e.charsFull,
200
+ truncatedHeadChars: e.truncatedHeadChars,
201
+ }
202
+ }
203
+
204
+ /** Everything retained, failures first, ready to ship/store. */
205
+ drain(runId: string, workspaceId: string): TaskLogBundle {
206
+ const entries = [...this.retained.values()].sort((a, b) => {
207
+ // Failures ahead of successes; within a tier, oldest first (stable).
208
+ const fa = a.status === 'failed' ? 0 : 1
209
+ const fb = b.status === 'failed' ? 0 : 1
210
+ return fa !== fb ? fa - fb : a.seq - b.seq
211
+ })
212
+ return {
213
+ v: LOG_WIRE_VERSION,
214
+ runId,
215
+ workspaceId,
216
+ tasks: entries.map((e) => ({
217
+ taskId: e.taskId,
218
+ ...(e.hash !== undefined ? { hash: e.hash } : {}),
219
+ status: e.status,
220
+ content: e.chunks.join(''),
221
+ charsFull: e.charsFull,
222
+ truncatedHeadChars: e.truncatedHeadChars,
223
+ })),
224
+ }
225
+ }
226
+
227
+ /** Retained-tasks count (test/observability). */
228
+ size(): number {
229
+ return this.retained.size
230
+ }
231
+
232
+ /**
233
+ * Budget charged right now, in char-equivalents (test/observability).
234
+ *
235
+ * Exposed because the charge/release symmetry is the invariant that keeps
236
+ * the cap meaningful and the one that fails SILENTLY: a release that does
237
+ * not match its charge drifts `retainedChars` a little per task, and a
238
+ * long run ends up bounding something other than what it thinks.
239
+ */
240
+ budgetUsed(): number {
241
+ return this.retainedChars
242
+ }
243
+
244
+ /**
245
+ * Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
246
+ * (oldest by seq); only when failures ALONE still exceed the budget do the
247
+ * oldest failures go — a failure is never dropped to keep a success.
248
+ *
249
+ * An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
250
+ * = everything it emitted) rather than removed. Dropping the entry makes a
251
+ * budget eviction indistinguishable from a task that printed nothing — the
252
+ * reader gets "no logs captured" either way, which is a confident false
253
+ * statement in the one case where they most need the output (a run with
254
+ * enough failing tasks to blow the budget). A stub costs a few dozen bytes,
255
+ * keeps the `content.length === charsFull - truncatedHeadChars` accounting
256
+ * honest, and renders through the truncation banner the per-task cap
257
+ * already uses — a store that enforces its own run budget should degrade
258
+ * the same way (slice to empty, add the remainder to the truncated count).
259
+ *
260
+ * The cost is a stub per evicted task in the end-of-run bundle: measured at
261
+ * ~190 JSON bytes each, so ~1.9 MiB at the 10k-task scale target and ~9.3 MiB
262
+ * at 50k — a sink that posts the whole bundle in one request sizes its cap
263
+ * against that; one that ships per task as it finishes (`takeEntry`) never
264
+ * sees it.
265
+ */
266
+ private evictToBudget(): void {
267
+ if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
268
+ const order = [...this.retained.values()].sort((a, b) => {
269
+ const sa = a.status === 'success' ? 0 : 1
270
+ const sb = b.status === 'success' ? 0 : 1
271
+ if (sa !== sb) return sa - sb // successes are dropped first, always
272
+ // Within a tier the tiebreak DIFFERS by status, and the failed one is
273
+ // the point: when failures alone blow the budget, the FIRST failure is
274
+ // usually the root cause and the later ones its cascade, so it must be
275
+ // the last thing stubbed. Successes keep oldest-first — none of them
276
+ // is more interesting than another, and recency is the better guess.
277
+ return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
278
+ })
279
+ for (const e of order) {
280
+ if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
281
+ if (e.chars === 0) continue // already a stub — frees nothing
282
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
283
+ e.truncatedHeadChars += e.chars
284
+ e.chunks = []
285
+ e.chars = 0
286
+ }
287
+ }
288
+ }
@@ -31,6 +31,28 @@ export interface TelemetryHandle {
31
31
  dispose(): void
32
32
  }
33
33
 
34
+ /** Reject anything that is not sink-shaped, naming what arrived. */
35
+ function checkSink(sink: unknown): TelemetrySink {
36
+ if (typeof sink !== 'object' || sink === null) {
37
+ throw new Error(`telemetry sink must be an object, got ${sink === null ? 'null' : typeof sink}`)
38
+ }
39
+ const wants = (sink as TelemetrySink).wants
40
+ if (wants !== undefined && !Array.isArray(wants)) {
41
+ throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
42
+ }
43
+ // Every handler is optional, so `{ nope: true }` was a valid sink that
44
+ // subscribed to the bus and heard nothing — the plugin was "on" and did
45
+ // nothing. Refused here, which the caller turns into the same warning
46
+ // a throwing hook gets: never a failed run.
47
+ const s = sink as TelemetrySink
48
+ if (typeof s.onRecord !== 'function' && typeof s.onRunSummary !== 'function') {
49
+ throw new Error(
50
+ 'telemetry sink handles nothing: neither onRecord nor onRunSummary is a function',
51
+ )
52
+ }
53
+ return s
54
+ }
55
+
34
56
  /**
35
57
  * Consult every plugin's `telemetry` capability, collect the sinks, and —
36
58
  * only if at least one sink results — create a TelemetrySource and subscribe
@@ -49,23 +71,29 @@ export async function subscribeTelemetry(
49
71
  const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
50
72
  for (const plugin of plugins) {
51
73
  if (plugin.telemetry === undefined) continue
52
- let result
74
+ // A plugin's return value is user input, so it is checked here rather
75
+ // than trusted downstream: `createTelemetrySource` reads `.wants` off
76
+ // every sink immediately, so one `null` in the list used to abort the
77
+ // whole run with a raw TypeError before a single task had run. Staging
78
+ // into `accepted` keeps a partly-bad array all-or-nothing, and the shape
79
+ // check sits INSIDE the try so a throwing `wants` getter is caught too.
80
+ let accepted: TelemetrySink[]
53
81
  try {
54
- result = await plugin.telemetry(ctx)
82
+ const result = await plugin.telemetry(ctx)
83
+ const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
84
+ accepted = list.map((sink) => checkSink(sink))
55
85
  } catch (err) {
56
86
  ctx.warn(
57
87
  `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
58
88
  )
59
89
  continue
60
90
  }
61
- if (result === undefined) continue
62
- if (Array.isArray(result)) sinks.push(...result)
63
- else sinks.push(result)
91
+ sinks.push(...accepted)
64
92
  }
65
93
 
66
94
  if (sinks.length === 0) return undefined
67
95
 
68
- const source = createTelemetrySource({ sinks, run })
96
+ const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
69
97
  const dispose = bus.subscribe(source.subscriber)
70
98
  let disposed = false
71
99
  return {
@@ -13,10 +13,11 @@
13
13
  // record; its `TelemetryContext` carries read-only metadata and NO mutable
14
14
  // run handle (no bus, no Cache, no RunRequest). There is no API path from a
15
15
  // sink back into scheduling/caching/exec — telemetry provably cannot change
16
- // what or how tasks run. Contrast `backend`/`cache`, which return objects
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 { OutputFingerprint, TaskStatus, VerifyVerdict } from '../graph/index.js'
19
+ import type { TaskStatus } from '../graph/index.js'
20
+ import { settleWithin, teardownTimeoutMs } from '../util/index.js'
20
21
  import type { RunEvent, RunEventSubscriber } from './events.js'
21
22
 
22
23
  /** Bumped when the record shape changes. Readers MUST check `v`. */
@@ -29,8 +30,9 @@ export type CacheSource = 'miss' | 'local' | 'remote' | 'none'
29
30
  * Map a task outcome status to its cache source. `success`/`failed` ran
30
31
  * (a miss as far as the cache read is concerned); the two cache-hit
31
32
  * statuses restored from local/remote; `skipped`/`aborted` never engaged
32
- * the cache. Pure function — the single place this mapping is decided, so
33
- * no consumer re-implements `status LIKE 'cache-hit%'`.
33
+ * the cache. Pure function — the single place this mapping is decided;
34
+ * `isCacheHit` below is derived from it rather than re-listing the two
35
+ * hit statuses, so there is one decision, not two that can disagree.
34
36
  */
35
37
  export function deriveCacheSource(status: TaskStatus): CacheSource {
36
38
  switch (status) {
@@ -47,6 +49,59 @@ export function deriveCacheSource(status: TaskStatus): CacheSource {
47
49
  }
48
50
  }
49
51
 
52
+ /**
53
+ * Whether each status counts as a PASS. Written as a `Record<TaskStatus, …>`
54
+ * rather than a `new Set([...])` on purpose: a Record must name every member,
55
+ * so adding a status to the union is a COMPILE error HERE and the omission
56
+ * cannot ship. A Set of string literals has no such tripwire — it silently
57
+ * answers `false` for the new member, which is how ten hand-rolled copies of
58
+ * this vocabulary accumulated across core, cloud and the dashboard.
59
+ */
60
+ const PASSES: Record<TaskStatus, boolean> = {
61
+ success: true,
62
+ 'cache-hit': true,
63
+ 'cache-hit-remote': true,
64
+ failed: false,
65
+ skipped: false,
66
+ aborted: false,
67
+ }
68
+
69
+ /**
70
+ * Every `TaskStatus`, at runtime. Read off `PASSES`'s keys, which the
71
+ * `Record<TaskStatus, …>` type guarantees is exactly the union — so this is
72
+ * derived, not a second list that can fall behind. Exported so a consumer that
73
+ * cannot import the type (a test asserting a copy of this vocabulary in
74
+ * another package agrees) can still enumerate the real set.
75
+ */
76
+ export const TASK_STATUSES: readonly TaskStatus[] = Object.keys(PASSES) as TaskStatus[]
77
+
78
+ const KNOWN_STATUSES: ReadonlySet<string> = new Set(TASK_STATUSES)
79
+
80
+ /**
81
+ * Did the task pass? A cache hit counts — it produced the same result without
82
+ * spending the time, which is the whole point. `skipped` and `aborted` do NOT:
83
+ * neither finished on its own terms, so neither can vouch for anything.
84
+ *
85
+ * Takes `string`, not `TaskStatus`, because most callers hold a status that
86
+ * arrived over a wire or out of a database column. An unrecognised string
87
+ * reads as NOT passing — the safe direction, since the alternative is calling
88
+ * a run green on a status this build has never heard of.
89
+ */
90
+ export function isPassStatus(status: string): boolean {
91
+ return PASSES[status as TaskStatus] === true
92
+ }
93
+
94
+ /**
95
+ * Did the task's result come out of the cache (either layer)? Derived from
96
+ * `deriveCacheSource` rather than re-listing the two hit statuses, so the two
97
+ * cannot disagree about what a hit is. Unknown strings read as not-a-hit.
98
+ */
99
+ export function isCacheHit(status: string): boolean {
100
+ if (!KNOWN_STATUSES.has(status)) return false
101
+ const source = deriveCacheSource(status as TaskStatus)
102
+ return source === 'local' || source === 'remote'
103
+ }
104
+
50
105
  /** Identifies which run a record belongs to + its captured context. Maps
51
106
  * cleanly onto OTel CI/CD + VCS resource attributes. */
52
107
  export interface RunContextRecord {
@@ -71,6 +126,16 @@ export interface RunContextRecord {
71
126
  // git / CI / host — straight from run-context.ts.
72
127
  commitSha: string | null
73
128
  branch: string | null
129
+ /**
130
+ * The repository's DEFAULT (trunk) branch, when detectable (v2 additive).
131
+ * A run is a TRUNK run iff `branch === defaultBranch` (both non-null);
132
+ * everything else is PR / feature-branch work. Consumers use it to keep
133
+ * branch-experiment timings out of the shared scheduling baseline; null
134
+ * (undetectable) means "count all runs" — no regression. Required of a v2
135
+ * producer (every one emits it); a reader parsing an older v1 push, which
136
+ * predates the field, treats absent as null.
137
+ */
138
+ defaultBranch: string | null
74
139
  dirty: boolean | null
75
140
  ci: boolean
76
141
  ciProvider: string | null
@@ -94,22 +159,16 @@ export interface TaskTelemetry {
94
159
  hash?: string
95
160
  cpuMs?: number
96
161
  peakRssBytes?: number
162
+ /** Executor-reported placement (a worker id) — absent for local runs.
163
+ * Additive-optional, no schema bump (the `attempts` precedent). */
164
+ where?: string
165
+ /** `'deferred'` when the outputs stayed remote (`--download=none`).
166
+ * Additive-optional, same no-bump precedent as `where`. */
167
+ outputs?: 'deferred'
97
168
  /** Total attempts when the task RETRIED (>1) — set only when `exec.retries`
98
169
  * / `--retry` produced more than one attempt. A retried-then-passed task is
99
170
  * flaky by definition; this is the telemetry-side flaky signal. */
100
171
  attempts?: number
101
- /** Cache-correctness verdict — set ONLY on a `--verify` run (absent
102
- * otherwise). A `nondeterministic` verdict means the task's cache entry is
103
- * unsound (its outputs aren't a pure function of its declared inputs); this
104
- * is the telemetry-side hermeticity signal a dashboard surfaces. */
105
- verify?: VerifyVerdict
106
- /** Output-tree fingerprint — set ONLY under a fingerprinting `--verify*`
107
- * mode (`--verify` / `=all` / `=fingerprint`), executed tasks only. A
108
- * connected serve pairs fingerprints for the SAME `hash` across platforms
109
- * and names diverging outputs (the cross-machine diff). Absent on plain
110
- * runs — additive-optional, no schema bump (the `attempts`/`verify`
111
- * precedent). */
112
- outputFp?: OutputFingerprint
113
172
  /** bigint hrtime ns relative to run t=0, encoded as a decimal string. */
114
173
  wallclockStartNs?: string
115
174
  wallclockEndNs?: string
@@ -122,7 +181,16 @@ export interface TaskTelemetry {
122
181
  * `task.log` records are large and OPT-IN (see `TelemetrySink.wants`).
123
182
  */
124
183
  export type TelemetryRecord =
125
- | { v: number; kind: 'run.start'; run: RunContextRecord; total: number; ts: number }
184
+ | {
185
+ v: number
186
+ kind: 'run.start'
187
+ run: RunContextRecord
188
+ total: number
189
+ ts: number
190
+ /** The run's canonical start (epoch ms) — equals the summary's
191
+ * `startedAt`; a sink derives per-task timing from it during the run. */
192
+ startedAt: number
193
+ }
126
194
  | {
127
195
  v: number
128
196
  kind: 'task.start'
@@ -166,6 +234,46 @@ export interface RunSummaryRecord {
166
234
  tasks: readonly TaskTelemetry[]
167
235
  }
168
236
 
237
+ /**
238
+ * Assemble the canonical per-run summary from the per-task telemetry + the
239
+ * run-level timing/verdict. THE one place the `RunSummaryRecord` tallies are
240
+ * computed: both a local `run()` and the distributed controller build their
241
+ * `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
242
+ * distributed run and a local run produce byte-identical summaries and land in
243
+ * the same ingest. The per-task tallies (taskCount / failedCount /
244
+ * hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
245
+ * `exitOk` (the run's overall verdict — which counts skipped tasks beyond the
246
+ * recorded task list) are run-level facts and are passed in.
247
+ */
248
+ export function assembleRunSummary(
249
+ run: RunContextRecord,
250
+ tasks: readonly TaskTelemetry[],
251
+ timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
252
+ ): RunSummaryRecord {
253
+ let failedCount = 0
254
+ let hitLocalCount = 0
255
+ let hitRemoteCount = 0
256
+ for (const t of tasks) {
257
+ if (t.status === 'failed') failedCount++
258
+ if (t.cacheSource === 'local') hitLocalCount++
259
+ else if (t.cacheSource === 'remote') hitRemoteCount++
260
+ }
261
+ return {
262
+ v: TELEMETRY_SCHEMA_VERSION,
263
+ run,
264
+ startedAt: timing.startedAt,
265
+ endedAt: timing.endedAt,
266
+ totalDurationMs: timing.totalDurationMs,
267
+ taskCount: tasks.length,
268
+ failedCount,
269
+ hitCount: hitLocalCount + hitRemoteCount,
270
+ hitLocalCount,
271
+ hitRemoteCount,
272
+ exitOk: timing.exitOk,
273
+ tasks,
274
+ }
275
+ }
276
+
169
277
  /** A telemetry consumer. Observe-only: receives records, holds no run handle. */
170
278
  export interface TelemetrySink {
171
279
  readonly name?: string
@@ -180,7 +288,13 @@ export interface TelemetrySink {
180
288
  onRecord?(record: TelemetryRecord): void
181
289
  /** The per-run summary, at run:end. MUST return promptly. */
182
290
  onRunSummary?(summary: RunSummaryRecord): void
183
- /** Drain buffered data. Awaited (time-bounded by the sink) at run:end. */
291
+ /**
292
+ * Drain buffered data. Awaited at run:end under a deadline — a sink that
293
+ * has not settled by then is abandoned and its buffered records are lost.
294
+ * Losing a slow sink's telemetry is strictly better than the alternative:
295
+ * `run()` never returns, so the cache never closes and `vx` exits 0 on a
296
+ * failed run.
297
+ */
184
298
  flush?(): Promise<void>
185
299
  }
186
300
 
@@ -199,7 +313,7 @@ export interface TelemetrySource {
199
313
  readonly subscriber: RunEventSubscriber
200
314
  /** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
201
315
  emitSummary(summary: RunSummaryRecord): void
202
- /** Await every sink's `flush()` (each crash-isolated). */
316
+ /** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
203
317
  flush(): Promise<void>
204
318
  }
205
319
 
@@ -224,12 +338,27 @@ const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
224
338
  export function createTelemetrySource(args: {
225
339
  sinks: readonly TelemetrySink[]
226
340
  run: RunContextRecord
341
+ /** Where a dropped-flush notice goes; absent = stay silent. */
342
+ warn?: (message: string) => void
227
343
  }): TelemetrySource {
228
- const { sinks, run } = args
344
+ const { sinks, run, warn } = args
229
345
  const runId = run.runId
230
346
  // A sink is disabled the first time it throws — its name (or index) goes
231
- // here and it's skipped for the rest of the run.
347
+ // here and it's skipped for the rest of the run, FLUSH INCLUDED. Flushing a
348
+ // disabled sink would write a buffer that is incomplete by construction: it
349
+ // stopped being fed records at the moment it threw, so the export would look
350
+ // like a run that simply had fewer tasks.
232
351
  const disabled = new Set<TelemetrySink>()
352
+ // Never silently. The standing rule is that a never-fail path must still
353
+ // WARN — telemetry that vanishes without a word is indistinguishable from
354
+ // telemetry nobody configured.
355
+ const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
356
+ if (disabled.has(sink)) return
357
+ disabled.add(sink)
358
+ warn?.(
359
+ `[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
360
+ )
361
+ }
233
362
  // Precompute which sinks want each kind, so per-event fan-out is a plain
234
363
  // array walk with no per-record `wants` scanning.
235
364
  const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
@@ -241,8 +370,8 @@ export function createTelemetrySource(args: {
241
370
  if (!kinds.includes(record.kind)) continue
242
371
  try {
243
372
  sink.onRecord(record)
244
- } catch {
245
- disabled.add(sink)
373
+ } catch (err) {
374
+ disable(sink, 'onRecord', err)
246
375
  }
247
376
  }
248
377
  }
@@ -258,6 +387,7 @@ export function createTelemetrySource(args: {
258
387
  run,
259
388
  total: event.info.total,
260
389
  ts,
390
+ startedAt: event.info.startedAtMs ?? ts,
261
391
  })
262
392
  return
263
393
  case 'task:start': {
@@ -309,9 +439,9 @@ export function createTelemetrySource(args: {
309
439
  if (outcome.hash !== undefined) rec.hash = outcome.hash
310
440
  if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
311
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
312
444
  if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
313
- if (outcome.verify !== undefined) rec.verify = outcome.verify
314
- if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
315
445
  if (outcome.wallclockStartNs !== undefined)
316
446
  rec.wallclockStartNs = outcome.wallclockStartNs.toString()
317
447
  if (outcome.wallclockEndNs !== undefined)
@@ -337,22 +467,38 @@ export function createTelemetrySource(args: {
337
467
  if (disabled.has(sink) || sink.onRunSummary === undefined) continue
338
468
  try {
339
469
  sink.onRunSummary(summary)
340
- } catch {
341
- disabled.add(sink)
470
+ } catch (err) {
471
+ disable(sink, 'onRunSummary', err)
342
472
  }
343
473
  }
344
474
  },
345
475
  async flush(): Promise<void> {
346
- await Promise.all(
347
- sinks.map(async (sink) => {
348
- if (sink.flush === undefined) return
349
- try {
350
- await sink.flush()
351
- } catch {
352
- // a sink's flush failure can never break the run
353
- }
354
- }),
476
+ const ms = teardownTimeoutMs()
477
+ // Bounded, for the same reason plugin teardown is bounded in
478
+ // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
479
+ // returns, and bin.ts is `process.exit(await run(...))`. A sink whose
480
+ // flush never settles therefore drains the event loop with no exit code
481
+ // pending — Bun exits 0 and a FAILED run reports green, with the cache's
482
+ // accessed_at bumps and every later plugin's teardown lost with it.
483
+ // Sinks race concurrently, so each still gets the whole budget.
484
+ const settled = await settleWithin(
485
+ Promise.all(
486
+ sinks.map(async (sink) => {
487
+ if (disabled.has(sink) || sink.flush === undefined) return
488
+ try {
489
+ await sink.flush()
490
+ } catch (err) {
491
+ // A flush failure can never break the run — but it still costs
492
+ // this sink its export, so say so rather than dropping it.
493
+ warn?.(
494
+ `[vx] telemetry sink '${sink.name}' failed to flush: ${err instanceof Error ? err.message : String(err)}`,
495
+ )
496
+ }
497
+ }),
498
+ ),
499
+ ms,
355
500
  )
501
+ if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
356
502
  },
357
503
  }
358
504
  }