@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,291 @@
1
+ // The shared per-task log capture primitive (task-logs-2026-07 §1). ONE
2
+ // bounded-tail buffer every telemetry sink uses — the cloud client sink (local
3
+ // runs), the cloud serve sink (delegated runs), the dist scheduler, and the
4
+ // OTLP logs exporter — so the capping rules can't drift between capture sites.
5
+ //
6
+ // It lives in CORE, beside the telemetry contract, for the same reason
7
+ // `assembleRunSummary` does: two sinks that each roll their own bounded buffer
8
+ // is how the retention rule forks, and a forked rule means two sinks disagree
9
+ // about which task's output survived. A sink is the only consumer — core
10
+ // itself never captures logs — but the vocabulary it decides in (`TaskStatus`,
11
+ // `CacheSource`, "a hit's bytes belong to the run that executed") is core's.
12
+ //
13
+ // The law: a log-spewing task can never OOM the capturer. Two caps enforce it:
14
+ // - per task: only the last TASK_LOG_TAIL_CHARS of merged stdout+stderr is
15
+ // retained (chunks evicted from the head, no concatenation until drain);
16
+ // - per run: only RUN_LOG_BUDGET_CHARS of RETAINED completed tails ship, and
17
+ // failed tails are never evicted by successes — a failure's output is the
18
+ // one thing you always want to read.
19
+ //
20
+ // Both caps report themselves: an evicted or head-trimmed task still ships,
21
+ // carrying how much was dropped, so "we truncated this" never reads as "this
22
+ // task printed nothing".
23
+
24
+ import type { TaskStatus } from '../graph/index.js'
25
+ import type { CacheSource } from './telemetry.js'
26
+
27
+ /** Version of the drained-bundle shape below. It is the canonical drained
28
+ * logs format, not one transport's: cloud's `POST /v1/ingest/logs` accepts
29
+ * it verbatim, and any other sink ships the same object. */
30
+ export const LOG_WIRE_VERSION = 1
31
+
32
+ /**
33
+ * Per-task tail retained (UTF-16 units — a cheap capture-side proxy; the store
34
+ * records true stored bytes). Merged streams: what a terminal actually shows.
35
+ */
36
+ export const TASK_LOG_TAIL_CHARS = 128 * 1024
37
+
38
+ /** Total RETAINED completed-task chars a single run ships. */
39
+ export const RUN_LOG_BUDGET_CHARS = 4 * 1024 * 1024
40
+
41
+ export interface TaskLogEntry {
42
+ taskId: string
43
+ /** The task's cache key when known — the hit→executed-run resolution key. */
44
+ hash?: string
45
+ status: 'success' | 'failed'
46
+ /** Merged stdout+stderr in arrival order, tail-capped, ANSI preserved. */
47
+ content: string
48
+ /** Chars emitted before capping. */
49
+ charsFull: number
50
+ /** Chars dropped from the head; 0 = complete. */
51
+ truncatedHeadChars: number
52
+ }
53
+
54
+ export interface TaskLogBundle {
55
+ v: typeof LOG_WIRE_VERSION
56
+ runId: string
57
+ workspaceId: string
58
+ tasks: TaskLogEntry[]
59
+ }
60
+
61
+ /** Retained-until-drain state for one completed task. */
62
+ interface Retained {
63
+ taskId: string
64
+ hash?: string
65
+ status: 'success' | 'failed'
66
+ /** Emptied when the run budget evicts this task; `chars` goes to 0 and the
67
+ * dropped count moves to `truncatedHeadChars` (see `evictToBudget`). */
68
+ chunks: string[]
69
+ chars: number
70
+ charsFull: number
71
+ truncatedHeadChars: number
72
+ /** Insertion order — the eviction tiebreak (oldest first). */
73
+ seq: number
74
+ }
75
+
76
+ /** In-flight (not yet ended) accumulation for one task. */
77
+ interface InFlight {
78
+ chunks: string[]
79
+ chars: number
80
+ charsFull: number
81
+ truncatedHeadChars: number
82
+ }
83
+
84
+ /**
85
+ * Bounded per-run capture. `append` keeps a chunk LIST + running char count per
86
+ * task, evicting whole chunks from the head past `TASK_LOG_TAIL_CHARS` — no
87
+ * string concatenation until `drain`, so a cache-hit replay (one big chunk) is
88
+ * one array push, zero copies. `finish` decides retention; `drain` emits the
89
+ * bundle, failures first.
90
+ */
91
+ /**
92
+ * Per-retained-chunk overhead, in char-equivalents, charged against the RUN
93
+ * budget on top of the characters themselves.
94
+ *
95
+ * The budget exists to bound MEMORY, but a char count is not one: a task that
96
+ * emits a byte at a time (unbuffered progress output) stores the same content
97
+ * as thousands of separate strings, each with its own header and allocation.
98
+ * MEASURED on Bun 1.4: 1 000 000 chars arrive as one chunk for ~1.4 MB of
99
+ * marginal RSS, and as 1 000 000 one-char chunks for ~30 MB — so a 4 MiB
100
+ * char budget could hold ~80 MB of actual memory. Charging each chunk ~24
101
+ * char-equivalents makes the budget track what it is trying to limit; chunky
102
+ * output is unaffected (one 128 KiB chunk pays 24 on 131 072).
103
+ *
104
+ * The per-task TAIL cap deliberately stays a pure char count — it bounds how
105
+ * much a reader is shown, which is a different question from how much the
106
+ * process holds.
107
+ */
108
+ const CHUNK_OVERHEAD_CHARS = 24
109
+
110
+ /** What one retained entry costs the run budget: content + per-chunk overhead. */
111
+ function budgetCost(chars: number, chunkCount: number): number {
112
+ return chars + chunkCount * CHUNK_OVERHEAD_CHARS
113
+ }
114
+
115
+ export class TaskLogBuffer {
116
+ private readonly inFlight = new Map<string, InFlight>()
117
+ private readonly retained = new Map<string, Retained>()
118
+ private retainedChars = 0
119
+ private seq = 0
120
+
121
+ append(taskId: string, chunk: string): void {
122
+ if (chunk.length === 0) return
123
+ let acc = this.inFlight.get(taskId)
124
+ if (acc === undefined) {
125
+ acc = { chunks: [], chars: 0, charsFull: 0, truncatedHeadChars: 0 }
126
+ this.inFlight.set(taskId, acc)
127
+ }
128
+ acc.chunks.push(chunk)
129
+ acc.chars += chunk.length
130
+ acc.charsFull += chunk.length
131
+ // Evict whole chunks from the head until under the per-task tail cap. A
132
+ // single over-cap chunk is sliced to its tail (the only place we copy).
133
+ while (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length > 1) {
134
+ const dropped = acc.chunks.shift()!
135
+ acc.chars -= dropped.length
136
+ acc.truncatedHeadChars += dropped.length
137
+ }
138
+ if (acc.chars > TASK_LOG_TAIL_CHARS && acc.chunks.length === 1) {
139
+ const only = acc.chunks[0]!
140
+ const keep = only.slice(only.length - TASK_LOG_TAIL_CHARS)
141
+ acc.truncatedHeadChars += only.length - keep.length
142
+ acc.chunks[0] = keep
143
+ acc.chars = keep.length
144
+ }
145
+ }
146
+
147
+ /**
148
+ * task.end: decide retention.
149
+ * - cacheSource !== 'miss' (a hit) → DROP: the executed run already stored
150
+ * these bytes; a hit resolves by hash to that run.
151
+ * - 'skipped' / 'aborted' → DROP (no meaningful output; a tearing-down run).
152
+ * - a success/failed MISS → RETAIN the tail, under the run budget: adding a
153
+ * task that would exceed `RUN_LOG_BUDGET_CHARS` evicts oldest retained
154
+ * SUCCESS tails first; failed tails are evicted only by newer failed tails
155
+ * (oldest first) once failures alone exceed the budget.
156
+ */
157
+ finish(taskId: string, status: TaskStatus, cacheSource: CacheSource, hash?: string): void {
158
+ const acc = this.inFlight.get(taskId)
159
+ this.inFlight.delete(taskId)
160
+ if (cacheSource !== 'miss') return
161
+ if (status !== 'success' && status !== 'failed') return
162
+ if (acc === undefined) return
163
+
164
+ const entry: Retained = {
165
+ taskId,
166
+ ...(hash !== undefined ? { hash } : {}),
167
+ status,
168
+ chunks: acc.chunks,
169
+ chars: acc.chars,
170
+ charsFull: acc.charsFull,
171
+ truncatedHeadChars: acc.truncatedHeadChars,
172
+ seq: this.seq++,
173
+ }
174
+ // Replace an earlier retention for the same task id (a retried task ends
175
+ // once per attempt only via the winning outcome, but be defensive).
176
+ const prior = this.retained.get(taskId)
177
+ if (prior !== undefined) this.retainedChars -= budgetCost(prior.chars, prior.chunks.length)
178
+ this.retained.set(taskId, entry)
179
+ this.retainedChars += budgetCost(entry.chars, entry.chunks.length)
180
+ this.evictToBudget()
181
+ }
182
+
183
+ /**
184
+ * Take ONE finished task's retained tail as a wire entry, removing it from
185
+ * the buffer (so a later `drain` won't re-ship it). Used by per-task
186
+ * incremental delivery: `finish()` retains, then the sink `takeEntry()`s and
187
+ * ships that one task. Returns undefined for a task that wasn't retained (a
188
+ * cache hit / skipped / still in-flight / unknown id) — the task result still
189
+ * ships; there is just no log tail to send.
190
+ */
191
+ takeEntry(taskId: string): TaskLogEntry | undefined {
192
+ const e = this.retained.get(taskId)
193
+ if (e === undefined) return undefined
194
+ this.retained.delete(taskId)
195
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
196
+ return {
197
+ taskId: e.taskId,
198
+ ...(e.hash !== undefined ? { hash: e.hash } : {}),
199
+ status: e.status,
200
+ content: e.chunks.join(''),
201
+ charsFull: e.charsFull,
202
+ truncatedHeadChars: e.truncatedHeadChars,
203
+ }
204
+ }
205
+
206
+ /** Everything retained, failures first, ready to ship/store. */
207
+ drain(runId: string, workspaceId: string): TaskLogBundle {
208
+ const entries = [...this.retained.values()].sort((a, b) => {
209
+ // Failures ahead of successes; within a tier, oldest first (stable).
210
+ const fa = a.status === 'failed' ? 0 : 1
211
+ const fb = b.status === 'failed' ? 0 : 1
212
+ return fa !== fb ? fa - fb : a.seq - b.seq
213
+ })
214
+ return {
215
+ v: LOG_WIRE_VERSION,
216
+ runId,
217
+ workspaceId,
218
+ tasks: entries.map((e) => ({
219
+ taskId: e.taskId,
220
+ ...(e.hash !== undefined ? { hash: e.hash } : {}),
221
+ status: e.status,
222
+ content: e.chunks.join(''),
223
+ charsFull: e.charsFull,
224
+ truncatedHeadChars: e.truncatedHeadChars,
225
+ })),
226
+ }
227
+ }
228
+
229
+ /** Retained-tasks count (test/observability). */
230
+ size(): number {
231
+ return this.retained.size
232
+ }
233
+
234
+ /**
235
+ * Budget charged right now, in char-equivalents (test/observability).
236
+ *
237
+ * Exposed because the charge/release symmetry is the invariant that keeps
238
+ * the cap meaningful and the one that fails SILENTLY: a release that does
239
+ * not match its charge drifts `retainedChars` a little per task, and a
240
+ * long run ends up bounding something other than what it thinks.
241
+ */
242
+ budgetUsed(): number {
243
+ return this.retainedChars
244
+ }
245
+
246
+ /**
247
+ * Evict until `retainedChars <= RUN_LOG_BUDGET_CHARS`. Successes go first
248
+ * (oldest by seq); only when failures ALONE still exceed the budget do the
249
+ * oldest failures go — a failure is never dropped to keep a success.
250
+ *
251
+ * An evicted task is degraded to a STUB (no content, `truncatedHeadChars`
252
+ * = everything it emitted) rather than removed. Dropping the entry makes a
253
+ * budget eviction indistinguishable from a task that printed nothing — the
254
+ * reader gets "no logs captured" either way, which is a confident false
255
+ * statement in the one case where they most need the output (a run with
256
+ * enough failing tasks to blow the budget). A stub costs a few dozen bytes,
257
+ * keeps the `content.length === charsFull - truncatedHeadChars` accounting
258
+ * honest, and renders through the truncation banner the per-task cap
259
+ * already uses. It is also what the STORE does when its own run budget runs
260
+ * out (`Analytics.ingestLogs` slices to empty and adds the remainder to
261
+ * `truncated_head`) — the two sides now degrade the same way.
262
+ *
263
+ * The cost is a stub per evicted task in the end-of-run bundle: measured at
264
+ * ~190 JSON bytes each, so ~1.9 MiB at the 10k-task scale target and ~9.3 MiB
265
+ * at 50k, against the 16 MiB `/v1/ingest/logs` cap. A run evicting past ~66k
266
+ * tasks would 413 the batch — remote (6x the scale target), and it does not
267
+ * touch the connected default, which ships per task as it finishes.
268
+ */
269
+ private evictToBudget(): void {
270
+ if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
271
+ const order = [...this.retained.values()].sort((a, b) => {
272
+ const sa = a.status === 'success' ? 0 : 1
273
+ const sb = b.status === 'success' ? 0 : 1
274
+ if (sa !== sb) return sa - sb // successes are dropped first, always
275
+ // Within a tier the tiebreak DIFFERS by status, and the failed one is
276
+ // the point: when failures alone blow the budget, the FIRST failure is
277
+ // usually the root cause and the later ones its cascade, so it must be
278
+ // the last thing stubbed. Successes keep oldest-first — none of them
279
+ // is more interesting than another, and recency is the better guess.
280
+ return a.status === 'failed' ? b.seq - a.seq : a.seq - b.seq
281
+ })
282
+ for (const e of order) {
283
+ if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) break
284
+ if (e.chars === 0) continue // already a stub — frees nothing
285
+ this.retainedChars -= budgetCost(e.chars, e.chunks.length)
286
+ e.truncatedHeadChars += e.chars
287
+ e.chunks = []
288
+ e.chars = 0
289
+ }
290
+ }
291
+ }
@@ -31,6 +31,18 @@ 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
+ return sink as TelemetrySink
44
+ }
45
+
34
46
  /**
35
47
  * Consult every plugin's `telemetry` capability, collect the sinks, and —
36
48
  * only if at least one sink results — create a TelemetrySource and subscribe
@@ -49,23 +61,29 @@ export async function subscribeTelemetry(
49
61
  const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
50
62
  for (const plugin of plugins) {
51
63
  if (plugin.telemetry === undefined) continue
52
- let result
64
+ // A plugin's return value is user input, so it is checked here rather
65
+ // than trusted downstream: `createTelemetrySource` reads `.wants` off
66
+ // every sink immediately, so one `null` in the list used to abort the
67
+ // whole run with a raw TypeError before a single task had run. Staging
68
+ // into `accepted` keeps a partly-bad array all-or-nothing, and the shape
69
+ // check sits INSIDE the try so a throwing `wants` getter is caught too.
70
+ let accepted: TelemetrySink[]
53
71
  try {
54
- result = await plugin.telemetry(ctx)
72
+ const result = await plugin.telemetry(ctx)
73
+ const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
74
+ accepted = list.map((sink) => checkSink(sink))
55
75
  } catch (err) {
56
76
  ctx.warn(
57
77
  `[vx] plugin '${plugin.name}' telemetry failed to initialize; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
58
78
  )
59
79
  continue
60
80
  }
61
- if (result === undefined) continue
62
- if (Array.isArray(result)) sinks.push(...result)
63
- else sinks.push(result)
81
+ sinks.push(...accepted)
64
82
  }
65
83
 
66
84
  if (sinks.length === 0) return undefined
67
85
 
68
- const source = createTelemetrySource({ sinks, run })
86
+ const source = createTelemetrySource({ sinks, run, warn: (m) => ctx.warn(m) })
69
87
  const dispose = bus.subscribe(source.subscriber)
70
88
  let disposed = false
71
89
  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
19
  import type { OutputFingerprint, TaskStatus, VerifyVerdict } 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,6 +159,12 @@ 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`/`verify` 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. */
