@vzn/vx 0.0.16 → 0.0.21

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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -0,0 +1,137 @@
1
+ // What stands between the scheduler's `execute` callback and executeTask:
2
+ // two rules that decide whether a ready task runs NOW and whether its
3
+ // result may be SAVED. Neither changes what the task is.
4
+ //
5
+ // - In-flight dedup: an embedder running concurrent delegated runs in one
6
+ // process (a daemon built on the façade; core ships none) supplies an
7
+ // `inflight` registry; a task whose key
8
+ // a sibling is already computing waits for it and cache-hits on what it
9
+ // saved. A stateless `vx run` passes none and takes the untouched path.
10
+ // - Continue-taint: under `continueMode: 'always'` a task runs although an
11
+ // upstream failed. Its key is the healthy one (pure-input hashing) but
12
+ // its bytes are not, so the taint is tracked here and propagates through
13
+ // every success built on it — or a grand-dependent would cache the same
14
+ // partial tree one hop later. Only that mode ever executes a task behind
15
+ // a failure; the other modes skip it, so a default run carries no check.
16
+ //
17
+ // Split from `run.ts` on 2026-09-10 (pure motion).
18
+
19
+ import type { CachePolicy } from '../cache/index.js'
20
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
21
+ import { executeTask, type ExecuteArgs } from './execute-task.js'
22
+ import type { ShortCircuit } from './local-shortcircuit.js'
23
+ import { computeTaskHash, type ComputeHashArgs } from './task-hash.js'
24
+
25
+ /**
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
+ */
30
+ export function taintTracker(
31
+ enabled: boolean,
32
+ ): (node: TaskNode, upstream: TaskOutcome[]) => boolean {
33
+ if (!enabled) return () => false
34
+ const tainted = new Set<string>()
35
+ 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
+ )
46
+ if (taint) tainted.add(node.id)
47
+ return taint
48
+ }
49
+ }
50
+
51
+ export interface AdmissionArgs {
52
+ /** The shared registry, or undefined for a stateless run (no dedup). */
53
+ inflight: Map<string, Promise<void>> | undefined
54
+ policy: CachePolicy
55
+ shortCircuit: ShortCircuit
56
+ /** Everything `computeTaskHash` needs besides the node and its upstream. */
57
+ hashArgs: Omit<ComputeHashArgs, 'node' | 'upstream' | 'nestedProjectDirs'> & {
58
+ nestedDirsByProject: ReadonlyMap<string, string[]>
59
+ }
60
+ /**
61
+ * The run's execute-args builder. `reuseProbe: false` drops the up-front
62
+ * probe: a task joining a sibling must let executeTask probe fresh,
63
+ * because the probe predates the sibling's save.
64
+ */
65
+ buildExecuteArgs: (node: TaskNode, upstream: TaskOutcome[], reuseProbe?: boolean) => ExecuteArgs
66
+ }
67
+
68
+ /** The scheduler's `execute` callback: dedup when it can help, else executeTask. */
69
+ export function admitTasks(
70
+ args: AdmissionArgs,
71
+ ): (node: TaskNode, upstream: TaskOutcome[]) => Promise<TaskOutcome> {
72
+ const { inflight, policy, shortCircuit, hashArgs, buildExecuteArgs } = args
73
+ return async (node, upstream) => {
74
+ // Dedup only helps when the sibling will WRITE the artifact and this
75
+ // task can READ it back — i.e. both axes effectively on.
76
+ const canRead = policy.localRead || policy.remoteRead
77
+ const canWrite = policy.localWrite || policy.remoteWrite
78
+ const cacheable =
79
+ !isGroupTask(node) &&
80
+ node.config.exec?.persistent === undefined &&
81
+ node.config.cache !== undefined &&
82
+ canRead &&
83
+ canWrite
84
+ // A restore-tier task (confirmed local hit, may run before its deps)
85
+ // needs no dedup — it's a restore, not an executor, and its live
86
+ // `upstream` is incomplete, so the dedup hash recompute would be
87
+ // wrong. Route it straight to executeTask, which reuses the up-front
88
+ // probe.
89
+ const restorable = shortCircuit.restoreTier.has(node.id)
90
+ if (inflight === undefined || !cacheable || restorable) {
91
+ return executeTask(buildExecuteArgs(node, upstream))
92
+ }
93
+ const { nestedDirsByProject, ...rest } = hashArgs
94
+ const hash = await computeTaskHash({
95
+ ...rest,
96
+ node,
97
+ upstream,
98
+ nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
99
+ })
100
+ const existing = inflight.get(hash)
101
+ if (existing !== undefined) {
102
+ // Join a sibling already computing this exact task: wait, then
103
+ // executeTask cache-hits on the artifact it just saved.
104
+ await existing.catch(() => {})
105
+ return executeTask(buildExecuteArgs(node, upstream, false))
106
+ }
107
+ // Become the executor: register a barrier siblings await. get→set has
108
+ // no await between, so registration is atomic — at most one executor
109
+ // per hash. Released on every exit (success / failure / throw).
110
+ let release!: () => void
111
+ inflight.set(
112
+ hash,
113
+ new Promise<void>((resolve) => {
114
+ release = resolve
115
+ }),
116
+ )
117
+ const execArgs = buildExecuteArgs(node, upstream)
118
+ try {
119
+ // `return await`, deliberately: the finally must run after the task
120
+ // settles, not when its promise is handed back.
121
+ return await executeTask(execArgs)
122
+ } finally {
123
+ // The barrier lifts when the ENTRY is there, not when the task is:
124
+ // the save runs off the slot (save-lane.ts), and a sibling released
125
+ // before it landed would probe a miss and run the task again. The
126
+ // executor's own return is not held — only the joiners wait.
127
+ const landed = execArgs.deferredSaves?.get(node.id)
128
+ const lift = (): void => {
129
+ execArgs.deferredSaves?.delete(node.id)
130
+ inflight.delete(hash)
131
+ release()
132
+ }
133
+ if (landed !== undefined) void landed.then(lift, lift)
134
+ else lift()
135
+ }
136
+ }
137
+ }
@@ -0,0 +1,147 @@
1
+ // The run-scoped registry of tasks whose outputs were left in the remote
2
+ // store (`--download=none`), and the lazy materialisation that fetches
3
+ // them when a locally-placed task turns out to need them.
4
+ //
5
+ // See docs/design/download-policy-cas-cache-2026-08.md §5. The two
6
+ // invariants that keep this from becoming a second cache:
7
+ //
8
+ // - A deferred task writes NOTHING locally — no artifact, no rows. The
9
+ // remote store (the executor's own record) is the entry.
10
+ // - Materialisation CONVERGES: after the bytes land, core runs the
11
+ // ordinary `cache.save`, so the machine is indistinguishable from a
12
+ // `--download=all` run and every later reader (get, prune, verify,
13
+ // metrics) sees one entry shape, not two.
14
+
15
+ import {
16
+ cleanOutputs,
17
+ resolveOutputs,
18
+ type CacheLayer,
19
+ type GitFilesCache,
20
+ } from '../cache/index.js'
21
+ import type { TaskNode } from '../graph/index.js'
22
+
23
+ export interface DeferredEntry {
24
+ /** Fetches this task's outputs onto disk. At-most-once by construction. */
25
+ materialize: () => Promise<void>
26
+ hash: string
27
+ entry: { taskId: string; command: string; durationMs: number; stdout: string }
28
+ }
29
+
30
+ export interface DeferredOutputsArgs {
31
+ nodes: Map<string, TaskNode>
32
+ cache: CacheLayer
33
+ workspaceRoot: string
34
+ nestedDirsByProject: Map<string, string[]>
35
+ gitFilesCache?: GitFilesCache
36
+ /** False when the run's policy writes no local entries. */
37
+ localWrite: boolean
38
+ }
39
+
40
+ export class DeferredOutputs {
41
+ private readonly entries = new Map<string, DeferredEntry>()
42
+ private readonly inflight = new Map<string, Promise<void>>()
43
+
44
+ constructor(private readonly args: DeferredOutputsArgs) {}
45
+
46
+ register(taskId: string, entry: DeferredEntry): void {
47
+ this.entries.set(taskId, entry)
48
+ }
49
+
50
+ /**
51
+ * Task ids whose outputs are still remote — the summary's list. An entry
52
+ * is removed only when materialisation SUCCEEDS, so this covers both
53
+ * "nothing needed them" and "fetching them failed"; the second is
54
+ * precisely when a user needs to be told the tree is not current, and an
55
+ * `inflight` filter here used to hide it.
56
+ */
57
+ pending(): string[] {
58
+ return [...this.entries.keys()].sort()
59
+ }
60
+
61
+ get size(): number {
62
+ return this.entries.size
63
+ }
64
+
65
+ /**
66
+ * Materialise every deferred producer in `node`'s TRANSITIVE dependency
67
+ * closure. Which upstream bytes a command reads is unknowable — that is
68
+ * what `dependsOn` declares — so the whole closure is taken. Each
69
+ * producer materialises at most once per run (memoised on the promise,
70
+ * so two concurrent consumers share one fetch), and they run
71
+ * concurrently.
72
+ *
73
+ * A failure is the CONSUMER's failure: executing against a
74
+ * half-materialised tree is the stale-input class with extra steps, so
75
+ * this throws and the caller fails the task naming the producer.
76
+ */
77
+ async materializeFor(node: TaskNode): Promise<void> {
78
+ const needed: string[] = []
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)
86
+ }
87
+ }
88
+ walk(node.id)
89
+ if (needed.length === 0) return
90
+ await Promise.all(needed.map((id) => this.materializeOne(id)))
91
+ }
92
+
93
+ private materializeOne(taskId: string): Promise<void> {
94
+ const existing = this.inflight.get(taskId)
95
+ if (existing !== undefined) return existing
96
+ const started = this.run(taskId)
97
+ this.inflight.set(taskId, started)
98
+ return started
99
+ }
100
+
101
+ private async run(taskId: string): Promise<void> {
102
+ const entry = this.entries.get(taskId)!
103
+ const producer = this.args.nodes.get(taskId)!
104
+ // Project outputs only: a task declaring `outputs.workspaceFiles` is
105
+ // never deferred (`deferralEligibility` forces it eager), so nothing
106
+ // workspace-anchored can reach this path.
107
+ const outputs = producer.config.cache?.outputs.files ?? []
108
+ const nestedProjectDirs = this.args.nestedDirsByProject.get(producer.projectName) ?? []
109
+ const cleanArgs = { projectDir: producer.projectDir, outputs, nestedProjectDirs }
110
+
111
+ // Mirror `restoreHit`'s sequencing so the two paths cannot drift: wipe
112
+ // the declared outputs, write, then tell the git snapshot what moved.
113
+ if (outputs.length > 0) {
114
+ const cleaned = await cleanOutputs(cleanArgs)
115
+ this.args.gitFilesCache?.markOutputsChanged(producer.projectDir, cleaned)
116
+ }
117
+ try {
118
+ await entry.materialize()
119
+ } catch (err) {
120
+ const msg = err instanceof Error ? err.message : String(err)
121
+ throw new Error(
122
+ `could not fetch deferred outputs of ${taskId}: ${msg} — re-run it with --force, or use --download=all`,
123
+ )
124
+ }
125
+ const outputFiles = await resolveOutputs({
126
+ projectDir: producer.projectDir,
127
+ outputs,
128
+ nestedProjectDirs,
129
+ })
130
+ this.args.gitFilesCache?.markOutputsChanged(
131
+ producer.projectDir,
132
+ outputFiles.map((f) => f.slice(producer.projectDir.length + 1)),
133
+ )
134
+ // Convergence: an ordinary entry, so the next run is a plain local hit
135
+ // and nothing downstream learns a second shape. Policy still governs —
136
+ // a run that writes no local entries writes none here either.
137
+ if (this.args.localWrite) {
138
+ await this.args.cache.save({
139
+ hash: entry.hash,
140
+ projectDir: producer.projectDir,
141
+ outputFiles,
142
+ entry: entry.entry,
143
+ })
144
+ }
145
+ this.entries.delete(taskId)
146
+ }
147
+ }
@@ -0,0 +1,255 @@
1
+ // The workspace doctor's facts — what `vx info` prints and `vx mcp`'s
2
+ // `getWorkspaceInfo` returns: versions, the git status cache, the
3
+ // workspace's shape, the plugins and the seams each fills, what a run
4
+ // will use (workers, memory) and where those come from, the cache's
5
+ // versions and state, the flaky tasks, the lock. One collector, so the
6
+ // verb and the agent tool cannot disagree; the CLI renders, this gathers.
7
+
8
+ import os from 'node:os'
9
+ import path from 'node:path'
10
+ import { Cache, CACHE_VERSION, noteSchemaReset, SCHEMA_VERSION } from '../cache/index.js'
11
+ import { PLUGIN_HOOKS } from '../config.js'
12
+ import {
13
+ cgroupCpuQuota,
14
+ cgroupMemoryLimitBytes,
15
+ machineMemoryBytes,
16
+ machineParallelism,
17
+ } from '../util/index.js'
18
+ import { VERSION } from '../version.js'
19
+ import {
20
+ buildPackageGraph,
21
+ computeWorkspaceFingerprint,
22
+ findWorkspaceRoot,
23
+ listProjects,
24
+ loadProjectConfig,
25
+ loadWorkspace,
26
+ lockfilePath,
27
+ type ProjectMeta,
28
+ resolveCacheDir,
29
+ } from '../workspace/index.js'
30
+ import { flakyTasks, type FlakyTask } from './failure-mode.js'
31
+ import type { VxPlugin } from './plugin.js'
32
+ import { loadProjects, loadWorkspacePlugins } from './projects.js'
33
+
34
+ const warnToStderr = (message: string): void => {
35
+ process.stderr.write(`${message}\n`)
36
+ }
37
+
38
+ /** The doctor's facts, typed: what `--format json` prints and the pretty rows render. */
39
+ export interface InfoFacts {
40
+ vx: string
41
+ bun: string
42
+ git: string | null
43
+ /** null when git could not answer. */
44
+ gitStatusCache: { fsmonitor: boolean; untrackedCache: boolean } | null
45
+ workspaceRoot: string
46
+ projects: number
47
+ tasks: number
48
+ plugins: Array<{ name: string; seams: string[] }>
49
+ /**
50
+ * The worker count a run defaults to and where it comes from: the
51
+ * workspace's `concurrency`, else the cores this process may use — the
52
+ * CPU count capped by a cgroup quota (`cpuQuota`, in cores, null when
53
+ * none binds).
54
+ */
55
+ workers: {
56
+ count: number
57
+ source: 'workspace' | 'cgroup' | 'cores'
58
+ cores: number
59
+ cpuQuota: number | null
60
+ }
61
+ /**
62
+ * What a memory-packing policy budgets: the machine's total capped by
63
+ * the cgroup limit (`cgroupLimitBytes`, null when none binds).
64
+ */
65
+ memory: { usableBytes: number; totalBytes: number; cgroupLimitBytes: number | null }
66
+ cacheDir: string
67
+ cacheVersion: string
68
+ schemaVersion: string
69
+ cacheEntries: number
70
+ cacheBytes: number
71
+ orphans: { artifacts: number; bytes: number }
72
+ runs24h: number
73
+ hits24h: number
74
+ /** Tasks the retained history shows both passing and failing on unchanged inputs. */
75
+ flakyTasks: FlakyTask[]
76
+ lockfile: boolean
77
+ }
78
+
79
+ export interface CollectInfoOptions {
80
+ /** `--cache-dir`: report on the cache a run with the same flag uses. */
81
+ readonly cacheDir?: string
82
+ /** Where a warning (a schema reset, a plugin's note) goes. Default: stderr. */
83
+ readonly warn?: (message: string) => void
84
+ }
85
+
86
+ export async function collectInfo(cwd: string, opts: CollectInfoOptions = {}): Promise<InfoFacts> {
87
+ const warn = opts.warn ?? warnToStderr
88
+ const root = await findWorkspaceRoot(cwd)
89
+ const metas = await listProjects(await loadWorkspace(root))
90
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(root, warn)
91
+ const cacheDir =
92
+ opts.cacheDir === undefined
93
+ ? resolveCacheDir(root, workspaceConfig)
94
+ : path.resolve(cwd, opts.cacheDir)
95
+ const cache = new Cache(cacheDir)
96
+ noteSchemaReset(cache, warn)
97
+ let stats
98
+ let orphans
99
+ let flaky: FlakyTask[]
100
+ let taskCount = 0
101
+ try {
102
+ stats = cache.stats()
103
+ orphans = await cache.orphanStats()
104
+ flaky = flakyTasks(cache.dbHandle())
105
+ // The run path's load — a plugin's `project` stage counts — so the
106
+ // doctor's task count is the number a run would see. A broken config
107
+ // must not take the doctor down with it: the count then falls back to
108
+ // the configs that do load, one by one, the broken ones as zero.
109
+ try {
110
+ const loaded = await loadProjects({
111
+ workspaceRoot: root,
112
+ cacheDir,
113
+ plugins,
114
+ projectMetas: metas,
115
+ packageGraph: buildPackageGraph([...metas]),
116
+ seeds: 'all',
117
+ closure: false,
118
+ lock: null,
119
+ evalCache: { store: cache, workspaceFingerprint: await computeWorkspaceFingerprint(root) },
120
+ warn,
121
+ })
122
+ for (const p of loaded.projects.values()) {
123
+ taskCount += Object.keys(p.config.tasks ?? {}).length
124
+ }
125
+ } catch {
126
+ taskCount = await countLoadableTasks(metas)
127
+ }
128
+ } finally {
129
+ cache.close()
130
+ }
131
+
132
+ const lockPresent = await Bun.file(lockfilePath(root)).exists()
133
+ return {
134
+ vx: VERSION,
135
+ bun: Bun.version,
136
+ git: gitVersion(),
137
+ // The one `git status` walk per run is the warm path's critical path on
138
+ // a large tree (~55 ms at 1000 projects, measured 2026-09-02). git's
139
+ // own caches make it near-free after the first run, and they are OFF by
140
+ // default — say so, since nothing else in a run would.
141
+ gitStatusCache: gitStatusCache(root),
142
+ workspaceRoot: root,
143
+ projects: metas.length,
144
+ tasks: taskCount,
145
+ // Which plugins loaded and which seams each fills, in pipeline order —
146
+ // the answer to "why did this task run there / cache there / not at
147
+ // all" before reading any config. A declined seam still costs nothing;
148
+ // this names the declarations, not what a run consulted.
149
+ plugins: plugins.map((p) => ({ name: p.name, seams: filledSeams(p) })),
150
+ workers: workersFact(workspaceConfig?.concurrency),
151
+ memory: memoryFact(),
152
+ cacheDir,
153
+ // The two versions a bug report needs and the reset notice names: the
154
+ // key prefix (a bump orphans every entry) and the index schema (a
155
+ // mismatch drops every table).
156
+ cacheVersion: CACHE_VERSION,
157
+ schemaVersion: SCHEMA_VERSION,
158
+ cacheEntries: stats.entryCount,
159
+ cacheBytes: stats.totalBytes,
160
+ // The index is authoritative, so a row-less artifact is bytes nothing
161
+ // will ever hit — and only `vx cache prune` reclaims them (after an
162
+ // upgrade's schema reset, most often).
163
+ orphans: { artifacts: orphans.orphans, bytes: orphans.orphanBytes },
164
+ runs24h: stats.runCountLast24h,
165
+ hits24h: stats.hitCountLast24h,
166
+ // Same inputs, both outcomes — the history's definition of flaky, over
167
+ // the 30 days it keeps. A run names its own findings in its footer;
168
+ // this is the workspace's standing list.
169
+ flakyTasks: flaky,
170
+ lockfile: lockPresent,
171
+ }
172
+ }
173
+
174
+ /** The seams a plugin can fill, in pipeline order: the one hook list, less the lifecycle end. */
175
+ const SEAMS = PLUGIN_HOOKS.filter((h) => h !== 'teardown')
176
+
177
+ function filledSeams(p: VxPlugin): string[] {
178
+ return SEAMS.filter((s) => p[s as keyof VxPlugin] !== undefined)
179
+ }
180
+
181
+ /**
182
+ * Inside a container the CPU count and `os.totalmem()` are the HOST's; the
183
+ * cgroup is what the kernel enforces, and a run that used the host's
184
+ * numbers would be the OOM killer's. The doctor says which one a run
185
+ * reads, because nothing else would.
186
+ */
187
+ function workersFact(workspaceConcurrency: number | undefined): InfoFacts['workers'] {
188
+ const cores = Math.max(1, navigator.hardwareConcurrency)
189
+ const quota = process.platform === 'linux' ? (cgroupCpuQuota() ?? null) : null
190
+ if (workspaceConcurrency !== undefined) {
191
+ return { count: workspaceConcurrency, source: 'workspace', cores, cpuQuota: quota }
192
+ }
193
+ const machine = machineParallelism()
194
+ return { count: machine, source: machine < cores ? 'cgroup' : 'cores', cores, cpuQuota: quota }
195
+ }
196
+
197
+ function memoryFact(): InfoFacts['memory'] {
198
+ const totalBytes = os.totalmem()
199
+ const limit = process.platform === 'linux' ? (cgroupMemoryLimitBytes() ?? null) : null
200
+ return { usableBytes: machineMemoryBytes(), totalBytes, cgroupLimitBytes: limit }
201
+ }
202
+
203
+ /**
204
+ * Whether git's fsmonitor / untracked cache are on. Reported as a fact,
205
+ * not a remedy: interleaved A/B at 1000 projects measured neither moving
206
+ * the warm run (STATUS, waves 5 and the 2026-09-03 refutations) — the
207
+ * status walk's cost is git's own, and vx already overlaps it.
208
+ */
209
+ function gitStatusCache(root: string): InfoFacts['gitStatusCache'] {
210
+ try {
211
+ const p = Bun.spawnSync({
212
+ cmd: ['git', 'config', '--get-regexp', '^core\\.(fsmonitor|untrackedcache)$'],
213
+ cwd: root,
214
+ stdout: 'pipe',
215
+ stderr: 'pipe',
216
+ })
217
+ // exit 1 is git's "no key matched": both off, still an answer.
218
+ if (p.exitCode !== 0 && p.exitCode !== 1) return null
219
+ const out = p.exitCode === 0 ? new TextDecoder().decode(p.stdout) : ''
220
+ const on = (key: string): boolean =>
221
+ new RegExp(`^core\\.${key} (true|1|yes|on)$`, 'im').test(out)
222
+ return { fsmonitor: on('fsmonitor'), untrackedCache: on('untrackedcache') }
223
+ } catch {
224
+ return null
225
+ }
226
+ }
227
+
228
+ function gitVersion(): string | null {
229
+ try {
230
+ const p = Bun.spawnSync({ cmd: ['git', '--version'], stdout: 'pipe', stderr: 'pipe' })
231
+ if (p.exitCode !== 0) return null
232
+ return new TextDecoder()
233
+ .decode(p.stdout)
234
+ .trim()
235
+ .replace(/^git version /, '')
236
+ } catch {
237
+ return null
238
+ }
239
+ }
240
+
241
+ async function countLoadableTasks(metas: readonly ProjectMeta[]): Promise<number> {
242
+ let count = 0
243
+ await Promise.all(
244
+ metas.map(async (meta) => {
245
+ if (meta.configPath === null) return
246
+ try {
247
+ const config = await loadProjectConfig(meta.configPath)
248
+ count += Object.keys(config.tasks ?? {}).length
249
+ } catch {
250
+ // counted as zero
251
+ }
252
+ }),
253
+ )
254
+ return count
255
+ }