@vzn/vx 0.0.519 → 0.0.520
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 +55 -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.520",
|
|
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.520",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.520",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.520",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.520"
|
|
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,8 @@ 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[]
|
|
184
195
|
/**
|
|
185
196
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
186
197
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -217,6 +228,12 @@ export type TelemetryRecord =
|
|
|
217
228
|
project: string
|
|
218
229
|
task: string
|
|
219
230
|
command?: string
|
|
231
|
+
/**
|
|
232
|
+
* The tasks with a command this one waits on: its direct dependencies,
|
|
233
|
+
* a group's seen through to the tasks with a command behind it.
|
|
234
|
+
* Additive.
|
|
235
|
+
*/
|
|
236
|
+
dependsOn?: readonly string[]
|
|
220
237
|
ts: number
|
|
221
238
|
}
|
|
222
239
|
| {
|
|
@@ -276,6 +293,19 @@ export interface RunSummaryRecord {
|
|
|
276
293
|
restoredRemoteCount: number
|
|
277
294
|
exitOk: boolean
|
|
278
295
|
tasks: readonly TaskTelemetry[]
|
|
296
|
+
/**
|
|
297
|
+
* The run's stages as `VX_TIMING` marks them (startup, load configs,
|
|
298
|
+
* classify + probe, run graph, …), each a wall window in epoch ms. The
|
|
299
|
+
* stages ahead of the run lock end before `startedAt`. Additive.
|
|
300
|
+
*/
|
|
301
|
+
stages?: readonly RunStage[]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** One stage of a run, a wall window in epoch ms. */
|
|
305
|
+
export interface RunStage {
|
|
306
|
+
name: string
|
|
307
|
+
startedAt: number
|
|
308
|
+
endedAt: number
|
|
279
309
|
}
|
|
280
310
|
|
|
281
311
|
/**
|
|
@@ -298,6 +328,7 @@ export function assembleRunSummary(
|
|
|
298
328
|
totalDurationMs: number
|
|
299
329
|
exitOk: boolean
|
|
300
330
|
abortedCount: number
|
|
331
|
+
stages?: readonly RunStage[]
|
|
301
332
|
},
|
|
302
333
|
): RunSummaryRecord {
|
|
303
334
|
let failedCount = 0
|
|
@@ -332,6 +363,7 @@ export function assembleRunSummary(
|
|
|
332
363
|
restoredRemoteCount,
|
|
333
364
|
exitOk: timing.exitOk,
|
|
334
365
|
tasks,
|
|
366
|
+
...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
|
|
335
367
|
}
|
|
336
368
|
}
|
|
337
369
|
|
|
@@ -418,6 +450,8 @@ export function createTelemetrySource(args: {
|
|
|
418
450
|
warn?: (message: string) => void
|
|
419
451
|
/** What a sink with no `name` of its own is called: its plugin's (the host's map). */
|
|
420
452
|
owners?: ReadonlyMap<TelemetrySink, string>
|
|
453
|
+
/** The run's task graph, to see a group dependency through to the tasks behind it. */
|
|
454
|
+
nodes?: ReadonlyMap<string, TaskNode>
|
|
421
455
|
}): TelemetrySource {
|
|
422
456
|
const { sinks, run, warn } = args
|
|
423
457
|
const runId = run.runId
|
|
@@ -547,6 +581,8 @@ export function createTelemetrySource(args: {
|
|
|
547
581
|
}
|
|
548
582
|
if (node.config.exec.command !== undefined)
|
|
549
583
|
rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
|
|
584
|
+
const deps = commandDeps(node, args.nodes)
|
|
585
|
+
if (deps.length > 0) rec.dependsOn = deps
|
|
550
586
|
deliver(rec)
|
|
551
587
|
return
|
|
552
588
|
}
|
|
@@ -637,6 +673,23 @@ export function createTelemetrySource(args: {
|
|
|
637
673
|
}
|
|
638
674
|
}
|
|
639
675
|
|
|
676
|
+
/** `node`'s dependencies with a command; a group is seen through, once each. */
|
|
677
|
+
function commandDeps(node: TaskNode, nodes: ReadonlyMap<string, TaskNode> | undefined): string[] {
|
|
678
|
+
if (nodes === undefined) return node.deps
|
|
679
|
+
const out: string[] = []
|
|
680
|
+
const seen = new Set<string>()
|
|
681
|
+
const stack = [...node.deps].reverse()
|
|
682
|
+
while (stack.length > 0) {
|
|
683
|
+
const id = stack.pop()!
|
|
684
|
+
if (seen.has(id)) continue
|
|
685
|
+
seen.add(id)
|
|
686
|
+
const dep = nodes.get(id)
|
|
687
|
+
if (dep !== undefined && isGroupTask(dep)) stack.push(...[...dep.deps].reverse())
|
|
688
|
+
else out.push(id)
|
|
689
|
+
}
|
|
690
|
+
return out
|
|
691
|
+
}
|
|
692
|
+
|
|
640
693
|
/**
|
|
641
694
|
* The one projection of an outcome into `TaskTelemetry`, for the streaming
|
|
642
695
|
* `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
|
|
@@ -664,6 +717,7 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
664
717
|
if (o.timedOut === true) t.timedOut = true
|
|
665
718
|
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
666
719
|
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
720
|
+
if (o.failedAttempts !== undefined) t.failedAttempts = o.failedAttempts
|
|
667
721
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
668
722
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
669
723
|
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
|
}
|