@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -42,6 +42,8 @@ export interface OutputWriter {
42
42
  * `setStatus`; the two share one display slot.
43
43
  */
44
44
  setRegion(lines: readonly string[], opts?: { force?: boolean }): void
45
+ /** Hand over what `coalesce` holds and write straight through from now on. */
46
+ settle(): void
45
47
  /** Permanently remove the status display; later set* is a no-op. */
46
48
  clearStatus(): void
47
49
  }
@@ -61,6 +63,13 @@ export interface OutputWriterOptions {
61
63
  forceFloorMs?: number
62
64
  /** Injectable clock for tests. */
63
65
  now?: () => number
66
+ /**
67
+ * Off a TTY, hold writes and hand them to the stream once per turn of
68
+ * the event loop until `settle()`. A warm 476-package run wrote each of
69
+ * its 952 task lines as its own syscall (item 753). Off by default: a
70
+ * caller reading its stream right after a write sees it.
71
+ */
72
+ coalesce?: boolean
64
73
  }
65
74
 
66
75
  export function createOutputWriter(
@@ -71,6 +80,17 @@ export function createOutputWriter(
71
80
  const minRedrawMs = opts.minRedrawMs ?? 100
72
81
  const forceFloorMs = opts.forceFloorMs ?? 30
73
82
  const now = opts.now ?? Date.now
83
+ // The coalesced writes not yet handed over; null once settled or when
84
+ // not coalescing.
85
+ let held: string[] | null = !enabled && opts.coalesce === true ? [] : null
86
+ let flushQueued = false
87
+ const flushHeld = (): void => {
88
+ flushQueued = false
89
+ if (held === null || held.length === 0) return
90
+ const text = held.join('')
91
+ held.length = 0
92
+ stream.write(text)
93
+ }
74
94
 
75
95
  let current: readonly string[] | null = null
76
96
  let shown = false
@@ -169,7 +189,15 @@ export function createOutputWriter(
169
189
  enabled,
170
190
  write(chunk) {
171
191
  if (!enabled) {
172
- stream.write(chunk)
192
+ if (held === null) {
193
+ stream.write(chunk)
194
+ return
195
+ }
196
+ held.push(chunk)
197
+ if (!flushQueued) {
198
+ flushQueued = true
199
+ setImmediate(flushHeld)
200
+ }
173
201
  return
174
202
  }
175
203
  if (shown) {
@@ -187,6 +215,10 @@ export function createOutputWriter(
187
215
  setRegion(lines, o = {}) {
188
216
  set(lines, o)
189
217
  },
218
+ settle() {
219
+ flushHeld()
220
+ held = null
221
+ },
190
222
  clearStatus() {
191
223
  if (!enabled || dead) return
192
224
  dead = true
@@ -248,13 +280,14 @@ export function formatFailureLine(
248
280
  id: string,
249
281
  durationMs: number | null,
250
282
  colors: ColorSupport = NO_COLOR,
283
+ cacheWord: 'miss' | 'no-cache' = 'miss',
251
284
  ): string {
252
285
  return formatTaskRow(
253
286
  paint(ERROR, '◼\uFE0E', colors),
254
287
  durationMs,
255
288
  'failed',
256
289
  ERROR,
257
- 'miss',
290
+ cacheWord,
258
291
  '',
259
292
  paintPinnedId(id, colors),
260
293
  colors,
@@ -4,6 +4,7 @@
4
4
  // aren't real work and would inflate "N total" misleadingly.
5
5
 
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
+ import type { FlakyFinding } from './failure-mode.js'
7
8
  import { paint, type ColorSupport } from './colors.js'
8
9
  import { tallyOutcomes } from './tally.js'
9
10
  import { isGroupTask } from '../graph/index.js'
@@ -101,13 +102,17 @@ export interface SummaryStats {
101
102
  upToDate: number
102
103
  restoredLocal: number
103
104
  restoredRemote: number
104
- /** Tasks that executed (success + failed) — the cache misses. */
105
+ /** Tasks that executed (success + failed) with a `cache` block — the misses. */
105
106
  miss: number
107
+ /** Executed with no `cache` block: never consulted the cache, not a miss. */
108
+ noCache?: number
106
109
  /** Still to run (live section only; 0 in the final summary) — renders
107
110
  * as a gray ▱ remainder so the live meters FILL toward the final. */
108
111
  left?: number
109
112
  /** Cache-miss duration spread (only tasks that actually executed). */
110
113
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
114
+ /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
115
+ held?: { count: number; sumMs: number }
111
116
  }
112
117
 
113
118
  /**
@@ -125,14 +130,6 @@ export interface RunContext {
125
130
  remoteCacheEnabled: boolean
126
131
  /** Total projects discovery found — the bar's denominator. */
127
132
  workspaceProjectCount?: number
128
- /**
129
- * Resource-admission budgets (`exec.resources`), set ONLY when at
130
- * least one task declared a reservation — a plain run's footer is
131
- * byte-identical. Shown on the `info` row so a packed run says what
132
- * it packed against. memBudget in bytes.
133
- */
134
- cpuBudget?: number
135
- memBudget?: number
136
133
  }
137
134
 
138
135
  export function formatSummarySection(
@@ -143,6 +140,7 @@ export function formatSummarySection(
143
140
  ): string[] {
144
141
  const hits = stats.upToDate + stats.restoredLocal + stats.restoredRemote
145
142
  const miss = stats.miss
143
+ const noCache = stats.noCache ?? 0
146
144
  const left = stats.left ?? 0
147
145
  const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
148
146
 
@@ -206,10 +204,11 @@ export function formatSummarySection(
206
204
 
207
205
  // Cache meter + numbers, fixed order: miss · up-to-date · local ·
208
206
  // remote.
209
- if (miss + hits + left + stats.skipped > 0) {
207
+ if (miss + noCache + hits + left + stats.skipped > 0) {
210
208
  const cacheBar = segmentBar(
211
209
  [
212
210
  { n: miss, color: ERROR },
211
+ { n: noCache, color: '', dim: true },
213
212
  { n: stats.upToDate, color: SUCCESS },
214
213
  { n: stats.restoredLocal, color: LOCAL },
215
214
  { n: stats.restoredRemote, color: REMOTE },
@@ -220,6 +219,7 @@ export function formatSummarySection(
220
219
  )
221
220
  const cacheParts: string[] = []
222
221
  if (miss > 0) cacheParts.push(paint(ERROR, `${miss} miss`, colors))
222
+ if (noCache > 0) cacheParts.push(paint('', `${noCache} no-cache`, colors, { dim: true }))
223
223
  if (stats.upToDate > 0) cacheParts.push(paint(SUCCESS, `${stats.upToDate} up-to-date`, colors))
224
224
  if (stats.restoredLocal > 0)
225
225
  cacheParts.push(paint(LOCAL, `${stats.restoredLocal} local`, colors))
@@ -248,9 +248,16 @@ export function formatSummarySection(
248
248
  if (context.concurrency !== undefined)
249
249
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
250
250
  info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
251
- if (context.cpuBudget !== undefined) info.push(`cpu budget ${context.cpuBudget}`)
252
- if (context.memBudget !== undefined && Number.isFinite(context.memBudget))
253
- info.push(`mem budget ${formatBudgetBytes(context.memBudget)}`)
251
+ // The plugin's hand on the run: a task a policy held with a worker
252
+ // free would otherwise have started; without the line nothing says
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.
256
+ if (stats.held !== undefined && stats.held.count > 0) {
257
+ info.push(
258
+ `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatDuration(stats.held.sumMs)} in all`,
259
+ )
260
+ }
254
261
  lines.push('', row('info', join(info)), row('time', `${formatDuration(totalMs)}${spread}`))
255
262
  } else {
256
263
  lines.push('', row('time', `${formatDuration(totalMs)}${spread}`))
@@ -260,29 +267,28 @@ export function formatSummarySection(
260
267
 
261
268
  // Local byte formatter — the orchestrator can't import cli/format.ts
262
269
  // (module boundary), and the footer only needs one compact form.
263
- function formatBudgetBytes(bytes: number): string {
264
- const units = ['B', 'KB', 'MB', 'GB', 'TB']
265
- let v = bytes
266
- let u = 0
267
- while (v >= 1024 && u < units.length - 1) {
268
- v /= 1024
269
- u++
270
- }
271
- return `${u === 0 ? v : v.toFixed(1)} ${units[u]}`
272
- }
273
-
274
270
  export function formatRunSummary(
275
271
  outcomes: readonly TaskOutcome[],
276
272
  totalMs: number,
277
273
  colors: ColorSupport = NO_COLOR,
278
274
  context?: RunContext,
279
275
  ): string[] {
276
+ // Counted here, not in the Tally: that object is the `--summarize` payload
277
+ // (ten documented fields) and a task with no `cache` block is a terminal
278
+ // distinction — it never consulted the cache, so it is not a miss.
279
+ const noCache = outcomes.filter(
280
+ (o) =>
281
+ !isGroupTask(o.node) &&
282
+ (o.status === 'success' || o.status === 'failed') &&
283
+ o.node.config.cache === undefined,
284
+ ).length
280
285
  const t = tallyOutcomes(outcomes)
281
286
  // Spread over executed tasks only — `success`/`failed` statuses ran
282
287
  // a command; cache-hit statuses replayed one.
283
288
  const durations = outcomes
284
289
  .filter((o) => (o.status === 'success' || o.status === 'failed') && !isGroupTask(o.node))
285
290
  .map((o) => o.durationMs)
291
+ const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
286
292
  return formatSummarySection(
287
293
  {
288
294
  failed: t.failed,
@@ -292,7 +298,8 @@ export function formatRunSummary(
292
298
  upToDate: t.upToDate,
293
299
  restoredLocal: t.restoredLocal,
294
300
  restoredRemote: t.restoredRemote,
295
- miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote),
301
+ miss: t.total - t.skipped - (t.cachedLocal + t.cachedRemote) - noCache,
302
+ noCache,
296
303
  spread:
297
304
  durations.length > 0
298
305
  ? {
@@ -302,6 +309,14 @@ export function formatRunSummary(
302
309
  count: durations.length,
303
310
  }
304
311
  : null,
312
+ ...(heldOutcomes.length > 0
313
+ ? {
314
+ held: {
315
+ count: heldOutcomes.length,
316
+ sumMs: heldOutcomes.reduce((sum, o) => sum + o.admissionHeldMs!, 0),
317
+ },
318
+ }
319
+ : {}),
305
320
  },
306
321
  totalMs,
307
322
  colors,
@@ -330,6 +345,81 @@ export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[]
330
345
  return lines
331
346
  }
332
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
+
393
+ /**
394
+ * Post-summary section naming the tasks this run proved flaky: identical
395
+ * inputs, both outcomes on record (or a retry within the run). A red run
396
+ * whose failure has passed on these exact inputs before is not a break to
397
+ * bisect, and a green run that only passed on the second attempt is not
398
+ * green — either way the footer's counts alone would say nothing. Empty
399
+ * when nothing was.
400
+ */
401
+ export function formatFlakySection(findings: readonly FlakyFinding[]): string[] {
402
+ if (findings.length === 0) return []
403
+ const lines = [
404
+ '',
405
+ ` Flaky: ${findings.length} task${findings.length === 1 ? '' : 's'} with the same inputs both passing and failing on record`,
406
+ ]
407
+ for (const f of findings) {
408
+ const times = (n: number): string => `${n}\u00d7`
409
+ const history =
410
+ f.status === 'failed'
411
+ ? `failed on inputs that passed ${times(f.passes)} before`
412
+ : f.failures > 0
413
+ ? `passed on inputs that failed ${times(f.failures)} before`
414
+ : 'passed'
415
+ const retried = f.attempts > 1 ? ` \u00b7 ${f.attempts} attempts this run` : ''
416
+ lines.push(
417
+ ` ${f.status === 'failed' ? '\u2717' : '\u2713'} ${f.taskId} \u2014 ${history}${retried}`,
418
+ )
419
+ }
420
+ return lines
421
+ }
422
+
333
423
  export function formatDuration(ms: number): string {
334
424
  if (ms < 1000) return `${Math.round(ms)}ms`
335
425
  return `${(ms / 1000).toFixed(2)}s`
@@ -1,8 +1,11 @@
1
+ import { lstatSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import type { TaskConfig, CacheConfig } from '../config.js'
4
+ import type { ProjectFilesCache, WorkspaceFilesCache } from '../cache/index.js'
3
5
  import {
4
6
  type CacheKeyInput,
5
7
  type CacheLayer,
8
+ FILE_HASH_RACY_MS,
6
9
  resolveInputs,
7
10
  type GitFilesCache,
8
11
  WORKSPACE_OUTPUT_PREFIX,
@@ -19,6 +22,9 @@ import { expandGroupUpstream, filterUpstreamHashes } from './upstream.js'
19
22
  * project re-reads the same `package.json`; without this, a
20
23
  * monorepo with N projects × M tasks per project does N×M reads
21
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`.
22
28
  * - `taskConfig`: keyed by the resolved-config object reference.
23
29
  * Each task's config is created once at prepareRun time; the
24
30
  * JSON.stringify + xxh3 of it is deterministic. WeakMap so
@@ -32,6 +38,8 @@ export interface HashCache {
32
38
  taskConfig: WeakMap<TaskConfig, string>
33
39
  runtime: Map<string, Promise<string>>
34
40
  workspaceRuntime: Map<string, Promise<string>>
41
+ workspaceFiles: WorkspaceFilesCache
42
+ projectFiles: ProjectFilesCache
35
43
  }
36
44
 
37
45
  export function createHashCache(): HashCache {
@@ -40,6 +48,8 @@ export function createHashCache(): HashCache {
40
48
  taskConfig: new WeakMap(),
41
49
  runtime: new Map(),
42
50
  workspaceRuntime: new Map(),
51
+ workspaceFiles: new Map(),
52
+ projectFiles: new Map(),
43
53
  }
44
54
  }
45
55
 
@@ -98,6 +108,18 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
98
108
  }
99
109
  }
100
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
+
101
123
  /**
102
124
  * The key AND the structured input set behind it, for the executor seam.
103
125
  * Miss path only: it re-runs the (memoized) resolution `computeTaskHash`
@@ -109,7 +131,8 @@ export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
109
131
  */
110
132
  export async function describeTaskInputs(
111
133
  args: ComputeHashArgs,
112
- ): Promise<{ hash: string; inputs: TaskInputs }> {
134
+ ): Promise<{ hash: string; inputs: TaskInputs; facts: InputFact[]; describedAt: number }> {
135
+ const hashedAt = Date.now()
113
136
  const input = await resolveKeyInput(args)
114
137
  const hash = await args.cache.key(input)
115
138
  const sorted = [...input.inputFiles].sort()
@@ -120,6 +143,22 @@ export async function describeTaskInputs(
120
143
  path: relPosix(input.workspaceRoot, f),
121
144
  digest: digests[i]!,
122
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
+ }
123
162
  // Groups expanded: this list describes the INPUT ROOT, not the key, and a
124
163
  // group produces nothing of its own (see `CacheKeyInput.upstreamGraft`).
125
164
  // Sorted by hash so the list — and any action digest derived from it — is
@@ -140,6 +179,8 @@ export async function describeTaskInputs(
140
179
  })
141
180
  return {
142
181
  hash,
182
+ facts,
183
+ describedAt: hashedAt,
143
184
  inputs: {
144
185
  files,
145
186
  env: input.envValues.map(([name, value]) => ({ name, value })),
@@ -156,6 +197,41 @@ export async function describeTaskInputs(
156
197
  }
157
198
  }
158
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
+
159
235
  async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
160
236
  const cfg = args.node.config
161
237
  const cacheCfg: CacheConfig | undefined = cfg.cache
@@ -174,6 +250,8 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
174
250
  ? {
175
251
  runtimeCache: args.hashCache.runtime,
176
252
  workspaceRuntimeCache: args.hashCache.workspaceRuntime,
253
+ workspaceFilesCache: args.hashCache.workspaceFiles,
254
+ projectFilesCache: args.hashCache.projectFiles,
177
255
  }
178
256
  : {}),
179
257
  })
@@ -247,8 +325,9 @@ async function resolveKeyInput(args: ComputeHashArgs): Promise<CacheKeyInput> {
247
325
  * participate because the loader has already baked them into the
248
326
  * resolved object before we serialize.
249
327
  *
250
- * The schema is JSON-serializable by construction (no functions in
251
- * 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 —
252
331
  * each task's config object is created once per run, so a hit there
253
332
  * skips the JSON.stringify + xxh3 entirely.
254
333
  */
@@ -264,25 +343,28 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
264
343
  }
265
344
 
266
345
  /**
267
- * Project the config for hashing: `exec.resources` (cores, memory AND the
268
- * image a task must be matched to) and `exec.remote` are pure PLACEMENT
269
- * hints with zero effect on outputs, so they're stripped — tuning a
270
- * reservation, pinning a task to this machine, or naming the container it
271
- * runs in never busts a cache. `remote` in particular must not: the whole
272
- * contract of a remote executor is that the same command over the same
273
- * inputs produces the same outputs, so a key that moved when placement
274
- * changed would split a laptop from a worker pool over nothing. `image`
275
- * rides the same rule one step further: a task that ran locally and the
276
- * same task on a worker must share an entry, and a task whose output really
277
- * does depend on its toolchain says so with `cache.inputs.runtime`, which
278
- * works on both. A config that declares neither takes the fast path and stringifies byte-identically to before the fields existed
279
- * (why this needs no CACHE_VERSION bump). `timeout`/`retries` stay folded
280
- * — their keys are distinct by design (see the decision log); stripping
281
- * them retroactively would bump CACHE_VERSION.
346
+ * Project the config for hashing: `exec.remote` is a pure PLACEMENT hint
347
+ * with zero effect on outputs, so it is stripped — pinning a task to this
348
+ * machine never busts a cache. It must not: the whole contract of a remote
349
+ * executor is that the same command over the same inputs produces the
350
+ * same outputs, so a key that moved when placement changed would split a
351
+ * laptop from a worker pool over nothing. (`exec.resources` rode the same
352
+ * rule until 2026-09-12, when reservations left the config for the
353
+ * schedule plugin that learns them; a config never declares them now.) A
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
362
+ * distinct by design (see the decision log); stripping them retroactively
363
+ * would bump CACHE_VERSION.
282
364
  */
283
365
  function hashableConfig(cfg: TaskConfig): unknown {
284
- if (cfg.exec?.resources === undefined && cfg.exec?.remote === undefined) return cfg
285
- const { resources: _resources, remote: _remote, ...execRest } = cfg.exec
366
+ if (cfg.exec?.remote === undefined) return cfg
367
+ const { remote: _remote, ...execRest } = cfg.exec
286
368
  return { ...cfg, exec: execRest }
287
369
  }
288
370
 
@@ -343,5 +425,5 @@ async function doHashProjectPackageJson(
343
425
  if (!(await Bun.file(filePath).exists())) return ''
344
426
  // Route through the cache layer's mtime+size fast path so the
345
427
  // typical re-run sees a stat + SQLite lookup instead of a file read.
346
- return await cache.hashFile(filePath)
428
+ return cache.hashFile(filePath)
347
429
  }
@@ -1,7 +1,6 @@
1
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.
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.
5
4
  //
6
5
  // It lives in CORE, beside the telemetry contract, for the same reason
7
6
  // `assembleRunSummary` does: two sinks that each roll their own bounded buffer
@@ -25,8 +24,7 @@ import type { TaskStatus } from '../graph/index.js'
25
24
  import type { CacheSource } from './telemetry.js'
26
25
 
27
26
  /** 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. */
27
+ * logs format, not one transport's: every sink ships the same object. */
30
28
  export const LOG_WIRE_VERSION = 1
31
29
 
32
30
  /**
@@ -81,13 +79,6 @@ interface InFlight {
81
79
  truncatedHeadChars: number
82
80
  }
83
81
 
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
82
  /**
92
83
  * Per-retained-chunk overhead, in char-equivalents, charged against the RUN
93
84
  * budget on top of the characters themselves.
@@ -112,6 +103,13 @@ function budgetCost(chars: number, chunkCount: number): number {
112
103
  return chars + chunkCount * CHUNK_OVERHEAD_CHARS
113
104
  }
114
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
+ */
115
113
  export class TaskLogBuffer {
116
114
  private readonly inFlight = new Map<string, InFlight>()
117
115
  private readonly retained = new Map<string, Retained>()
@@ -256,15 +254,14 @@ export class TaskLogBuffer {
256
254
  * enough failing tasks to blow the budget). A stub costs a few dozen bytes,
257
255
  * keeps the `content.length === charsFull - truncatedHeadChars` accounting
258
256
  * 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.
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).
262
259
  *
263
260
  * The cost is a stub per evicted task in the end-of-run bundle: measured at
264
261
  * ~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.
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.
268
265
  */
269
266
  private evictToBudget(): void {
270
267
  if (this.retainedChars <= RUN_LOG_BUDGET_CHARS) return
@@ -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
 
@@ -40,7 +41,17 @@ function checkSink(sink: unknown): TelemetrySink {
40
41
  if (wants !== undefined && !Array.isArray(wants)) {
41
42
  throw new Error(`telemetry sink 'wants' must be an array, got ${typeof wants}`)
42
43
  }
43
- return sink as TelemetrySink
44
+ // Every handler is optional, so `{ nope: true }` was a valid sink that
45
+ // subscribed to the bus and heard nothing — the plugin was "on" and did
46
+ // nothing. Refused here, which the caller turns into the same warning
47
+ // a throwing hook gets: never a failed run.
48
+ const s = sink as TelemetrySink
49
+ if (typeof s.onRecord !== 'function' && typeof s.onRunSummary !== 'function') {
50
+ throw new Error(
51
+ 'telemetry sink handles nothing: neither onRecord nor onRunSummary is a function',
52
+ )
53
+ }
54
+ return s
44
55
  }
45
56
 
46
57
  /**
@@ -69,7 +80,18 @@ export async function subscribeTelemetry(
69
80
  // check sits INSIDE the try so a throwing `wants` getter is caught too.
70
81
  let accepted: TelemetrySink[]
71
82
  try {
72
- 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`)
73
95
  const list = result === undefined ? [] : Array.isArray(result) ? result : [result]
74
96
  accepted = list.map((sink) => checkSink(sink))
75
97
  } catch (err) {
@@ -84,15 +106,12 @@ export async function subscribeTelemetry(
84
106
  if (sinks.length === 0) return undefined
85
107
 
86
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).
87
111
  const dispose = bus.subscribe(source.subscriber)
88
- let disposed = false
89
112
  return {
90
113
  emitSummary: (summary) => source.emitSummary(summary),
91
114
  flush: () => source.flush(),
92
- dispose() {
93
- if (disposed) return
94
- disposed = true
95
- dispose()
96
- },
115
+ dispose,
97
116
  }
98
117
  }