@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.518",
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.518",
25
- "@vzn/vx-linux-arm64": "0.0.518",
26
- "@vzn/vx-darwin-x64": "0.0.518",
27
- "@vzn/vx-darwin-arm64": "0.0.518"
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"
@@ -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.
@@ -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.
@@ -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({ sinks, run, warn: (m) => ctx.warn(m), owners })
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 type { TaskOutcome, TaskStatus } from '../graph/index.js'
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 { mark, printTimings, restartTimings, span } from './timing.js'
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,
@@ -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, and costs one boolean check per mark when off. Marks are
6
- // cumulative from this module's load — process start and the imports ahead
7
- // of it sit outside the table; the table shows each stage's own share.
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
- /** Record the end of a stage. No-op unless `VX_TIMING` is set. */
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
  }