Running Workers
A worker is a long-running process that polls the Scarn server for pending tasks, claims them under a time-limited lease, executes them using a local Docker environment, and reports results back. Each machine runs one worker in the background, and it works on every project you add to it.
Start a worker
Section titled “Start a worker”scarn workThis starts the machine’s background worker if it isn’t already running, adds the project in your checkout to it, and returns; follow it with scarn worker status and scarn worker logs -f. The worker runs continuously, polling your project’s task queue every 5 seconds. When a poll finds nothing to claim, the interval doubles, up to once every 10 minutes, and drops back to 5 seconds as soon as it claims work. With push wake-up connected, an idle worker still picks up new work within seconds.
If your current directory contains .scarn/scarn.jsonc, the project is read from it automatically. Otherwise pass it explicitly, as owner/slug (see Project refs):
scarn work <owner/slug>Run scarn work again in another checkout to add that project too. Running it again for a project that’s already added updates its settings.
Manage the background worker
Section titled “Manage the background worker”scarn worker statusscarn worker logs acme/app -fscarn worker config --max-concurrency 4scarn worker remove acme/appscarn worker stop- Projects. The worker remembers each project’s repository directory, source branch, limit and
--work-item-idfilter. They’re saved, so when the worker starts again every project comes back.scarn worker removeforgets one; its running runs finish first. The project’s.scarn/.envis read again for every claim. - Limits.
--max-concurrencycaps how many runs the worker has going at once across every project (1until you set it;SCARN_WORK_CONCURRENCYsets the first value).scarn work -c <n>caps one project within that. When there isn’t room for every project, the free slots go to projects in the order they asked. Lowering a limit, or removing a project, never stops a run that’s already going. - Stopping.
scarn worker stopstops claiming and waits for running runs to finish.scarn worker stop --forceaborts them, and each one fails with the codeWORKER_STOPPED. A worker that crashes or is killed doesn’t resume its runs; the server marks them abandoned. After a reboot, runscarn worker start(orscarn work) to bring it back. - Cancelling one run.
scarn worker cancel <taskRunId>cancels the run’s Task on the server, then stops it on this machine straight away. A Task that a pipeline step owns cancels the whole pipeline run, so it asks first. Code Tasks can’t be cancelled. - Problems.
scarn worker statusshows a project that can’t claim, with the reason: its repository is gone, the project isn’t reachable, or its sign-in failed or now belongs to another user. Its running runs carry on, and it retries by itself. Fix your sign-in withscarn auth login; the worker picks it up without a restart. - Upgrades. The CLI only talks to a worker of its own version. After upgrading, run
scarn worker stop && scarn worker start. - In a terminal.
scarn worker start --foregroundruns the worker in your terminal instead of in the background. The first Ctrl-C lets running runs finish; the second aborts them.
The worker’s files are in ~/.scarn/workers/scarn/: worker.log (rotated by size and by day) and worker.stdio.log, which catches anything printed before the log opens. A worker belongs to one server. To run against another one on the same machine, start a separate worker with SCARN_WORKER_INSTANCE=<name>; it gets its own directory, saved projects and limit. Every command is in the CLI reference.
Push wake-up
Section titled “Push wake-up”A backed-off worker would otherwise take up to 10 minutes to notice new work. Push wake-up closes that gap: the worker holds a WebSocket open to Scarn’s wake-up service, and when a run is queued for your project the server tells every connected worker to poll now. There’s nothing to configure. The worker connects automatically when the server supports it, and the worker log shows Push wake-up connected.
The message is only a hint. It carries no task data, and the worker still claims work through the normal poll, so a missed message only costs latency:
- On every (re)connect the worker polls straight away, so it catches anything queued while it was disconnected.
- While connected, an empty poll waits a full 60 minutes before the next safety-net poll (
SCARN_WORK_CONNECTED_MAX_POLL_INTERVAL_MS; see Polling backoff). - If the connection drops (the log shows
Push wake-up disconnected; polling), the worker falls back to the normal 10-minute backoff right away and keeps retrying the connection in the background. - If the server has push wake-up turned off, the worker just polls, exactly as before.
The connection is outbound only, over wss:// to a *.workers.dev host. If a firewall or proxy blocks that, the worker keeps working and polls instead.
Worker flags
Section titled “Worker flags”Flags are in the CLI reference.
How execution works
Section titled “How execution works”- Poll — The worker calls the server to claim a batch of pending tasks.
- Claim — Each claimed task is assigned a lease. The worker sends heartbeats to extend the lease while processing.
- Environment setup — For
Runtime.devcontainer()steps (the default), the worker clones your repository and launches a single Docker runtime from your devcontainer config:.devcontainer/devcontainer.jsonunless the step selects another withRuntime.devcontainer({ config }). Before bringing the container up, it injects mounts for a pinned, Scarn-managed OpenCode runtime payload and a session-scoped agent home. ForRuntime.host()steps, this is skipped entirely — see Runtime. Code steps default to a worker-supplied managed runtime instead of your devcontainer. - Agent startup — For
Runtime.devcontainer()and agentRuntime.managed.<id>()steps, OpenCode runs inside that same container (same environment as your workspace), launched by absolute path from the mounted runtime payload — never the project’s Node or a globalopencode. There is no separate AI container, cross-container network, or MCP-host bridge. ForRuntime.host()steps, the same Scarn-managed OpenCode runtime runs directly on the worker host against a temporary empty directory — no Docker, no clone. - Agent execution — The step is handed to the in-container OpenCode agent with the step’s prompt, input payload, and any configured MCP servers. Provider access is resolved through a normalized contract (currently
directmode: an explicit provider base URL + token, with your local OpenCode config as a fallback source). - Result parsing — The agent’s structured output is parsed and checked against the step’s
resultschema. - Report — The worker marks the task complete (or failed) and posts the result back to the server.
- Cleanup — The Docker environment is torn down (unless
--preserve-runtime-on-completeis set).
Devcontainer config
Section titled “Devcontainer config”A step selects a non-default devcontainer config with Runtime.devcontainer({ config }), where config is the full repo-relative path to the file. The worker resolves it in the clone, on the branch the step is based on, before the container launches. An explicit config has no fallback: if it is missing, the step fails without launching anything, and the failure names the path and that branch:
Devcontainer config ".devcontainer/frontend/devcontainer.json" not found in the cloned repository (branch "scarn/build-frontend-1234")The branch in the message is the one the step was created off: your base branch for the first write step (see Base branch), or the work branch of the nearest earlier step that produced one for a later step. If a step expects a config that an earlier step is supposed to add, check that branch. A config path that resolves outside the clone (for example through a symlink) is rejected with ... resolves outside the cloned repository. A --dry-run for the step resolves the same config, so a wrong path shows up in the dry-run report.
The worker patches the config it launches (mounts, ports, and containerEnv), and does not commit that patched file to the step’s work branch. See Selecting a devcontainer config and Multiple configs.
Managed runtimes
Section titled “Managed runtimes”A code step runs in a managed runtime (Runtime.managed.<id>(), default bun1) unless it opts into Runtime.devcontainer(). An agent step can opt into one explicitly (see Managed runtimes for agent steps). The worker still clones your repository, but it launches its own container instead of your devcontainer config. For each run it:
- Looks up the identifier in its registry for the image (
oven/bun:1.4.0-debianforbun1,node:24-bookworm-slimfornode24). - For a code step, picks the dependency install from the lockfile in the clone’s
.scarn/pipeline-builder(see Runtime for code steps). An agent step gets no install; instead the worker mounts the OpenCode runtime payload and runs the agent inside the container, as it does for a devcontainer. - Writes a generated
devcontainer.jsonto.scarn/managed-devcontainers/<id>/devcontainer.jsoninside the clone, and launches from it.
That directory exists only in the clone for the run. It is excluded from work-branch commits and is never committed to your repository. It holds a devcontainer config that names an image, not an image, and it is unrelated to the host-side ~/.scarn/runtimes/ directory where the worker caches the OpenCode runtime payload.
A worker only knows the identifiers its version ships. If a step names one it does not know, for example because the step was pushed with a newer SDK than the worker’s CLI, the step fails before anything launches, and the failure names the identifier and the ones the worker supports:
Managed runtime "node26" is not supported by this worker. Supported managed runtimes: bun1, node24. Upgrade the Scarn CLI to a version that supports it.Upgrade the CLI on the worker host to fix it. Because image tags move with the worker version, a worker upgrade can also change the runtime under every managed step.
Work branches
Section titled “Work branches”For devcontainer steps that use Repo.readWrite() (the default), the worker creates a dedicated git branch for each task right after cloning, commits the agent’s changes to it, and pushes it. What a step does with the repository is set by its repo option: see Read-only steps and Commit message and push failures below.
Branches are named <prefix>/<stepKey>-<taskId>. The prefix defaults to scarn. To use your own prefix, add branchPrefix to the repo’s .scarn/scarn.jsonc:
{ "project": "acme/web-app", "branchPrefix": "myteam",}With the config above, a step keyed build produces a branch like myteam/build-<taskId>.
Notes:
- The prefix is sanitized to a valid git ref (whitespace and unsafe characters become
-). If it is missing, empty, or sanitizes to nothing, the worker falls back toscarn. - The prefix is read from the cloned repo’s config on disk, so it lives alongside the code it applies to.
Read-only steps
Section titled “Read-only steps”A step with Repo.readOnly() still clones the repo and checks out its base branch (see Base branch), but the worker creates no work branch, commits nothing, and pushes nothing. The task reports no work branch, so no branches pile up on your remote for steps that only read code. Edits the agent makes stay in the workspace and are discarded with it. This is a policy, not a sandbox: the repository is not mounted read-only.
A step with Repo.none() (the Runtime.host() default) never clones, so none of this applies to it.
Private submodules and dependencies
Section titled “Private submodules and dependencies”Git inside the dev container has no credentials of its own. By default the worker answers git’s credential requests from the container with the worker machine’s own git credentials, for your repo’s host only (add more with SCARN_GIT_CREDENTIAL_HOSTS). So a postCreateCommand that runs git submodule update --init for a private submodule, or a package install that pulls a private git dependency, just works. Nothing goes into .scarn/.env or your devcontainer.json. Pushing from inside the container is blocked: the worker pushes the work branch itself. A step opts out with gitCredentials: "none" on its repo (see Git credentials), and you can turn it off for the whole worker with SCARN_GIT_CREDENTIALS=off.
Commit message and push failures
Section titled “Commit message and push failures”The commit message defaults to scarn: task <taskId>. A step sets its own with Repo.readWrite({ message }), rendered from the step’s input and the result the agent submitted. The rendered subject is single-line, at most 200 characters, and falls back to the default when empty. The same message is used for submodule commits. See Repo access for the token rules.
readOnly and none steps never push, so they are not affected.
Base branch
Section titled “Base branch”The worker always clones the repo’s default branch, then creates the step’s work branch off a base branch:
- Later steps in a pipeline are created off the work branch of the nearest earlier step, in the same pipeline run attempt, that produced one. The server hands this branch to the worker, and it takes precedence over
--source-branchand the configured base. - The first write step in a pipeline (nothing upstream produced a branch) and standalone steps are created off a base branch resolved with the following precedence:
-
Your current local branch.
scarn workresolves the branch you’re on in the directory you ran it from and checks it out immediately after clone — so the devcontainer config and everything the step runs against comes from the branch you’re actually working on, not the repo’s default branch. Before proceeding, the CLI verifies your current branch exists onoriginand is in exact sync with it (not just an ancestor/descendant); if it isn’t pushed, has diverged, or doesn’t exist onoriginat all, the command fails fast with a message telling you what to do — it never auto-pushes on your behalf. Use--source-branch <branch>to target a different branch instead (e.g. from CI, or a colleague’s branch) — an override only needs to exist onorigin, not be checked out locally.--dry-runperforms the same resolution and checks. -
A configured base branch, when your current branch can’t be resolved (e.g.
scarn workisn’t run from inside a git checkout, orHEADis detached). SetbaseWorkBranchin the repo’s.scarn/scarn.jsonc:{"project": "acme/web-app","baseWorkBranch": "develop",}You can override the configured value per worker with the
SCARN_BASE_WORK_BRANCHenv var in.scarn/.env. The env var takes precedence over the jsonc field. -
The repo’s cloned default branch, when neither of the above applies.
-
How the predecessor is chosen:
| Situation | Base |
|---|---|
| Earlier step wrote a work branch | That branch |
Earlier step is readOnly or none (no work branch), or a wait (decision, reply) |
Skipped: the walk continues to that node’s predecessors |
| Earlier step failed or is otherwise unsatisfied | Ignored; only satisfied runs count. A rerun’s inherited satisfied runs keep their branch |
| Loop iteration after the first | The previous iteration’s branch, when it wrote one |
| Node after a loop | The last iteration that wrote a branch |
| Lanes started by a split | All share the base of the node that started them |
| Node after a merge where every arriving lane ended on the same branch | That branch |
| Node after a merge where lanes ended on different branches | No single base: falls back to the source branch, configured base, then default; the server logs a warning |
| Nothing upstream wrote a branch | The precedence above |
Earlier step used onPushFailure: "warn" and its push failed |
It reported no branch, so it is treated as if it produced none |
If the resolved base branch cannot be fetched/checked out (for example it was deleted from the remote between steps), the step fails.
Git cache
Section titled “Git cache”To avoid a full network clone for every step, the worker keeps a local bare mirror of your repo on the worker machine and clones each step’s workspace from it. The mirror is refreshed from your real remote before each clone, and the workspace’s origin is still your real remote, so pushes behave exactly as before. If the cache can’t be used for any reason (offline, a repo URL that embeds a password or token, a lock timeout), the worker logs git-cache: falling back to plain clone (<reason>) in the step log and does a normal clone.
| Env var | Description | Default |
|---|---|---|
SCARN_GIT_CACHE |
on or off. Any other value stops the worker with an error. |
on |
SCARN_GIT_CACHE_DIR |
Absolute path of the directory that holds the mirrors | ~/.scarn/git-cache |
Both can be set in the environment or in .scarn/.env (the latter wins). There is no size limit; it is always safe to delete the cache directory, and it is rebuilt on demand.
Only your repo itself is cached. Submodules are not touched. When the cache is on, the worker passes SCARN_GIT_CACHE_DIR to the host-side environment of your devcontainer’s initializeCommand, so a script in your repo can opt in to reusing the mirrors. Such a script must tolerate a missing mirror and treat the cache as read-only.
Git credentials
Section titled “Git credentials”By default, git in every readOnly or readWrite step’s dev container can fetch private repositories with your worker’s git credentials (see Private submodules and dependencies). These settings control it:
| Env var | Description | Default |
|---|---|---|
SCARN_GIT_CREDENTIALS |
on or off (any case). off gives containers no credentials and leaves devcontainer.json untouched. Any other value stops the worker with an error. |
on |
SCARN_GIT_CREDENTIAL_SOURCE |
Where credentials come from. Only host (the worker machine’s own git credentials) works today. Any other value stops the worker with an error. |
host |
SCARN_GIT_CREDENTIAL_HOSTS |
Comma-separated extra hosts (host or host:port) the container may get credentials for, besides your repo’s host. |
none |
SCARN_GIT_CREDENTIAL_RELAY_BIND |
Address the worker’s credential endpoint listens on. | 127.0.0.1 on macOS and Windows, 0.0.0.0 on Linux |
All four can be set in the environment or in .scarn/.env (the latter wins). The container needs curl or wget for this to work. Compose-based dev containers, and dev containers whose containerEnv already sets GIT_CONFIG_COUNT, are skipped with one line in the step log; the step still runs. Every request is written to the step log, and issued tokens are masked there.
Step environment variables
Section titled “Step environment variables”A step can declare the environment variables it needs with environment.vars. The worker handles them in three stages:
- Resolution. Right after claiming an execution and fetching its definition, and before anything launches, the worker resolves the declared variables into one set of values.
valueentries are rendered against the run’s input.inheritentries are read from the worker’s environment:.scarn/.envmerged over the worker’sprocess.env, then the entry’sdefault. A variable set to an empty string counts as set. - Injection. The resolved values are passed to the agent and to code steps. With
Runtime.devcontainer()they are added to the in-container OpenCode process (and a code step’s runner) asdocker exec -earguments, so they override the devcontainer’scontainerEnv. WithRuntime.host()they are merged into the host process’s environment. Variables withapplies: "setup"or"both"are also passed to the host-side environment ofdevcontainer up, whereinitializeCommandsees them (see Setup variables). Only variable names are logged, never values. - Masking. Values of
secretentries are registered with the log masker before any runtime log is shipped. Values shorter than 4 characters are not masked.
Because the lookup happens on the worker, an inherit variable has to be set where the worker runs — in .scarn/.env or the worker’s environment — not in devcontainer.json.
Missing required variables
Section titled “Missing required variables”If a required inherit variable (not optional, no default) is undefined on the worker, the step fails during the preflight, before the workspace is cloned or any container starts. The failure message lists all the missing names at once, each as NAME or FROM (for NAME) when from renames it, and never any values:
Step declares required environment variables that are not set on the worker: WAREHOUSE_TOKEN, STAGING_DB_PASSWORD (for DB_PASSWORD). Set them in .scarn/.env or the worker's environment, or mark them optional.Set the variables, or mark them optional or give them a default, and re-run. inherit also cannot read SCARN_* variables; such a definition is rejected when you push, by the server, and by the worker.
Dry runs
Section titled “Dry runs”scarn work --dry-run --step-id <id> (or --pipeline-id <id>) resolves and injects the step’s env into the rehearsal environment, so a missing variable shows up as a launch error in the dry-run report. A dry run has no run input, so {{input.…}} templates render as empty strings. With --global-only there is no step, hence no step env.
Environment requirements
Section titled “Environment requirements”Docker, an AI provider, and a Scarn sign-in are listed in Installation; which steps need Docker and a devcontainer is under Runtime. For a worker on a CI box, see Authentication.
Single-job mode
Section titled “Single-job mode”For debugging or CI use cases, run a specific work item:
scarn worker start --foreground --once --work-item-id <id> --preserve-runtime-on-completeThis runs in the foreground, polls once for the specified item, waits for what it claimed, and keeps the container alive so you can inspect the execution environment. It saves nothing and doesn’t start a background worker. If the background worker is running, the runs count toward its --max-concurrency.
Clean up Docker networks
Section titled “Clean up Docker networks”Newer single-container runs no longer create per-session Docker networks. This command remains as a maintenance utility to remove any unused Scarn runtime networks left over from older runs:
scarn runtime cleanup-networksscarn runtime cleanup-networks --verboseAuthentication
Section titled “Authentication”Workers use credentials stored in ~/.scarn/auth.jsonc. If running in CI, set the SCARN_BASE_URL environment variable and ensure credentials are available (e.g., via a secret injected at ~/.scarn/auth.jsonc).