@vzn/vx 0.0.519 → 0.0.521
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/graph/scheduler.ts +2 -0
- package/src/orchestrator/execute-task.ts +7 -0
- package/src/orchestrator/prepare.ts +2 -1
- package/src/orchestrator/run.ts +3 -0
- package/src/orchestrator/telemetry-host.ts +9 -1
- package/src/orchestrator/telemetry.ts +74 -1
- package/src/util/index.ts +9 -1
- package/src/util/timing.ts +41 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.521",
|
|
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.521",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.521",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.521",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.521"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/graph/scheduler.ts
CHANGED
|
@@ -139,6 +139,8 @@ export interface TaskOutcome {
|
|
|
139
139
|
* flaky detection reads it off the outcome stream.
|
|
140
140
|
*/
|
|
141
141
|
attempts?: number
|
|
142
|
+
/** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
|
|
143
|
+
failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
|
|
142
144
|
/**
|
|
143
145
|
* Set when this run proved the task flaky: its key holds both outcomes on
|
|
144
146
|
* record (this run's included) or it needed a retry. Its row carries it.
|
|
@@ -1083,6 +1083,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1083
1083
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
1084
1084
|
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
1085
1085
|
let flushMasked = (): void => {}
|
|
1086
|
+
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1086
1087
|
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1087
1088
|
let untrack: (() => void) | undefined
|
|
1088
1089
|
// The entry's command is shown by `vx why` and sent with the entry to a
|
|
@@ -1139,6 +1140,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1139
1140
|
}
|
|
1140
1141
|
|
|
1141
1142
|
if (effectiveExitCode === 0 || attempt >= maxAttempts) break
|
|
1143
|
+
failedAttempts.push({
|
|
1144
|
+
endedAt: Date.now(),
|
|
1145
|
+
exitCode: effectiveExitCode,
|
|
1146
|
+
...(result.timedOut === true ? { timedOut: true as const } : {}),
|
|
1147
|
+
})
|
|
1142
1148
|
log.taskStderr(
|
|
1143
1149
|
node,
|
|
1144
1150
|
`vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
|
|
@@ -1354,6 +1360,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1354
1360
|
durationMs: spentMs,
|
|
1355
1361
|
hash,
|
|
1356
1362
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1363
|
+
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
1357
1364
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1358
1365
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1359
1366
|
...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import path from 'node:path'
|
|
11
11
|
import type { WorkspaceConfig } from '../config.js'
|
|
12
|
-
import { mark, nearest, UserError } from '../util/index.js'
|
|
12
|
+
import { beginRun, mark, nearest, UserError } from '../util/index.js'
|
|
13
13
|
import {
|
|
14
14
|
Cache,
|
|
15
15
|
noteSchemaReset,
|
|
@@ -182,6 +182,7 @@ export interface PreparedRun {
|
|
|
182
182
|
* empty plan).
|
|
183
183
|
*/
|
|
184
184
|
export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
|
|
185
|
+
beginRun()
|
|
185
186
|
mark('startup')
|
|
186
187
|
// The root manifest is read once for the root, the globs and the
|
|
187
188
|
// fingerprint. A watch cycle is a new run and reads it afresh.
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../graph/index.js'
|
|
27
27
|
import {
|
|
28
28
|
mark,
|
|
29
|
+
stageTimes,
|
|
29
30
|
killGraceMs,
|
|
30
31
|
MAX_TIMEOUT_MS,
|
|
31
32
|
parseDuration,
|
|
@@ -755,6 +756,7 @@ async function runOnBus(
|
|
|
755
756
|
{ workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
|
|
756
757
|
runContextRecord,
|
|
757
758
|
options.telemetrySinks,
|
|
759
|
+
nodes,
|
|
758
760
|
)
|
|
759
761
|
}
|
|
760
762
|
|
|
@@ -1202,6 +1204,7 @@ async function runOnBus(
|
|
|
1202
1204
|
totalDurationMs: Math.round(totalMs),
|
|
1203
1205
|
exitOk: ok,
|
|
1204
1206
|
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1207
|
+
stages: stageTimes(),
|
|
1205
1208
|
})
|
|
1206
1209
|
telemetry.emitSummary(summary)
|
|
1207
1210
|
await telemetry.flush()
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// See docs/design/observability-architecture-2026-06.md §1, §3.
|
|
13
13
|
|
|
14
14
|
import type { EventBus } from './events.js'
|
|
15
|
+
import type { TaskNode } from '../graph/index.js'
|
|
15
16
|
import type {
|
|
16
17
|
RunContextRecord,
|
|
17
18
|
RunSummaryRecord,
|
|
@@ -70,6 +71,7 @@ export async function subscribeTelemetry(
|
|
|
70
71
|
ctx: TelemetryContext,
|
|
71
72
|
run: RunContextRecord,
|
|
72
73
|
extraSinks?: readonly TelemetrySink[],
|
|
74
|
+
nodes?: ReadonlyMap<string, TaskNode>,
|
|
73
75
|
): Promise<TelemetryHandle | undefined> {
|
|
74
76
|
const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
|
|
75
77
|
const owners = new Map<TelemetrySink, string>()
|
|
@@ -111,7 +113,13 @@ export async function subscribeTelemetry(
|
|
|
111
113
|
|
|
112
114
|
if (sinks.length === 0) return undefined
|
|
113
115
|
|
|
114
|
-
const source = createTelemetrySource({
|
|
116
|
+
const source = createTelemetrySource({
|
|
117
|
+
sinks,
|
|
118
|
+
run,
|
|
119
|
+
warn: (m) => ctx.warn(m),
|
|
120
|
+
owners,
|
|
121
|
+
...(nodes !== undefined ? { nodes } : {}),
|
|
122
|
+
})
|
|
115
123
|
// The bus's unsubscribe is idempotent, so it is the handle's dispose as
|
|
116
124
|
// is (a once-flag here survived item 654).
|
|
117
125
|
const dispose = bus.subscribe(source.subscriber)
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
19
|
import { sampleTrees } from '../exec/index.js'
|
|
20
|
-
import type
|
|
20
|
+
import { isGroupTask, type TaskNode, type TaskOutcome, type TaskStatus } from '../graph/index.js'
|
|
21
21
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
22
22
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
23
23
|
|
|
@@ -146,6 +146,15 @@ export interface RunContextRecord {
|
|
|
146
146
|
tags: Readonly<Record<string, string>>
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
/** One attempt of a retried task that failed and was run again. */
|
|
150
|
+
export interface FailedAttempt {
|
|
151
|
+
/** When the attempt ended, epoch ms. */
|
|
152
|
+
endedAt: number
|
|
153
|
+
exitCode: number
|
|
154
|
+
/** vx's own `timeout` ended it. */
|
|
155
|
+
timedOut?: true
|
|
156
|
+
}
|
|
157
|
+
|
|
149
158
|
/** Denormalized per-task analytics — shared by the streaming `task.end`
|
|
150
159
|
* record and the per-run summary's `tasks[]`. */
|
|
151
160
|
export interface TaskTelemetry {
|
|
@@ -181,6 +190,19 @@ export interface TaskTelemetry {
|
|
|
181
190
|
sandboxViolations?: number
|
|
182
191
|
/** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
|
|
183
192
|
notReady?: 'timeout' | 'exited' | 'spawn'
|
|
193
|
+
/** On a task that retried: each attempt that failed before the last, in order. Additive. */
|
|
194
|
+
failedAttempts?: readonly FailedAttempt[]
|
|
195
|
+
/** On a task proved flaky this run: its key's passes and failures on record. Additive. */
|
|
196
|
+
flaky?: { passes: number; failures: number }
|
|
197
|
+
/** On a sandboxed task: the violation lines themselves, one per denial. Additive. */
|
|
198
|
+
sandboxViolationLines?: readonly string[]
|
|
199
|
+
/** On a cache hit: how long the run that stored the entry took — the time this hit saved. Additive. */
|
|
200
|
+
storedDurationMs?: number
|
|
201
|
+
/** On a cache hit: the CPU and peak memory of the run that stored it. Additive. */
|
|
202
|
+
storedCpuMs?: number
|
|
203
|
+
storedPeakRssBytes?: number
|
|
204
|
+
/** How long an `admit` policy held the task once it was ready. Additive. */
|
|
205
|
+
admissionHeldMs?: number
|
|
184
206
|
/**
|
|
185
207
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
186
208
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -217,6 +239,12 @@ export type TelemetryRecord =
|
|
|
217
239
|
project: string
|
|
218
240
|
task: string
|
|
219
241
|
command?: string
|
|
242
|
+
/**
|
|
243
|
+
* The tasks with a command this one waits on: its direct dependencies,
|
|
244
|
+
* a group's seen through to the tasks with a command behind it.
|
|
245
|
+
* Additive.
|
|
246
|
+
*/
|
|
247
|
+
dependsOn?: readonly string[]
|
|
220
248
|
ts: number
|
|
221
249
|
}
|
|
222
250
|
| {
|
|
@@ -276,6 +304,19 @@ export interface RunSummaryRecord {
|
|
|
276
304
|
restoredRemoteCount: number
|
|
277
305
|
exitOk: boolean
|
|
278
306
|
tasks: readonly TaskTelemetry[]
|
|
307
|
+
/**
|
|
308
|
+
* The run's stages as `VX_TIMING` marks them (startup, load configs,
|
|
309
|
+
* classify + probe, run graph, …), each a wall window in epoch ms. The
|
|
310
|
+
* stages ahead of the run lock end before `startedAt`. Additive.
|
|
311
|
+
*/
|
|
312
|
+
stages?: readonly RunStage[]
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** One stage of a run, a wall window in epoch ms. */
|
|
316
|
+
export interface RunStage {
|
|
317
|
+
name: string
|
|
318
|
+
startedAt: number
|
|
319
|
+
endedAt: number
|
|
279
320
|
}
|
|
280
321
|
|
|
281
322
|
/**
|
|
@@ -298,6 +339,7 @@ export function assembleRunSummary(
|
|
|
298
339
|
totalDurationMs: number
|
|
299
340
|
exitOk: boolean
|
|
300
341
|
abortedCount: number
|
|
342
|
+
stages?: readonly RunStage[]
|
|
301
343
|
},
|
|
302
344
|
): RunSummaryRecord {
|
|
303
345
|
let failedCount = 0
|
|
@@ -332,6 +374,7 @@ export function assembleRunSummary(
|
|
|
332
374
|
restoredRemoteCount,
|
|
333
375
|
exitOk: timing.exitOk,
|
|
334
376
|
tasks,
|
|
377
|
+
...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
|
|
335
378
|
}
|
|
336
379
|
}
|
|
337
380
|
|
|
@@ -418,6 +461,8 @@ export function createTelemetrySource(args: {
|
|
|
418
461
|
warn?: (message: string) => void
|
|
419
462
|
/** What a sink with no `name` of its own is called: its plugin's (the host's map). */
|
|
420
463
|
owners?: ReadonlyMap<TelemetrySink, string>
|
|
464
|
+
/** The run's task graph, to see a group dependency through to the tasks behind it. */
|
|
465
|
+
nodes?: ReadonlyMap<string, TaskNode>
|
|
421
466
|
}): TelemetrySource {
|
|
422
467
|
const { sinks, run, warn } = args
|
|
423
468
|
const runId = run.runId
|
|
@@ -547,6 +592,8 @@ export function createTelemetrySource(args: {
|
|
|
547
592
|
}
|
|
548
593
|
if (node.config.exec.command !== undefined)
|
|
549
594
|
rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
|
|
595
|
+
const deps = commandDeps(node, args.nodes)
|
|
596
|
+
if (deps.length > 0) rec.dependsOn = deps
|
|
550
597
|
deliver(rec)
|
|
551
598
|
return
|
|
552
599
|
}
|
|
@@ -637,6 +684,23 @@ export function createTelemetrySource(args: {
|
|
|
637
684
|
}
|
|
638
685
|
}
|
|
639
686
|
|
|
687
|
+
/** `node`'s dependencies with a command; a group is seen through, once each. */
|
|
688
|
+
function commandDeps(node: TaskNode, nodes: ReadonlyMap<string, TaskNode> | undefined): string[] {
|
|
689
|
+
if (nodes === undefined) return node.deps
|
|
690
|
+
const out: string[] = []
|
|
691
|
+
const seen = new Set<string>()
|
|
692
|
+
const stack = [...node.deps].reverse()
|
|
693
|
+
while (stack.length > 0) {
|
|
694
|
+
const id = stack.pop()!
|
|
695
|
+
if (seen.has(id)) continue
|
|
696
|
+
seen.add(id)
|
|
697
|
+
const dep = nodes.get(id)
|
|
698
|
+
if (dep !== undefined && isGroupTask(dep)) stack.push(...[...dep.deps].reverse())
|
|
699
|
+
else out.push(id)
|
|
700
|
+
}
|
|
701
|
+
return out
|
|
702
|
+
}
|
|
703
|
+
|
|
640
704
|
/**
|
|
641
705
|
* The one projection of an outcome into `TaskTelemetry`, for the streaming
|
|
642
706
|
* `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
|
|
@@ -664,6 +728,15 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
664
728
|
if (o.timedOut === true) t.timedOut = true
|
|
665
729
|
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
666
730
|
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
731
|
+
if (o.failedAttempts !== undefined) t.failedAttempts = o.failedAttempts
|
|
732
|
+
if (o.flaky !== undefined) t.flaky = o.flaky
|
|
733
|
+
if (o.sandboxViolationLines !== undefined && o.sandboxViolationLines.length > 0) {
|
|
734
|
+
t.sandboxViolationLines = o.sandboxViolationLines
|
|
735
|
+
}
|
|
736
|
+
if (o.storedDurationMs !== undefined) t.storedDurationMs = o.storedDurationMs
|
|
737
|
+
if (o.storedCpuMs !== undefined) t.storedCpuMs = o.storedCpuMs
|
|
738
|
+
if (o.storedPeakRssBytes !== undefined) t.storedPeakRssBytes = o.storedPeakRssBytes
|
|
739
|
+
if (o.admissionHeldMs !== undefined) t.admissionHeldMs = o.admissionHeldMs
|
|
667
740
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
668
741
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
669
742
|
if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
|
package/src/util/index.ts
CHANGED
|
@@ -18,7 +18,15 @@ export {
|
|
|
18
18
|
OUT_OF_FDS_HINT,
|
|
19
19
|
} from './errors.js'
|
|
20
20
|
export { xxh3, xxh3hex } from './hash.js'
|
|
21
|
-
export {
|
|
21
|
+
export {
|
|
22
|
+
beginRun,
|
|
23
|
+
mark,
|
|
24
|
+
printTimings,
|
|
25
|
+
restartTimings,
|
|
26
|
+
span,
|
|
27
|
+
stageTimes,
|
|
28
|
+
type StageTime,
|
|
29
|
+
} from './timing.js'
|
|
22
30
|
export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
|
|
23
31
|
export {
|
|
24
32
|
asTrees,
|
package/src/util/timing.ts
CHANGED
|
@@ -2,20 +2,55 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The CPU profiler attributes a tight loop's cost unreliably and hides
|
|
4
4
|
// where an `await` waited; a stage table answers "where did the warm run
|
|
5
|
-
// go?" directly
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// go?" directly. Marks are cumulative from this module's load — process
|
|
6
|
+
// start and the imports ahead of it sit outside the table; the table shows
|
|
7
|
+
// each stage's own share. Stage marks are kept whether or not the table
|
|
8
|
+
// prints (a push per stage, ~15 a run): a telemetry sink draws them as
|
|
9
|
+
// spans (`stageTimes`). The per-call `span`s stay off unless enabled.
|
|
8
10
|
|
|
9
11
|
const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
|
|
10
12
|
let t0 = Bun.nanoseconds()
|
|
13
|
+
// The wall clock at t0, read then: `performance.timeOrigin` plus the
|
|
14
|
+
// monotonic clock drifted 34 ms from `Date.now()` on macOS CI, so stages
|
|
15
|
+
// drawn from it sat outside the run they belong to.
|
|
16
|
+
let wall0 = Date.now()
|
|
11
17
|
const marks: Array<[label: string, ns: number]> = []
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
let begun = false
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A run begins. The process's first counts from this module's load, so
|
|
23
|
+
* `startup` holds the imports; a later one in the same process (an
|
|
24
|
+
* embedder's second `run()`) starts a table of its own, or its stages
|
|
25
|
+
* would follow the last run's and its `startup` would hold the idle between.
|
|
26
|
+
*/
|
|
27
|
+
export function beginRun(): void {
|
|
28
|
+
if (begun) restartTimings()
|
|
29
|
+
begun = true
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Record the end of a stage. */
|
|
14
33
|
export function mark(label: string): void {
|
|
15
|
-
if (!enabled) return
|
|
16
34
|
marks.push([label, Bun.nanoseconds() - t0])
|
|
17
35
|
}
|
|
18
36
|
|
|
37
|
+
/** One stage's wall window, in epoch ms. */
|
|
38
|
+
export interface StageTime {
|
|
39
|
+
name: string
|
|
40
|
+
startedAt: number
|
|
41
|
+
endedAt: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The stages marked so far, each from the previous mark's end (the first from this module's load). */
|
|
45
|
+
export function stageTimes(): StageTime[] {
|
|
46
|
+
let prev = 0
|
|
47
|
+
return marks.map(([name, ns]) => {
|
|
48
|
+
const stage = { name, startedAt: wall0 + prev / 1e6, endedAt: wall0 + ns / 1e6 }
|
|
49
|
+
prev = ns
|
|
50
|
+
return stage
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
19
54
|
const spans = new Map<string, [count: number, ns: number]>()
|
|
20
55
|
const noop = (): void => {}
|
|
21
56
|
|
|
@@ -25,8 +60,8 @@ const noop = (): void => {}
|
|
|
25
60
|
* its first stage counted the idle wait before it.
|
|
26
61
|
*/
|
|
27
62
|
export function restartTimings(): void {
|
|
28
|
-
if (!enabled) return
|
|
29
63
|
t0 = Bun.nanoseconds()
|
|
64
|
+
wall0 = Date.now()
|
|
30
65
|
marks.length = 0
|
|
31
66
|
spans.clear()
|
|
32
67
|
}
|