This document traces what happens between vx run build typed at the
terminal and a task succeeding or failing. Read it alongside
architecture.md (module map) and
caching.md (cache mechanics).
┌─ CLI dispatch (src/bin.ts → src/cli.ts → src/cli/run.ts)
│ 1. bin.ts spawns; forwards process.argv to cli.run().
│ 2. cli.ts dispatches by subcommand (run / cache / help / version).
│ 3. cli/run.ts:parseRunArgs(argv) → RunArgs (validated).
│ 4. cli/run.ts:runCmd resolves the project scope:
│ - bare positionals respect --all / --filter / --affected / cwd
│ - anchored positionals (pkg#task) target directly
│ - no positionals + TTY → interactive picker → pkg#task
│ 5. Programmatic options are assembled and orchestrator.run() is
│ called.
│
├─ Workspace setup (src/orchestrator.ts:run)
│ 1. findWorkspaceRoot(cwd) — walks up; first match wins across
│ pnpm-workspace.yaml, package.json with a `workspaces` field
│ (npm/yarn/bun), or a bare package.json (single-project mode).
│ 2. loadWorkspace — parses the appropriate manifest. Bun.YAML
│ for pnpm; Bun.file().json() for the package.json forms.
│ 3. loadWorkspaceConfig — optional vx.workspace.{ts,mts,js,mjs}
│ at the root. Validates shape; returns null if absent.
│ 4. listProjects — globs every workspace member's package.json,
│ finds sibling vx.config.* files, detects duplicate package
│ names (hard error with both paths).
│ 5. loadProjectConfig — per project. Native Bun await import()
│ with a content-hash query-string bust so config edits across
│ same-process calls are picked up. Loader validates each
│ TaskConfig shape (UserError on malformed).
│ 6. buildPackageGraph — workspace dep edges from package.json.
│ 7. computeNestedProjectDirs — set of projects rooted inside each
│ project. Passed to every glob pass for boundary enforcement.
│ 8. computeWorkspaceFingerprint — sha256 over every supported
│ lockfile + pnpm-workspace.yaml found at the root. Computed
│ once; reused for every task's cache key.
│
├─ Task selection (graph/task-graph.ts:expandRequested)
│ Bare task names fan out across the resolved candidate projects
│ (every project that declares the task). Anchored entries
│ (`pkg#task`) resolve directly. Duplicates are deduped.
│ Empty result → run returns `{ ok: false, outcomes: [] }` —
│ no task is treated as a CI footgun.
│
├─ Task graph (src/graph/task-graph.ts:buildTaskGraph)
│ Starting from the resolved {project, task}[] pairs, walk
│ dependsOn:
│ - 'name' → same-project task
│ - '^name' → task in the nearest deps declaring it
│ (frontier walk; non-holders passed through)
│ - 'pkg#name' → specific package's task
│ Excluded edges (per --excludeDependencies) are dropped.
│ Detect cycles — throws with the path.
│ Each node carries: id (`${project}#${task}`), projectName,
│ projectDir, taskName, config, sorted deps, `requested: boolean`
│ (was this an explicit user request vs a dep-pulled expansion).
│
├─ Cache construction
│ 1. new Cache(cacheDir) — local SQLite + on-disk v13 store.
│ 2. wrapWithRemoteCache(local, log) — when VX_REMOTE_CACHE_URL +
│ _TOKEN are set, wraps in LayeredCache(local, remote).
│ 3. Either way, executeTask consumes the `CacheLayer` interface.
│
├─ Run-level state
│ • runId — ULID stamped once per `vx run` invocation; every
│ task in the resulting graph carries it.
│ • runStartHrTimeNs — hrtime.bigint() anchor; per-task wallclock
│ spans are stored relative to it.
│ • persistentRegistry — Map<taskId, Subprocess> of long-running
│ children. SIGTERMed at end-of-run.
│ • liveChildren — Set<Subprocess> of in-flight children; the
│ runner adds/removes each around its spawn.
│ • SIGINT/SIGTERM handlers (removed in a finally): on signal,
│ SIGTERM everything in liveChildren +
│ persistentRegistry, close the cache, exit 128+signo
│ (SIGINT → 130, SIGTERM → 143).
│
├─ Scheduling (src/graph/scheduler.ts:runGraph)
│ Up to N tasks concurrently, ordered topologically.
│ For each ready node: execute(node, upstream) → TaskOutcome.
│ On failure: dependents are marked `skipped` (exit code 1,
│ durationMs 0, no spawn); independent siblings keep running.
│ The scheduler doesn't know about caching; the execute callback
│ is the seam.
│
├─ Per-task execution (src/orchestrator/execute-task.ts:executeTask)
│ Each task takes one of three paths:
│
│ A. GROUP — no `exec`.
│ Return success with a derived hash rolled up from upstream
│ outcomes. No spawn, no I/O. Wallclock = 0.
│
│ B. PERSISTENT — `exec.persistent` set.
│ 1. Build isolated env (essentials + passThrough + define +
│ <projectDir>/node_modules/.bin in PATH).
│ 2. runPersistent — Bun.spawn the command; subscribe to
│ stdout/stderr chunk-by-chunk.
│ 3. Resolve `ready` when:
│ - no readyWhen → immediately on spawn
│ - readyWhen matches the output (complete lines OR the
│ trailing partial line) → on that match
│ - child exits before either → reject with the captured stderr
│ 4. On ready: stash child in persistentRegistry; return
│ success. Downstream tasks unblock.
│ 5. End-of-run: orchestrator SIGTERMs every registry entry
│ and waits for exit.
│ Note: cache + persistent is a config error (rejected by the
│ project loader). Persistent tasks never write to cache.
│
│ C. NORMAL — `exec.command` only.
│ 1. resolveInputs(files, env)
│ - glob inputs.files (gitignore-aware, declared-outputs-
│ excluded, nested-projects-excluded)
│ - glob inputs.workspaceFiles from the WORKSPACE ROOT
│ (git-aware, NO boundary rule — the documented escape
│ hatch); resolved paths join the same input list
│ - read host process.env values for inputs.env names
│ 2. hashTaskConfig (resolved config JSON)
│ 3. hashProjectPackageJson (project package.json bytes)
│ 4. filterUpstreamHashes (apply cache.inputs.tasks filter)
│ 5. cache.key({ taskId, workspaceFingerprint,
│ projectPackageJsonHash, taskConfigHash,
│ envValues, inputFiles, upstreamHashes,
│ forwardArgs }) → 16-hex xxh3
│ 6. If cache is on AND `cache` block exists:
│ cache.get(hash)
│ · hit → cleanOutputs → restoreOutputs → replay logs →
│ return cache-hit (durationMs = restore op time)
│ · miss → fall through; cleanOutputs first so a stale
│ prior build can't survive the fresh exec
│ 7. Build isolated env.
│ 8. runCommand → Bun.spawn shell with `command` + forwardArgs
│ (shell-quoted). Buffer chunks via onStdout/onStderr (the
│ logger flushes them as one framed block on completion).
│ 9. On exit 0 + cache enabled:
│ resolveOutputs(outputs) + resolveWorkspaceOutputs(
│ outputs.workspaceFiles, root-anchored) → file lists
│ cache.save(...) — pack <hash>.tar.zst with stdout +
│ outputs/<rel> (+ workspace-outputs/<rel-to-root> when
│ declared), upsert SQLite rows.
│ 10. Return TaskOutcome { node, status, exitCode, durationMs,
│ hash, stdout, stderr, cpuMs?, peakRssBytes?,
│ wallclockStartNs, wallclockEndNs }.
│
└─ End-of-run
1. SIGTERM every persistent child; Promise.allSettled their exits.
2. Run summary (Tasks / Cached / Time) — counts only real tasks
(with `exec`); group tasks don't pollute the totals.
3. Optional --summarize JSON (default <cacheDir>/runs/<run_id>.json).
4. Optional --profile Chrome-trace JSON (default profile.json).
5. recordRun() once per executed task. Group tasks skipped.
6. cache.close().
7. Return { ok, outcomes }; ok = every real task ended success
or cache-hit (any failed/skipped → ok = false → exit 1).
exec.command is a single shell command — there's no multi-step
sequence. Three ways to chain:
- Shell composition —
&&,;, pipes. The shell is the API.exec: { command: 'gen && tsc && cp -r assets dist/' }
- Separate tasks linked by
dependsOn:codegen: { exec: { command: 'gen' }, ... }, build: { exec: { command: 'tsc' }, dependsOn: ['codegen'], ... },
- Group task that fans out:
release: { dependsOn: ['build', 'test', 'package'] }
Per-task caching is the right granularity for invalidation. Splitting
gives you per-step caching naturally; combining with && gives you
one cache slot for the whole chain.
The child process gets, in priority order (lowest first):
- Essential allowlist (
PATH,HOME,SHELL,TMPDIR,LANG,TERM,COLORTERM,FORCE_COLOR,NO_COLOR,CI,NODE_OPTIONS, plus Windows essentials likeSYSTEMROOT). exec.env.passThroughnames → values from hostprocess.env.exec.env.defineliteral name/value pairs.- PATH augmentation —
<projectDir>/node_modules/.binis prepended so local tools (oxlint,vite, etc.) work withoutnpx. Only the project's own bin; sibling-project bins stay invisible.
Anything not in these four layers is invisible to the child. This prevents incidental env leakage between machines and gives reproducible runs.
The allowlist + isolation contract lives in
modules/env.md and is the only field the
contract assumes for "what every command needs to function on
*nix / Windows". Adding to the allowlist would be a deliberate
schema-extending change (consumer code expects a particular set;
broader access has cache-stability implications).
| Failure | Behavior |
|---|---|
| Exec exits non-zero | Task is failed; cache NOT written; live stream + outcome.stderr |
execute() throws (internal error) |
Task marked failed; stderr written [vx] internal error in <id> |
| Persistent task exits before ready | Task marked failed; outcome.stderr carries the captured output |
| Upstream task fails | Dependents marked skipped (exit 1, durationMs 0); no command runs |
| Workspace yaml missing | findWorkspaceRoot throws (UserError); vx run exits 1 |
Same-project task referenced in dependsOn not declared |
buildTaskGraph throws with the offending edge |
| Duplicate workspace package name | listProjects throws with both paths |
| Cycle in task graph | detectCycle throws with the cycle path |
| Malformed config | loadProjectConfig throws (UserError) with file + field |
--no-cache AND outputs cleaned by user |
Build is re-run from scratch; nothing read from cache |
Failures don't kill the scheduler — independent tasks already in
flight finish, and unrelated tasks not yet started still run. The
overall exit code is 1 if any task ended in failed or skipped
status.
- Flow-aware policy. What gets rendered depends on the run's
intent: FOCUSED (no selection flag) streams the requested task's
output raw and live and silences successful dependencies; BROAD
(
--all/--filter/--affected) prints news only — oneexecutedline per executed task, full frames for failures, silence for cache hits; truthyCIenv (and the programmatic default) keeps full grouped output.--output-logsoverrides everything. Full table incli.md. - Buffered, framed (non-focused paths).
runCommandlistens to the child's stdout/stderr and callsonStdout/onStderrper chunk. Outside focused streaming, the default logger buffers the chunks per-task and dumps the full body as a Turbo-style framed block on task completion. No per-line prefix, no interleaving between concurrent tasks. - Cache write. Full stdout text is stored in the entry; replay is stdout-only (v17).
- Cache hit replay. The stored stdout is fed through the same logger path, so it renders per the active flow (streamed raw for a focused requested task, framed in full mode, silent in broad).
- Live stream for failures. Even though cached output is not written for failures, the live stream means the user sees the failure as it happens.
- Status line. On TTY stdout outside CI, a single bottom line shows running/done/total/elapsed (+failed), rewritten in place via one clear-line escape and removed before the summary. All default-logger writes are serialized through one writer so content and the status line never interleave.
- GitHub Actions. With
GITHUB_ACTIONStruthy in full mode, task blocks collapse under::group::commands; failures stay open and emit::errorannotations.
There is no special handling for binary output, very large output, or
interactive prompts. Stdin is 'ignore' (child sees a closed stdin)
— tasks that need TTY input won't work and shouldn't be cached anyway.
Every surface uses one outcome vocabulary: executed,
restored-local, restored-remote, up-to-date, failed,
skipped.
The colors / framing modules:
orchestrator/colors.ts— ANSI truecolor (ansi-16m), gated byNO_COLOR/FORCE_COLOR/isTTY. Programmatic-logger callers always see plain text.orchestrator/framed-output.ts— the┌─ task ─┐borders + one-liners.orchestrator/status-line.ts— the serialized writer + the bottom status line.orchestrator/logger.ts— composes them; resolves the output view and applies the per-flow visibility policy.orchestrator/summary.ts— the closingTasks / Cached / Timeblock.
- Default —
navigator.hardwareConcurrency(Bun's CPU-count primitive), orvx.workspace.ts'sconcurrencyfield when set. - Override —
--concurrency N(CLI). CLI wins over workspace config. concurrency: 1serializes execution while still respecting topo order.- The scheduler never exceeds the cap; tasks queue.
- Failure of a task doesn't pause the scheduler — independent siblings continue running and starting.
Bypasses cache reads and writes. Every task runs, and nothing is persisted to the cache directory.
Useful when:
- You suspect cache corruption.
- You want to validate that a cache-hit task can re-run cleanly.
- You're benchmarking and need fresh timings.
- You're forwarding args via
--and want a one-off run that doesn't populate the cache — though note that forwarded args are already in the key, so a separate entry would form anyway.
When --no-cache is set, cleanOutputs is also skipped — the
user is debugging and managing the tree themselves, and silently
wiping dist/ mid-debug would be hostile.
--cache is accepted as a no-op for parity with vite-task.
Both short-circuit execution. The planner (orchestrator/plan.ts)
runs the same setup steps — workspace discovery, config load, package
graph, task graph — and the same per-task hash derivation, then
probes the cache (read-only — cache.get bumps accessed_at as a
side effect, which is fine).
The two flags differ only in output format:
--dry[=text|json]→ text (default) or JSON list of predicted outcomes per task:would-cache-hit-local,would-cache-hit-remote,would-exec,no-cache,group. Formatter:cli/plan-format.ts:formatPlanText/formatPlanJson.--graph[=<path>]→ Graphviz DOT, colored by predicted status. Pipe todotfor SVG/PNG render. Formatter:formatGraphDot.
Mutually exclusive: --dry and --graph together is a parse error.
Combining either with --summarize or --profile is a parse error
(those need a real run).
--summarize[=<path>]— per-run JSON to<cacheDir>/runs/<run_id>.jsonby default (or the explicit path). Mirrors therunstable shape — one task entry per executed task with status, hash, duration, cpu_ms, peak RSS, hrtime spans (bigint serialized as strings to preserve ns precision).--profile[=<path>]— Chrome-trace JSON of every task's wallclock span. Default path:profile.json(cwd-relative). Onetidper project so concurrent tasks render on distinct lanes. Open withchrome://tracingor https://ui.perfetto.dev.
Writers live in orchestrator/run-artifacts.ts:writeRunSummary /
writeRunProfile. Errors are surfaced to the user via log.status
but don't change the run's exit code — the run already happened.
- Cache failures are never cached. Caching a failure prevents retry flows. The next run gets the same failure even after the user fixes the cause.
- Group tasks are silent in the summary. Including them in the count is confusing — "3 of 4 cached" when one was a group that ran nothing isn't informative.
- Project scope defaults to cwd. Most invocations are "build/
test the thing I'm working on".
--all/--filterexist for the workspace-wide case. - The scheduler doesn't bail on first failure. A flaky test failing shouldn't stop an unrelated build. Independent siblings continue; only dependents are skipped.
- Forwarded args don't reach upstream tasks. Otherwise
vx run build -- --watchwould set--watchon every upstream's build, and upstream cache keys would partition by CLI args that don't change their behavior. recordRunskips group tasks. They aren't real runs; analytics queries that sum duration would double-count without it.