Merge changes

Runners

The runner finishes each merge after the agent leaves.

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

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

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

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.

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

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

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

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.

ActionInputWhat it does
submitJSON on stdin: action, op_id, attempt_id (null), request_digest and candidatePrints {"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.

Refusals

CodeMeaning
TOOL_BELOW_FLOORdetail.tool is git: Git on this machine is older than 2.50. bay runner run fetched and claimed nothing.
DISK_LOWFrom bay start: this machine has less free space than bay.minFreeMiB. bay made no change and no view.
CHECK_UNAVAILABLENo machine serves the check's environment, or the check answered unavailable. The operation waits.
QUEUE_DEADLINE_EXCEEDEDThe operation lost its place at the head of the queue three times, past the execution budget plus publish_window_s.
DEPENDENCY_PENDINGA change named by --after has not merged yet. The operation stays queued and holds no queue place.
DEPENDENCY_ABANDONEDA 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_ABSENTFrom bay doctor: this machine runs checks and has no check sandbox.
CHECK_ENV_UNSETFrom bay doctor --role executor: a check's env names a variable this machine does not set.
RUNNER_KEY_MISSINGFrom 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.