@vzn/vx 0.0.519 → 0.0.520

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.519",
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.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"
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"
@@ -139,6 +139,8 @@ export interface TaskOutcome {
139
139
  * flaky detection reads it off the outcome stream.
140
140
  */
141
141
  attempts?: number
142
+ /** The attempts that failed and were run again, in order: when each ended (epoch ms) and how. */
143
+ failedAttempts?: readonly { endedAt: number; exitCode: number; timedOut?: true }[]
142
144
  /**
143
145
  * Set when this run proved the task flaky: its key holds both outcomes on
144
146
  * record (this run's included) or it needed a retry. Its row carries it.
@@ -1083,6 +1083,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1083
1083
  // and what the cache keeps of it (L-11); null when there are none.
1084
1084
  const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1085
1085
  let flushMasked = (): void => {}
1086
+ const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1086
1087
  // The sampling of the attempt's process tree, stopped once it settles.
1087
1088
  let untrack: (() => void) | undefined
1088
1089
  // The entry's command is shown by `vx why` and sent with the entry to a
@@ -1139,6 +1140,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1139
1140
  }
1140
1141
 
1141
1142
  if (effectiveExitCode === 0 || attempt >= maxAttempts) break
1143
+ failedAttempts.push({
1144
+ endedAt: Date.now(),
1145
+ exitCode: effectiveExitCode,
1146
+ ...(result.timedOut === true ? { timedOut: true as const } : {}),
1147
+ })
1142
1148
  log.taskStderr(
1143
1149
  node,
1144
1150
  `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
@@ -1354,6 +1360,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1354
1360
  durationMs: spentMs,
1355
1361
  hash,
1356
1362
  ...(attempt > 1 ? { attempts: attempt } : {}),
1363
+ ...(failedAttempts.length > 0 ? { failedAttempts } : {}),
1357
1364
  ...(unkeyed ? { unkeyed: true as const } : {}),
1358
1365
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1359
1366
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
@@ -9,7 +9,7 @@
9
9
 
10
10
  import path from 'node:path'
11
11
  import type { WorkspaceConfig } from '../config.js'
12
- import { mark, nearest, UserError } from '../util/index.js'
12
+ import { beginRun, mark, nearest, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
15
  noteSchemaReset,
@@ -182,6 +182,7 @@ export interface PreparedRun {
182
182
  * empty plan).
183
183
  */
184
184
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
185
+ beginRun()
185
186
  mark('startup')
186
187
  // The root manifest is read once for the root, the globs and the
187
188
  // fingerprint. A watch cycle is a new run and reads it afresh.
@@ -26,6 +26,7 @@ import {
26
26
  } from '../graph/index.js'
27
27
  import {
28
28
  mark,
29
+ stageTimes,
29
30
  killGraceMs,
30
31
  MAX_TIMEOUT_MS,
31
32
  parseDuration,
@@ -755,6 +756,7 @@ async function runOnBus(
755
756
  { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
756
757
  runContextRecord,
757
758
  options.telemetrySinks,
759
+ nodes,
758
760
  )
759
761
  }
760
762
 
@@ -1202,6 +1204,7 @@ async function runOnBus(
1202
1204
  totalDurationMs: Math.round(totalMs),
1203
1205
  exitOk: ok,
1204
1206
  abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1207
+ stages: stageTimes(),
1205
1208
  })
1206
1209
  telemetry.emitSummary(summary)
1207
1210
  await telemetry.flush()
@@ -12,6 +12,7 @@
12
12
  // See docs/design/observability-architecture-2026-06.md §1, §3.
13
13
 
14
14
  import type { EventBus } from './events.js'
15
+ import type { TaskNode } from '../graph/index.js'
15
16
  import type {
16
17
  RunContextRecord,
17
18
  RunSummaryRecord,
@@ -70,6 +71,7 @@ export async function subscribeTelemetry(
70
71
  ctx: TelemetryContext,
71
72
  run: RunContextRecord,
72
73
  extraSinks?: readonly TelemetrySink[],
74
+ nodes?: ReadonlyMap<string, TaskNode>,
73
75
  ): Promise<TelemetryHandle | undefined> {
74
76
  const sinks: TelemetrySink[] = extraSinks === undefined ? [] : [...extraSinks]
75
77
  const owners = new Map<TelemetrySink, string>()
@@ -111,7 +113,13 @@ export async function subscribeTelemetry(
111
113
 
112
114
  if (sinks.length === 0) return undefined
113
115
 
114
- const source = createTelemetrySource({ 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
+ })
115
123
  // The bus's unsubscribe is idempotent, so it is the handle's dispose as
116
124
  // is (a once-flag here survived item 654).
117
125
  const dispose = bus.subscribe(source.subscriber)
@@ -17,7 +17,7 @@
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
19
  import { sampleTrees } from '../exec/index.js'
20
- import type { TaskOutcome, TaskStatus } from '../graph/index.js'
20
+ import { isGroupTask, type TaskNode, type TaskOutcome, type TaskStatus } from '../graph/index.js'
21
21
  import { maskedCommand, settleWithin, teardownTimeoutMs } from '../util/index.js'
22
22
  import type { RunEvent, RunEventSubscriber } from './events.js'
23
23
 
@@ -146,6 +146,15 @@ export interface RunContextRecord {
146
146
  tags: Readonly<Record<string, string>>
147
147
  }
148
148
 
149
+ /** One attempt of a retried task that failed and was run again. */
150
+ export interface FailedAttempt {
151
+ /** When the attempt ended, epoch ms. */
152
+ endedAt: number
153
+ exitCode: number
154
+ /** vx's own `timeout` ended it. */
155
+ timedOut?: true
156
+ }
157
+
149
158
  /** Denormalized per-task analytics — shared by the streaming `task.end`
150
159
  * record and the per-run summary's `tasks[]`. */
151
160
  export interface TaskTelemetry {
@@ -181,6 +190,8 @@ export interface TaskTelemetry {
181
190
  sandboxViolations?: number
182
191
  /** On a failed persistent task: why it never became ready. `exitCode` is the child's own when it exited. Additive. */
183
192
  notReady?: 'timeout' | 'exited' | 'spawn'
193
+ /** On a task that retried: each attempt that failed before the last, in order. Additive. */
194
+ failedAttempts?: readonly FailedAttempt[]
184
195
  /**
185
196
  * On a cache hit: whether outputs were written this run (`true`) or the
186
197
  * disk already matched the entry and nothing was restored (`false`, an
@@ -217,6 +228,12 @@ export type TelemetryRecord =
217
228
  project: string
218
229
  task: string
219
230
  command?: string
231
+ /**
232
+ * The tasks with a command this one waits on: its direct dependencies,
233
+ * a group's seen through to the tasks with a command behind it.
234
+ * Additive.
235
+ */
236
+ dependsOn?: readonly string[]
220
237
  ts: number
221
238
  }
222
239
  | {
@@ -276,6 +293,19 @@ export interface RunSummaryRecord {
276
293
  restoredRemoteCount: number
277
294
  exitOk: boolean
278
295
  tasks: readonly TaskTelemetry[]
296
+ /**
297
+ * The run's stages as `VX_TIMING` marks them (startup, load configs,
298
+ * classify + probe, run graph, …), each a wall window in epoch ms. The
299
+ * stages ahead of the run lock end before `startedAt`. Additive.
300
+ */
301
+ stages?: readonly RunStage[]
302
+ }
303
+
304
+ /** One stage of a run, a wall window in epoch ms. */
305
+ export interface RunStage {
306
+ name: string
307
+ startedAt: number
308
+ endedAt: number
279
309
  }
280
310
 
281
311
  /**
@@ -298,6 +328,7 @@ export function assembleRunSummary(
298
328
  totalDurationMs: number
299
329
  exitOk: boolean
300
330
  abortedCount: number
331
+ stages?: readonly RunStage[]
301
332
  },
302
333
  ): RunSummaryRecord {
303
334
  let failedCount = 0
@@ -332,6 +363,7 @@ export function assembleRunSummary(
332
363
  restoredRemoteCount,
333
364
  exitOk: timing.exitOk,
334
365
  tasks,
366
+ ...(timing.stages !== undefined && timing.stages.length > 0 ? { stages: timing.stages } : {}),
335
367
  }
336
368
  }
337
369
 
@@ -418,6 +450,8 @@ export function createTelemetrySource(args: {
418
450
  warn?: (message: string) => void
419
451
  /** What a sink with no `name` of its own is called: its plugin's (the host's map). */
420
452
  owners?: ReadonlyMap<TelemetrySink, string>
453
+ /** The run's task graph, to see a group dependency through to the tasks behind it. */
454
+ nodes?: ReadonlyMap<string, TaskNode>
421
455
  }): TelemetrySource {
422
456
  const { sinks, run, warn } = args
423
457
  const runId = run.runId
@@ -547,6 +581,8 @@ export function createTelemetrySource(args: {
547
581
  }
548
582
  if (node.config.exec.command !== undefined)
549
583
  rec.command = maskedCommand(node.config.exec.command, node.config.exec.env)
584
+ const deps = commandDeps(node, args.nodes)
585
+ if (deps.length > 0) rec.dependsOn = deps
550
586
  deliver(rec)
551
587
  return
552
588
  }
@@ -637,6 +673,23 @@ export function createTelemetrySource(args: {
637
673
  }
638
674
  }
639
675
 
676
+ /** `node`'s dependencies with a command; a group is seen through, once each. */
677
+ function commandDeps(node: TaskNode, nodes: ReadonlyMap<string, TaskNode> | undefined): string[] {
678
+ if (nodes === undefined) return node.deps
679
+ const out: string[] = []
680
+ const seen = new Set<string>()
681
+ const stack = [...node.deps].reverse()
682
+ while (stack.length > 0) {
683
+ const id = stack.pop()!
684
+ if (seen.has(id)) continue
685
+ seen.add(id)
686
+ const dep = nodes.get(id)
687
+ if (dep !== undefined && isGroupTask(dep)) stack.push(...[...dep.deps].reverse())
688
+ else out.push(id)
689
+ }
690
+ return out
691
+ }
692
+
640
693
  /**
641
694
  * The one projection of an outcome into `TaskTelemetry`, for the streaming
642
695
  * `task.end` record and the summary's `tasks[]` alike. Two copies drifted:
@@ -664,6 +717,7 @@ export function taskTelemetryOf(o: TaskOutcome): TaskTelemetry {
664
717
  if (o.timedOut === true) t.timedOut = true
665
718
  if (o.sandboxViolations !== undefined) t.sandboxViolations = o.sandboxViolations
666
719
  if (o.notReady !== undefined) t.notReady = o.notReady
720
+ if (o.failedAttempts !== undefined) t.failedAttempts = o.failedAttempts
667
721
  if (isCacheHit(o.status)) t.restored = o.restored === true
668
722
  if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
669
723
  if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
package/src/util/index.ts CHANGED
@@ -18,7 +18,15 @@ export {
18
18
  OUT_OF_FDS_HINT,
19
19
  } from './errors.js'
20
20
  export { xxh3, xxh3hex } from './hash.js'
21
- export { 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
  }