# Runners

> The runner finishes each merge after the agent leaves.

Source: https://getbay.io/docs/runners (HTML).

`bay merge` returns at once with an operation id. A runner then runs that operation to an outcome. It claims the operation, runs the required checks, and merges the candidate into main. With keys in the registry, the runner signs each merge commit. When it cannot merge, it records a refusal with a reason and a next step.

## Run the local runner

`bay runner run` drives queued operations on one machine. It makes a pass every 10 seconds by default. `--once` makes one pass and exits. Run it under a supervisor that restarts it, such as the one `bay runner install` writes. Between operations, it moves to the bay that `bay update` installed, and keeps its process id. With `--once`, or on Windows, it keeps the version it started with.

```sh
bay runner run                      # a pass every 10 seconds
bay runner run --interval-s 30      # a pass every 30 seconds
bay runner run --once               # one pass, then exit
```

### Keep it running

`bay runner install` writes a service that keeps `bay runner run` going for this repository. On macOS, that is a launchd agent in `~/Library/LaunchAgents/`. On Linux, it is a systemd user unit in `~/.config/systemd/user/`. The service runs the bay that wrote it, with your current `PATH`, and restarts it whenever it ends. It logs to `bay/executor.log` in the repository's Git directory. bay starts nothing. It prints the one command that starts the service, also under `detail.load`. `--print` shows the file and writes nothing. `--force` replaces a file that is already there.

```sh
bay runner install             # write the service; print the command that starts it
bay runner install --print     # show the file, write nothing
```

> On Linux, `loginctl enable-linger` keeps the service running after you log out.
> Without `--force`, bay leaves a service file that is already there, and the command fails.
> On other platforms, it fails, so run `bay runner run` under a supervisor of your own.

## Check the machine

`bay doctor --role executor` judges this machine as a runner. It checks Git 2.50 or newer, the check sandbox, the environments the machine serves, and each check's `env` variables. With a key registry, it also checks the runner's `executor` and `producer` keys. It exits 10 when a row is red. `bay runner run` itself refuses with `TOOL_BELOW_FLOOR` below Git 2.50, before it fetches or claims anything. Its `presence` row shows this runner's own presence, its age, and how its last push went.

```sh
bay doctor --role executor
```

## Choose the environments a machine serves

Each `[[check]]` names an `environment`. `bay.checkEnvironment` in Git config lists the environments this machine serves, one value each. Without it, the machine serves every environment. A single empty value serves nothing, for example during maintenance. When no machine serves an operation's environment, the operation waits with `CHECK_UNAVAILABLE`. That wait never counts toward a queue deadline.

```sh
git config --global --add bay.checkEnvironment linux-x86_64
git config --global --replace-all bay.checkEnvironment ""   # serve nothing during maintenance
```

## Run a change's checks at once

By default, a runner runs a candidate's checks one after another. `[runner] parallel_checks = N` runs up to N of them at once, each in its own directory. The default is 1 and the most is 16. bay refuses a config outside that range. bay still keeps the records in the policy's check order, and the time budget starts with the first check. When a check answers `unavailable`, no further check starts, and bay keeps no record for the checks after it. A check with `baseline = "base"` runs alone, after the checks before it.

```toml
# .bay/config.toml
[runner]
parallel_checks = 4                # up to 4 of a candidate's checks at once; 1 to 16
```

> The checks share this machine, so give it the CPU and memory for N checks at once.
> A check's reruns for `retry_on_failure` stay in that check's own slot.
> bay's bisect of a red post check and `bay plugin test` still run one check at a time.

## Keep a disk floor

`bay.minFreeMiB` in Git config sets the least free disk space for this machine, in MiB. Below it, `bay start` refuses `DISK_LOW`, and makes no change and no view. Below it, a check copies nothing and does not run. It ends `infra_failure` naming `DISK_LOW`, never a pass. bay reads the space that `df` says your account can use, where the view or the copy would go. Set it globally or for one clone. A repository cannot set it. Without it, bay refuses nothing. On Windows, bay does not judge the floor.

