@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 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-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
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.517",
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.517",
25
- "@vzn/vx-linux-arm64": "0.0.517",
26
- "@vzn/vx-darwin-x64": "0.0.517",
27
- "@vzn/vx-darwin-arm64": "0.0.517"
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"
@@ -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
+ }
@@ -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 } : {}),
@@ -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