@vzn/vx 0.0.21 → 0.0.23

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.
Files changed (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
package/src/index.ts CHANGED
@@ -30,6 +30,10 @@ export { collectInfo, type CollectInfoOptions, type InfoFacts } from './orchestr
30
30
  // first; a policy that budgets memory (`@vzn/vx-schedule-history`) reads
31
31
  // the second, so the cgroup walk has one implementation.
32
32
  export { machineMemoryBytes, machineParallelism } from './util/index.js'
33
+ // How core reads a task glob — its spelling, and which characters are
34
+ // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
35
+ // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
36
+ export { isLiteralPattern, normalizeGlob } from './util/index.js'
33
37
 
34
38
  // Schema types and helpers (used by user vx.config files and presets).
35
39
  export type {
@@ -60,6 +64,11 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
60
64
  export { LocalHistoryProvider } from './orchestrator/index.js'
61
65
  export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
62
66
  export type { PreparedRun } from './orchestrator/index.js'
67
+ // `planRun` returns these, and an embedder that cannot NAME a return type
68
+ // cannot hold it: `run` and `prepareRun` both had theirs here from the
69
+ // start, so the omission read as "planRun is not really public" while
70
+ // docs/cli.md § Programmatic API listed all three (item 387, 2026-09-19).
71
+ export type { RunPlan, PlannedTask } from './orchestrator/index.js'
63
72
  export type { CiContext, GitContext, HostContext, WorkspaceIdentity } from './orchestrator/index.js'
64
73
  export type {
65
74
  CachePolicy,
@@ -72,7 +81,7 @@ export type {
72
81
  // with only `taskId()` to JOIN an id and nothing exported to SPLIT one,
73
82
  // consumers roll their own `split('#', 2)` and drift from the graph, which
74
83
  // splits on the FIRST '#'.
75
- export { splitTaskId } from './graph/index.js'
84
+ export { outputsOverlap, splitTaskId } from './graph/index.js'
76
85
  export type { TaskNode, TaskOutcome, TaskStatus } from './graph/index.js'
77
86
 
78
87
  // Cache classes + the layer interface (the `cache` capability's currency).
@@ -106,6 +115,7 @@ export type { ProjectEntry } from './workspace/index.js'
106
115
  // `@vzn/vx-migrate` (Turbo, Nx) and any other adoption tool use it from here.
107
116
  export {
108
117
  applyMigration,
118
+ foldScriptHooks,
109
119
  PERSISTENT_TASK_NAMES,
110
120
  PERSISTENT_TODO,
111
121
  quoteTsLiteral,
@@ -193,6 +203,10 @@ export {
193
203
  // a hit's bytes belong to the run that executed) are a decision, not an
194
204
  // implementation detail. Two sinks rolling their own is how they fork.
195
205
  export { LOG_WIRE_VERSION, TaskLogBuffer } from './orchestrator/index.js'
206
+ // `exitSignal` is on the façade on the same demonstrated need: a sink that
207
+ // renders a failed task's exit code faces 137 where the frame and `vx last`
208
+ // say `128 + SIGKILL`; the decode is the runner's convention, one copy.
209
+ export { exitSignal } from './orchestrator/index.js'
196
210
  export type {
197
211
  CacheSource,
198
212
  RunContextRecord,
@@ -212,4 +226,4 @@ export type { RunResult, TaskView, OutcomeView } from './orchestrator/index.js'
212
226
  // answer, which `@vzn/vx-mcp` serves. The event bus / wire form and the
213
227
  // other history readers (`listRuns`, `getRun`, …) left the façade 2026-09-10
214
228
  // with no consumer; the telemetry seam above is the canonical export path.
215
- export { whyDidThisRerunQuery } from './orchestrator/index.js'
229
+ export { latestRunId, whyDidThisRerunQuery } from './orchestrator/index.js'
@@ -13,36 +13,42 @@
13
13
  // every success built on it — or a grand-dependent would cache the same
14
14
  // partial tree one hop later. Only that mode ever executes a task behind
15
15
  // a failure; the other modes skip it, so a default run carries no check.
16
+ // `--exclude-dependencies` seeds the same taint: a task whose key folds a
17
+ // dependency that did not run has bytes nothing vouches for
18
+ // (excluded-keys.ts).
16
19
  //
17
20
  // Split from `run.ts` on 2026-09-10 (pure motion).
18
21
 
19
22
  import type { CachePolicy } from '../cache/index.js'
20
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
23
+ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
21
24
  import { executeTask, type ExecuteArgs } from './execute-task.js'
22
25
  import type { ShortCircuit } from './local-shortcircuit.js'
23
26
  import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
24
27
 
25
28
  /**
26
- * Records which tasks ran behind a failure and answers, per task, whether
27
- * this one does. Disabled (always `false`, nothing recorded) unless the
28
- * run's `continueMode` is `'always'`.
29
+ * Records which tasks ran behind a failure, or on the key of a dependency
30
+ * that did not run (`seeds`), and answers, per task, whether this one does.
31
+ * Disabled (always `false`, nothing recorded) unless the run's
32
+ * `continueMode` is `'always'` or there is a seed.
29
33
  */
30
34
  export function taintTracker(
31
- enabled: boolean,
35
+ continueAlways: boolean,
36
+ seeds: ReadonlySet<string>,
32
37
  ): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
33
- if (!enabled) return () => false
38
+ if (!continueAlways && seeds.size === 0) return () => false
34
39
  const tainted = new Set<string>()
35
40
  return (node, upstream) => {
36
- const taint = upstream.some(
37
- // A restore-tier task may run before its deps and see holes here;
38
- // it never saves anyway (a hit restores), so a hole is not taint.
39
- (u) =>
40
- u !== undefined &&
41
- (u.status === 'failed' ||
42
- u.status === 'aborted' ||
43
- u.status === 'skipped' ||
44
- tainted.has(u.node.id)),
45
- )
41
+ const taint =
42
+ seeds.has(node.id) ||
43
+ upstream.some(
44
+ // A restore-tier task may run before its deps and see holes here;
45
+ // it never saves anyway (a hit restores), so a hole is not taint.
46
+ (u) =>
47
+ u !== undefined &&
48
+ (tainted.has(u.node.id) ||
49
+ (continueAlways &&
50
+ (u.status === 'failed' || u.status === 'aborted' || u.status === 'skipped'))),
51
+ )
46
52
  if (taint) tainted.add(node.id)
47
53
  return taint
48
54
  }
@@ -70,6 +76,15 @@ export function admitTasks(
70
76
  args: AdmissionArgs,
71
77
  ): (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome> {
72
78
  const { inflight, policy, shortCircuit, hashArgs, buildExecuteArgs } = args
79
+ // A probed hit whose artifact vanished before its restore is thrown back
80
+ // to the scheduler (`RestoreDemoted`, execute-task.ts), which dispatches
81
+ // the task again once its deps are done. That dispatch probes for itself:
82
+ // the up-front probe answered for an artifact that is gone. The task
83
+ // stays in the restore-tier SET, so it still skips dedup below.
84
+ const dropProbe = (err: unknown): never => {
85
+ if (err instanceof RestoreDemoted) shortCircuit.preProbed.delete(err.taskId)
86
+ throw err
87
+ }
73
88
  return async (node, upstream) => {
74
89
  // Dedup only helps when the sibling will WRITE the artifact and this
75
90
  // task can READ it back — i.e. both axes effectively on.
@@ -87,8 +102,15 @@ export function admitTasks(
87
102
  // wrong. Route it straight to executeTask, which reuses the up-front
88
103
  // probe.
89
104
  const restorable = shortCircuit.restoreTier.has(node.id)
105
+ // `!cacheable` is an early-out for bookkeeping, not a guard: measured
106
+ // (item 506), a non-cacheable task never finds a sibling barrier,
107
+ // because the hash folds the taskId and a taskId executes once per
108
+ // run. Removing it reddens nothing and a throw on "a non-cacheable
109
+ // task joined a barrier" never fires across the whole suite. It saves
110
+ // the set/delete, and the wait a joiner would spend before running the
111
+ // task anyway — there is no artifact for it to hit.
90
112
  if (inflight === undefined || !cacheable || restorable) {
91
- return executeTask(buildExecuteArgs(node, upstream))
113
+ return executeTask(buildExecuteArgs(node, upstream)).catch(dropProbe)
92
114
  }
93
115
  const { nestedDirsByProject, ...rest } = hashArgs
94
116
  const hash = await computeTaskHash({
@@ -119,6 +141,8 @@ export function admitTasks(
119
141
  // `return await`, deliberately: the finally must run after the task
120
142
  // settles, not when its promise is handed back.
121
143
  return await executeTask(execArgs)
144
+ } catch (err) {
145
+ return dropProbe(err)
122
146
  } finally {
123
147
  // The barrier lifts when the ENTRY is there, not when the task is:
124
148
  // the save runs off the slot (save-lane.ts), and a sibling released
@@ -24,13 +24,18 @@ export interface PaintOptions {
24
24
 
25
25
  /**
26
26
  * Standard env precedence:
27
- * NO_COLOR=anything → off (overrides FORCE_COLOR)
28
- * FORCE_COLOR=anything → on
27
+ * NO_COLOR non-empty → off (overrides FORCE_COLOR)
28
+ * FORCE_COLOR=0 or =false → off, even on a TTY
29
+ * FORCE_COLOR any other value → on (the empty string too)
29
30
  * else: on iff stdout is a TTY.
31
+ * `0` and `false` are the convention's "off" (supports-color, chalk,
32
+ * Node); reading every non-empty value as on painted a piped CI log that
33
+ * set `FORCE_COLOR=0` (nx#35292's bug).
30
34
  */
31
35
  export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
32
36
  if (process.env['NO_COLOR']) return { enabled: false }
33
- if (process.env['FORCE_COLOR']) return { enabled: true }
37
+ const force = process.env['FORCE_COLOR']
38
+ if (force !== undefined) return { enabled: force !== '0' && force !== 'false' }
34
39
  return { enabled: stream.isTTY === true }
35
40
  }
36
41
 
@@ -77,15 +77,24 @@ export class DeferredOutputs {
77
77
  async materializeFor(node: TaskNode): Promise<void> {
78
78
  const needed: string[] = []
79
79
  const seen = new Set<string>()
80
- const walk = (id: string): void => {
81
- for (const dep of this.args.nodes.get(id)?.deps ?? []) {
82
- if (seen.has(dep)) continue
83
- seen.add(dep)
84
- if (this.entries.has(dep)) needed.push(dep)
85
- walk(dep)
80
+ // Pre-order on an explicit stack of dependency lists: a closure is as
81
+ // deep as the graph, and a recursion per edge threw `RangeError` at the
82
+ // 50,000 the builder takes (item 737).
83
+ const stack: Array<[deps: readonly string[], next: number]> = [
84
+ [this.args.nodes.get(node.id)?.deps ?? [], 0],
85
+ ]
86
+ while (stack.length > 0) {
87
+ const frame = stack[stack.length - 1]!
88
+ if (frame[1] === frame[0].length) {
89
+ stack.pop()
90
+ continue
86
91
  }
92
+ const dep = frame[0][frame[1]++]!
93
+ if (seen.has(dep)) continue
94
+ seen.add(dep)
95
+ if (this.entries.has(dep)) needed.push(dep)
96
+ stack.push([this.args.nodes.get(dep)?.deps ?? [], 0])
87
97
  }
88
- walk(node.id)
89
98
  if (needed.length === 0) return
90
99
  await Promise.all(needed.map((id) => this.materializeOne(id)))
91
100
  }
@@ -110,6 +119,12 @@ export class DeferredOutputs {
110
119
 
111
120
  // Mirror `restoreHit`'s sequencing so the two paths cannot drift: wipe
112
121
  // the declared outputs, write, then tell the git snapshot what moved.
122
+ // The wipe is a rule (a straggler in the declared tree is gone after
123
+ // the fetch); the two marks are the mirror's cost, not a rule: no
124
+ // reader of these outputs can be in the run — `deferralEligibility`
125
+ // keeps every same-project reader and every workspaceFiles reader
126
+ // eager — so deleting the post-fetch mark survives the whole core
127
+ // suite (item 643).
113
128
  if (outputs.length > 0) {
114
129
  const cleaned = await cleanOutputs(cleanArgs)
115
130
  this.args.gitFilesCache?.markOutputsChanged(producer.projectDir, cleaned)
@@ -12,14 +12,18 @@ import { PLUGIN_HOOKS } from '../config.js'
12
12
  import {
13
13
  cgroupCpuQuota,
14
14
  cgroupMemoryLimitBytes,
15
+ executablePath,
16
+ isUnsupportedBun,
15
17
  machineMemoryBytes,
16
18
  machineParallelism,
17
19
  } from '../util/index.js'
18
20
  import { VERSION } from '../version.js'
21
+ import { probeSandbox, resetSandbox } from '../exec/index.js'
19
22
  import {
20
23
  buildPackageGraph,
21
24
  computeWorkspaceFingerprint,
22
25
  findWorkspaceRoot,
26
+ type LoadReads,
23
27
  listProjects,
24
28
  loadProjectConfig,
25
29
  loadWorkspace,
@@ -39,12 +43,27 @@ const warnToStderr = (message: string): void => {
39
43
  export interface InfoFacts {
40
44
  vx: string
41
45
  bun: string
46
+ /**
47
+ * False when `bun` is below `MIN_BUN`. A separate field on purpose: `bun`
48
+ * is a machine surface (`vx info --format json`, and a test holds it to
49
+ * `Bun.version` exactly), so the verdict is a boolean here and prose only
50
+ * in the rendered row. What an unsupported runtime breaks is measured in
51
+ * `util/bun-version.ts`.
52
+ */
53
+ bunSupported: boolean
42
54
  git: string | null
43
55
  /** null when git could not answer. */
44
56
  gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null
45
57
  workspaceRoot: string
46
58
  projects: number
47
59
  tasks: number
60
+ /**
61
+ * The project configs that did not load, each with the loader's own
62
+ * message (the path stripped). A broken config counts as zero tasks
63
+ * rather than failing the doctor — and the doctor says which, since a
64
+ * `0 tasks` that hides a typo is the one fact a bug report needs.
65
+ */
66
+ configErrors: Array<{ path: string; message: string }>
48
67
  plugins: Array<{ name: string; seams: string[] }>
49
68
  /**
50
69
  * The worker count a run defaults to and where it comes from: the
@@ -74,6 +93,14 @@ export interface InfoFacts {
74
93
  /** Tasks the retained history shows both passing and failing on unchanged inputs. */
75
94
  flakyTasks: FlakyTask[]
76
95
  lockfile: boolean
96
+ /**
97
+ * Whether this host can run a task's `exec.sandbox`, and how many loaded
98
+ * tasks declare one. A declared sandbox whose runtime cannot start is a
99
+ * hard failure at run time, not a downgrade — so the doctor says so
100
+ * first (root inside a container, a missing bubblewrap, a nested
101
+ * seatbelt), with the probe's own reason.
102
+ */
103
+ sandbox: { available: boolean; reason: string; declared: number }
77
104
  }
78
105
 
79
106
  export interface CollectInfoOptions {
@@ -85,19 +112,22 @@ export interface CollectInfoOptions {
85
112
 
86
113
  export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
87
114
  const warn = opts.warn ?? warnToStderr
88
- const root = await findWorkspaceRoot(cwd)
89
- const metas = await listProjects(await loadWorkspace(root))
115
+ const reads: LoadReads = new Map()
116
+ const root = await findWorkspaceRoot(cwd, reads)
117
+ const metas = await listProjects(await loadWorkspace(root, reads))
90
118
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
91
119
  const cacheDir =
92
120
  opts.cacheDir === undefined
93
121
  ? resolveCacheDir(root, workspaceConfig)
94
122
  : path.resolve(cwd, opts.cacheDir)
95
- const cache = new Cache(cacheDir)
123
+ const cache = Cache.inspect(cacheDir)
96
124
  noteSchemaReset(cache, warn)
97
125
  let stats
98
126
  let orphans
99
127
  let flaky: FlakyTask[]
100
128
  let taskCount = 0
129
+ let sandboxed = 0
130
+ let configErrors: InfoFacts['configErrors'] = []
101
131
  try {
102
132
  stats = cache.stats()
103
133
  orphans = await cache.orphanStats()
@@ -116,23 +146,42 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
116
146
  seeds: 'all',
117
147
  closure: false,
118
148
  lock: null,
119
- evalCache: { store: cache, workspaceFingerprint: await computeWorkspaceFingerprint(root) },
149
+ evalCache: {
150
+ store: cache,
151
+ workspaceFingerprint: await computeWorkspaceFingerprint(root, reads),
152
+ },
120
153
  warn,
121
154
  })
122
155
  for (const p of loaded.projects.values()) {
123
- taskCount += Object.keys(p.config.tasks ?? {}).length
156
+ const tasks = p.config.tasks ?? {}
157
+ taskCount += Object.keys(tasks).length
158
+ for (const t of Object.values(tasks)) if (t?.exec?.sandbox !== undefined) sandboxed++
124
159
  }
125
160
  } catch {
126
- taskCount = await countLoadableTasks(metas)
161
+ // A config that will not load counts as zero, for both numbers: the
162
+ // sandbox row's "N tasks declare" must not read 0 because one other
163
+ // project's config is broken while this one's declares a sandbox.
164
+ ;({
165
+ tasks: taskCount,
166
+ sandboxed,
167
+ errors: configErrors,
168
+ } = await countLoadableTasks(metas, root))
127
169
  }
128
170
  } finally {
129
171
  cache.close()
130
172
  }
131
173
 
132
174
  const lockPresent = await Bun.file(lockfilePath(root)).exists()
175
+ const sandbox = await sandboxFact(sandboxed)
133
176
  return {
134
177
  vx: VERSION,
178
+ // An unsupported Bun does not stop a run, it makes the run's ANSWERS
179
+ // wrong (util/bun-version.ts measures which). The row a reader already
180
+ // consults for "is my setup sane" is where that belongs: a warning on
181
+ // every invocation would put a line on stderr that a clean run must not
182
+ // have, which is a property 19 tests hold on purpose.
135
183
  bun: Bun.version,
184
+ bunSupported: !isUnsupportedBun(Bun.version),
136
185
  git: gitVersion(),
137
186
  // The one `git status` walk per run is the warm path's critical path on
138
187
  // a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
@@ -142,6 +191,7 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
142
191
  workspaceRoot: root,
143
192
  projects: metas.length,
144
193
  tasks: taskCount,
194
+ configErrors,
145
195
  // Which plugins loaded and which seams each fills, in pipeline order —
146
196
  // the answer to "why did this task run there / cache there / not at
147
197
  // all" before reading any config. A declined seam still costs nothing;
@@ -168,9 +218,39 @@ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): P
168
218
  // this is the workspace's standing list.
169
219
  flakyTasks: flaky,
170
220
  lockfile: lockPresent,
221
+ sandbox,
171
222
  }
172
223
  }
173
224
 
225
+ /**
226
+ * The runtime probe's verdict — one sandboxed `true` (memoized) — and the
227
+ * count of loaded tasks that would meet it. The probe initializes the
228
+ * Linux runtime, whose proxy sockets would keep a standalone process
229
+ * alive; reset after asking, since the doctor runs nothing.
230
+ */
231
+ async function sandboxFact(declared: number): Promise<InfoFacts['sandbox']> {
232
+ try {
233
+ const verdict = await probeSandbox()
234
+ return { available: verdict.available, reason: stableSandboxReason(verdict.reason), declared }
235
+ } catch (err) {
236
+ const message = err instanceof Error ? err.message : String(err)
237
+ return { available: false, reason: stableSandboxReason(message), declared }
238
+ } finally {
239
+ await resetSandbox()
240
+ }
241
+ }
242
+
243
+ /**
244
+ * The doctor's output is pasted into bug reports and compared between
245
+ * invocations (`vx stats` is pinned byte-identical to `vx info`), so a
246
+ * reason must not carry this process's id. The Linux runtime names its
247
+ * mux socket after the pid (`srt-mux-<pid>-<n>.sock`), and a listen that
248
+ * fails — a nested sandbox, a read-only tmpdir — quotes that path.
249
+ */
250
+ export function stableSandboxReason(reason: string): string {
251
+ return reason.replace(/srt-mux-\d+-\d+\.sock/g, 'srt-mux-<pid>.sock')
252
+ }
253
+
174
254
  /** The seams a plugin can fill, in pipeline order: the one hook list, less the lifecycle end. */
175
255
  const SEAMS = PLUGIN_HOOKS.filter((h) => h !== 'teardown')
176
256
 
@@ -209,7 +289,7 @@ function memoryFact(): InfoFacts['memory'] {
209
289
  function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
210
290
  try {
211
291
  const p = Bun.spawnSync({
212
- cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
292
+ cmd: [executablePath('git'), 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
213
293
  cwd: root,
214
294
  stdout: 'pipe',
215
295
  stderr: 'pipe',
@@ -227,7 +307,11 @@ function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
227
307
 
228
308
  function gitVersion(): string | null {
229
309
  try {
230
- const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
310
+ const p = Bun.spawnSync({
311
+ cmd: [executablePath('git'), '--version'],
312
+ stdout: 'pipe',
313
+ stderr: 'pipe',
314
+ })
231
315
  if (p.exitCode !== 0) return null
232
316
  return new TextDecoder()
233
317
  .decode(p.stdout)
@@ -238,18 +322,42 @@ function gitVersion(): string | null {
238
322
  }
239
323
  }
240
324
 
241
- async function countLoadableTasks(metas: readonly ProjectMeta[]): Promise<number> {
242
- let count = 0
325
+ async function countLoadableTasks(
326
+ metas: readonly ProjectMeta[],
327
+ root: string,
328
+ ): Promise<{ tasks: number; sandboxed: number; errors: InfoFacts['configErrors'] }> {
329
+ let tasks = 0
330
+ let sandboxed = 0
331
+ const errors: InfoFacts['configErrors'] = []
243
332
  await Promise.all(
244
333
  metas.map(async (meta) => {
245
334
  if (meta.configPath === null) return
246
335
  try {
247
336
  const config = await loadProjectConfig(meta.configPath)
248
- count += Object.keys(config.tasks ?? {}).length
249
- } catch {
250
- // counted as zero
337
+ const declared = Object.values(config.tasks ?? {})
338
+ tasks += declared.length
339
+ for (const t of declared) if (t?.exec?.sandbox !== undefined) sandboxed++
340
+ } catch (err) {
341
+ // Counted as zero, and named: the loader's message opens with the
342
+ // absolute path — bare for a schema refusal, behind `Project config`
343
+ // for an import that failed — which the row carries
344
+ // workspace-relative instead, once (the second form named the file
345
+ // twice, 2026-09-16).
346
+ const raw = err instanceof Error ? err.message : String(err)
347
+ let message = raw
348
+ for (const prefix of [`${meta.configPath}: `, `Project config ${meta.configPath}: `]) {
349
+ if (raw.startsWith(prefix)) {
350
+ message = raw.slice(prefix.length)
351
+ break
352
+ }
353
+ }
354
+ errors.push({
355
+ path: path.relative(root, meta.configPath).split(path.sep).join('/'),
356
+ message,
357
+ })
251
358
  }
252
359
  }),
253
360
  )
254
- return count
361
+ errors.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
362
+ return { tasks, sandboxed, errors }
255
363
  }
@@ -6,6 +6,7 @@
6
6
  // additional subscribers later, so a slow/wedged renderer can never
7
7
  // stall task exec. See docs/design/event-stream-2026-06.md.
8
8
 
9
+ import { exitSignal } from '../exec/index.js'
9
10
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
10
11
  import { isGroupTask } from '../graph/index.js'
11
12
  import type { Logger } from './logger.js'
@@ -231,6 +232,13 @@ export interface OutcomeView {
231
232
  hash?: string
232
233
  cpuMs?: number
233
234
  peakRssBytes?: number
235
+ /** A failure vx's own `timeout` killed (see `TaskOutcome`). */
236
+ timedOut?: true
237
+ /** A persistent task that never became ready, and why (see `TaskOutcome`). */
238
+ notReady?: 'timeout' | 'exited' | 'spawn'
239
+ /** A skipped task: the failed (or aborted) task at the root of the block
240
+ * (see `TaskOutcome`); absent for fail-fast's skip, which nothing blocked. */
241
+ blockedBy?: string
234
242
  /** How long an `admit` policy held the task with a worker free (see `TaskOutcome`). */
235
243
  admissionHeldMs?: number
236
244
  restored?: boolean
@@ -260,8 +268,66 @@ export function outcomeWord(o: Pick<OutcomeView, 'status' | 'restored'>): string
260
268
  }
261
269
 
262
270
  /** `outcomeWord` with the exit code a failure carries: `failed (exit 9)`. */
263
- export function outcomeLabel(o: Pick<OutcomeView, 'status' | 'restored' | 'exitCode'>): string {
264
- return o.status === 'failed' ? `failed (exit ${o.exitCode})` : outcomeWord(o)
271
+ export function outcomeLabel(
272
+ o: Pick<
273
+ OutcomeView,
274
+ 'status' | 'restored' | 'exitCode' | 'timedOut' | 'sandboxViolations' | 'notReady' | 'blockedBy'
275
+ >,
276
+ ): string {
277
+ if (o.status === 'failed')
278
+ return failedLabel(o.exitCode, o.timedOut, o.sandboxViolations, o.notReady)
279
+ if (o.status === 'skipped') return skippedLabel(o.blockedBy)
280
+ return outcomeWord(o)
281
+ }
282
+
283
+ /**
284
+ * Why a task was skipped, one copy for every surface: `blocked by lib#build`
285
+ * (the failed or aborted task at the root of the block, what the footer's
286
+ * Skipped section groups by), or '' for fail-fast's skip — the run stopped,
287
+ * nothing blocked it, and a label claiming an upstream failure would lie.
288
+ */
289
+ export function skippedReason(blockedBy: string | undefined): string {
290
+ return blockedBy === undefined ? '' : `blocked by ${blockedBy}`
291
+ }
292
+
293
+ /** `skipped (blocked by lib#build)`, or a bare `skipped` for fail-fast's. */
294
+ export function skippedLabel(blockedBy: string | undefined): string {
295
+ const why = skippedReason(blockedBy)
296
+ return why === '' ? 'skipped' : `skipped (${why})`
297
+ }
298
+
299
+ /**
300
+ * A failure's label, one copy for every surface (the frame's footer, the
301
+ * status line, the run report, the Actions annotation): the exit code, and
302
+ * above 128 the signal it stands for — `failed (exit 137, 128 + SIGKILL)` —
303
+ * or, for vx's own timeout, `failed (timed out, exit 143)`, or a persistent
304
+ * task that never became ready, `failed (never ready: timed out, exit 1)`; a
305
+ * sandboxed task's violations are counted after: `failed (exit 1, 2 sandbox
306
+ * violations)`.
307
+ */
308
+ export function failedLabel(
309
+ exitCode: number,
310
+ timedOut?: true,
311
+ sandboxViolations?: number,
312
+ notReady?: 'timeout' | 'exited' | 'spawn',
313
+ ): string {
314
+ // A sandboxed task that touched what it never declared fails on that
315
+ // alone (its exit is forced to 1 when it was 0): the count is the reason.
316
+ const violations =
317
+ sandboxViolations === undefined || sandboxViolations === 0
318
+ ? ''
319
+ : `, ${sandboxViolations} sandbox violation${sandboxViolations === 1 ? '' : 's'}`
320
+ // A persistent task that never became ready: the reason reads first; its
321
+ // exit is the child's own when it exited, else vx's 1.
322
+ if (notReady !== undefined) {
323
+ const why =
324
+ notReady === 'timeout' ? 'timed out' : notReady === 'exited' ? 'exited' : 'spawn failed'
325
+ return `failed (never ready: ${why}, exit ${exitCode}${violations})`
326
+ }
327
+ // A timeout's 143 is vx's own SIGTERM: the reason, not the signal, reads.
328
+ if (timedOut === true) return `failed (timed out, exit ${exitCode}${violations})`
329
+ const signal = exitSignal(exitCode)
330
+ return `failed (exit ${exitCode}${signal === undefined ? '' : `, 128 + ${signal}`}${violations})`
265
331
  }
266
332
 
267
333
  export function projectNode(node: TaskNode): TaskView {
@@ -294,6 +360,9 @@ export function projectOutcome(outcome: TaskOutcome): OutcomeView {
294
360
  if (outcome.cpuMs !== undefined) view.cpuMs = outcome.cpuMs
295
361
  if (outcome.peakRssBytes !== undefined) view.peakRssBytes = outcome.peakRssBytes
296
362
  if (outcome.admissionHeldMs !== undefined) view.admissionHeldMs = outcome.admissionHeldMs
363
+ if (outcome.timedOut === true) view.timedOut = true
364
+ if (outcome.notReady !== undefined) view.notReady = outcome.notReady
365
+ if (outcome.blockedBy !== undefined) view.blockedBy = outcome.blockedBy
297
366
  if (outcome.restored !== undefined) view.restored = outcome.restored
298
367
  if (outcome.sandboxViolations !== undefined) view.sandboxViolations = outcome.sandboxViolations
299
368
  if (outcome.sandboxViolationLines !== undefined)