```sh
git config --global bay.minFreeMiB 2048   # start nothing below 2 GiB free
```

> `bay doctor` also names a `gc.log` that stops Git's automatic garbage collection, in its `gc-log` row.

## When a check cannot reach a worker

A check command can answer that no worker could run the check. It prints an `unavailable` reason and no `checks`. bay then writes no evidence and records no attempt. The operation waits with `CHECK_UNAVAILABLE`, and the wait never counts toward a queue deadline. The runner stops renewing its lease, so a machine that can serve the check claims it.

```json
{"schema": "bay.check.result/1", "unavailable": "<reason>"}
```

## Run at least two

One runner is a single point of failure. Run at least two, so that one can take over from the other. An owner renews its claim with a heartbeat, every `heartbeat_s` seconds, 10 by default. After `lease_s` seconds with no heartbeat, 60 by default, another runner can claim the operation. A replaced runner can never merge that operation. Before a takeover, a runner watches the claim for one full lease on its own clock, even after a reboot.

> When a fault ends a runner's run of an operation, the runner tries to give its claim back.
> Then another runner can claim the operation at once, without waiting out the lease.

## See that a runner runs

Each pass writes the runner's presence: version, machine, environments, last pass, held operation and queue depth. It writes at most once per heartbeat, to `refs/bay/executor/<runner>`. `bay status --fleet` lists each runner under `runners`, with its age and whether it is `stale`. When operations wait and no runner is fresh, `bay status` warns `no_live_runner` under `detail.warnings`. The warning refuses nothing. Queued operations merge once a runner runs again. With agent hooks on, a session also hears once, at its start or next prompt, that no runner runs.

```sh
bay status           # detail.warnings: no_live_runner when no runner is fresh
bay status --fleet   # runners: machine, bay, environments, last_pass, holding, queue_depth, stale
```

> The Fleet page says when a presence turns stale.
> The hook reads only local refs, as of the last fetch.
> It says so again only after that state cleared and came back.

## Operation states

An operation moves through `submitted`, `admitted`, `running`, `checked` and `merged`. It can also end as `blocked`, `failed` or `superseded`, or as `no_effect` when its work is already on main. Each step is a commit in the change chain, so `bay status` reads the true state. While an operation runs, `refs/bay/inflight/<op id>` keeps its candidate safe from Git maintenance. That ref stays local, and bay never pushes it.

### How long an operation took

When an operation ends, `operation.timing` holds its times, in Unix seconds. bay reads each timestamp from a commit it already wrote, never from a wall clock. `submitted_at` is the request, `queued_at` its queue place, and `head_at` its first claim at the head. `merged_at` is when it merged. Another outcome has `ended_at` instead. `checks[]` holds each attempt's start, finish and check runs, and `attempts` counts them. `queue_wait_s`, `check_s` and `total_s` follow from those times.

```sh
bay status <change>   # operation.timing, once the operation ends
```

> `bay status <change>` shows the same fields as `operation.timing`.
> bay leaves out a field whose source it cannot read.

## Conflicts found early

At the start of each pass, and after each merge, `bay runner run` test-merges queued operations onto main. It tests up to 8 operations behind the head of the queue, and the test writes nothing to main. A conflict shows in `bay status` as `operation.conflict`, with `MERGE_CONFLICT` and the conflicting paths. The operation keeps its place, so its agent can refresh and repair it before it reaches the head. Its own merge at the head still decides, and a later clean test clears the conflict. With agent hooks on, the change's session hears of each conflict once, with its paths.

## Merge after another change

