@vzn/vx 0.0.631 → 0.0.633

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 (86) hide show
  1. package/README.md +21 -18
  2. package/docs/caching.md +2247 -0
  3. package/docs/cli.md +3214 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +172 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2137 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +14 -5
  10. package/schemas/cache.json +24 -0
  11. package/schemas/docs.json +28 -0
  12. package/schemas/error.json +23 -0
  13. package/schemas/info.json +147 -0
  14. package/schemas/init.json +57 -0
  15. package/schemas/last.json +188 -0
  16. package/schemas/lock.json +21 -0
  17. package/schemas/plan.json +88 -0
  18. package/schemas/show.json +124 -0
  19. package/schemas/summary.json +133 -0
  20. package/schemas/why.json +168 -0
  21. package/skills/vx/SKILL.md +89 -0
  22. package/src/bin.ts +9 -0
  23. package/src/cache/archive.ts +153 -51
  24. package/src/cache/cache.ts +542 -137
  25. package/src/cache/chained-cache.ts +9 -0
  26. package/src/cache/inputs.ts +5 -0
  27. package/src/cache/layer.ts +10 -5
  28. package/src/cache/layered-cache.ts +4 -0
  29. package/src/cache/schema.ts +44 -17
  30. package/src/cache/tar-stream.ts +204 -66
  31. package/src/cache/zstd.ts +1 -1
  32. package/src/cli/cache.ts +14 -14
  33. package/src/cli/docs-corpus.ts +21 -0
  34. package/src/cli/docs-text.d.ts +5 -0
  35. package/src/cli/docs.ts +167 -0
  36. package/src/cli/foreign-flags.ts +1 -1
  37. package/src/cli/help.ts +42 -7
  38. package/src/cli/index.ts +25 -11
  39. package/src/cli/info.ts +38 -9
  40. package/src/cli/init.ts +131 -24
  41. package/src/cli/last.ts +54 -3
  42. package/src/cli/lock.ts +43 -15
  43. package/src/cli/plan-format.ts +23 -1
  44. package/src/cli/plugin-commands.ts +3 -0
  45. package/src/cli/plugin-templates.ts +1 -1
  46. package/src/cli/run.ts +40 -21
  47. package/src/cli/show.ts +10 -7
  48. package/src/cli/upgrade.ts +19 -7
  49. package/src/cli/watch.ts +7 -2
  50. package/src/cli/why.ts +14 -40
  51. package/src/exec/runner.ts +27 -10
  52. package/src/graph/scheduler.ts +36 -0
  53. package/src/graph/task-graph.ts +4 -4
  54. package/src/index.ts +22 -5
  55. package/src/orchestrator/admission.ts +29 -1
  56. package/src/orchestrator/affected-tasks.ts +87 -18
  57. package/src/orchestrator/doctor.ts +14 -1
  58. package/src/orchestrator/execute-task.ts +149 -64
  59. package/src/orchestrator/failure-mode.ts +5 -3
  60. package/src/orchestrator/framed-output.ts +17 -3
  61. package/src/orchestrator/index.ts +11 -0
  62. package/src/orchestrator/logger.ts +57 -6
  63. package/src/orchestrator/metrics.ts +76 -4
  64. package/src/orchestrator/miss-reason.ts +3 -5
  65. package/src/orchestrator/miss-save.ts +6 -5
  66. package/src/orchestrator/options.ts +12 -0
  67. package/src/orchestrator/output-log.ts +14 -1
  68. package/src/orchestrator/path-links.ts +79 -0
  69. package/src/orchestrator/plan.ts +20 -3
  70. package/src/orchestrator/plugin.ts +7 -0
  71. package/src/orchestrator/prepare.ts +14 -4
  72. package/src/orchestrator/projects.ts +2 -2
  73. package/src/orchestrator/run-artifacts.ts +18 -2
  74. package/src/orchestrator/run-failures.ts +192 -0
  75. package/src/orchestrator/run-records.ts +10 -8
  76. package/src/orchestrator/run.ts +87 -22
  77. package/src/orchestrator/task-hash.ts +4 -4
  78. package/src/orchestrator/terminal-signals.ts +16 -0
  79. package/src/orchestrator/upstream.ts +65 -10
  80. package/src/util/errors.ts +55 -1
  81. package/src/util/index.ts +3 -0
  82. package/src/util/secret-mask.ts +13 -1
  83. package/src/util/verbs.ts +1 -0
  84. package/src/workspace/config-schema.ts +10 -8
  85. package/src/workspace/migration.ts +30 -3
  86. package/src/workspace/workspace.ts +1 -0
