@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.
- package/LICENSE +1 -1
- package/README.md +107 -70
- package/package.json +10 -20
- package/src/bin.ts +64 -5
- package/src/cache/archive.ts +279 -28
- package/src/cache/cache.ts +952 -1584
- package/src/cache/chained-cache.ts +14 -10
- package/src/cache/config-evals.ts +112 -0
- package/src/cache/file-hashes.ts +290 -0
- package/src/cache/git-inputs.ts +966 -0
- package/src/cache/index.ts +23 -7
- package/src/cache/inputs.ts +553 -739
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +674 -0
- package/src/cache/layered-cache.ts +211 -49
- package/src/cache/output-index.ts +323 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +152 -0
- package/src/cache/zstd.ts +183 -0
- package/src/cli/cache.ts +88 -30
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +76 -38
- package/src/cli/index.ts +82 -17
- package/src/cli/info.ts +162 -91
- package/src/cli/init.ts +87 -0
- package/src/cli/last.ts +130 -23
- package/src/cli/lock.ts +36 -18
- package/src/cli/plan-format.ts +18 -7
- package/src/cli/plugin-commands.ts +34 -12
- package/src/cli/run.ts +138 -313
- package/src/cli/select.ts +342 -0
- package/src/cli/show.ts +151 -71
- package/src/cli/upgrade.ts +136 -15
- package/src/cli/watch.ts +1175 -116
- package/src/cli/why.ts +51 -29
- package/src/cli/workspace-config.ts +138 -0
- package/src/config.ts +183 -126
- package/src/exec/env.ts +11 -0
- package/src/exec/executor.ts +62 -31
- package/src/exec/index.ts +16 -2
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +361 -77
- package/src/exec/sandbox-binds.ts +211 -0
- package/src/exec/sandbox-paths.ts +70 -0
- package/src/exec/sandbox-runtime.ts +1152 -415
- package/src/exec/sandbox-violations.ts +293 -0
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +6 -4
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +332 -329
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +111 -102
- package/src/orchestrator/admission.ts +161 -0
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +25 -7
- package/src/orchestrator/doctor.ts +363 -0
- package/src/orchestrator/events.ts +111 -8
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +450 -565
- package/src/orchestrator/failure-mode.ts +169 -28
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +121 -49
- package/src/orchestrator/history.ts +123 -104
- package/src/orchestrator/hit-restore.ts +285 -0
- package/src/orchestrator/index.ts +42 -5
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +401 -0
- package/src/orchestrator/logger.ts +128 -41
- package/src/orchestrator/metrics.ts +201 -72
- package/src/orchestrator/miss-save.ts +232 -0
- package/src/orchestrator/options.ts +63 -34
- package/src/orchestrator/persistent.ts +99 -0
- package/src/orchestrator/placement.ts +212 -0
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +200 -49
- package/src/orchestrator/plugin.ts +190 -14
- package/src/orchestrator/prepare.ts +168 -137
- package/src/orchestrator/projects.ts +270 -0
- package/src/orchestrator/remote-prefetch.ts +22 -2
- package/src/orchestrator/run-artifacts.ts +34 -3
- package/src/orchestrator/run-context.ts +20 -5
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +132 -0
- package/src/orchestrator/run-report.ts +6 -4
- package/src/orchestrator/run.ts +467 -611
- package/src/orchestrator/sandbox-request.ts +569 -0
- package/src/orchestrator/save-lane.ts +44 -0
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +167 -0
- package/src/orchestrator/stable-keys.ts +259 -10
- package/src/orchestrator/status-line.ts +35 -2
- package/src/orchestrator/summary.ts +115 -25
- package/src/orchestrator/task-hash.ts +103 -21
- package/src/orchestrator/task-log-buffer.ts +15 -18
- package/src/orchestrator/telemetry-host.ts +27 -8
- package/src/orchestrator/telemetry.ts +71 -44
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +49 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +42 -5
- package/src/util/paths.ts +168 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +23 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/verbs.ts +39 -0
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +368 -60
- package/src/workspace/config-cache.ts +271 -32
- package/src/workspace/config-eval.ts +60 -16
- package/src/workspace/config-imports.ts +131 -9
- package/src/workspace/config-schema.ts +1033 -0
- package/src/workspace/filter.ts +69 -17
- package/src/workspace/fingerprint.ts +59 -7
- package/src/workspace/index.ts +37 -3
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +41 -14
- package/src/workspace/migrate-scripts.ts +187 -0
- package/src/workspace/migration.ts +353 -0
- package/src/workspace/package-graph.ts +239 -31
- package/src/workspace/project-loader.ts +295 -815
- package/src/workspace/workspace.ts +266 -31
- package/plugins/local-cache/index.ts +0 -2
- package/plugins/local-executor/index.ts +0 -2
- package/plugins/schedule-history/index.ts +0 -2
- package/src/cache/cas-backend.ts +0 -115
- package/src/cache/digest.ts +0 -32
- package/src/cli/migrate-emit.ts +0 -21
- package/src/cli/migrate-nx.ts +0 -473
- package/src/cli/migrate-scripts.ts +0 -127
- package/src/cli/migrate-turbo.ts +0 -401
- package/src/cli/migrate.ts +0 -305
- package/src/cli/prune.ts +0 -291
- package/src/orchestrator/missing-plugin.ts +0 -9
- package/src/orchestrator/resources.ts +0 -45
- package/src/orchestrator/verify.ts +0 -174
- package/src/plugins/local-cache/index.ts +0 -11
- package/src/plugins/local-executor/index.ts +0 -45
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
*
|
|
251
|
-
*
|
|
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.
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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?.
|
|
285
|
-
const {
|
|
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
|
|
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 —
|
|
3
|
-
//
|
|
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:
|
|
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
|
|
260
|
-
*
|
|
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
|
|
266
|
-
*
|
|
267
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|