@vzn/vx 0.0.518 → 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/exec/executor.ts +7 -0
- package/src/exec/index.ts +1 -0
- package/src/exec/local-executor.ts +1 -0
- package/src/exec/proc-sample.ts +151 -0
- package/src/exec/runner.ts +4 -0
- package/src/exec/sandbox-runtime.ts +3 -0
- package/src/graph/scheduler.ts +2 -0
- package/src/orchestrator/execute-task.ts +24 -0
- package/src/orchestrator/prepare.ts +2 -1
- package/src/orchestrator/run.ts +4 -0
- package/src/orchestrator/telemetry-host.ts +12 -1
- package/src/orchestrator/telemetry.ts +136 -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/exec/executor.ts
CHANGED
|
@@ -152,6 +152,13 @@ export interface ExecuteRequest {
|
|
|
152
152
|
readonly signal?: AbortSignal
|
|
153
153
|
/** See `RunOptions.liveChildren`: the run's SIGINT/SIGTERM registry. */
|
|
154
154
|
readonly liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
155
|
+
/**
|
|
156
|
+
* An executor that runs the command as a process on THIS machine calls
|
|
157
|
+
* this with its pid once spawned, so a telemetry plugin can sample the
|
|
158
|
+
* process tree's CPU and memory while it runs. Absent when nobody samples;
|
|
159
|
+
* an executor that runs elsewhere never calls it.
|
|
160
|
+
*/
|
|
161
|
+
readonly onSpawn?: (pid: number) => void
|
|
155
162
|
readonly sandbox?: ExecuteSandbox
|
|
156
163
|
/**
|
|
157
164
|
* The task holds vx's terminal (`exec.interactive` on a TTY): its stdin,
|
package/src/exec/index.ts
CHANGED
|
@@ -45,6 +45,7 @@ export {
|
|
|
45
45
|
export { bindableWrites, punchWalls } from './sandbox-binds.js'
|
|
46
46
|
export { isLocalExecutor, localExecutor } from './local-executor.js'
|
|
47
47
|
export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
|
|
48
|
+
export { sampleTrees } from './proc-sample.js'
|
|
48
49
|
export {
|
|
49
50
|
assertExecuteResult,
|
|
50
51
|
executorFallback,
|
|
@@ -42,6 +42,7 @@ export function localExecutor(): TaskExecutor {
|
|
|
42
42
|
onStderr: req.onStderr,
|
|
43
43
|
capture: req.capture,
|
|
44
44
|
...(req.liveChildren !== undefined ? { liveChildren: req.liveChildren } : {}),
|
|
45
|
+
...(req.onSpawn !== undefined ? { onSpawn: req.onSpawn } : {}),
|
|
45
46
|
...(req.timeoutMs !== undefined ? { timeoutMs: req.timeoutMs } : {}),
|
|
46
47
|
}
|
|
47
48
|
if (req.sandbox === undefined) {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// What a running task's process tree uses right now: CPU time and resident
|
|
2
|
+
// memory summed over a root pid and every descendant. `resourceUsage()`
|
|
3
|
+
// answers only at exit, so a task's curve needs a look while it runs.
|
|
4
|
+
//
|
|
5
|
+
// A tree, not a process group: a sandboxed task runs under bwrap in a
|
|
6
|
+
// session of its own, so its processes carry another group, but each still
|
|
7
|
+
// has its parent. A process that left the tree (re-parented to init) is not
|
|
8
|
+
// counted; nor is the CPU of a descendant that already exited.
|
|
9
|
+
|
|
10
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
11
|
+
import { procfsIsOwn } from '../util/index.js'
|
|
12
|
+
|
|
13
|
+
export interface TreeUsage {
|
|
14
|
+
/** CPU time of the live tree, user + system, in ms. */
|
|
15
|
+
cpuMs: number
|
|
16
|
+
/** Resident memory of the live tree, in bytes. */
|
|
17
|
+
rssBytes: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Proc {
|
|
21
|
+
ppid: number
|
|
22
|
+
cpuMs: number
|
|
23
|
+
/** Resident KiB when the table carries it; Linux reads it per tree member. */
|
|
24
|
+
rssKiB?: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Linux's `/proc/<pid>/stat` counts CPU in USER_HZ ticks, fixed at 100 by
|
|
29
|
+
* the ABI on every architecture; `tests/proc-sample.test.ts` pins it by
|
|
30
|
+
* burning a known span and reading it back.
|
|
31
|
+
*/
|
|
32
|
+
const TICK_MS = 10
|
|
33
|
+
|
|
34
|
+
/** Each root's tree usage; a root no longer alive has no entry. */
|
|
35
|
+
export async function sampleTrees(roots: readonly number[]): Promise<Map<number, TreeUsage>> {
|
|
36
|
+
const out = new Map<number, TreeUsage>()
|
|
37
|
+
if (roots.length === 0) return out
|
|
38
|
+
const table = process.platform === 'linux' ? await linuxTable() : await psTable()
|
|
39
|
+
if (table === undefined) return out
|
|
40
|
+
const children = new Map<number, number[]>()
|
|
41
|
+
for (const [pid, p] of table) {
|
|
42
|
+
const list = children.get(p.ppid)
|
|
43
|
+
if (list === undefined) children.set(p.ppid, [pid])
|
|
44
|
+
else list.push(pid)
|
|
45
|
+
}
|
|
46
|
+
for (const root of roots) {
|
|
47
|
+
if (!table.has(root)) continue
|
|
48
|
+
const tree: number[] = []
|
|
49
|
+
const stack = [root]
|
|
50
|
+
while (stack.length > 0) {
|
|
51
|
+
const pid = stack.pop()!
|
|
52
|
+
tree.push(pid)
|
|
53
|
+
for (const c of children.get(pid) ?? []) stack.push(c)
|
|
54
|
+
}
|
|
55
|
+
let cpuMs = 0
|
|
56
|
+
let rssKiB = 0
|
|
57
|
+
const rss = await Promise.all(tree.map((pid) => table.get(pid)!.rssKiB ?? linuxRssKiB(pid)))
|
|
58
|
+
for (const [i, pid] of tree.entries()) {
|
|
59
|
+
cpuMs += table.get(pid)!.cpuMs
|
|
60
|
+
rssKiB += rss[i]!
|
|
61
|
+
}
|
|
62
|
+
out.set(root, { cpuMs, rssBytes: rssKiB * 1024 })
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function linuxTable(): Promise<Map<number, Proc> | undefined> {
|
|
68
|
+
// Another namespace's /proc is a table of strangers: no answer beats a wrong one.
|
|
69
|
+
if (!procfsIsOwn()) return undefined
|
|
70
|
+
let entries: string[]
|
|
71
|
+
try {
|
|
72
|
+
entries = await readdir('/proc')
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined
|
|
75
|
+
}
|
|
76
|
+
const table = new Map<number, Proc>()
|
|
77
|
+
await Promise.all(
|
|
78
|
+
entries.map(async (entry) => {
|
|
79
|
+
const first = entry.charCodeAt(0)
|
|
80
|
+
if (first < 48 || first > 57) return
|
|
81
|
+
let stat: string
|
|
82
|
+
try {
|
|
83
|
+
stat = await readFile(`/proc/${entry}/stat`, 'utf8')
|
|
84
|
+
} catch {
|
|
85
|
+
return // exited between the listing and the read
|
|
86
|
+
}
|
|
87
|
+
// `<pid> (<comm>) <state> <ppid> …` — comm may hold spaces and parens,
|
|
88
|
+
// so the fields start after the LAST ')': state is [0], ppid [1],
|
|
89
|
+
// utime [11], stime [12].
|
|
90
|
+
const f = stat.slice(stat.lastIndexOf(')') + 2).split(' ')
|
|
91
|
+
if (f[0] === 'Z' || f[0] === 'X') return
|
|
92
|
+
table.set(Number(entry), {
|
|
93
|
+
ppid: Number(f[1]),
|
|
94
|
+
cpuMs: (Number(f[11]) + Number(f[12])) * TICK_MS,
|
|
95
|
+
})
|
|
96
|
+
}),
|
|
97
|
+
)
|
|
98
|
+
return table
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `VmRSS` carries its unit (kB); 0 for a process gone or a kernel thread. */
|
|
102
|
+
async function linuxRssKiB(pid: number): Promise<number> {
|
|
103
|
+
try {
|
|
104
|
+
const status = await readFile(`/proc/${pid}/status`, 'utf8')
|
|
105
|
+
const m = /^VmRSS:\s+(\d+) kB$/m.exec(status)
|
|
106
|
+
return m === null ? 0 : Number(m[1])
|
|
107
|
+
} catch {
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** macOS (and any non-Linux): one `ps` for the whole table. */
|
|
113
|
+
async function psTable(): Promise<Map<number, Proc> | undefined> {
|
|
114
|
+
let text: string
|
|
115
|
+
try {
|
|
116
|
+
const ps = Bun.spawn(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], {
|
|
117
|
+
stdin: 'ignore',
|
|
118
|
+
stdout: 'pipe',
|
|
119
|
+
stderr: 'ignore',
|
|
120
|
+
})
|
|
121
|
+
text = await new Response(ps.stdout).text()
|
|
122
|
+
if ((await ps.exited) !== 0) return undefined
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined
|
|
125
|
+
}
|
|
126
|
+
const table = new Map<number, Proc>()
|
|
127
|
+
for (const line of text.split('\n')) {
|
|
128
|
+
const f = line.trim().split(/\s+/)
|
|
129
|
+
if (f.length < 4) continue
|
|
130
|
+
const cpuMs = psTimeMs(f[3]!)
|
|
131
|
+
if (cpuMs === undefined) continue
|
|
132
|
+
table.set(Number(f[0]), { ppid: Number(f[1]), rssKiB: Number(f[2]), cpuMs })
|
|
133
|
+
}
|
|
134
|
+
return table
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** `ps`'s `time`: `[[dd-]hh:]mm:ss[.cc]`, macOS printing hundredths. */
|
|
138
|
+
export function psTimeMs(s: string): number | undefined {
|
|
139
|
+
const dash = s.indexOf('-')
|
|
140
|
+
const days = dash === -1 ? 0 : Number(s.slice(0, dash))
|
|
141
|
+
const parts = s
|
|
142
|
+
.slice(dash + 1)
|
|
143
|
+
.split(':')
|
|
144
|
+
.map(Number)
|
|
145
|
+
if (parts.length < 2 || parts.some((n) => !Number.isFinite(n)) || !Number.isFinite(days)) {
|
|
146
|
+
return undefined
|
|
147
|
+
}
|
|
148
|
+
let seconds = 0
|
|
149
|
+
for (const n of parts) seconds = seconds * 60 + n
|
|
150
|
+
return Math.round((days * 86_400 + seconds) * 1000)
|
|
151
|
+
}
|
package/src/exec/runner.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface RunOptions {
|
|
|
87
87
|
* handler can SIGTERM everything still alive mid-run.
|
|
88
88
|
*/
|
|
89
89
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
90
|
+
/** Told the child's pid once it is spawned (telemetry samples its tree). */
|
|
91
|
+
onSpawn?: (pid: number) => void
|
|
90
92
|
/**
|
|
91
93
|
* Hand the child vx's own stdin, stdout and stderr (`exec.interactive` on
|
|
92
94
|
* a TTY). Nothing passes through vx: the callbacks hear nothing and the
|
|
@@ -632,6 +634,7 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
|
|
|
632
634
|
void consumeChunks(child.stderr, true)
|
|
633
635
|
|
|
634
636
|
opts.liveChildren?.add(child)
|
|
637
|
+
opts.onSpawn?.(child.pid)
|
|
635
638
|
|
|
636
639
|
// Readiness deadline. Reject FIRST so the failure reads as a
|
|
637
640
|
// timeout, then SIGTERM — the exit handler's later reject is a
|
|
@@ -738,6 +741,7 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
|
|
|
738
741
|
}
|
|
739
742
|
|
|
740
743
|
opts.liveChildren?.add(proc)
|
|
744
|
+
opts.onSpawn?.(proc.pid)
|
|
741
745
|
const timeout = armTimeout(proc, opts.timeoutMs)
|
|
742
746
|
const ac = new AbortController()
|
|
743
747
|
const streams = Promise.all([
|
|
@@ -808,6 +808,8 @@ export interface SandboxedRunArgs {
|
|
|
808
808
|
onStderr?: (chunk: string) => void
|
|
809
809
|
/** See `RunOptions.liveChildren` — same contract for sandboxed spawns. */
|
|
810
810
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
811
|
+
/** See `RunOptions.onSpawn`. */
|
|
812
|
+
onSpawn?: (pid: number) => void
|
|
811
813
|
/** `ExecuteRequest.signal`: once aborted, the tracer retry spawns nothing. */
|
|
812
814
|
signal?: AbortSignal
|
|
813
815
|
/** See `RunOptions.timeoutMs` — SIGTERM the child after this many ms. */
|
|
@@ -1836,6 +1838,7 @@ async function runSandboxedOnce(
|
|
|
1836
1838
|
}
|
|
1837
1839
|
|
|
1838
1840
|
args.liveChildren?.add(proc)
|
|
1841
|
+
args.onSpawn?.(proc.pid)
|
|
1839
1842
|
const timeout = armTimeout(proc, args.timeoutMs)
|
|
1840
1843
|
const ac = new AbortController()
|
|
1841
1844
|
// The unfinished last line of stderr, and whether a line was strace's.
|
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.
|
|
@@ -145,6 +145,8 @@ export interface ExecuteArgs {
|
|
|
145
145
|
* SIGTERMs whatever is in here.
|
|
146
146
|
*/
|
|
147
147
|
liveChildren?: Set<ReturnType<typeof Bun.spawn>>
|
|
148
|
+
/** Sample a spawned task's process tree (`TelemetrySource.track`); absent when nobody samples. */
|
|
149
|
+
track?: (taskId: string, pid: number) => () => void
|
|
148
150
|
/**
|
|
149
151
|
* Run-level retry default (`--retry <n>` / `RunOptions.retries`).
|
|
150
152
|
* Explicit `exec.retries` wins, including an explicit 0. Threaded as
|
|
@@ -376,6 +378,9 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
376
378
|
onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
|
|
377
379
|
onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
|
|
378
380
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
381
|
+
...(args.track !== undefined
|
|
382
|
+
? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
|
|
383
|
+
: {}),
|
|
379
384
|
...(signalChannel ? { signalChannel } : {}),
|
|
380
385
|
...(args.terminal === true ? { terminal: true } : {}),
|
|
381
386
|
}
|
|
@@ -953,6 +958,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
953
958
|
unlistenStop?.()
|
|
954
959
|
unlistenStop = undefined
|
|
955
960
|
flushMasked()
|
|
961
|
+
untrack?.()
|
|
962
|
+
untrack = undefined
|
|
956
963
|
})
|
|
957
964
|
endExec()
|
|
958
965
|
if (secrets !== null)
|
|
@@ -1076,6 +1083,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1076
1083
|
// and what the cache keeps of it (L-11); null when there are none.
|
|
1077
1084
|
const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
|
|
1078
1085
|
let flushMasked = (): void => {}
|
|
1086
|
+
const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
|
|
1087
|
+
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1088
|
+
let untrack: (() => void) | undefined
|
|
1079
1089
|
// The entry's command is shown by `vx why` and sent with the entry to a
|
|
1080
1090
|
// remote cache: a value a config interpolated stays out of both.
|
|
1081
1091
|
const storedCommand = secrets?.mask(step.command) ?? step.command
|
|
@@ -1130,6 +1140,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1130
1140
|
}
|
|
1131
1141
|
|
|
1132
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
|
+
})
|
|
1133
1148
|
log.taskStderr(
|
|
1134
1149
|
node,
|
|
1135
1150
|
`vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
|
|
@@ -1155,6 +1170,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1155
1170
|
onStdout: out ? (chunk) => out.push(chunk) : (chunk) => log.taskStdout(node, chunk),
|
|
1156
1171
|
onStderr: err ? (chunk) => err.push(chunk) : (chunk) => log.taskStderr(node, chunk),
|
|
1157
1172
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
1173
|
+
...(args.track !== undefined
|
|
1174
|
+
? {
|
|
1175
|
+
onSpawn: (pid: number) => {
|
|
1176
|
+
untrack?.()
|
|
1177
|
+
untrack = args.track!(node.id, pid)
|
|
1178
|
+
},
|
|
1179
|
+
}
|
|
1180
|
+
: {}),
|
|
1158
1181
|
signal: requestSignal(),
|
|
1159
1182
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
1160
1183
|
...(inputs !== undefined ? { inputs } : {}),
|
|
@@ -1337,6 +1360,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1337
1360
|
durationMs: spentMs,
|
|
1338
1361
|
hash,
|
|
1339
1362
|
...(attempt > 1 ? { attempts: attempt } : {}),
|
|
1363
|
+
...(failedAttempts.length > 0 ? { failedAttempts } : {}),
|
|
1340
1364
|
...(unkeyed ? { unkeyed: true as const } : {}),
|
|
1341
1365
|
...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
|
|
1342
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
|
|
|
@@ -938,6 +940,7 @@ async function runOnBus(
|
|
|
938
940
|
runStartHrTimeNs,
|
|
939
941
|
persistentRegistry,
|
|
940
942
|
liveChildren,
|
|
943
|
+
...(telemetry?.track !== undefined ? { track: telemetry.track } : {}),
|
|
941
944
|
gitFilesCache,
|
|
942
945
|
hashCache,
|
|
943
946
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
@@ -1201,6 +1204,7 @@ async function runOnBus(
|
|
|
1201
1204
|
totalDurationMs: Math.round(totalMs),
|
|
1202
1205
|
exitOk: ok,
|
|
1203
1206
|
abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
|
|
1207
|
+
stages: stageTimes(),
|
|
1204
1208
|
})
|
|
1205
1209
|
telemetry.emitSummary(summary)
|
|
1206
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,
|
|
@@ -30,6 +31,8 @@ export interface TelemetryHandle {
|
|
|
30
31
|
flush(): Promise<void>
|
|
31
32
|
/** Remove the bus subscription. Idempotent. */
|
|
32
33
|
dispose(): void
|
|
34
|
+
/** Sample a running task's process tree; see `TelemetrySource.track`. */
|
|
35
|
+
readonly track?: (taskId: string, pid: number) => () => void
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
/** Reject anything that is not sink-shaped, naming what arrived. */
|
|
@@ -68,6 +71,7 @@ export async function subscribeTelemetry(
|
|
|
68
71
|
ctx: TelemetryContext,
|
|
69
72
|
run: RunContextRecord,
|
|
70
73
|
extraSinks?: readonly TelemetrySink[],
|
|
74
|
+
nodes?: ReadonlyMap<string, TaskNode>,
|
|
71
75
|
): Promise<TelemetryHandle | undefined> {
|
|
72
76
|
const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
|
|
73
77
|
const owners = new Map<TelemetrySink, string>()
|
|
@@ -109,7 +113,13 @@ export async function subscribeTelemetry(
|
|
|
109
113
|
|
|
110
114
|
if (sinks.length === 0) return undefined
|
|
111
115
|
|
|
112
|
-
const source = createTelemetrySource({
|
|
116
|
+
const source = createTelemetrySource({
|
|
117
|
+
sinks,
|
|
118
|
+
run,
|
|
119
|
+
warn: (m) => ctx.warn(m),
|
|
120
|
+
owners,
|
|
121
|
+
...(nodes !== undefined ? { nodes } : {}),
|
|
122
|
+
})
|
|
113
123
|
// The bus's unsubscribe is idempotent, so it is the handle's dispose as
|
|
114
124
|
// is (a once-flag here survived item 654).
|
|
115
125
|
const dispose = bus.subscribe(source.subscriber)
|
|
@@ -117,5 +127,6 @@ export async function subscribeTelemetry(
|
|
|
117
127
|
emitSummary: (summary) => source.emitSummary(summary),
|
|
118
128
|
flush: () => source.flush(),
|
|
119
129
|
dispose,
|
|
130
|
+
...(source.track !== undefined ? { track: source.track } : {}),
|
|
120
131
|
}
|
|
121
132
|
}
|
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
// what or how tasks run. Contrast `cache`/`executor`, which return objects
|
|
17
17
|
// core calls INTO; those are the behavior capabilities, kept separate.
|
|
18
18
|
|
|
19
|
-
import
|
|
19
|
+
import { sampleTrees } from '../exec/index.js'
|
|
20
|
+
import { isGroupTask, type TaskNode, type TaskOutcome, type TaskStatus } from '../graph/index.js'
|
|
20
21
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
21
22
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
22
23
|
|
|
@@ -145,6 +146,15 @@ export interface RunContextRecord {
|
|
|
145
146
|
tags: Readonly<Record<string, string>>
|
|
146
147
|
}
|
|
147
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
|
+
|
|
148
158
|
/** Denormalized per-task analytics — shared by the streaming `task.end`
|
|
149
159
|
* record and the per-run summary's `tasks[]`. */
|
|
150
160
|
export interface TaskTelemetry {
|
|
@@ -180,6 +190,8 @@ export interface TaskTelemetry {
|
|
|
180
190
|
sandboxViolations?: number
|
|
181
191
|
/** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
|
|
182
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[]
|
|
183
195
|
/**
|
|
184
196
|
* On a cache hit: whether outputs were written this run (`true`) or the
|
|
185
197
|
* disk already matched the entry and nothing was restored (`false`, an
|
|
@@ -216,6 +228,12 @@ export type TelemetryRecord =
|
|
|
216
228
|
project: string
|
|
217
229
|
task: string
|
|
218
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[]
|
|
219
237
|
ts: number
|
|
220
238
|
}
|
|
221
239
|
| {
|
|
@@ -227,6 +245,17 @@ export type TelemetryRecord =
|
|
|
227
245
|
chunk: string
|
|
228
246
|
ts: number
|
|
229
247
|
}
|
|
248
|
+
| {
|
|
249
|
+
v: number
|
|
250
|
+
kind: 'task.sample'
|
|
251
|
+
runId: string
|
|
252
|
+
taskId: string
|
|
253
|
+
ts: number
|
|
254
|
+
/** CPU time of the task's live process tree so far, in ms. */
|
|
255
|
+
cpuMs: number
|
|
256
|
+
/** Resident memory of the task's live process tree, in bytes. */
|
|
257
|
+
rssBytes: number
|
|
258
|
+
}
|
|
230
259
|
| ({ v: number; kind: 'task.end'; runId: string; ts: number } & TaskTelemetry)
|
|
231
260
|
| { v: number; kind: 'run.end'; runId: string; ts: number }
|
|
232
261
|
|
|
@@ -264,6 +293,19 @@ export interface RunSummaryRecord {
|
|
|
264
293
|
restoredRemoteCount: number
|
|
265
294
|
exitOk: boolean
|
|
266
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
|
|
267
309
|
}
|
|
268
310
|
|
|
269
311
|
/**
|
|
@@ -286,6 +328,7 @@ export function assembleRunSummary(
|
|
|
286
328
|
totalDurationMs: number
|
|
287
329
|
exitOk: boolean
|
|
288
330
|
abortedCount: number
|
|
331
|
+
stages?: readonly RunStage[]
|
|
289
332
|
},
|
|
290
333
|
): RunSummaryRecord {
|
|
291
334
|
let failedCount = 0
|
|
@@ -320,6 +363,7 @@ export function assembleRunSummary(
|
|
|
320
363
|
restoredRemoteCount,
|
|
321
364
|
exitOk: timing.exitOk,
|
|
322
365
|
tasks,
|
|
366
|
+
...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
|
|
323
367
|
}
|
|
324
368
|
}
|
|
325
369
|
|
|
@@ -370,8 +414,17 @@ export interface TelemetrySource {
|
|
|
370
414
|
emitSummary(summary: RunSummaryRecord): void
|
|
371
415
|
/** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
|
|
372
416
|
flush(): Promise<void>
|
|
417
|
+
/**
|
|
418
|
+
* Sample this task's process tree every `SAMPLE_MS` until the returned
|
|
419
|
+
* function is called, the root exits, or the run ends. Present only when
|
|
420
|
+
* a sink wants `task.sample`: nobody asking costs no timer and no read.
|
|
421
|
+
*/
|
|
422
|
+
readonly track?: (taskId: string, pid: number) => () => void
|
|
373
423
|
}
|
|
374
424
|
|
|
425
|
+
/** How often a running task's process tree is sampled. */
|
|
426
|
+
const SAMPLE_MS = 1000
|
|
427
|
+
|
|
375
428
|
const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
376
429
|
'run.start',
|
|
377
430
|
'task.start',
|
|
@@ -397,6 +450,8 @@ export function createTelemetrySource(args: {
|
|
|
397
450
|
warn?: (message: string) => void
|
|
398
451
|
/** What a sink with no `name` of its own is called: its plugin's (the host's map). */
|
|
399
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>
|
|
400
455
|
}): TelemetrySource {
|
|
401
456
|
const { sinks, run, warn } = args
|
|
402
457
|
const runId = run.runId
|
|
@@ -426,6 +481,62 @@ export function createTelemetrySource(args: {
|
|
|
426
481
|
// Precompute which sinks want each kind, so per-event fan-out is a plain
|
|
427
482
|
// array walk with no per-record `wants` scanning.
|
|
428
483
|
const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
|
|
484
|
+
const wantsSample = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.sample'))
|
|
485
|
+
|
|
486
|
+
// taskId → the pid of its running root. One timer for every task, alive
|
|
487
|
+
// only while one runs; a tick still reading when the next is due is not
|
|
488
|
+
// doubled.
|
|
489
|
+
const tracked = new Map<string, number>()
|
|
490
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
491
|
+
let sampling = false
|
|
492
|
+
let ended = false
|
|
493
|
+
const stopTimer = (): void => {
|
|
494
|
+
if (timer !== undefined) clearInterval(timer)
|
|
495
|
+
timer = undefined
|
|
496
|
+
}
|
|
497
|
+
const untrack = (taskId: string, pid: number): void => {
|
|
498
|
+
if (tracked.get(taskId) === pid) tracked.delete(taskId)
|
|
499
|
+
if (tracked.size === 0) stopTimer()
|
|
500
|
+
}
|
|
501
|
+
const tick = async (): Promise<void> => {
|
|
502
|
+
if (sampling) return
|
|
503
|
+
sampling = true
|
|
504
|
+
try {
|
|
505
|
+
const entries = [...tracked]
|
|
506
|
+
const usage = await sampleTrees(entries.map(([, pid]) => pid))
|
|
507
|
+
const ts = Date.now()
|
|
508
|
+
for (const [taskId, pid] of entries) {
|
|
509
|
+
if (ended || tracked.get(taskId) !== pid) continue
|
|
510
|
+
const u = usage.get(pid)
|
|
511
|
+
if (u === undefined) {
|
|
512
|
+
untrack(taskId, pid)
|
|
513
|
+
continue
|
|
514
|
+
}
|
|
515
|
+
deliver({
|
|
516
|
+
v: TELEMETRY_SCHEMA_VERSION,
|
|
517
|
+
kind: 'task.sample',
|
|
518
|
+
runId,
|
|
519
|
+
taskId,
|
|
520
|
+
ts,
|
|
521
|
+
cpuMs: u.cpuMs,
|
|
522
|
+
rssBytes: u.rssBytes,
|
|
523
|
+
})
|
|
524
|
+
}
|
|
525
|
+
} catch {
|
|
526
|
+
// A failed look is a missing point, never a broken run.
|
|
527
|
+
} finally {
|
|
528
|
+
sampling = false
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
const track = (taskId: string, pid: number): (() => void) => {
|
|
532
|
+
if (ended) return () => {}
|
|
533
|
+
tracked.set(taskId, pid)
|
|
534
|
+
if (timer === undefined) {
|
|
535
|
+
timer = setInterval(() => void tick(), SAMPLE_MS)
|
|
536
|
+
timer.unref()
|
|
537
|
+
}
|
|
538
|
+
return () => untrack(taskId, pid)
|
|
539
|
+
}
|
|
429
540
|
|
|
430
541
|
function deliver(record: TelemetryRecord): void {
|
|
431
542
|
for (const sink of sinks) {
|
|
@@ -470,6 +581,8 @@ export function createTelemetrySource(args: {
|
|
|
470
581
|
}
|
|
471
582
|
if (node.config.exec.command !== undefined)
|
|
472
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
|
|
473
586
|
deliver(rec)
|
|
474
587
|
return
|
|
475
588
|
}
|
|
@@ -502,6 +615,9 @@ export function createTelemetrySource(args: {
|
|
|
502
615
|
case 'run:status':
|
|
503
616
|
return // status lines are terminal-rendering noise, not telemetry
|
|
504
617
|
case 'run:end':
|
|
618
|
+
ended = true
|
|
619
|
+
tracked.clear()
|
|
620
|
+
stopTimer()
|
|
505
621
|
deliver({ v: TELEMETRY_SCHEMA_VERSION, kind: 'run.end', runId, ts })
|
|
506
622
|
return
|
|
507
623
|
}
|
|
@@ -509,6 +625,7 @@ export function createTelemetrySource(args: {
|
|
|
509
625
|
|
|
510
626
|
return {
|
|
511
627
|
subscriber,
|
|
628
|
+
...(wantsSample ? { track } : {}),
|
|
512
629
|
emitSummary(summary: RunSummaryRecord): void {
|
|
513
630
|
for (const sink of sinks) {
|
|
514
631
|
if (disabled.has(sink) || sink.onRunSummary === undefined) continue
|
|
@@ -556,6 +673,23 @@ export function createTelemetrySource(args: {
|
|
|
556
673
|
}
|
|
557
674
|
}
|
|
558
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
|
+
|
|
559
693
|
/**
|
|
560
694
|
* The one projection of an outcome into `TaskTelemetry`, for the streaming
|
|
561
695
|
* `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
|
|
@@ -583,6 +717,7 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
|
|
|
583
717
|
if (o.timedOut === true) t.timedOut = true
|
|
584
718
|
if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
|
|
585
719
|
if (o.notReady !== undefined) t.notReady = o.notReady
|
|
720
|
+
if (o.failedAttempts !== undefined) t.failedAttempts = o.failedAttempts
|
|
586
721
|
if (isCacheHit(o.status)) t.restored = o.restored === true
|
|
587
722
|
if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
|
|
588
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
|
}
|