@vzn/vx 0.0.517 → 0.0.519
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/README.md +1 -1
- 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/orchestrator/execute-task.ts +17 -0
- package/src/orchestrator/run.ts +1 -0
- package/src/orchestrator/telemetry-host.ts +3 -0
- package/src/orchestrator/telemetry.ts +81 -0
package/README.md
CHANGED
|
@@ -145,7 +145,7 @@ Declared in `vx.workspace.ts`; without any, vx runs and caches locally.
|
|
|
145
145
|
| [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
|
|
146
146
|
| [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
|
|
147
147
|
| [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
|
|
148
|
-
| [`@vzn/vx-
|
|
148
|
+
| [`@vzn/vx-ci`](packages/vx-ci) | GitHub Actions job summary and Checks API |
|
|
149
149
|
| [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
|
|
150
150
|
| [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
|
|
151
151
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.519",
|
|
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.519",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.519",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.519",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.519"
|
|
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.
|
|
@@ -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,8 @@ 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
|
+
// The sampling of the attempt's process tree, stopped once it settles.
|
|
1087
|
+
let untrack: (() => void) | undefined
|
|
1079
1088
|
// The entry's command is shown by `vx why` and sent with the entry to a
|
|
1080
1089
|
// remote cache: a value a config interpolated stays out of both.
|
|
1081
1090
|
const storedCommand = secrets?.mask(step.command) ?? step.command
|
|
@@ -1155,6 +1164,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
|
|
|
1155
1164
|
onStdout: out ? (chunk) => out.push(chunk) : (chunk) => log.taskStdout(node, chunk),
|
|
1156
1165
|
onStderr: err ? (chunk) => err.push(chunk) : (chunk) => log.taskStderr(node, chunk),
|
|
1157
1166
|
...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
|
|
1167
|
+
...(args.track !== undefined
|
|
1168
|
+
? {
|
|
1169
|
+
onSpawn: (pid: number) => {
|
|
1170
|
+
untrack?.()
|
|
1171
|
+
untrack = args.track!(node.id, pid)
|
|
1172
|
+
},
|
|
1173
|
+
}
|
|
1174
|
+
: {}),
|
|
1158
1175
|
signal: requestSignal(),
|
|
1159
1176
|
...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
|
|
1160
1177
|
...(inputs !== undefined ? { inputs } : {}),
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -938,6 +938,7 @@ async function runOnBus(
|
|
|
938
938
|
runStartHrTimeNs,
|
|
939
939
|
persistentRegistry,
|
|
940
940
|
liveChildren,
|
|
941
|
+
...(telemetry?.track !== undefined ? { track: telemetry.track } : {}),
|
|
941
942
|
gitFilesCache,
|
|
942
943
|
hashCache,
|
|
943
944
|
...(probe !== undefined ? { preProbed: probe } : {}),
|
|
@@ -30,6 +30,8 @@ export interface TelemetryHandle {
|
|
|
30
30
|
flush(): Promise<void>
|
|
31
31
|
/** Remove the bus subscription. Idempotent. */
|
|
32
32
|
dispose(): void
|
|
33
|
+
/** Sample a running task's process tree; see `TelemetrySource.track`. */
|
|
34
|
+
readonly track?: (taskId: string, pid: number) => () => void
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
/** Reject anything that is not sink-shaped, naming what arrived. */
|
|
@@ -117,5 +119,6 @@ export async function subscribeTelemetry(
|
|
|
117
119
|
emitSummary: (summary) => source.emitSummary(summary),
|
|
118
120
|
flush: () => source.flush(),
|
|
119
121
|
dispose,
|
|
122
|
+
...(source.track !== undefined ? { track: source.track } : {}),
|
|
120
123
|
}
|
|
121
124
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
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 { sampleTrees } from '../exec/index.js'
|
|
19
20
|
import type { TaskOutcome, TaskStatus } from '../graph/index.js'
|
|
20
21
|
import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
|
|
21
22
|
import type { RunEvent, RunEventSubscriber } from './events.js'
|
|
@@ -227,6 +228,17 @@ export type TelemetryRecord =
|
|
|
227
228
|
chunk: string
|
|
228
229
|
ts: number
|
|
229
230
|
}
|
|
231
|
+
| {
|
|
232
|
+
v: number
|
|
233
|
+
kind: 'task.sample'
|
|
234
|
+
runId: string
|
|
235
|
+
taskId: string
|
|
236
|
+
ts: number
|
|
237
|
+
/** CPU time of the task's live process tree so far, in ms. */
|
|
238
|
+
cpuMs: number
|
|
239
|
+
/** Resident memory of the task's live process tree, in bytes. */
|
|
240
|
+
rssBytes: number
|
|
241
|
+
}
|
|
230
242
|
| ({ v: number; kind: 'task.end'; runId: string; ts: number } & TaskTelemetry)
|
|
231
243
|
| { v: number; kind: 'run.end'; runId: string; ts: number }
|
|
232
244
|
|
|
@@ -370,8 +382,17 @@ export interface TelemetrySource {
|
|
|
370
382
|
emitSummary(summary: RunSummaryRecord): void
|
|
371
383
|
/** Await every sink's `flush()` (each crash-isolated, all time-bounded). */
|
|
372
384
|
flush(): Promise<void>
|
|
385
|
+
/**
|
|
386
|
+
* Sample this task's process tree every `SAMPLE_MS` until the returned
|
|
387
|
+
* function is called, the root exits, or the run ends. Present only when
|
|
388
|
+
* a sink wants `task.sample`: nobody asking costs no timer and no read.
|
|
389
|
+
*/
|
|
390
|
+
readonly track?: (taskId: string, pid: number) => () => void
|
|
373
391
|
}
|
|
374
392
|
|
|
393
|
+
/** How often a running task's process tree is sampled. */
|
|
394
|
+
const SAMPLE_MS = 1000
|
|
395
|
+
|
|
375
396
|
const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
|
|
376
397
|
'run.start',
|
|
377
398
|
'task.start',
|
|
@@ -426,6 +447,62 @@ export function createTelemetrySource(args: {
|
|
|
426
447
|
// Precompute which sinks want each kind, so per-event fan-out is a plain
|
|
427
448
|
// array walk with no per-record `wants` scanning.
|
|
428
449
|
const wantsLog = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.log'))
|
|
450
|
+
const wantsSample = sinks.some((s) => (s.wants ?? DEFAULT_KINDS).includes('task.sample'))
|
|
451
|
+
|
|
452
|
+
// taskId → the pid of its running root. One timer for every task, alive
|
|
453
|
+
// only while one runs; a tick still reading when the next is due is not
|
|
454
|
+
// doubled.
|
|
455
|
+
const tracked = new Map<string, number>()
|
|
456
|
+
let timer: ReturnType<typeof setInterval> | undefined
|
|
457
|
+
let sampling = false
|
|
458
|
+
let ended = false
|
|
459
|
+
const stopTimer = (): void => {
|
|
460
|
+
if (timer !== undefined) clearInterval(timer)
|
|
461
|
+
timer = undefined
|
|
462
|
+
}
|
|
463
|
+
const untrack = (taskId: string, pid: number): void => {
|
|
464
|
+
if (tracked.get(taskId) === pid) tracked.delete(taskId)
|
|
465
|
+
if (tracked.size === 0) stopTimer()
|
|
466
|
+
}
|
|
467
|
+
const tick = async (): Promise<void> => {
|
|
468
|
+
if (sampling) return
|
|
469
|
+
sampling = true
|
|
470
|
+
try {
|
|
471
|
+
const entries = [...tracked]
|
|
472
|
+
const usage = await sampleTrees(entries.map(([, pid]) => pid))
|
|
473
|
+
const ts = Date.now()
|
|
474
|
+
for (const [taskId, pid] of entries) {
|
|
475
|
+
if (ended || tracked.get(taskId) !== pid) continue
|
|
476
|
+
const u = usage.get(pid)
|
|
477
|
+
if (u === undefined) {
|
|
478
|
+
untrack(taskId, pid)
|
|
479
|
+
continue
|
|
480
|
+
}
|
|
481
|
+
deliver({
|
|
482
|
+
v: TELEMETRY_SCHEMA_VERSION,
|
|
483
|
+
kind: 'task.sample',
|
|
484
|
+
runId,
|
|
485
|
+
taskId,
|
|
486
|
+
ts,
|
|
487
|
+
cpuMs: u.cpuMs,
|
|
488
|
+
rssBytes: u.rssBytes,
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
} catch {
|
|
492
|
+
// A failed look is a missing point, never a broken run.
|
|
493
|
+
} finally {
|
|
494
|
+
sampling = false
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const track = (taskId: string, pid: number): (() => void) => {
|
|
498
|
+
if (ended) return () => {}
|
|
499
|
+
tracked.set(taskId, pid)
|
|
500
|
+
if (timer === undefined) {
|
|
501
|
+
timer = setInterval(() => void tick(), SAMPLE_MS)
|
|
502
|
+
timer.unref()
|
|
503
|
+
}
|
|
504
|
+
return () => untrack(taskId, pid)
|
|
505
|
+
}
|
|
429
506
|
|
|
430
507
|
function deliver(record: TelemetryRecord): void {
|
|
431
508
|
for (const sink of sinks) {
|
|
@@ -502,6 +579,9 @@ export function createTelemetrySource(args: {
|
|
|
502
579
|
case 'run:status':
|
|
503
580
|
return // status lines are terminal-rendering noise, not telemetry
|
|
504
581
|
case 'run:end':
|
|
582
|
+
ended = true
|
|
583
|
+
tracked.clear()
|
|
584
|
+
stopTimer()
|
|
505
585
|
deliver({ v: TELEMETRY_SCHEMA_VERSION, kind: 'run.end', runId, ts })
|
|
506
586
|
return
|
|
507
587
|
}
|
|
@@ -509,6 +589,7 @@ export function createTelemetrySource(args: {
|
|
|
509
589
|
|
|
510
590
|
return {
|
|
511
591
|
subscriber,
|
|
592
|
+
...(wantsSample ? { track } : {}),
|
|
512
593
|
emitSummary(summary: RunSummaryRecord): void {
|
|
513
594
|
for (const sink of sinks) {
|
|
514
595
|
if (disabled.has(sink) || sink.onRunSummary === undefined) continue
|