@vzn/vx 0.0.530 → 0.0.532
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/package.json +5 -5
- package/src/cache/cache.ts +26 -2
- package/src/cache/layer.ts +4 -0
- package/src/cache/layered-cache.ts +17 -4
- package/src/graph/scheduler.ts +14 -4
- package/src/orchestrator/events.ts +9 -0
- package/src/orchestrator/execute-task.ts +4 -2
- package/src/orchestrator/hit-restore.ts +2 -0
- package/src/orchestrator/miss-save.ts +26 -5
- package/src/orchestrator/run.ts +26 -23
- package/src/orchestrator/telemetry.ts +19 -0
- package/src/workspace/project-loader.ts +6 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.532",
|
|
4
4
|
"description": "The fastest task runner and build cache for JavaScript monorepos.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -21,10 +21,10 @@
|
|
|
21
21
|
"node": ">=18"
|
|
22
22
|
},
|
|
23
23
|
"optionalDependencies": {
|
|
24
|
-
"@vzn/vx-linux-x64": "0.0.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.532",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.532",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.532",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.532"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/cache.ts
CHANGED
|
@@ -339,6 +339,7 @@ function entryOf(row: EntryRow, fileRows: OutputFileRow[]): CacheEntry {
|
|
|
339
339
|
command: row.command,
|
|
340
340
|
exitCode: row.exit_code,
|
|
341
341
|
durationMs: row.duration_ms,
|
|
342
|
+
sizeBytes: row.size_bytes,
|
|
342
343
|
...(row.cpu_ms !== null ? { cpuMs: row.cpu_ms } : {}),
|
|
343
344
|
...(row.peak_rss_bytes !== null ? { peakRssBytes: row.peak_rss_bytes } : {}),
|
|
344
345
|
outputFiles: fileRows.map((r) => r.path),
|
|
@@ -487,9 +488,24 @@ function errorText(err: unknown): string {
|
|
|
487
488
|
return err instanceof Error ? err.message : String(err)
|
|
488
489
|
}
|
|
489
490
|
|
|
491
|
+
/** What a run's background uploads moved: totals over the ones that landed. */
|
|
492
|
+
export interface UploadTally {
|
|
493
|
+
count: number
|
|
494
|
+
bytes: number
|
|
495
|
+
/** Summed per-upload time; uploads overlap, so this exceeds the wall. */
|
|
496
|
+
ms: number
|
|
497
|
+
failed: number
|
|
498
|
+
}
|
|
499
|
+
|
|
490
500
|
export class Cache implements CacheLayer {
|
|
491
501
|
/** This IS the local layer — there is nothing slower behind it. */
|
|
492
502
|
readonly hasRemote = false
|
|
503
|
+
/**
|
|
504
|
+
* The run's uploads to a remote behind this cache, tallied by the
|
|
505
|
+
* `LayeredCache` over it (a plugin's, constructed over `ctx.localCache`)
|
|
506
|
+
* so the run can report them without a method on the layer seam.
|
|
507
|
+
*/
|
|
508
|
+
readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
|
|
493
509
|
|
|
494
510
|
private readonly db: Database
|
|
495
511
|
private readonly insertEntry: ReturnType<Database['prepare']>
|
|
@@ -1457,9 +1473,17 @@ export class Cache implements CacheLayer {
|
|
|
1457
1473
|
// leaves it or loops (item 748).
|
|
1458
1474
|
const code = (err as NodeJS.ErrnoException).code
|
|
1459
1475
|
if (code === 'EISDIR' || code === 'ENOTDIR' || code === 'EEXIST' || code === 'ENOTEMPTY') {
|
|
1476
|
+
// Bun's message already opens with the code: `EEXIST: EEXIST: …` read
|
|
1477
|
+
// as a stutter. EEXIST and ENOTDIR are a FILE in a directory's
|
|
1478
|
+
// place; the told-backwards sentence named only the reverse (X-7).
|
|
1479
|
+
const msg = (err as Error).message
|
|
1480
|
+
const what =
|
|
1481
|
+
code === 'EISDIR' || code === 'ENOTEMPTY'
|
|
1482
|
+
? 'a directory stands where the entry holds a file'
|
|
1483
|
+
: 'a file stands where the entry needs a directory'
|
|
1460
1484
|
throw new UserError(
|
|
1461
|
-
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${code}: ${
|
|
1462
|
-
`The clean before a restore removes the files the output globs select
|
|
1485
|
+
`restore of ${hash} into ${projectDir} was blocked by what is on disk (${msg.startsWith(code) ? msg : `${code}: ${msg}`}): ${what}. ` +
|
|
1486
|
+
`The clean before a restore removes the files the output globs select and the directories that empties, not this — remove it and re-run.`,
|
|
1463
1487
|
)
|
|
1464
1488
|
}
|
|
1465
1489
|
// A legal name under a destination deep enough that the two together
|
package/src/cache/layer.ts
CHANGED
|
@@ -159,6 +159,10 @@ export interface CacheEntry {
|
|
|
159
159
|
* them work vs. a stale-local replay.
|
|
160
160
|
*/
|
|
161
161
|
source?: 'local' | 'remote'
|
|
162
|
+
/** The artifact's compressed size, when the index knows it. */
|
|
163
|
+
sizeBytes?: number
|
|
164
|
+
/** On a remote hit this run pulled: how long the download and its ingest took. */
|
|
165
|
+
fetchMs?: number
|
|
162
166
|
}
|
|
163
167
|
|
|
164
168
|
export interface RunRecord {
|
|
@@ -260,8 +260,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
260
260
|
* remote cache — so we flip `source` to `'remote'` and the
|
|
261
261
|
* orchestrator reports `cache-hit-remote`. Without this, a prefetch
|
|
262
262
|
* followed by a `get` would mislabel a genuine remote hit as local.
|
|
263
|
+
* Each maps to how long its download and ingest took.
|
|
263
264
|
*/
|
|
264
|
-
private readonly remoteSourced = new
|
|
265
|
+
private readonly remoteSourced = new Map<string, number>()
|
|
265
266
|
|
|
266
267
|
/**
|
|
267
268
|
* Background write-through uploads. `save()` returns after the local
|
|
@@ -364,7 +365,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
364
365
|
// A prefetch this run may have materialized this entry FROM
|
|
365
366
|
// remote; the local row exists now, but the work was saved by
|
|
366
367
|
// the remote cache, so the provenance stays 'remote'.
|
|
367
|
-
return this.
|
|
368
|
+
return this.asRemote(hash, localHit)
|
|
368
369
|
}
|
|
369
370
|
|
|
370
371
|
// Remote reads disabled (e.g. `--cache=remote:`): a local miss is a
|
|
@@ -395,7 +396,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
395
396
|
// above and that skip's `local.has`, stamping 'remote' here reported a
|
|
396
397
|
// purely-local hit as `cache-hit-remote` and inflated the remote
|
|
397
398
|
// hit-rate. Whatever provenance the entry has is the truth.
|
|
398
|
-
return this.
|
|
399
|
+
return this.asRemote(hash, materialized)
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
private asRemote(hash: string, entry: CacheEntry): CacheEntry {
|
|
403
|
+
const fetchMs = this.remoteSourced.get(hash)
|
|
404
|
+
return fetchMs === undefined ? entry : { ...entry, source: 'remote', fetchMs }
|
|
399
405
|
}
|
|
400
406
|
|
|
401
407
|
// Existence probe: local first, then a remote HEAD — no body
|
|
@@ -439,6 +445,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
439
445
|
// or remote if a concurrent prefetch set it).
|
|
440
446
|
if ((await this.local.has(hash)) === 'local') return true
|
|
441
447
|
|
|
448
|
+
const fetchStart = performance.now()
|
|
442
449
|
let remoteResult: unknown
|
|
443
450
|
try {
|
|
444
451
|
remoteResult = await this.wire.get(hash)
|
|
@@ -490,7 +497,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
490
497
|
this.reportRemoteError('download', hash, err)
|
|
491
498
|
return false
|
|
492
499
|
}
|
|
493
|
-
this.remoteSourced.
|
|
500
|
+
this.remoteSourced.set(hash, Math.round(performance.now() - fetchStart))
|
|
494
501
|
return true
|
|
495
502
|
}
|
|
496
503
|
|
|
@@ -543,11 +550,17 @@ export class LayeredCache implements CacheLayer {
|
|
|
543
550
|
}
|
|
544
551
|
}
|
|
545
552
|
this.enqueueUpload(async () => {
|
|
553
|
+
const tally = this.local.uploads
|
|
554
|
+
const start = performance.now()
|
|
546
555
|
try {
|
|
547
556
|
const body =
|
|
548
557
|
packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
|
|
549
558
|
await this.wire.put(hash, body, { durationMs })
|
|
559
|
+
tally.count++
|
|
560
|
+
tally.bytes += body.size
|
|
561
|
+
tally.ms += Math.round(performance.now() - start)
|
|
550
562
|
} catch (err) {
|
|
563
|
+
tally.failed++
|
|
551
564
|
this.reportRemoteError('upload', hash, err)
|
|
552
565
|
}
|
|
553
566
|
})
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -86,6 +86,15 @@ export interface TaskOutcome {
|
|
|
86
86
|
queuedMs?: number
|
|
87
87
|
/** On a cacheable task that ran: how many files its key read. */
|
|
88
88
|
inputFiles?: number
|
|
89
|
+
/**
|
|
90
|
+
* The artifact's compressed size: on a hit, the entry's; on a miss, the one
|
|
91
|
+
* its save wrote (measured only when a telemetry sink listens).
|
|
92
|
+
*/
|
|
93
|
+
artifactBytes?: number
|
|
94
|
+
/** On a remote hit this run pulled: the download and its ingest. */
|
|
95
|
+
fetchMs?: number
|
|
96
|
+
/** On a miss that saved, with a sink listening: the save's own time. */
|
|
97
|
+
saveMs?: number
|
|
89
98
|
/**
|
|
90
99
|
* On a cacheable task that ran: what its key changed since the last entry
|
|
91
100
|
* saved for it. Only when a telemetry sink asked, and the cache holds one.
|
|
@@ -234,7 +243,7 @@ export interface ScheduleOptions {
|
|
|
234
243
|
* execute request), so it waits for the save to land, while the freed
|
|
235
244
|
* slot admits other work at once. Undefined: nothing owed.
|
|
236
245
|
*/
|
|
237
|
-
settledOf?: (outcome: TaskOutcome) => Promise<void> | undefined
|
|
246
|
+
settledOf?: (outcome: TaskOutcome) => Promise<Partial<TaskOutcome> | void> | undefined
|
|
238
247
|
/**
|
|
239
248
|
* Optional priority override: callers pass their own per-node weight
|
|
240
249
|
* (e.g. `computePredictedPriorities` from the orchestrator's history
|
|
@@ -826,11 +835,12 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
|
|
|
826
835
|
return
|
|
827
836
|
}
|
|
828
837
|
tick()
|
|
829
|
-
|
|
830
|
-
|
|
838
|
+
// What the save cost rides the outcome it settles.
|
|
839
|
+
const done = (owed?: Partial<TaskOutcome> | void): void => {
|
|
840
|
+
finishOne(id, owed ? { ...outcome, ...owed } : outcome)
|
|
831
841
|
tick()
|
|
832
842
|
}
|
|
833
|
-
void settled.then(done, done)
|
|
843
|
+
void settled.then(done, () => done())
|
|
834
844
|
},
|
|
835
845
|
(err: unknown) => {
|
|
836
846
|
if (err instanceof RestoreDemoted) {
|
|
@@ -256,6 +256,12 @@ export interface OutcomeView {
|
|
|
256
256
|
inputFiles?: number
|
|
257
257
|
/** What a missed task's key changed since its last saved entry (see `TaskOutcome`). */
|
|
258
258
|
inputChanges?: InputChanges
|
|
259
|
+
/** The artifact's compressed size, a hit's or a measured save's (see `TaskOutcome`). */
|
|
260
|
+
artifactBytes?: number
|
|
261
|
+
/** A remote hit's download and ingest (see `TaskOutcome`). */
|
|
262
|
+
fetchMs?: number
|
|
263
|
+
/** A measured save's own time (see `TaskOutcome`). */
|
|
264
|
+
saveMs?: number
|
|
259
265
|
restored?: boolean
|
|
260
266
|
sandboxViolations?: number
|
|
261
267
|
sandboxViolationLines?: string[]
|
|
@@ -378,6 +384,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
|
|
|
378
384
|
if (outcome.queuedMs !== undefined) view.queuedMs = outcome.queuedMs
|
|
379
385
|
if (outcome.inputFiles !== undefined) view.inputFiles = outcome.inputFiles
|
|
380
386
|
if (outcome.inputChanges !== undefined) view.inputChanges = outcome.inputChanges
|
|
387
|
+
if (outcome.artifactBytes !== undefined) view.artifactBytes = outcome.artifactBytes
|
|
388
|
+
if (outcome.fetchMs !== undefined) view.fetchMs = outcome.fetchMs
|
|
389
|
+
if (outcome.saveMs !== undefined) view.saveMs = outcome.saveMs
|
|
381
390
|
if (outcome.timedOut === true) view.timedOut = true
|
|
382
391
|
if (outcome.notReady !== undefined) view.notReady = outcome.notReady
|
|
383
392
|
if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
|
|
@@ -65,7 +65,7 @@ import {
|
|
|
65
65
|
type WithheldLink,
|
|
66
66
|
withheldLinkLine,
|
|
67
67
|
} from './sandbox-request.js'
|
|
68
|
-
import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
|
|
68
|
+
import { markUnsaved, saveMiss, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
|
|
69
69
|
import type { FingerprintWatch } from './fingerprint-watch.js'
|
|
70
70
|
import { restoreHit } from './hit-restore.js'
|
|
71
71
|
import type { MissExplainer } from './miss-reason.js'
|
|
@@ -211,7 +211,7 @@ export interface ExecuteArgs {
|
|
|
211
211
|
* in-flight join (admission.ts): a duplicate of this task in another run
|
|
212
212
|
* must not probe the cache before the entry is there.
|
|
213
213
|
*/
|
|
214
|
-
deferredSaves?: Map<string, Promise<
|
|
214
|
+
deferredSaves?: Map<string, Promise<SaveFacts>>
|
|
215
215
|
/**
|
|
216
216
|
* `continueMode: 'always'` let this task run although an upstream —
|
|
217
217
|
* directly or through a chain of successes — failed or aborted. It still
|
|
@@ -1261,6 +1261,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1261
1261
|
...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
|
|
1262
1262
|
outputDirSnapshots: args.outputDirSnapshots,
|
|
1263
1263
|
deferSave: args.deferSave,
|
|
1264
|
+
// A sink listens: say what the save cost.
|
|
1265
|
+
measure: args.explainMiss !== undefined,
|
|
1264
1266
|
})
|
|
1265
1267
|
args.deferredSaves?.set(node.id, landed)
|
|
1266
1268
|
} else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
|
|
@@ -303,6 +303,8 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
|
|
|
303
303
|
storedDurationMs: hit.durationMs,
|
|
304
304
|
...(hit.cpuMs !== undefined ? { storedCpuMs: hit.cpuMs } : {}),
|
|
305
305
|
...(hit.peakRssBytes !== undefined ? { storedPeakRssBytes: hit.peakRssBytes } : {}),
|
|
306
|
+
...(hit.sizeBytes !== undefined ? { artifactBytes: hit.sizeBytes } : {}),
|
|
307
|
+
...(hit.fetchMs !== undefined ? { fetchMs: hit.fetchMs } : {}),
|
|
306
308
|
hash,
|
|
307
309
|
restored,
|
|
308
310
|
wallclockStartNs: taskStartNs,
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
|
|
8
8
|
// code motion.
|
|
9
9
|
|
|
10
|
-
import { lstatSync, realpathSync } from 'node:fs'
|
|
10
|
+
import { lstatSync, realpathSync, statSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import {
|
|
13
13
|
type CacheLayer,
|
|
@@ -91,10 +91,21 @@ export interface SaveMissArgs {
|
|
|
91
91
|
* index go to the run's save lane (save-lane.ts).
|
|
92
92
|
*/
|
|
93
93
|
deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
|
|
94
|
+
/** Time the save and size its artifact, for a telemetry sink: one stat. */
|
|
95
|
+
measure?: boolean | undefined
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
/**
|
|
97
|
-
export
|
|
98
|
+
/** What a measured save cost, on the outcome once it lands. */
|
|
99
|
+
export interface SaveFacts {
|
|
100
|
+
saveMs?: number
|
|
101
|
+
artifactBytes?: number
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `landed` settles when the entry is in the cache — at once without a lane —
|
|
106
|
+
* with what it cost when `measure` asked.
|
|
107
|
+
*/
|
|
108
|
+
export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveFacts> }> {
|
|
98
109
|
const { node, cache, log } = a
|
|
99
110
|
const endResolve = span('miss: resolve outputs')
|
|
100
111
|
const outputFiles =
|
|
@@ -145,7 +156,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
|
|
|
145
156
|
: ''),
|
|
146
157
|
)
|
|
147
158
|
}
|
|
159
|
+
const facts: SaveFacts = {}
|
|
148
160
|
const save = async (): Promise<void> => {
|
|
161
|
+
const saveStart = a.measure === true ? performance.now() : 0
|
|
149
162
|
const endSave = span('miss: save')
|
|
150
163
|
// Tier-3 input fingerprint: the digest rows captured by the pre-exec
|
|
151
164
|
// describe above, persisted with the entry inside `cache.save`'s
|
|
@@ -171,6 +184,14 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
|
|
|
171
184
|
},
|
|
172
185
|
})
|
|
173
186
|
endSave()
|
|
187
|
+
if (a.measure === true) {
|
|
188
|
+
facts.saveMs = Math.round(performance.now() - saveStart)
|
|
189
|
+
try {
|
|
190
|
+
facts.artifactBytes = statSync(cache.outputsPath(a.hash)).size
|
|
191
|
+
} catch {
|
|
192
|
+
// No local artifact (local writes off): no size to say.
|
|
193
|
+
}
|
|
194
|
+
}
|
|
174
195
|
// The files the task just wrote are the entry's bytes: stamp them,
|
|
175
196
|
// so the next hit can tell them from another entry's with the same
|
|
176
197
|
// size and mtime (item 886).
|
|
@@ -198,9 +219,9 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void>
|
|
|
198
219
|
// Off the slot when the run keeps a lane (the save lane bounds and
|
|
199
220
|
// drains it); in the slot otherwise — an embedder without a lane gets
|
|
200
221
|
// the entry before the outcome.
|
|
201
|
-
if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
|
|
222
|
+
if (a.deferSave !== undefined) return { landed: a.deferSave(save).then(() => facts) }
|
|
202
223
|
await save()
|
|
203
|
-
return { landed: Promise.resolve() }
|
|
224
|
+
return { landed: Promise.resolve(facts) }
|
|
204
225
|
}
|
|
205
226
|
|
|
206
227
|
/** What `markUnsaved` reads of a miss: where its declared outputs are. */
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -42,7 +42,7 @@ import {
|
|
|
42
42
|
} from '../util/index.js'
|
|
43
43
|
import { keyedProjects } from './keyed-projects.js'
|
|
44
44
|
import { prepareSandbox } from './sandbox-request.js'
|
|
45
|
-
import type { OutputDirSnapshot } from './miss-save.js'
|
|
45
|
+
import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
|
|
46
46
|
import { admitTasks, taintTracker } from './admission.js'
|
|
47
47
|
import { excludedTaint } from './excluded-keys.js'
|
|
48
48
|
import { keyUpstream } from './upstream.js'
|
|
@@ -800,7 +800,7 @@ async function runOnBus(
|
|
|
800
800
|
const saveLane = createSaveLane(2 * concurrency, (err) =>
|
|
801
801
|
log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
802
802
|
)
|
|
803
|
-
const deferredSaves = new Map<string, Promise<
|
|
803
|
+
const deferredSaves = new Map<string, Promise<SaveFacts>>()
|
|
804
804
|
|
|
805
805
|
// Focused flow: a requested GROUP has no output of its own, so
|
|
806
806
|
// surface the same-project, non-group tasks it chains (one level)
|
|
@@ -1209,27 +1209,6 @@ async function runOnBus(
|
|
|
1209
1209
|
log.status(`[vx] run history not recorded: ${message} — the verdict above stands`)
|
|
1210
1210
|
}
|
|
1211
1211
|
mark('record history')
|
|
1212
|
-
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1213
|
-
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1214
|
-
// whole block is skipped and the run is byte-identical to before.
|
|
1215
|
-
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
1216
|
-
// the run; flush is the sink's last chance to ship buffered records.
|
|
1217
|
-
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
1218
|
-
const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
|
|
1219
|
-
startedAt: endedAtMsAtStart,
|
|
1220
|
-
endedAt: endedAtMs,
|
|
1221
|
-
totalDurationMs: Math.round(totalMs),
|
|
1222
|
-
exitOk: ok,
|
|
1223
|
-
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1224
|
-
stages: stageTimes(),
|
|
1225
|
-
})
|
|
1226
|
-
telemetry.emitSummary(summary)
|
|
1227
|
-
await telemetry.flush()
|
|
1228
|
-
}
|
|
1229
|
-
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1230
|
-
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
1231
|
-
// fail nor hang the run. Here on the normal path, after the drains
|
|
1232
|
-
// below; every other exit takes it through `teardown` too.
|
|
1233
1212
|
// Drain any still-in-flight background prefetches before closing the
|
|
1234
1213
|
// cache handle — a prefetch ingesting into a closed SQLite DB would
|
|
1235
1214
|
// throw. Tasks that resolved as local hits never awaited their
|
|
@@ -1252,6 +1231,30 @@ async function runOnBus(
|
|
|
1252
1231
|
// drain of the lane stood here until item 634; deleting it changed
|
|
1253
1232
|
// nothing the suite could see, because this gate is the rule.
|
|
1254
1233
|
await cache.drainUploads?.()
|
|
1234
|
+
// Hand the per-run summary to the telemetry sinks + drain them. Only
|
|
1235
|
+
// when a sink is active (telemetry !== undefined) — otherwise this
|
|
1236
|
+
// whole block is skipped and the run is byte-identical to before.
|
|
1237
|
+
// emitSummary/flush are crash-isolated, so a faulty sink can't fail
|
|
1238
|
+
// the run; flush is the sink's last chance to ship buffered records.
|
|
1239
|
+
// After the drain, so the summary says what the uploads moved; a live
|
|
1240
|
+
// sink's task records went out as each task ended.
|
|
1241
|
+
if (telemetry !== undefined && runContextRecord !== undefined) {
|
|
1242
|
+
const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
|
|
1243
|
+
startedAt: endedAtMsAtStart,
|
|
1244
|
+
endedAt: endedAtMs,
|
|
1245
|
+
totalDurationMs: Math.round(totalMs),
|
|
1246
|
+
exitOk: ok,
|
|
1247
|
+
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1248
|
+
stages: stageTimes(),
|
|
1249
|
+
uploads: prepared.localCache.uploads,
|
|
1250
|
+
})
|
|
1251
|
+
telemetry.emitSummary(summary)
|
|
1252
|
+
await telemetry.flush()
|
|
1253
|
+
}
|
|
1254
|
+
// End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
|
|
1255
|
+
// + time-bounded inside teardownPlugins, so a faulty plugin can neither
|
|
1256
|
+
// fail nor hang the run. Here on the normal path, after the drains
|
|
1257
|
+
// above; every other exit takes it through `teardown` too.
|
|
1255
1258
|
// The miss path's output-directory snapshots, taken now that the
|
|
1256
1259
|
// directories are old enough for the snapshot's racy window (see
|
|
1257
1260
|
// miss-save.ts). A few at a time: each is an lstat + readdir per
|
|
@@ -232,6 +232,12 @@ export interface TaskTelemetry {
|
|
|
232
232
|
* it: what its key changed since, the first ten named. Additive.
|
|
233
233
|
*/
|
|
234
234
|
inputChanges?: InputChanges
|
|
235
|
+
/** The artifact's compressed size: on a hit the entry's, on a miss the save's. Additive. */
|
|
236
|
+
artifactBytes?: number
|
|
237
|
+
/** On a remote hit this run pulled: the download and its ingest. Additive. */
|
|
238
|
+
fetchMs?: number
|
|
239
|
+
/** On a miss that saved: the save's own time (pack, write, index). Additive. */
|
|
240
|
+
saveMs?: number
|
|
235
241
|
/**
|
|
236
242
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
237
243
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -339,6 +345,12 @@ export interface RunSummaryRecord {
|
|
|
339
345
|
* stages ahead of the run lock end before `startedAt`. Additive.
|
|
340
346
|
*/
|
|
341
347
|
stages?: readonly RunStage[]
|
|
348
|
+
/**
|
|
349
|
+
* The run's uploads to a remote cache, once they settled: how many landed,
|
|
350
|
+
* their bytes, their summed time (they overlap), and how many failed.
|
|
351
|
+
* Absent when nothing was uploaded or tried. Additive.
|
|
352
|
+
*/
|
|
353
|
+
uploads?: { count: number; bytes: number; ms: number; failed: number }
|
|
342
354
|
}
|
|
343
355
|
|
|
344
356
|
/** One stage of a run, a wall window in epoch ms. */
|
|
@@ -369,6 +381,7 @@ export function assembleRunSummary(
|
|
|
369
381
|
exitOk: boolean
|
|
370
382
|
abortedCount: number
|
|
371
383
|
stages?: readonly RunStage[]
|
|
384
|
+
uploads?: { count: number; bytes: number; ms: number; failed: number }
|
|
372
385
|
},
|
|
373
386
|
): RunSummaryRecord {
|
|
374
387
|
let failedCount = 0
|
|
@@ -404,6 +417,9 @@ export function assembleRunSummary(
|
|
|
404
417
|
exitOk: timing.exitOk,
|
|
405
418
|
tasks,
|
|
406
419
|
...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
|
|
420
|
+
...(timing.uploads !== undefined && timing.uploads.count + timing.uploads.failed > 0
|
|
421
|
+
? { uploads: { ...timing.uploads } }
|
|
422
|
+
: {}),
|
|
407
423
|
}
|
|
408
424
|
}
|
|
409
425
|
|
|
@@ -769,6 +785,9 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
769
785
|
if (o.queuedMs !== undefined) t.queuedMs = o.queuedMs
|
|
770
786
|
if (o.inputFiles !== undefined) t.inputFiles = o.inputFiles
|
|
771
787
|
if (o.inputChanges !== undefined) t.inputChanges = o.inputChanges
|
|
788
|
+
if (o.artifactBytes !== undefined) t.artifactBytes = o.artifactBytes
|
|
789
|
+
if (o.fetchMs !== undefined) t.fetchMs = o.fetchMs
|
|
790
|
+
if (o.saveMs !== undefined) t.saveMs = o.saveMs
|
|
772
791
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
773
792
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
774
793
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
|
@@ -294,7 +294,12 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
|
|
|
294
294
|
// below are Bun's own and nothing else answers to them, so this is
|
|
295
295
|
// narrower than it looks: every other throw still passes through (2026-09-20).
|
|
296
296
|
if (err === null || typeof err !== 'object') return null
|
|
297
|
-
const { name, message } = err as { name?: unknown; message?: unknown }
|
|
297
|
+
const { name, message, errors } = err as { name?: unknown; message?: unknown; errors?: unknown }
|
|
298
|
+
// Two syntax errors arrive as one `AggregateError` of `BuildMessage`s,
|
|
299
|
+
// which printed a stack and no position (X-6). The first is where to look.
|
|
300
|
+
if (name === 'AggregateError' && Array.isArray(errors) && errors.length > 0) {
|
|
301
|
+
return configLoadError(errors[0], configPath, kind)
|
|
302
|
+
}
|
|
298
303
|
if (typeof message !== 'string') return null
|
|
299
304
|
if (name === 'ResolveMessage') {
|
|
300
305
|
const spec = /Cannot find (?:package|module) ['"]([^'"]+)['"]/.exec(message)?.[1]
|