exec
exec is the one command a task runs, and how: its environment,
time limit, retries and where it may run. A task without exec is a
group (below).
build: { exec: { command: 'tsc -b', timeout: '5m', env: { define: { NODE_ENV: 'production' } } },}interface ExecConfig { command: string // shell command, run from the project's dir remote?: boolean | 'only' // placement: pin here, or remote-only (see below) platforms?: readonly Platform[] // the machines it can run on, any of them (see below) env?: ExecEnv // optional per-task env layering timeout?: Duration // '30s', '2m': before vx SIGTERMs the child (see below) retries?: number // max additional attempts after a failure (see below) persistent?: PersistentConfig // long-running task (dev server, watcher) interactive?: boolean // reads the terminal: a prompt, a REPL (see below) sandbox?: SandboxConfig // opt-in OS sandbox for this command}command is a string. Run via sh -c (dash on macOS, as Linux’s
sh is), so POSIX
shell semantics work directly — pipes, redirects, && chaining:
exec: { command: 'tsc -b'}exec: { command: 'gen && tsc && cp -r assets dist/'}exec: { command: 'find src -name "*.snap" | xargs rm -f'}CLI args after -- are appended to command, each POSIX single-quoted
unless it holds only safe characters:
vx run test -- --bail 'a b'# child sees: bun test --bail 'a b'Forwarded args are folded into the cache key — different args produce distinct cache entries.
timeout (optional)
Section titled “timeout (optional)”Maximum time before vx SIGTERMs the child, as a duration string: a
number and a unit, ms, s, m, h or d ('500ms', '2m'). A bare
number is refused by vx lock. Omitted → no limit.
build: { exec: { command: 'tsc -b', timeout: '2m' } }- For a normal task,
timeoutbounds the total run time, counted from the spawn (a sandbox’s setup, its wrap included, is vx’s, not the task’s). A task that overruns is killed — its whole process group, so what it forked goes with it — and reportedfailed(timed out) — never cached. (A timeout SIGTERM is a real failure, distinct from a Ctrl-C teardown, which is reportedaborted.) - For a persistent task,
timeoutbounds the readiness wait instead: ifreadyWhenhasn’t matched within the window the child is SIGTERMed and the task fails — seepersistentbelow. A persistent task that’s ready on spawn (noreadyWhen) becomes ready before the timer can fire, so the timeout is a no-op for it. - On a plugin executor, the timeout aborts the request’s
signalinstead (core cannot kill a process the executor spawned); any exit after it is reported timed out, a 0 from a child that trapped the stop included (X-125), and an executor still running after the kill grace is abandoned (H-12, H-14).
Upper bound. timeout must be at most 2147483647 ms (~24.8 days) ('2147483647ms'),
the largest delay a timer can hold. A larger value does not mean “no
limit” — the platform silently reduces it to 1 ms, so the task would
be killed the moment it spawns and reported failed. vx refuses it at
load rather than let that happen. Omit timeout entirely for no
limit. The same bound applies to --timeout and to the workspace-level
run.timeout and the VX_RUN_TIMEOUT env rung: past it, each is refused
under its own name.
A task with no exec.timeout falls back to a run-level default, if
one is set. Precedence, highest first: per-task exec.timeout →
--timeout <duration> / RunOptions.timeout → VX_RUN_TIMEOUT →
workspace run.timeout (see workspace config below). Per-task always
wins. The run-level defaults are threaded as run options, so — like
--retries — they never touch a cache key: a --timeout run cache-hits a
plain run’s entry.
Why no multi-step commands: string[]? Per-task caching is the
right granularity for invalidation. If you’d benefit from caching each
step independently (e.g. codegen then build), split into two
tasks linked by dependsOn. If you don’t need that, && in shell is
the right tool.
retries (optional)
Section titled “retries (optional)”Maximum ADDITIONAL attempts after a failed attempt. retries: 2 means
up to 3 executions total. 0 / omitted → no retries (fail on the first
non-zero exit).
test: { exec: { command: 'bun test', retries: 1 } }- A retry fires after ANY failure,
timeoutkills included. A Ctrl-C teardown (aborted) is never retried — the run is tearing down. Nor is a task in flight when--continue=neverstops the run: its attempt finishes, and its failure is the last, even when its retry was already announced and preparing. - Declared outputs are re-cleaned before each retry, exactly like the first attempt — a failed attempt’s partial outputs can’t leak into the next.
- Every attempt streams its output live. Between attempts vx emits one
stderr line into the task’s stream, ending in the reason:
vx: retrying <id> (attempt <k>/<total>) after exit <code>, orafter a timeoutwhen the attempt was killed bytimeout. - The final outcome is the LAST attempt’s: the first success wins (and
is what gets cached — its output only, not a concatenation of failed
attempts); if every attempt fails, the task is
failedwith the last exit code and nothing is cached, as today. - The task’s reported duration sums every attempt; a saved entry keeps the producing attempt’s time.
retriesis part of the resolved config, so declaring it derives a distinct cache key (like every config field); tasks without it keep byte-identical keys.- Not allowed with
persistent— a persistent task has no exit to retry (config error, likecache+persistent).
The run-level default is run.retries (vx run --retries <n>, VX_RUN_RETRIES) — it applies to tasks
that don’t declare their own retries, never to a persistent one (a
server that fails before it is ready fails at once); explicit config
always wins, including an explicit retries: 0. The CLI flag never
affects cache keys.
remote (optional)
Section titled “remote (optional)”remote?: boolean | 'only' // default truePlacement. false pins the task to the machine that started the run:
it is never offered to an executor that declared itself remote. Use it
for a task that talks to something only this machine has — a local
daemon, a Docker socket, a device, a VPN-only host.
'docker:build': { exec: { command: 'docker build -t app .', remote: false },}-
Only meaningful with a remote executor declared. A workspace with no executor plugin already runs everything here, so the field changes nothing.
-
Lifted by an
exclusiveexecutor. One that runs every task elsewhere (@vzn/vx-dte) takes this task too, and every pin below. -
Pinned by inference too. A
persistenttask, and anything that depends on one (transitively), is pinned regardless of what it declares — a worker cannot reach a port served on the submitter — unless the executor starts the server where it runs the dependant (@vzn/vx-dte: each worker starts it once, beside its dependants, and the orchestrator never does; a server with asandboxorexec.remote: falsestays here). So is a task with asandboxblock, and its dependants: the sandbox is this machine’s machinery, and a boundary a worker does not enforce would pass vacuously; an executor that enforces it where it runs (@vzn/vx-dte) is offered them. So is a task whose key foldsinputs.runtimeorworkspaceRuntime: the key holds this machine’s answer, which a worker cannot prove it shares. Its dependants are not pinned. -
Placement is decided once per task, before scheduling, so the scheduler knows which pool a task will occupy (see execution.md).
-
'only'is the inverse pin — the install-as-action recipe. The task exists to produce a REMOTE input tree (canonicallypnpm installfeeding remote workers) and is a NO-OP on this machine: never executed locally, its declared outputs never cleaned or restored on this disk. With a remote executor declared it runs on a worker, its outputs stay in the remote store, and dependents’ input trees reference them there — the bytes flow worker→store→worker without ever transiting the submitter. With no remote executor it succeeds without running and dependents use the machine’s ambient state (the dev’s ownnode_modules), exactly as before the field existed. An'only'task must declarecache— its inputs are what a worker reproduces and its key is the address of its remote record. One without is refused at load.install: {exec: { command: 'bun install --frozen-lockfile', remote: 'only' },inputs: { files: ['package.json', 'bun.lock'] },cache: {outputs: { files: ['node_modules/**'] },},},build: { dependsOn: ['install'], /* … */ }, -
Never busts a cache:
remoteis stripped from the cache key —'only'included, and it is the onlyexecfield that is. The contract of a remote executor is that the same command over the same inputs produces the same outputs, so a key that moved with placement would split a laptop from a worker pool over nothing.
platforms (optional)
Section titled “platforms (optional)”platforms?: readonly Platform[] // 'linux-x64' | 'linux-arm64' | 'darwin-x64' | 'darwin-arm64'The machines this task can run on, any of them. Omitted, it runs anywhere.
import { defineProject } from '@vzn/vx/config'
export default defineProject({ tasks: { 'ios:build': { exec: { command: 'xcodebuild -scheme App', platforms: ['darwin-arm64', 'darwin-x64'] }, }, },})-
Locally, a task whose platforms leave this machine out is skipped, never failed, and so is everything that depends on it. The run stays green and says why, once and on the task’s row:
[vx] skipped on linux-x64, with what depends on them: app#ios:build needs darwin-arm64 or darwin-x64⊘ skipped app#ios:build • needs darwin-arm64 or darwin-x64, this is linux-x64A
run()outcome and telemetry carry it asotherPlatform: { needs, host }; a dependant’sblockedBynames the task (invx last --format jsontoo). -
On CI (
CIor a provider’s variable set) every task runs or the run fails: one whose platforms leave the machine out refuses the run before any task starts. Run a target that holds only this platform’s tasks, or one distributed run with a worker of each platform. -
With an executor that routes by platform (
@vzn/vx-dte, whose worker sizes each name aplatform), the task runs on a worker of one of its platforms: this machine’s if the executor has one, else the first it lists that the executor has. A task none of whose platforms the executor runs refuses the run before anything starts. Other remote executors are never offered it.@vzn/vx-dteruns a task naming none on this machine’s platform when it has workers of it, so a worker of another platform runs only the tasks that name it and leaves once they are done. -
In the key: the list is config, and the platform the task runs on is folded too, so each platform keeps its own entry. A task that names no platforms keys as before.
-
Names are fixed (
os-arch, as Bun’s compile targets); any other refuses the config at lock.
persistent (optional)
Section titled “persistent (optional)”interface PersistentConfig { readyWhen?: string // regex (as string); first matching line marks ready}Marks the task as a long-running process — a dev server, a file watcher, a daemon. The runner spawns the command but does NOT wait for it to exit. Instead it considers the task “ready”:
- Immediately on successful spawn when no
readyWhenis given. - On the first stdout/stderr line that matches the
readyWhenregex string. Each line is tested on its own, without its line break (\n,\r\nor a bare\r) and without terminal escapes (colour, OSC titles), so^and$anchor to a line as you read it andLocal:matches Vite’s boldLocalunderFORCE_COLOR. The trailing partial line is tested too, so prompt-style banners without a newline (printf 'Listening on :3000') count, and so does output read up to 250 ms after the shell exits. A server not ready after 10 s is said once, naming the pattern it waits for, and whetherexec.timeoutbounds the wait.
dev: { exec: { command: 'vite', timeout: '30s', // bound the readiness wait persistent: { readyWhen: 'Local:' }, },}
watch: { exec: { command: 'tsc --watch --preserveWatchOutput', persistent: { readyWhen: 'Watching for file changes' }, },}
// No readyWhen — ready as soon as spawn succeeds. Useful for daemons// that have no observable "ready" signal but downstream doesn't need// to gate on one.agent: { exec: { command: 'my-agent --daemon', persistent: {}, },}Semantics:
- Downstream unblocks on ready, not on exit. Useful for e2e tests
that need a dev server up first:
'dev:run': { exec: { command: 'vite', persistent: { readyWhen: 'Local:' } } },'e2e': { dependsOn: ['dev:run'], exec: { command: 'playwright test' } },
- Not started when nothing needs it. A persistent task you did not
request, whose every dependant is a confirmed cache hit, local or
remote, is never spawned: a fully cached
vx run e2edoes not boot the servere2edepends on, and the server is in no outcome or count. Behind a remote cache, the server waits for the remote’s answer on the dependants the local store missed, and starts at the first miss. It starts if one of those hits turns out to need running (its artifact went), and that task runs once it is ready. A dependant whose key the server’s writes could change (one in its own project, unless a sandbox bounds the server’s writes) is not probed ahead of it, so there the server still starts.vx run --drysays so: such a server isnot started(not-startedin--dry=json), notwould exec. - Exit before ready. A persistent task that exits 0 before
readyWhenmatches is ready: a setup step (migrate, seed) that did its work, and its dependants run. One that crashes or exits non-zero first is reported asfailed. - Crash after ready ⇒ failed run. A persistent task that exits
non-zero (or is killed) on its own after it became ready fails the
run, and vx names it:
vx: <id> exited with code <n>(a signal death as its128 + ncode), at once while the graph still runs (… while the run went on), so a dependant failing against it reads why. Its own outcome isfailedwith that exit code, and the footer counts it so (item 1071). An exit 0 on its own is fine (a daemon that forks and returns), and one kept only as a dependency does not end the foreground hold (WD-3). - End-of-graph SIGTERM. Once the rest of the graph finishes
(success OR failure of downstream), the orchestrator sends
SIGTERMto every persistent subprocess it does not keep, and waits for them to exit (SIGKILLpast the kill grace). In the foreground it KEEPS the persistent tasks you requested, those a requested group stands for, and the persistent tasks they depend on: vx stays up after the summary until one of them exits or you press Ctrl-C, streaming what they write. A run where anything else failed keeps none and exits 1, unless--continue=always(cli.md§ Output, “Pinned persistent tasks”). cacheis rejected. The config loader throws oncache + persistent— persistent tasks don’t terminate, so there’s no exit code to cache and no outputs to capture at a well-defined moment.
interactive (optional)
Section titled “interactive (optional)”true hands the task the terminal: a prompt (drizzle-kit push), a
REPL, a watch mode that reads keys. Turbo’s interactive.
- On a TTY (vx’s stdin is one) the task gets vx’s own stdin,
stdout and stderr. Its output is not framed, masked, kept or
replayed: it goes to the terminal as the task writes it. A frame
line opens before it and closes after (
--output-logs full, and the default views), and the live status region goes for the rest of the run, as it would repaint over a prompt. - Alone. It starts once nothing else runs, and nothing starts until it exits. A persistent one holds the terminal from its spawn to the run’s end, so other tasks may run once it is spawned (their output goes to the same terminal), a run may hold one, and every other interactive task must be its dependency. A run that breaks either is refused before any task runs.
- Off a TTY (CI, a pipe) it runs as any task: stdin is empty, or a pipe vx holds for a persistent one, and it runs beside others. A prompt there reads end of input; it never hangs.
- Here, never cached. No executor plugin is offered it, and
cacheis refused.sandboxis refused (a sandboxed task’s output passes through vx), and so ispersistent.readyWhen(vx reads none of its output; it is ready once spawned). - It runs in its own session, as every task does, so a tool that
opens
/dev/ttyitself rather than its stdin still cannot, and the terminal’s resize signal does not reach it. Ctrl-C reaches vx, which stops the run; a task that puts the terminal in raw mode reads the key itself. - Its terminal modes end with it. vx reads them (
stty -g) before the task starts and puts them back when it exits, so a task killed in raw mode, or one that never undid itsstty raw -echo, leaves no terminal without echo or Ctrl-C behind it (tests/terminal.unsafe.test.ts› “a task that leaves the terminal raw”). Withoutsttyon PATH the modes stay the task’s.
'db:push': { exec: { command: 'drizzle-kit push', interactive: true },}ExecEnv (optional)
Section titled “ExecEnv (optional)”interface ExecEnv { passThrough?: readonly string[] // names taken from host process.env; exact names, a wildcard is refused define?: Record<string, string> // explicit name=value pairs secret?: readonly string[] // names whose values are masked (`***`) whatever the name; see Masking}The child process sees a deliberately limited env. From lowest to highest priority:
-
Essential allowlist (hard-coded in
src/exec/env.ts, and pinned against this list by a test):PATH,HOME,SHELL,USER,LOGNAME,TMPDIR,TEMP,TMP,LANG,LC_ALL,LC_CTYPE,TERM,COLORTERM,FORCE_COLOR,NO_COLOR,CI,NODE_OPTIONS,COREPACK_HOME,PNPM_HOME. Nothing else from the parent environment reaches a task — that is the whole list. When neitherFORCE_COLORnor a non-emptyNO_COLORreaches the task by any layer, vx setsFORCE_COLOR=1(its own output strips the colour where it prints plain;docs/cli.md§ Colors). Without these, typical CLI tools break. vx adds two markers of its own on top,VX_RUN_WORKSPACE(the root of the workspace running the task) andVX_RUN_TASK(project#task): a task whose command shells out tovx runin that same workspace is refused — a loop back to itself forks a run per run without bound, and a nested run that terminates is still invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key); declare it withdependsOn. Driving a different workspace from a task (a fixture suite, a benchmark) is fine. NOT folded into the cache key — the same rulepassThroughstates below, and for the same reason.Read that carefully if your build’s output depends on one of them, because three can change what a task produces:
NODE_OPTIONS(--require/--import/--loader/--conditionsinject code or switch package-export resolution),LC_ALL/LANG(collation — anything shelling out tosortorders differently), andCI/FORCE_COLOR/TERM(output bytes, and vx caches and replays output).They are not hashed because hashing them would mean a laptop and a CI runner could never share a remote cache entry —
PATH,HOMEandTERMdiffer on every machine, which is the whole point of a remote cache. vx does not guess which ones matter to your build; if one does, say so explicitly:inputs: { files: ['src/**'], env: ['NODE_OPTIONS'] }, cache: { outputs: { files: [] } }That folds its value into the key. The two axes are orthogonal on purpose:
inputs.envdecides what the key sees,exec.env.passThroughdecides what the child gets. -
passThroughnames → value taken from hostprocess.envat spawn time. A project config cannot set one for it: a first load that writesprocess.envis refused (D-76). NOT folded into the cache key — for secrets, regional values, CI flags that legitimately vary between machines. -
define→ explicit literal values. ARE folded into the cache key via the task config hash (the values are in your config file, so they participate naturally).
exec: { command: 'bun test', env: { passThrough: ['CI', 'GH_TOKEN'], // values forwarded; cache-invariant define: { NODE_ENV: 'test' }, // literal; in the cache key },}Every name in these lists (and in inputs.env) must be one an
environment can hold: non-empty, with no = and no NUL, and a define
value holds no NUL. Such a name is refused at load; it used to reach the
child split at its = or not at all. A passThrough or define name is
also a shell variable name, [A-Za-z_][A-Za-z0-9_]*: the task runs under
sh -c, and dash (Linux’s sh) drops any other name from the environment
it hands the command, so my.var reached a task on macOS and never on
Linux. inputs.env takes any such name: the key reads it from vx’s
own environment.
vx also sets npm_execpath to the workspace’s package manager (the root
package.json’s packageManager, else its lockfile, found on the root’s
node_modules/.bin or PATH) unless a layer gives one, so a tool that calls the manager back finds
it. It names a binary on this machine, so it is not in the key.
Anything outside these three layers, the two variables vx sets for
the run (VX_RUN_WORKSPACE, VX_RUN_TASK) and npm_execpath, is absent from the child’s environment:
a host credential (SSH_AUTH_SOCK, GITHUB_TOKEN) reaches a task only
when passThrough names it, held end to end by env.test.ts (a
sandboxed task with a restricted network also gets the sandbox’s own
proxy, CA and temp-directory (TMPDIR, TMP, TEMP) values over these names:
modules/sandbox-runtime.md § The environment SRT sets). This
matches Turbo’s passThroughEnv semantics and exists for two reasons:
- Cache stability. If every host env var entered the key, every
shell with a different
PS1would cache-miss. - Determinism. If host env leaked into the child, two machines
with different
FOO=barset would produce different outputs and the user would never know why.
This is hygiene, not a boundary: an unsandboxed task runs as you, so it
can read your files. vx hides its own environment from a task
(modules/util-hide-env.md), not your files. Only exec.sandbox keeps them out (security.md § What the sandbox stops).
Masking. The value of a variable whose name holds TOKEN, SECRET,
KEY, PASSWORD, PASSWD or CREDENTIAL (vx’s own environment or a
task’s define, six characters or more; not a name ending _FILE,
_PATH or _DIR, nor git’s GIT_CONFIG_KEY_<n>) is printed as *** wherever vx
shows it: the task’s output and the line vx adds under a shell’s 127 or
126 (the command’s first word), the stdout the cache keeps and a hit
replays, the command a cache entry stores (what vx why prints and a
remote cache receives), the $ command line, telemetry records,
vx show, the hashes vx why gives for such a variable in
inputs.env (its value, unsalted: the row names it and its change), an executor’s error, a plugin’s warning or failure (a remote’s reply; the failure masked in the error run() rejects with), a failed inputs runtime probe’s error (its command and output), and the run’s own invocation line that vx last prints (a
secret passed after --). So are every string of a
task vx show prints (a runtime probe, readyWhen, the description),
the description --dry and the task picker print, a runtime probe’s
command as the run history stores it and vx why names its row (two
probes that mask alike are numbered, echo *** (2)), the readiness
notice’s pattern, a sandbox violation line,
the error a verb ends on (a plugin’s failure, a config’s own throw) and
vx watch’s cycle failed line. A multi-line value (a PEM
key) is also masked line by line, each line of six characters or more,
and a value holding a ' also as a shell-quoted line spells it ('\'').
A value
split across two output chunks is still caught; the output holds back
that many characters until the next chunk. A plugin that reads a task’s
config directly sees it as written. A secret whose name holds none of
those words (GH_PAT, NPM_AUTH) is listed in exec.env.secret, and is
then masked in the same places.
Two node_modules/.bin directories are prepended to PATH so
installed tools (oxlint, vite, etc.) run by name: the
project’s own, then the WORKSPACE ROOT’s, where a monorepo’s shared
tooling lives. Never a sibling project’s, so sibling bins stay invisible
(project isolation). A directory whose path holds : (PATH’s delimiter)
cannot be named in PATH and is left out; a task’s 127 then says so.