src/exec/executor.ts — the per-task execution contract
Purpose
Section titled “Purpose”The seam between “what to run” and “where it runs”. execute-task.ts
resolves everything about one attempt — command, cwd, env, capture,
timeout, sandbox baselines — into an ExecuteRequest; a TaskExecutor
runs it and returns an ExecuteResult (exit code, streams, rusage,
sandbox violations). Core’s own executor, localExecutor, is the same
runCommand / runSandboxed call the orchestrator used to make directly — it lives in src/exec/local-executor.ts and is appended to the TAIL of every executor list (resolveExecutors, plugin-host.ts), so a task every plugin declines runs here.
Public surface
Section titled “Public surface”TaskExecutor { name; remote?; capacity?; accepts?(task); demand?(remaining); execute(req) }—remote: truedeclares that the executor runs the command somewhere else (so it is never offered apinnedLocaltask);capacityis how many tasks it runs at once (a positive integer, else the run is refused), which makes its tasks a POOL of that size instead of local worker slots.TaskPlacement { taskId; projectName; projectDir; command; pinnedLocal; cacheable }— whataccepts()sees. Placement happens ONCE per task, before scheduling, so it cannot depend on anything resolved per attempt.InputFile { path; digest }— one entry ofTaskInputs.files: the workspace-relative POSIX path and the git blob OID of the WORKTREE bytes (for a symlink, of its target string) — the same digest the key folds, so an executor elsewhere reproduces what a hit would match.localExecutor()— the floor at the tail of every executor list. Runs the command on this machine; what a plugin declining a task hands it back to. Internal (src/exec/local-executor.ts), not on@vzn/vx.isLocalExecutor(executor)— whether it is core’s own, by identity (a plugin may name its executor ‘local’): core bounds a plugin’sexecuteafter the request’s abort, never the local one’s (H-14).ExecuteRequest—taskId,workspaceRoot,command,forwardArgs,cwd,env,envDefine(exec.env.defineverbatim: the host-free part ofenv, safe to ship),capture,outputs,timeoutMs?,onStdout,onStderr,signal?(aborted when the run stops ortimeoutMselapses: an executor ends its work and returns, since core cannot reach a process it spawned; a non-zero exit after the timeout’s abort is recordedtimedOut; one that has not returned within the kill grace of the abort is abandoned, the attempt settled without it),liveChildren?,sandbox?: ExecuteSandbox,inputs?: TaskInputs,cacheKey?(a cacheable task’s key, the address an executor’s own remote record uses),refresh?(cache reads are off: do not answer from that record),remoteOnly?(exec.remote: 'only': leave outputs off this disk),download?: 'eager' | 'deferred'.outputsis the DECLARED output globs (filesproject-relative,workspaceFilesroot-relative) — what an executor running elsewhere has to bring back.ExecuteSandbox—baseAllowRead,baseDenyRead,reportWithin(the project: denials there are reported),reportLinked(the canonical directories of the linked workspace packages core withheld from a cached task because its key does not answer for them: denials there are reported too),config. An executor that ships the sandbox elsewhere receives the narrowedbaseAllowRead; enforcing it is its own.TaskInputs— everything the cache key folds, WITH values:files(workspace-relative path + git-blob digest of the worktree bytes, or of a symlink’s target string, own outputs excluded),env(declared names + resolved values,undefinedfor an unset name),runtime/workspaceRuntime(command + the output that was folded — a toolchain expectation a worker must reproduce),upstream(dependency task ids + cache keys + each one’s declaredoutputs, workspace-relative — already restored on disk before this task runs, so an input-shipping executor can put them in the input root; empty when that dependency has no local cache entry),packageJsonDigest,configDigest,workspaceFingerprint. Present on the miss path of a cacheable task only; a task with nocacheships nothing. Built bytask-hash.describeTaskInputsfrom the SAME resolution that produced the key, so it cannot drift from what a hit would have matched; held in memory for the attempt and never persisted (env/runtimevalues may be secrets —entry_inputsstores digests only).ExecuteResult extends RunResult { violations; outputs?; where? }—outputsis{ kind: 'disk' }or{ kind: 'deferred'; materialize }(outputs left remote, fetched only if a local consumer needs them).spawnFailed: truesays the command never started (its 127 is the executor’s, so no “command not found” line follows; A-41). Checked at the seam (assertExecuteResult): a plugin that resolves something else is refused with one line naming the executor, the task and the field (“returned an invalid result for <task>: exitCode is undefined (expected a number) — a plugin bug, not a task failure”), in the task’s frame, never a TypeError inside core.whereis the executor-reported placement label (a REAPI worker id); absent = this host. RidesTaskOutcome.whereinto telemetry only (OTel:vx.task.where), never the analytics store.selectExecutor(executors, task, label?)— first executor, in order, that may take the task: aremoteexecutor is skipped outright for apinnedLocaltask, thenacceptsdecides. Anacceptsthat throws is aUserErrornaming the executor aslabeldoes (the run passesexecutorLabel, which names its plugin too; item 1022). The local executor is the tail of the list and accepts everything, so the throw for “every executor declined” is unreachable fromrun(); it stays for a caller that builds its own list.- An executor’s
demandis a hint: one that throws is warned once, naming the plugin, and that executor is asked no more that run (item 1022).
- The request is fully resolved; an executor never reads task config.
- Persistent tasks (
exec.persistent) never reach an executor — they are local by construction and stay onrunPersistent. - A task is
pinnedLocalwhen it is persistent, transitively depends on a persistent task (a worker cannot reach a port on the submitter), is sandboxed (the sandbox is this machine’s machinery), depends on a sandboxed task, declaresexec.remote: false, or folds a runtime probe (cache.inputs.runtime/workspaceRuntime) into its key: the probe is this machine’s answer, and a worker’s output under it is a stale hit (C-2).placement.tscomputes the set once per run (tests/placement.test.ts). - The executor list is resolved ONCE per run (
plugin-host.resolveExecutors) and each task is PLACED once, before scheduling — every attempt of a task, including retries, runs on the executor it was placed on. Placement must precede scheduling because the scheduler admits a pooled task against its executor’scapacityrather than a local worker slot. exec.remoteis stripped from the cache key (task-hash.hashableConfig): placement has no effect on outputs, and a key that moved with it would gut the remote hit rate.
What it does NOT do
Section titled “What it does NOT do”- Ship inputs or materialise outputs elsewhere —
inputsdescribes the set, the executor moves it. A remote executor is responsible for leaving the declared outputs undercwdwhen it returns (a later design’soutputsdiscriminator will say where they are; seedocs/design/plugin-executor-reapi-2026-08.md§4). - List the AMBIENT files a worker also needs (
tsconfig.json,.npmrc, root manifests,node_modules): the key treats them as environment, not input. Same-checkout agents get them from the checkout; an input-shipping executor needs them declared or provided by the worker image / an install action.
tests/executor.test.ts (unit), tests/plugin-capabilities.test.ts
(executor capability — end-to-end via run()).
Replacing this module
Section titled “Replacing this module”Contribute executor(ctx) from a plugin. localExecutor() is not on
@vzn/vx; an executor that declines a task (accepts → false) hands it
to the local floor.