Skip to content
vxvx
GitHubBlueskydev.toRSS

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:

Terminal window
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.

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, timeout bounds 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 reported failed (timed out) — never cached. (A timeout SIGTERM is a real failure, distinct from a Ctrl-C teardown, which is reported aborted.)
  • For a persistent task, timeout bounds the readiness wait instead: if readyWhen hasn’t matched within the window the child is SIGTERMed and the task fails — see persistent below. A persistent task that’s ready on spawn (no readyWhen) 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 signal instead (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.

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, timeout kills included. A Ctrl-C teardown (aborted) is never retried — the run is tearing down. Nor is a task in flight when --continue=never stops 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>, or after a timeout when the attempt was killed by timeout.
  • 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 failed with 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.
  • retries is 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, like cache + 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?: boolean | 'only' // default true

Placement. 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 exclusive executor. One that runs every task elsewhere (@vzn/vx-dte) takes this task too, and every pin below.

  • Pinned by inference too. A persistent task, 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 a sandbox or exec.remote: false stays here). So is a task with a sandbox block, 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 folds inputs.runtime or workspaceRuntime: 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 (canonically pnpm install feeding 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 own node_modules), exactly as before the field existed. An 'only' task must declare cache — 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: remote is stripped from the cache key — 'only' included, and it is the only exec field 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?: 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-x64

    A run() outcome and telemetry carry it as otherPlatform: { needs, host }; a dependant’s blockedBy names the task (in vx last --format json too).

  • On CI (CI or 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 a platform), 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-dte runs 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.

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 readyWhen is given.
  • On the first stdout/stderr line that matches the readyWhen regex string. Each line is tested on its own, without its line break (\n, \r\n or a bare \r) and without terminal escapes (colour, OSC titles), so ^ and $ anchor to a line as you read it and Local: matches Vite’s bold Local under FORCE_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 whether exec.timeout bounds 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 e2e does not boot the server e2e depends 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 --dry says so: such a server is not started (not-started in --dry=json), not would exec.
  • Exit before ready. A persistent task that exits 0 before readyWhen matches 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 as failed.
  • 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 its 128 + n code), at once while the graph still runs (… while the run went on), so a dependant failing against it reads why. Its own outcome is failed with 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 SIGTERM to every persistent subprocess it does not keep, and waits for them to exit (SIGKILL past 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”).
  • cache is rejected. The config loader throws on cache + persistent — persistent tasks don’t terminate, so there’s no exit code to cache and no outputs to capture at a well-defined moment.

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 cache is refused. sandbox is refused (a sandboxed task’s output passes through vx), and so is persistent.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/tty itself 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 its stty raw -echo, leaves no terminal without echo or Ctrl-C behind it (tests/terminal.unsafe.test.ts › “a task that leaves the terminal raw”). Without stty on PATH the modes stay the task’s.
'db:push': {
exec: { command: 'drizzle-kit push', interactive: true },
}
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:

  1. 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 neither FORCE_COLOR nor a non-empty NO_COLOR reaches the task by any layer, vx sets FORCE_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) and VX_RUN_TASK (project#task): a task whose command shells out to vx run in 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 with dependsOn. Driving a different workspace from a task (a fixture suite, a benchmark) is fine. NOT folded into the cache key — the same rule passThrough states 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 / --conditions inject code or switch package-export resolution), LC_ALL / LANG (collation — anything shelling out to sort orders differently), and CI / 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, HOME and TERM differ 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.env decides what the key sees, exec.env.passThrough decides what the child gets.

  2. passThrough names → value taken from host process.env at spawn time. A project config cannot set one for it: a first load that writes process.env is refused (D-76). NOT folded into the cache key — for secrets, regional values, CI flags that legitimately vary between machines.

  3. 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 PS1 would cache-miss.
  • Determinism. If host env leaked into the child, two machines with different FOO=bar set 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.

Turborepo, Nx and other product names are trademarks of their owners. vx is not affiliated with or endorsed by them.