@@ -0,0 +1,708 @@
1
+ # Task execution lifecycle
2
+
3
+ This document traces what happens between `vx run build` typed at the
4
+ terminal and a task succeeding or failing. Read it alongside
5
+ [`architecture.md`](./architecture.md) (module map) and
6
+ [`caching.md`](./caching.md) (cache mechanics).
7
+
8
+ ## End-to-end timeline
9
+
10
+ ```
11
+ ┌─ CLI dispatch (src/bin.ts → src/cli/index.ts → src/cli/run.ts)
12
+ │ 1. bin.ts spawns; forwards process.argv to cli.run().
13
+ │ 2. cli/index.ts dispatches by subcommand (run / watch / cache /
14
+ │ lock / init / upgrade / show / info / why / last / docs /
15
+ │ completions / help / version); any other verb is asked of the
16
+ │ workspace's plugins (`commands` seam — `vx mcp` is one).
17
+ │ 3. cli/run.ts:parseRunArgs(argv) → RunArgs (validated; resolves
18
+ │ the 4-axis cache policy from --cache / --no-cache / --force).
19
+ │ 4. cli/run.ts:runCmd resolves the project scope:
20
+ │ - bare positionals respect --all / --filter / --affected / cwd
21
+ │ - anchored positionals (pkg#task) target directly
22
+ │ - no positionals + TTY → interactive picker → pkg#task
23
+ │ 5. The options map to RunOptions and the run executes IN THIS
24
+ │ PROCESS — always. (A whole-run `backend` seam existed until
25
+ │ 2026-08; it moved the scheduler server-side, which is why it
26
+ │ went. Per-task placement is the `executor` capability.)
27
+ │ --dry / --graph short-circuit into planRun instead.
28
+ │
29
+ ├─ Prepare (src/orchestrator/prepare.ts:prepareRun — shared with planRun)
30
+ │ 1. findWorkspaceRoot(cwd) — walks up over pnpm-workspace.yaml,
31
+ │ package.json with a `workspaces` field (npm/yarn/bun), or a
32
+ │ bare package.json (single-project mode). The nearest one whose
33
+ │ package globs CLAIM cwd wins, so running from inside a member
34
+ │ resolves the declaring root, not the member; the first
35
+ │ directory holding pnpm-workspace.yaml stops the walk whether
36
+ │ or not its globs claim cwd (pnpm's rule); when nothing claims
37
+ │ cwd the nearest candidate wins. An UNSCOPED run (no explicit
38
+ │ project scope, at least one bare task name) then starts
39
+ │ step 12's git enumeration over the whole tree, overlapping
40
+ │ steps 2–11; a scoped run waits, since its pathspecs depend on
41
+ │ which projects the task graph holds.
42
+ │ 2. loadWorkspace — parses the appropriate manifest. Bun.YAML
43
+ │ for pnpm; the package.json forms read as bytes (readOnce,
44
+ │ shared with step 1) and JSON.parse.
45
+ │ 3. loadWorkspacePlugins — loadWorkspaceConfig reads the optional
46
+ │ vx.workspace.{ts,mts,js,mjs,cts,cjs} at the root (concurrency /
47
+ │ cacheDir / timeout / cacheRetention / affectedBase / cacheScope /
48
+ │ rules / plugins),
49
+ │ then the plugin `config` stage runs on it.
50
+ │ 4. listProjects — globs every workspace member's package.json,
51
+ │ finds sibling vx.config.* files, detects duplicate package
52
+ │ names (hard error with both paths).
53
+ │ 5. buildPackageGraph — workspace dep edges from package.json.
54
+ │ 6. Local cache open: new Cache(cacheDir, { read, write }, root)
55
+ │ with the policy's local slice — BEFORE the configs load,
56
+ │ because it also holds their cached evaluations. Then
57
+ │ computeWorkspaceFingerprints: one read of every supported
58
+ │ lockfile + pnpm-workspace.yaml + .yarnrc.yml + .npmrc + bunfig.toml at the root
59
+ │ yields `all` (every file; keys the config-evaluation cache),
60
+ │ `unclaimed` (minus the files a `fingerprint` plugin claims —
61
+ │ `@vzn/vx-lockfile` keys those per project; reused for every
62
+ │ task's cache key) and `files` (the bytes, for the mid-run
63
+ │ fingerprint check).
64
+ │ 7. SCOPED config loading — only in-scope projects plus their
65
+ │ transitive dependency closure evaluate ('^task' frontier
66
+ │ expansion never escapes the closure); one staged load,
67
+ │ loadProjects, shared with every reading verb. Under --frozen,
68
+ │ configs load from vx-lock.json — no evaluation, no staleness
69
+ │ check of its own (vx lock --check is the audit), frozen-env
70
+ │ semantics; a missing lock or entry is a hard UserError.
71
+ │ Otherwise loadProjectConfigs in one batch: native Bun await
72
+ │ import() with a content-hash query-string bust, cached
73
+ │ evaluations served for pure configs; the loader validates
74
+ │ each TaskConfig shape, and the plugin `project` stage runs
75
+ │ on each config with a re-validation after every plugin.
76
+ │ 8. computeNestedProjectDirs — set of projects rooted inside each
77
+ │ project, computed over EVERY config-bearing project (loaded
78
+ │ or not) for boundary enforcement.
79
+ │ 9. expandRequested (see Task selection below).
80
+ │ 10. Cache layer: an injected RunOptions.remoteCache is composed
81
+ │ with the local cache into a LayeredCache (it wins); else
82
+ │ resolveCache lets a plugin's `cache` capability wrap or
83
+ │ replace it; else bare local.
84
+ │ 11. buildTaskGraph (see below).
85
+ │ 12. Bulk git populate — the enumeration step 1 started is
86
+ │ awaited, or a scoped run starts it here over the projects
87
+ │ that own a task (not the dependency closure step 7 loaded,
88
+ │ which a `lint` of one package does not key). THREE spawns at
89
+ │ the root, concurrent, and the repository's facts asked while
90
+ │ they run (`ls-files -s -v -z` for the index: every tracked
91
+ │ path's OID and its cache-state flag;
92
+ │ `status --porcelain -z -uall` for
93
+ │ the dirty AND untracked sets, the one worktree walk; `var -l`
94
+ │ for core.autocrlf and the attributes files git reads outside
95
+ │ the tree; the facts — prefix, common dir, object format, index
96
+ │ file — read off a plain `.git` directory, else one
97
+ │ `rev-parse --show-prefix --git-common-dir --show-object-format
98
+ │ --git-path index`,
99
+ │ memoized per process and shared with the file hasher)
100
+ │ fill the per-project GitFilesCache with file lists + index
101
+ │ OIDs. `ls-files --others` is NOT among them — status's
102
+ │ `-uall` already answers untracked, and asking git twice
103
+ │ walked the same tree again. A fifth, `check-attr`, runs only
104
+ │ when an attributes file could rewrite bytes. An index the
105
+ │ cache holds no blob-size verdict for (keyed by the index
106
+ │ file's hash, A-60) adds `ls-files --debug` for the recorded
107
+ │ sizes and `cat-file --batch-check` for the blob sizes
108
+ │ `blob_sizes` lacks: an OID whose blob is not the recorded size
109
+ │ is not trusted. The run's HashCache is created
110
+ │ after it.
111
+ ├─ Task selection (graph/task-graph.ts:expandRequested)
112
+ │ Bare task names fan out across the resolved candidate projects
113
+ │ (every project that declares the task). Anchored entries
114
+ │ (`pkg#task`) resolve directly. Duplicates are deduped.
115
+ │ Empty result → run returns `{ ok: false, outcomes: [] }` —
116
+ │ no task is treated as a CI footgun. The exception: a diff-scoped
117
+ │ run (--affected) whose task names are all declared by projects
118
+ │ outside the selection is `none-affected` and returns
119
+ │ `{ ok: true, outcomes: [] }`.
120
+ │
121
+ ├─ Task graph (src/graph/task-graph.ts:buildTaskGraph)
122
+ │ Starting from the resolved {project, task}[] pairs, walk
123
+ │ dependsOn:
124
+ │ - 'name' → same-project task
125
+ │ - '^name' → task in the nearest deps declaring it
126
+ │ (frontier walk; non-holders passed through)
127
+ │ - 'pkg#name' → specific package's task
128
+ │ Detect cycles — throws with the path. After the graph and key
129
+ │ stages, excluded edges (per --exclude-dependencies[=names]) leave
130
+ │ the SCHEDULE; each dropped task is still keyed and folded into
131
+ │ its dependant (orchestrator/excluded-keys.ts).
132
+ │ Each node carries: id (`${project}#${task}`), projectName,
133
+ │ projectDir, taskName, config, sorted deps, `requested: boolean`.
134
+ │ markSurfacedDeps then flags the display-only `surfaced` tasks a
135
+ │ requested GROUP stands for (transparent folders).
136
+ │
137
+ ├─ Plugins + telemetry (src/orchestrator/run.ts)
138
+ │ 1. installPlugins — always called; each plugin's optional
139
+ │ setup() hook runs; a throw aborts the run with a clean
140
+ │ UserError naming it. With no plugins it runs nothing.
141
+ │ 2. Run context capture — always, for the `invocations` row:
142
+ │ commit + branch read from `.git` directly, `git rev-parse`
143
+ │ only when that reader does not understand the layout; dirty
144
+ │ reuses the GitFilesCache's status; CI-env detection,
145
+ │ host/os/arch. Never fails a run.
146
+ │ 3. Telemetry — only when a plugin has a `telemetry` hook or
147
+ │ RunOptions.telemetrySinks is passed: the run-context record
148
+ │ (adding captureWorkspaceIdentity) is built and
149
+ │ subscribeTelemetry collects the sinks; with ZERO sinks it
150
+ │ returns undefined and NOTHING subscribes (the no-telemetry
151
+ │ hot path is byte-identical).
152
+ │
153
+ ├─ Run-level state
154
+ │ • runId — UUIDv7 stamped once per `vx run` invocation; every
155
+ │ task in the resulting graph carries it.
156
+ │ • runStartHrTimeNs — hrtime.bigint() anchor; per-task wallclock
157
+ │ spans are stored relative to it.
158
+ │ • persistentRegistry — Map<taskId, Subprocess> of long-running
159
+ │ children.
160
+ │ • liveChildren — Set<Subprocess> of in-flight children; the
161
+ │ runner adds/removes each around its spawn.
162
+ │ • SIGINT/SIGTERM/SIGHUP handlers (removed in a finally): on
163
+ │ signal, forward it (SIGHUP as SIGTERM) to everything
164
+ │ in liveChildren + persistentRegistry (and kill any
165
+ │ running cache.inputs.runtime probe, C-65), wait
166
+ │ VX_KILL_GRACE_MS (2 s) for their groups, SIGKILL
167
+ │ what is still there, let the run finish its own
168
+ │ end (flush, teardown, cache close), then
169
+ │ die of the signal (SIGINT → 130, SIGTERM → 143,
170
+ │ SIGHUP → 129), so a shell script running vx stops
171
+ │ too; a run that handed a task the terminal exits
172
+ │ with that code instead, so Bun restores it. SIGHUP is registered because a task
173
+ │ runs in its own session, so a closing terminal
174
+ │ reaches vx and nothing else. A second signal
175
+ │ SIGKILLs and exits at once.
176
+ │
177
+ ├─ Cache acceleration (before scheduling)
178
+ │ • REMOTE PREFETCH (a cache layer with a remote only) — derive every
179
+ │ stable-key cacheable task's key up front (reusing the run's
180
+ │ hashCache memo) and fire the remote GETs in the background
181
+ │ under a bounded pool. Not awaited before scheduling (the
182
+ │ overlap is the point); drained before cache.close(). At most
183
+ │ one remote GET per key (shared in-flight map with the lazy
184
+ │ read-through).
185
+ │ • LOCAL SHORT-CIRCUIT (no remote layer, local reads on, ≥1
186
+ │ task) — derive the same stable keys and probe them in ONE
187
+ │ batched cache.getMany (a layer without getMany: cache.get once
188
+ │ per task under a bounded pool) → a `preProbed` map (hits AND
189
+ │ stable misses; execute reuses these probes) + a `restoreTier`
190
+ │ set (confirmed hits). Awaited before scheduling; never throws
191
+ │ (degrades to the normal schedule).
192
+ │
193
+ ├─ Scheduling (src/graph/scheduler.ts:runGraph — two-tier)
194
+ │ Two ready queues, each on its own lane:
195
+ │ - EXEC tier — dep-gated; ready when every dep completed; up
196
+ │ to N (concurrency) at once.
197
+ │ Priority: transitive-reverse-dependent count (bitset
198
+ │ closure), optionally overridden by a `priorities` map.
199
+ │ - RESTORE tier — confirmed stable local hits; ready
200
+ │ IMMEDIATELY (no dep gate, no failed-dep→skip check — their
201
+ │ key is dep-independent) at LOW priority, on a restore lane
202
+ │ of 2×N. The drain rule: exec-tier first; a restore never
203
+ │ takes an exec slot and the two lanes never wait on each
204
+ │ other — except at N = 1, where they share the one slot.
205
+ │ On failure: exec-tier dependents are marked `skipped` (exit 1,
206
+ │ durationMs 0, no spawn); independent siblings keep running.
207
+ │ The scheduler doesn't know about caching; the execute callback
208
+ │ is the seam. (An embedder running concurrent runs in one process
209
+ │ may pass a shared `inflight` map; admission.ts then dedupes
210
+ │ identical-key tasks across them. A plain `vx run` passes none.)
211
+ │
212
+ ├─ Per-task execution (src/orchestrator/execute-task.ts:executeTask)
213
+ │ Each task takes one of three paths:
214
+ │
215
+ │ A. GROUP — no `exec`.
216
+ │ Return success with a derived hash rolled up from upstream
217
+ │ outcomes. No spawn, no I/O. Wallclock = 0.
218
+ │
219
+ │ B. PERSISTENT — `exec.persistent` set.
220
+ │ 1. Build isolated env (essentials + passThrough + define +
221
+ │ <projectDir>/node_modules/.bin, then
222
+ │ <workspaceRoot>/node_modules/.bin, prepended to PATH).
223
+ │ 2. runPersistent — Bun.spawn the command; subscribe to
224
+ │ stdout/stderr chunk-by-chunk.
225
+ │ 3. Resolve `ready` when:
226
+ │ - no readyWhen → immediately on spawn
227
+ │ - readyWhen matches the output (complete lines OR the
228
+ │ trailing partial line) → on that match
229
+ │ - child exits before the match → reject with
230
+ │ PersistentReadyError('persistent task exited before
231
+ │ becoming ready (exit N)'); its output already streamed
232
+ │ through the logger
233
+ │ - readyWhen set and the timeout (exec.timeout, else the
234
+ │ run default) elapses first → fail, SIGTERM, SIGKILL
235
+ │ after the grace
236
+ │ 4. On ready: stash child in persistentRegistry; return
237
+ │ success. Downstream tasks unblock.
238
+ │ Note: cache + persistent is a config error (rejected by the
239
+ │ project loader). Persistent tasks never write to cache.
240
+ │
241
+ │ C. NORMAL — `exec.command` only.
242
+ │ 1. resolveInputs(files, env, runtime)
243
+ │ - glob inputs.files (git-backed, declared-outputs-
244
+ │ excluded, nested-projects-excluded)
245
+ │ - glob inputs.workspaceFiles from the WORKSPACE ROOT
246
+ │ (git-aware, NO boundary rule — the documented escape
247
+ │ hatch); resolved paths join the same input list
248
+ │ - read host process.env values for inputs.env names
249
+ │ - resolve inputs.runtime / workspaceRuntime command
250
+ │ output (deduped per run; a non-zero exit throws a
251
+ │ UserError that fails this task — its dependents skip,
252
+ │ independent tasks continue)
253
+ │ 2. hashTaskConfig + project package.json hash (both memoized
254
+ │ per run via HashCache)
255
+ │ 3. filterUpstreamHashes (apply cache.inputs.tasks filter)
256
+ │ 4. cache.key({ taskId, workspaceFingerprint,
257
+ │ projectPackageJsonHash, taskConfigHash,
258
+ │ forwardArgs, envValues, runtimeValues,
259
+ │ workspaceRuntimeValues, upstreamHashes,
260
+ │ pluginParts, inputFiles }) → 16-hex xxh3
261
+ │ (pluginParts is whatever the `key` stage contributed,
262
+ │ folded between the upstream keys and the input files —
263
+ │ see caching.md § Cache key derivation for the order.)
264
+ │ (Skipped when the local short-circuit pre-derived it.)
265
+ │ 5. If willRead (cache block + a read axis on):
266
+ │ consume the up-front probe when present, else
267
+ │ cache.get(hash)
268
+ │ · hit → up-to-date short-circuit when the on-disk tree
269
+ │ already matches; else cleanOutputs →
270
+ │ restoreOutputs → replay stored stdout →
271
+ │ return cache-hit / cache-hit-remote
272
+ │ · miss → fall through; cleanOutputs first (when
273
+ │ willWrite) so a stale prior build can't
274
+ │ survive the fresh exec
275
+ │ 6. Build isolated env. For a cacheable task,
276
+ │ describeTaskInputs({ captureInto }) then describes the input
277
+ │ set ONCE, before the spawn: the executor gets the input
278
+ │ values and the save reuses the captured per-component input
279
+ │ fingerprint (a re-fold over the memos, no I/O).
280
+ │ 7. runCommand (or runSandboxed when `sandbox` is declared) →
281
+ │ Bun.spawn shell with `command` + forwardArgs
282
+ │ (shell-quoted). Buffer chunks via onStdout/onStderr.
283
+ │ exec.timeout SIGTERMs an overrun → real `failed`, never
284
+ │ cached. An attempt that ends while the run is STOPPING
285
+ │ (Ctrl-C teardown) classifies as `aborted` instead, whatever
286
+ │ its exit — not counted, not recorded, not retried.
287
+ │ 8. On exit 0 + willSave (willWrite, no tainted upstream):
288
+ │ keyStillTrue() — if the workspace fingerprint or an
289
+ │ input moved since the key was taken, the result stands
290
+ │ but the save is withheld (said on the status line).
291
+ │ saveMiss — in the slot: resolveOutputs(outputs) +
292
+ │ resolveWorkspaceOutputs → file lists, and the
293
+ │ project's git snapshot notes the written output paths
294
+ │ so a same-project downstream task doesn't re-spawn git
295
+ │ unless its globs overlap. Deferred onto the run's save
296
+ │ lane (2×concurrency wide): cache.save(...) — pack
297
+ │ <hash>.tar.zst with stdout + outputs/<rel>
298
+ │ (+ workspace-outputs/<rel-to-root>), upsert entries +
299
+ │ output_files + entry_inputs rows in one transaction.
300
+ │ The slot frees at once; dependents wait on the save's
301
+ │ `landed` promise (the scheduler's settledOf). Under a
302
+ │ LayeredCache with remote writes on, the remote upload
303
+ │ fires in the BACKGROUND (drained at end of run).
304
+ │ 9. Return TaskOutcome { node, status, exitCode, durationMs,
305
+ │ hash, cpuMs?, peakRssBytes?, restored?,
306
+ │ wallclockStartNs, wallclockEndNs }.
307
+ │
308
+ └─ End-of-run
309
+ 1. SIGTERM dependency-only persistent children; persistent tasks
310
+ the user REQUESTED, those a requested group stands for (through
311
+ nested groups, not through a one-shot), and the persistent tasks
312
+ they depend on, are kept alive (see below).
313
+ 2. Run summary footer (projects / tasks / cache meters + info +
314
+ time + result) — counts only real tasks (with `exec`); group tasks
315
+ don't pollute the totals; failure frames replay just above it.
316
+ 3. Optional --summarize JSON (default <cacheDir>/runs/<run_id>.json)
317
+ and --profile Chrome-trace JSON (default profile.json).
318
+ 4. recordRunBundle — one transaction writing a `runs` row per real
319
+ task plus the `invocations` header row (command, git/CI/host
320
+ context, tags, counts). Group + aborted tasks skipped; a run
321
+ a signal stopped records nothing.
322
+ 5. Telemetry: emit the RunSummaryRecord to every active sink +
323
+ await their flush (crash-isolated; skipped when no sink).
324
+ 6. Drain background remote prefetches/uploads; cache.close();
325
+ sandbox teardown when any task was sandboxed.
326
+ 7. Return { ok, outcomes }; ok = every task ended success or a
327
+ cache hit (any failed/skipped/aborted → ok = false → exit 1),
328
+ and no persistent child exited on its own before the run
329
+ stopped it.
330
+ 8. FOREGROUND ONLY: if the user requested persistent tasks (dev
331
+ servers) and nothing else failed (or under `--continue=always`),
332
+ the process now blocks until ONE of them, or a persistent task they
333
+ depend on, exits — the
334
+ summary is already printed, `▸ <id> running` rows list what's
335
+ alive, Ctrl-C reaps the process group. That first exit ends the
336
+ session: the others are torn down (SIGTERM, grace, SIGKILL) and
337
+ a non-zero exit makes the run exit 1, so a script's `vx run dev`
338
+ fails when the server it started fell over. That server then reads
339
+ `failed` with its own exit in the rewritten `--summarize` and in the
340
+ report `--report-file` writes; one a Ctrl-C stopped does not. The
341
+ stdout `--report` prints above the footer, so it reads as the
342
+ footer does, before the wait. Under
343
+ `holdPersistent` (the watch loop) run() instead returns them on
344
+ `RunSummary.persistent`, still running, for the caller to stop;
345
+ one that dies on its own after that is said, its `stop()` is not.
346
+ ```
347
+
348
+ ## One command per task
349
+
350
+ `exec.command` is a single shell command — there's no multi-step
351
+ sequence. Three ways to chain:
352
+
353
+ - **Shell composition** — `&&`, `;`, pipes. The shell is the API.
354
+ ```ts
355
+ exec: {
356
+ command: 'gen && tsc && cp -r assets dist/'
357
+ }
358
+ ```
359
+ - **Separate tasks** linked by `dependsOn`:
360
+ ```ts
361
+ codegen: { exec: { command: 'gen' }, ... },
362
+ build: { exec: { command: 'tsc' }, dependsOn: ['codegen'], ... },
363
+ ```
364
+ - **Group task** that fans out:
365
+ ```ts
366
+ release: {
367
+ dependsOn: ['build', 'test', 'package']
368
+ }
369
+ ```
370
+
371
+ Per-task caching is the right granularity for invalidation. Splitting
372
+ gives you per-step caching naturally; combining with `&&` gives you
373
+ one cache slot for the whole chain.
374
+
375
+ A task is over when its command's own process exits. What a descendant
376
+ writes after that — one the command detached with `setsid … &`, or
377
+ daemonized — is not the task's output: the save has already run, and a
378
+ later hit's restore removes it ([caching](./caching.md#cache-write)).
379
+ `wait` for what the command backgrounds.
380
+
381
+ ## Env isolation
382
+
383
+ The child process gets, in priority order (lowest first):
384
+
385
+ 1. **Essential allowlist** (`PATH`, `HOME`, `SHELL`, `USER`, `LOGNAME`,
386
+ `TMPDIR`, `TEMP`, `TMP`, `LANG`, `LC_ALL`, `LC_CTYPE`, `TERM`,
387
+ `COLORTERM`, `FORCE_COLOR`, `NO_COLOR`, `CI`, `NODE_OPTIONS`,
388
+ `COREPACK_HOME`, `PNPM_HOME` — the list is
389
+ `ESSENTIAL_ENV` in `src/exec/env.ts`).
390
+ 2. **`exec.env.passThrough`** names → values from host `process.env`.
391
+ 3. **`exec.env.define`** literal name/value pairs.
392
+ 4. **PATH augmentation** — `<projectDir>/node_modules/.bin`, then
393
+ `<workspaceRoot>/node_modules/.bin`, are prepended so installed
394
+ tools (`oxlint`, `vite`, etc.) work without `npx`. Never a sibling
395
+ project's bin; those stay invisible. A bin directory whose path holds
396
+ PATH's delimiter (`:`) is left out: PATH cannot name it, and split it
397
+ became two entries, one relative to the task's cwd. A task whose shell exits 127 or 126 gets one more frame line
398
+ (`orchestrator/shell-verdict.ts`): for a bare word, that 127 is the
399
+ shell's "command not found", the word (when the command is a plain
400
+ `word args…`), the two bin directories vx puts first, and that a
401
+ sibling project's bin is never visible (or, when a bin directory was
402
+ left out for its `:`, that directory and the fix: move the
403
+ workspace), and on 126 `chmod +x`; for
404
+ a word with a slash, what the file says — missing (the resolved
405
+ path), a directory, not executable by this user, a `#!` interpreter that does
406
+ not exist (a CRLF line ending is named as such), or no `#!` line.
407
+ An exit above 128 gets the same line for its signal: which one, and
408
+ what sends it (the OOM killer, a crash in native code, an abort, a
409
+ reader that left a pipe, a ulimit, a seccomp refusal); vx's own
410
+ timeout and a shutdown's SIGTERM keep their own lines, and a task the
411
+ run's stop killed (the SIGKILL past the grace too) gets none.
412
+
413
+ Anything not in these four layers is invisible to the child, except
414
+ the two vx sets itself — `VX_RUN_WORKSPACE` and `VX_RUN_TASK` — so a
415
+ task that shells out to `vx run` in its own workspace is refused
416
+ before it forks without bound, and `npm_execpath`, the workspace's
417
+ package manager (unkeyed; `schema.md` § `env`). A sandboxed task also gets the sandbox's own
418
+ proxy, CA and `TMPDIR` values
419
+ ([`modules/sandbox-runtime.md`](./modules/sandbox-runtime.md#the-environment-srt-sets)). This prevents incidental env leakage
420
+ between machines and gives reproducible runs.
421
+
422
+ The allowlist + isolation contract lives in
423
+ [`modules/env.md`](./modules/env.md) and is the only field the
424
+ contract assumes for "what every command needs to function" on
425
+ Linux and macOS (Windows is WSL, which is Linux). Adding to the allowlist would be a deliberate
426
+ schema-extending change (consumer code expects a particular set;
427
+ broader access has cache-stability implications).
428
+
429
+ ## Failure handling
430
+
431
+ | Failure | Behavior |
432
+ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
433
+ | Exec exits non-zero | Task is `failed`; cache NOT written; output streamed live + the failure frame replays at run end |
434
+ | `exec.timeout` overrun | SIGTERM to the task's group, SIGKILL for whoever is left after the grace; task is `failed` (timed out), exit 143 (137 when the SIGKILL took it, and the line says so), never cached |
435
+ | Child killed by Ctrl-C teardown (SIGINT/SIGTERM/SIGHUP), or by an embedder's `RunOptions.signal` abort — which also completes every never-started task `aborted` | Task is `aborted` — not counted, not recorded. An attempt that ends while the run is stopping is `aborted` whatever its exit: a trap that exits 0 is not cached, and a failure is not retried (item 962). What it printed still shows in its frame. A child killed by a signal while the run is NOT stopping (a supervisor's SIGTERM, a `kill` from another shell) is `failed (exit 143, 128 + SIGTERM)`: retried, shown, and fail-fast trips (item 1100) |
436
+ | `execute()` throws (internal error) | Task marked `failed`; stderr written `[vx] internal error in <id>` (a `UserError` reports plainly); a plugin executor's throw reports plainly as `plugin '<p>' (executor '<e>') failed in execute: <reason>` (C-63, C-85) |
437
+ | Persistent task exits before ready | Task marked `failed` with `exited before becoming ready (exit N)`; its output already streamed live; `aborted` when the run's stop killed it (C-62) |
438
+ | Upstream task fails | Under the default `--continue=deps-ok`, dependents marked `skipped` (exit 1, durationMs 0); no command runs — EXCEPT a restore-tier task, whose confirmed cache hit still restores (its key is dep-independent). `--continue=never` stops dispatch; `--continue=always` runs dependents, never saved |
439
+ | Sandbox violation (macOS monitor / Linux structural) | Task is `failed`; violations render in the frame; nothing cached |
440
+ | Remote-cache error (500, timeout, corrupt artifact) | Degrades to a cache miss; never fails the run |
441
+ | No pnpm-workspace.yaml or package.json in cwd or any parent | `findWorkspaceRoot` throws (UserError); `vx run` exits 1 |
442
+ | Same-project task referenced in `dependsOn` not declared | `buildTaskGraph` throws with the offending edge |
443
+ | Duplicate workspace package name | `listProjects` throws with both paths |
444
+ | Cycle in task graph | `detectCycle` throws with the cycle path |
445
+ | Malformed config | `loadProjectConfigs` throws (UserError) with file + field |
446
+ | `cache.inputs.runtime` command exits non-zero | UserError naming the command + exit code; that task is `failed`, dependents skip |
447
+
448
+ By default (`--continue=deps-ok`) failures don't kill the scheduler —
449
+ independent tasks already in flight finish, and unrelated tasks not yet
450
+ started still run. `--continue=never` stops dispatch at the first
451
+ failure; `--continue=always` runs dependents too
452
+ ([`cli.md` § Failure propagation](./cli.md#failure-propagation----continue)). The
453
+ overall exit code is 1 if any task ended in `failed`, `skipped` or
454
+ `aborted` status, or a persistent child exited before the run stopped
455
+ it.
456
+
457
+ ## Output capture and rendering
458
+
459
+ The orchestrator emits `RunEvent`s through an in-process bus; the
460
+ terminal renderer is the always-on subscriber (plugins, telemetry, and
461
+ wire forwarders attach beside it). What renders:
462
+
463
+ - **Flow-aware policy.** What gets rendered depends on the run's
464
+ intent: FOCUSED (no selection flag) streams the requested task's
465
+ output raw and live and silences successful dependencies; BROAD
466
+ (`--all` / `--filter` / `--affected`) prints news only — one grid
467
+ one-liner per executed task, failure frames replayed at run end,
468
+ silence for cache hits; truthy `CI` env (and the programmatic
469
+ default) keeps full grouped output. `--output-logs` overrides
470
+ everything. Full table in [`cli.md`](./cli.md#output).
471
+ - **The glyph grid.** Reported task lines share one column grid —
472
+ `<glyph> <time> <status> <cache> <name>`. Glyph SHAPE = cache axis
473
+ (`⏺` miss / `►` fresh / `⇢` local / `⇣` remote / `◼` failed / `⊘`
474
+ skipped, plus `▸` for a pinned persistent row; a live worker row has
475
+ no glyph — its ticking elapsed time leads instead); glyph COLOR + the
476
+ status word = task axis
477
+ (success / failed / skipped / running); the cache word (miss /
478
+ fresh / local / remote) spells it out.
479
+ - **Buffered, framed (non-focused paths).** `runCommand` listens to
480
+ the child's stdout/stderr and calls `onStdout` / `onStderr` per
481
+ chunk. Outside focused streaming, the default logger buffers the
482
+ chunks per-task and dumps the full body as a framed block on task
483
+ completion. No per-line prefix, no interleaving between concurrent
484
+ tasks.
485
+ - **Cache write.** The captured stdout — its first and last 8 MiB,
486
+ the cut middle named — is stored in the entry; replay is stdout-only.
487
+ - **Cache hit replay.** The stored stdout is fed through the same
488
+ logger path, so it renders per the active flow (streamed raw for a
489
+ focused requested task, framed in full mode, silent in broad).
490
+ - **Live stream for failures.** Even though cached output is not
491
+ written for failures, the live stream means the user sees the
492
+ failure as it happens; the full frames replay together at run end,
493
+ right above the summary.
494
+ - **Status region.** On TTY stdout outside CI, a multi-row region
495
+ tracks the run live: a blank separator, pinned `▸` persistent rows,
496
+ one row per worker slot (the ticking elapsed time IS the motion —
497
+ no spinner), and the live summary section (the same meters as the
498
+ final footer). Redrawn in place; forced redraws coalesce under a
499
+ 30 ms floor; erased before the summary prints. All default-logger
500
+ writes serialize through one writer so content and the region never
501
+ interleave.
502
+ - **GitHub Actions.** With `GITHUB_ACTIONS` truthy in full mode, task
503
+ blocks collapse under `::group::` commands; failures stay open and
504
+ emit `::error` annotations.
505
+
506
+ A task's stdout and stderr are pipes, so `echo x > /dev/stdout` and
507
+ `cmd | tee /dev/stderr` work as in a terminal or under Turbo. Bun's own
508
+ `'pipe'` is a socketpair, which Linux will not open by path, and they
509
+ failed with "No such device or address" until X-113
510
+ (`tests/runner.test.ts` › "a task's stdout and stderr are pipes it can
511
+ open by path"; sandboxed in `tests/sandbox-runtime.unsafe.test.ts`).
512
+
513
+ There is no special handling for binary output. Stdin is never the
514
+ terminal unless the task declares `exec.interactive` and vx's stdin is
515
+ a TTY (schema.md § `interactive`): then the task gets vx's stdin,
516
+ stdout and stderr, runs alone, and nothing of it passes through vx.
517
+ Otherwise the rule splits on `exec.persistent`:
518
+
519
+ - **A one-shot task** gets `'ignore'`: it sees EOF at once, so a task
520
+ that reads stdin finishes instead of hanging CI
521
+ (`tests/runner.test.ts` › "a task that reads stdin sees EOF at once,
522
+ never a hang"). A task that needs TTY input declares
523
+ `exec.interactive`.
524
+ - **A persistent task** gets a pipe vx holds and never writes: it stays
525
+ open while vx lives and ends when vx does. A dev server that exits on
526
+ stdin EOF — esbuild `--watch`, the Vite case in turborepo#8915 —
527
+ became ready and exited 0 under `'ignore'`, and `vx run dev` ended
528
+ green (fixed 2026-09-24, `tests/keep-alive.test.ts` › "a server that
529
+ exits on stdin EOF stays up while vx runs"). Each task has its own
530
+ pipe, so several persistent tasks never compete for keystrokes; none
531
+ receives any, so a server's keyboard shortcuts (Vite's `h`, `r`) do
532
+ not reach it unless it declares `exec.interactive`. Not the terminal, because
533
+ several servers would steal each other's input, and a CI's
534
+ `/dev/null` stdin is the same EOF. Turbo's stream mode draws the same
535
+ line: its pin is named `nonpersistent_task_sees_eof_on_stdin`.
536
+
537
+ A one-shot task ends when its shell exits, not when its pipes close. A
538
+ process the task left running (`server & echo up`) keeps stdout and
539
+ stderr open, so vx reads on for 250 ms after the shell exits and then
540
+ stops. That bound is what keeps a leftover server from hanging the run.
541
+ What the leftover writes after it is not captured — not in the frame,
542
+ not in the cache entry — and the frame says so with one line on stderr:
543
+ `[vx] output after the task's shell exited was cut: a process it left
544
+ running still held its stdout/stderr 250 ms later`. Until 2026-09-24 the
545
+ cut was silent (nx#35302 reproduced on vx; `tests/runner.test.ts` ›
546
+ "output a backgrounded child writes after the drain bound is cut, and
547
+ the frame says so"). A task whose output matters waits for what it
548
+ starts (`wait`).
549
+
550
+ Every surface uses one outcome vocabulary: task axis `success` /
551
+ `failed` / `skipped` / `aborted` (+ `running` live), cache axis
552
+ `miss` / `up-to-date` (fresh) / `local` / `remote`. The `--verbosity 1`
553
+ table spells the combinations out as `success` / `restored-local` /
554
+ `restored-remote` / `up-to-date`; `--report` keeps the two axes as its
555
+ Status and Cache columns.
556
+
557
+ The colors / framing modules:
558
+
559
+ - `orchestrator/colors.ts` — ANSI truecolor (`ansi-16m`), gated by
560
+ `NO_COLOR` / `FORCE_COLOR` / `isTTY`. Programmatic-logger callers
561
+ always see plain text.
562
+ - `orchestrator/framed-output.ts` — the `┌─` frames, the glyph grid
563
+ (`formatTaskRow`), one-liners, persistent markers.
564
+ - `orchestrator/status-line.ts` — the serialized writer + the worker
565
+ region.
566
+ - `orchestrator/logger.ts` — composes them; resolves the output view
567
+ and applies the per-flow visibility policy.
568
+ - `orchestrator/summary.ts` — the closing footer (wordmark rule,
569
+ projects/tasks/cache meters, info, time and result rows).
570
+
571
+ ## Concurrency
572
+
573
+ - **Default** — the cores this process may use: `navigator.hardwareConcurrency`
574
+ capped by the cgroup CPU quota a container runs under (`cpu.max` on v2,
575
+ `cpu.cfs_quota_us` on v1, an ancestor's quota binding too; a 1.5-core
576
+ quota is two workers, never below one — `util/cgroup.ts`), or
577
+ `vx.workspace.ts`'s `concurrency` field when set. Inside a container the
578
+ raw count is the HOST's, and eight workers on a two-core quota is
579
+ oversubscription by four; `--concurrency <n>%` is a percentage of the
580
+ same capped count.
581
+ - **Override** — `--concurrency N` (CLI). CLI wins over workspace
582
+ config.
583
+ - **`concurrency: 1`** serializes everything while still respecting
584
+ topo order: execs and restores share the one slot.
585
+ - The scheduler never exceeds the cap; tasks queue. Above 1,
586
+ restore-tier tasks run on their own lane, `2 × concurrency` wide,
587
+ and never take an exec slot.
588
+ - Failure of a task doesn't pause the scheduler — independent
589
+ siblings continue running and starting (unless `--continue=never`).
590
+
591
+ ### Executor pools
592
+
593
+ `concurrency` counts LOCAL worker slots. An executor that runs its tasks
594
+ somewhere else declares its own `capacity`, and the tasks placed on it are
595
+ admitted against that number instead — so a 64-wide worker pool is not
596
+ throttled by a 10-core laptop, and a `--concurrency 1` run still keeps the
597
+ pool full. A `capacity` that is not a positive integer is refused:
598
+ `plugin '<p>' returned executor '<e>' with capacity <v>: it must be a
599
+ positive integer`.
600
+
601
+ - **Placement is decided once per task, before scheduling.** `run()` asks
602
+ the declared executors, in order, which one takes each task (see
603
+ [`schema.md` § `remote`](./schema.md#remote-optional) for what pins a
604
+ task here); the scheduler then knows which pool every task will occupy.
605
+ - **A pooled task holds nothing on this machine.** The `admit` stage is
606
+ asked only for tasks about to run here, with the tasks running here; a
607
+ task on an executor pool is never asked and never counted, so it can
608
+ never park behind a local reservation.
609
+ - **Restore-tier tasks always use the restore lane**, even when placed
610
+ on an executor with a `capacity`: a restore is local disk work.
611
+ - **No pooled executor declared = the legacy path.** With every task on
612
+ the local pool the admission gate is byte-identical to before pools
613
+ existed.
614
+
615
+ ## Cache control flags
616
+
617
+ `--no-cache` turns all four cache axes off: every task runs, nothing
618
+ is read or written, and `cleanOutputs` is skipped — the user is
619
+ debugging and managing the tree themselves; silently wiping `dist/`
620
+ mid-debug would be hostile.
621
+
622
+ `--force` turns only the READ axes off: every task re-executes, but
623
+ fresh artifacts are still written (outputs ARE cleaned so the saved
624
+ snapshot is clean). `--cache=<spec>` gives per-layer control. Full
625
+ semantics: [`cli.md` § Cache control](./cli.md#cache-control---cache---no-cache---force)
626
+ and [`caching.md` § Cache policy](./caching.md#cache-policy-readwrite-axes).
627
+
628
+ ## Planning paths (`--dry`, `--graph`)
629
+
630
+ Both short-circuit execution. The planner (`orchestrator/plan.ts`)
631
+ runs the same setup steps — workspace discovery, config load, package
632
+ graph, task graph — and the same per-task hash derivation, then
633
+ probes the cache. The probe is read-only; against a remote layer it
634
+ is an existence check only (no artifact download, no ingest). The one
635
+ deliberate side effect: `cache.inputs.runtime` / `workspaceRuntime`
636
+ probe commands DO run, because predicting a key requires resolving
637
+ them — keep runtime inputs side-effect-free.
638
+
639
+ The two flags differ only in output format:
640
+
641
+ - `--dry[=text|json]` → text (default) or JSON list of predicted
642
+ outcomes per task: `hit-local`, `hit-remote`, `miss`, `no-cache`,
643
+ `group`. Formatter: `cli/plan-format.ts:formatPlanText` /
644
+ `formatPlanJson`. When the workspace declares more than one executor,
645
+ each line also names the executor the task would be PLACED on — the only
646
+ surface that makes `exec.remote` checkable before the run. Resolving the
647
+ executors is a plugin-factory call, the same class the cache capability
648
+ already makes at plan time; a plugin that throws costs the label, never
649
+ the plan.
650
+ - `--graph[=<path>]` → Graphviz DOT, colored by predicted status.
651
+ Pipe to `dot` for SVG/PNG render. Formatter:
652
+ `formatGraphDot`.
653
+
654
+ Mutually exclusive: `--dry` and `--graph` together is a parse error.
655
+ Combining either with `--summarize`, `--profile`, `--report` or
656
+ `--report-file` is a parse error (those need a real run; the last two
657
+ were accepted and wrote nothing until item 992).
658
+
659
+ ## Run artifacts
660
+
661
+ - **`--summarize[=<path>]`** — per-run JSON to
662
+ `<cacheDir>/runs/<run_id>.json` by default (or the explicit path).
663
+ Mirrors the `runs` table shape — one task entry per real task, hits
664
+ included, with status, hash, duration, cpu_ms, peak RSS, hrtime spans
665
+ (bigint serialized as strings to preserve ns precision).
666
+ - **`--profile[=<path>]`** — Chrome-trace JSON of every task's
667
+ wallclock span. Default path: `profile.json` (cwd-relative). One
668
+ `tid` per project so concurrent tasks render on distinct lanes.
669
+ Open with `chrome://tracing` or https://ui.perfetto.dev.
670
+ - **`--report[=markdown]`** — a markdown table to stdout
671
+ above the footer (CI step summaries), through `RunOptions.beforeFooter`.
672
+ - **`--report-file <path>`** — the same report APPENDED to `<path>`
673
+ (`$GITHUB_STEP_SUMMARY`).
674
+
675
+ Writers live in `orchestrator/run-artifacts.ts:writeRunSummary` /
676
+ `writeRunProfile` and `orchestrator/run-report.ts`. A `--summarize` or
677
+ `--profile` write error is surfaced via `log.status`, a `--report-file`
678
+ one on stderr (`cli/run.ts`); neither changes the run's exit code — the
679
+ run already happened.
680
+
681
+ ## Why each rule
682
+
683
+ - **Cache failures are never cached.** Caching a failure prevents
684
+ retry flows. The next run gets the same failure even after the
685
+ user fixes the cause.
686
+ - **Group tasks are silent in the summary.** Including them in the
687
+ count is confusing — "3 of 4 cached" when one was a group that
688
+ ran nothing isn't informative.
689
+ - **Project scope defaults to cwd.** Most invocations are "build/
690
+ test the thing I'm working on". `--all` / `--filter` exist for
691
+ the workspace-wide case.
692
+ - **The scheduler doesn't bail on first failure by default.** A flaky
693
+ test failing shouldn't stop an unrelated build. Independent siblings
694
+ continue; only dependents are skipped. `--continue=never` opts into
695
+ fail-fast.
696
+ - **Misses own the worker pool.** A miss is the critical path, so
697
+ the exec lane is `concurrency` wide and only executions take it.
698
+ Restores run on their own lane, so warm work never queues behind
699
+ ordering it doesn't need and never takes a slot from real work.
700
+ - **Forwarded args don't reach upstream tasks.** Otherwise
701
+ `vx run build -- --watch` would set `--watch` on every upstream's
702
+ build, and upstream cache keys would partition by CLI args that
703
+ don't change their behavior.
704
+ - **`recordRunBundle` skips group tasks.** They aren't real runs;
705
+ analytics queries that sum duration would double-count without it.
706
+ - **A requested persistent task keeps the run alive.** The dev server
707
+ IS the point of the run; tearing it down the instant it became
708
+ ready would make `vx run dev` useless.