@@ -122,7 +193,16 @@ export interface TaskTelemetry {
122
193
  * `task.log` records are large and OPT-IN (see `TelemetrySink.wants`).
123
194
  */
124
195
  export type TelemetryRecord =
125
- | { v: number; kind: 'run.start'; run: RunContextRecord; total: number; ts: number }
196
+ | {
197
+ v: number
198
+ kind: 'run.start'
199
+ run: RunContextRecord
200
+ total: number
201
+ ts: number
202
+ /** The run's canonical start (epoch ms) — equals the summary's
203
+ * `startedAt`; a sink derives per-task timing from it during the run. */
204
+ startedAt: number
205
+ }
126
206
  | {
127
207
  v: number
128
208
  kind: 'task.start'
@@ -166,6 +246,46 @@ export interface RunSummaryRecord {
166
246
  tasks: readonly TaskTelemetry[]
167
247
  }
168
248
 
249
+ /**
250
+ * Assemble the canonical per-run summary from the per-task telemetry + the
251
+ * run-level timing/verdict. THE one place the `RunSummaryRecord` tallies are
252
+ * computed: both a local `run()` and the distributed controller build their
253
+ * `TaskTelemetry[]` (each via `deriveCacheSource`) and call this, so a
254
+ * distributed run and a local run produce byte-identical summaries and land in
255
+ * the same ingest. The per-task tallies (taskCount / failedCount /
256
+ * hitLocal|Remote|Count) derive from `tasks`; `totalDurationMs` (wall time) and
257
+ * `exitOk` (the run's overall verdict — which counts skipped/verify beyond the
258
+ * recorded task list) are run-level facts and are passed in.
259
+ */
260
+ export function assembleRunSummary(
261
+ run: RunContextRecord,
262
+ tasks: readonly TaskTelemetry[],
263
+ timing: { startedAt: number; endedAt: number; totalDurationMs: number; exitOk: boolean },
264
+ ): RunSummaryRecord {
265
+ let failedCount = 0
266
+ let hitLocalCount = 0
267
+ let hitRemoteCount = 0
268
+ for (const t of tasks) {
269
+ if (t.status === 'failed') failedCount++
270
+ if (t.cacheSource === 'local') hitLocalCount++
271
+ else if (t.cacheSource === 'remote') hitRemoteCount++
272
+ }
273
+ return {
274
+ v: TELEMETRY_SCHEMA_VERSION,
275
+ run,
276
+ startedAt: timing.startedAt,
277
+ endedAt: timing.endedAt,
278
+ totalDurationMs: timing.totalDurationMs,
279
+ taskCount: tasks.length,
280
+ failedCount,
281
+ hitCount: hitLocalCount + hitRemoteCount,
282
+ hitLocalCount,
283
+ hitRemoteCount,
284
+ exitOk: timing.exitOk,
285
+ tasks,
286
+ }
287
+ }
288
+
169
289
  /** A telemetry consumer. Observe-only: receives records, holds no run handle. */
170
290
  export interface TelemetrySink {
171
291
  readonly name?: string
@@ -180,7 +300,13 @@ export interface TelemetrySink {
180
300
  onRecord?(record: TelemetryRecord): void
181
301
  /** The per-run summary, at run:end. MUST return promptly. */
182
302
  onRunSummary?(summary: RunSummaryRecord): void
183
- /** Drain buffered data. Awaited (time-bounded by the sink) at run:end. */
303
+ /**
304
+ * Drain buffered data. Awaited at run:end under a deadline — a sink that
305
+ * has not settled by then is abandoned and its buffered records are lost.
306
+ * Losing a slow sink's telemetry is strictly better than the alternative:
307
+ * `run()` never returns, so the cache never closes and `vx` exits 0 on a
308
+ * failed run.
309
+ */
184
310
  flush?(): Promise<void>
185
311
  }
186
312
 
@@ -199,7 +325,7 @@ export interface TelemetrySource {
199
325
  readonly subscriber: RunEventSubscriber
200
326
  /** Fan the per-run summary to every sink's `onRunSummary` (crash-isolated). */
201
327
  emitSummary(summary: RunSummaryRecord): void
202
- /** Await every sink's `flush()` (each crash-isolated). */
328
+ /** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
203
329
  flush(): Promise<void>
204
330
  }
205
331
 
@@ -224,12 +350,27 @@ const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
224
350
  export function createTelemetrySource(args: {
225
351
  sinks: readonly TelemetrySink[]
226
352
  run: RunContextRecord
353
+ /** Where a dropped-flush notice goes; absent = stay silent. */
354
+ warn?: (message: string) => void
227
355
  }): TelemetrySource {
228
- const { sinks, run } = args
356
+ const { sinks, run, warn } = args
229
357
  const runId = run.runId
230
358
  // 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.
359
+ // here and it's skipped for the rest of the run, FLUSH INCLUDED. Flushing a
360
+ // disabled sink would write a buffer that is incomplete by construction: it
361
+ // stopped being fed records at the moment it threw, so the export would look
362
+ // like a run that simply had fewer tasks.
232
363
  const disabled = new Set<TelemetrySink>()
364
+ // Never silently. The standing rule is that a never-fail path must still
365
+ // WARN — telemetry that vanishes without a word is indistinguishable from
366
+ // telemetry nobody configured.
367
+ const disable = (sink: TelemetrySink, hook: string, err: unknown): void => {
368
+ if (disabled.has(sink)) return
369
+ disabled.add(sink)
370
+ warn?.(
371
+ `[vx] telemetry sink '${sink.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
372
+ )
373
+ }
233
374
  // Precompute which sinks want each kind, so per-event fan-out is a plain
234
375
  // array walk with no per-record `wants` scanning.
235
376
  const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
@@ -241,8 +382,8 @@ export function createTelemetrySource(args: {
241
382
  if (!kinds.includes(record.kind)) continue
242
383
  try {
243
384
  sink.onRecord(record)
244
- } catch {
245
- disabled.add(sink)
385
+ } catch (err) {
386
+ disable(sink, 'onRecord', err)
246
387
  }
247
388
  }
248
389
  }
@@ -258,6 +399,7 @@ export function createTelemetrySource(args: {
258
399
  run,
259
400
  total: event.info.total,
260
401
  ts,
402
+ startedAt: event.info.startedAtMs ?? ts,
261
403
  })
262
404
  return
263
405
  case 'task:start': {
@@ -309,6 +451,8 @@ export function createTelemetrySource(args: {
309
451
  if (outcome.hash !== undefined) rec.hash = outcome.hash
310
452
  if (outcome.cpuMs !== undefined) rec.cpuMs = outcome.cpuMs
311
453
  if (outcome.peakRssBytes !== undefined) rec.peakRssBytes = outcome.peakRssBytes
454
+ if (outcome.where !== undefined) rec.where = outcome.where
455
+ if (outcome.outputs !== undefined) rec.outputs = outcome.outputs
312
456
  if (outcome.attempts !== undefined) rec.attempts = outcome.attempts
313
457
  if (outcome.verify !== undefined) rec.verify = outcome.verify
314
458
  if (outcome.outputFp !== undefined) rec.outputFp = outcome.outputFp
@@ -337,22 +481,38 @@ export function createTelemetrySource(args: {
337
481
  if (disabled.has(sink) || sink.onRunSummary === undefined) continue
338
482
  try {
339
483
  sink.onRunSummary(summary)
340
- } catch {
341
- disabled.add(sink)
484
+ } catch (err) {
485
+ disable(sink, 'onRunSummary', err)
342
486
  }
343
487
  }
344
488
  },
345
489
  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
- }),
490
+ const ms = teardownTimeoutMs()
491
+ // Bounded, for the same reason plugin teardown is bounded in
492
+ // plugin-host.ts: run() awaits this BEFORE closeCache() and before it
493
+ // returns, and bin.ts is `process.exit(await run(...))`. A sink whose
494
+ // flush never settles therefore drains the event loop with no exit code
495
+ // pending — Bun exits 0 and a FAILED run reports green, with the cache's
496
+ // accessed_at bumps and every later plugin's teardown lost with it.
497
+ // Sinks race concurrently, so each still gets the whole budget.
498
+ const settled = await settleWithin(
499
+ Promise.all(
500
+ sinks.map(async (sink) => {
501
+ if (disabled.has(sink) || sink.flush === undefined) return
502
+ try {
503
+ await sink.flush()
504
+ } catch (err) {
505
+ // A flush failure can never break the run — but it still costs
506
+ // this sink its export, so say so rather than dropping it.
507
+ warn?.(
508
+ `[vx] telemetry sink '${sink.name}' failed to flush: ${err instanceof Error ? err.message : String(err)}`,
509
+ )
510
+ }
511
+ }),
512
+ ),
513
+ ms,
355
514
  )
515
+ if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
356
516
  },
357
517
  }
358
518
  }