Sometimes one change needs another change on main first, such as an API before its callers. `bay start <intent> --paths <paths> --after <change>` opens a change that merges only after that change. Repeat `--after` for each change it needs. Each must exist on the remote, and need not have merged yet. You can submit it at once. Until each named change merges, the operation waits with `DEPENDENCY_PENDING`. It holds no queue place meanwhile, so the changes behind it can merge. Its checks run once the wait ends. Its merge carries one `Bay-After: <change>` trailer for each change it named.

```sh
bay start CARD-124 --paths web/ --after c7k2m4qa   # merge only after c7k2m4qa merged
bay merge                                          # queued with DEPENDENCY_PENDING until then
```

> A named change that ends `no_effect` counts as merged. One abandoned or superseded refuses this one with `DEPENDENCY_ABANDONED`.
> Then abandon this change and start again without that `--after`, or restart the named change and merge it.
> Adopting a change with `--after` adds to the changes it waits for.
> bay refuses an `--after` that would make a change wait on itself, directly or through others.
> The wait counts toward no queue deadline, and a sandbox lane change never waits.

## Large histories

A runner pass costs the same however many closed changes and finished queue places the repository holds. After `[retention] window_s`, the runner removes finished queue places below the head. It always keeps the highest place, and removes at most 64 places in one push.

## The external runner contract

A CI system or a queue host can act as a runner, named by `[runner] command` in `.bay/config.toml`. `bay merge` runs that command with the `submit` argument, and the request as JSON on stdin. The command must exit `0` and print a JSON object with a `job` id within `submission_timeout_s`. That reply admits the operation. Otherwise the operation ends `failed`, and nothing merges it later. Your system then drives the operation, for example with `bay runner run --once`. No agent types these actions.

| Action | Input | What it does |
|---|---|---|
| `submit` | JSON on stdin: `action`, `op_id`, `attempt_id` (null), `request_digest` and `candidate` | Prints `{"job": "<id>"}` and exits 0; another exit refuses the submission. Must be idempotent: the same op id and request digest return the existing job, and a different digest under one op id is refused. |

> With a key registry, a runner needs its own key in the `executor` class.
> It never holds or uses an agent key.
> bay records each step by op id, so a crash between steps loses no work.
> bay reads `[runner] command` from the work tree's `.bay/config.toml`.
> It runs during `bay merge`, on the merging agent's machine and account, with no sandbox.
> A late reply ends as `submission_timeout`, a command that cannot start as `submission_failed`, and a refusal as `submission_refused`.

## Refusals

| Code | Meaning |
|---|---|
| `TOOL_BELOW_FLOOR` | `detail.tool` is `git`: Git on this machine is older than 2.50. `bay runner run` fetched and claimed nothing. |
| `DISK_LOW` | From `bay start`: this machine has less free space than `bay.minFreeMiB`. bay made no change and no view. |
| `CHECK_UNAVAILABLE` | No machine serves the check's environment, or the check answered `unavailable`. The operation waits. |
| `QUEUE_DEADLINE_EXCEEDED` | The operation lost its place at the head of the queue three times, past the execution budget plus `publish_window_s`. |
| `DEPENDENCY_PENDING` | A change named by `--after` has not merged yet. The operation stays `queued` and holds no queue place. |
| `DEPENDENCY_ABANDONED` | A change named by `--after` was abandoned or superseded, so this change cannot merge as it is. One that ends `no_effect` counts as merged. |
| `SANDBOX_ABSENT` | From `bay doctor`: this machine runs checks and has no check sandbox. |
| `CHECK_ENV_UNSET` | From `bay doctor --role executor`: a check's `env` names a variable this machine does not set. |
| `RUNNER_KEY_MISSING` | From `bay doctor --role executor` with a registry: the runner's `executor` or `producer` key is not set or not enrolled. |

## Not built yet

- `inspect`: return the job id, its owner, its state and the evidence so far.
- `resume`: reconcile an interrupted job with the work already done.
- `cancel`: record a cancel and report whether the merge already happened.
- `complete`: return the evidence for the exact candidate and the inputs it used.
