Several machines
This page shows how to run Coxswain lanes on more than one machine, launch them from one place, and bring their work home to land. It assumes you have run Coxswain on one machine.
The model
The chair is the machine where you run cox. It keeps the workspace, which
holds your tickets and task records. It also does all landing. Other
machines only run lanes.
All machines share one run store. The store is a Postgres named by
storage_url in the provider profile. Add the driver to cox with the
postgres extra:
uv tool install --force --editable "<home>/installs/coxswain[postgres]"
<home> is the coxswain home, ~/.local/share/coxswain by default. The
install replaces the extras cox had, so name each one you use, such as
[postgres,lake].
With a local SQLite store, everything stays on one machine.
Set up a lane machine
Do these steps on each machine that will run lanes.
- Install Coxswain as in Install. Also clone each repo your initiatives target,
at the same absolute path as on the chair, with its
.venvand check tools. The lane reads the initiative'srepo:path unchanged. - Install cox with the postgres extra, as above.
- Use the same provider profile as the chair. It is team configuration and
lives outside the code checkout, in
<home>/providers/or the workspace; copy it there and point the machine profile'sprovider_profileat it. Itsstorage_urlnames the shared Postgres. - Put
claudeon the PATH of a non-interactive ssh shell, and log in. Set a gituser.nameanduser.email. - Run
cox setup doctor.
The store row of the doctor shows the store's kind and run count. It never
shows the URL.
From the chair, cox setup doctor --host <name> also runs claude auth
status on the host. It adds a claude auth row. The row reads like this
when the login is good:
claude auth ok
When it is not, the row reads like this, and the doctor exits 1:
claude auth: not logged in on the host (run claude auth login there)
Run claude auth login on the host. A login can lapse on a machine nobody
types on, so a lane machine that worked last week can fail this check.
cox route launch --on <host> runs the same check first and refuses to
launch when it fails.
A login watchdog also checks every lane host's Claude login over ssh every 30 minutes, timing out after 60 seconds, and records the result with the host's beat. A host whose login has lapsed gets no new lanes; it stays in the hosts table and its running lanes continue.
Name the lane machines on the chair
On the chair, list the lane machines in the routing profile:
lane_hosts:
- {name: box2, ssh: user@box2, workspace_dir: /home/user/workspace}
workspace_dir is the workspace's absolute path on that machine.
ssh is a destination only. Put a port, user or key in your ssh config under
a Host alias, and use the alias here. Connect once by hand so the host key is
known.
Run the doctor on a lane machine from the chair with --host:
cox setup doctor --host box2
The doctor runs there over your own ssh config. The profile holds no keys.
Add a host to the shared table
Add the host to the hosts table in the shared store so placement can fill it:
cox host add box2 --ssh me@box2 --capacity 3
--capacity is the lanes the host may run at once. An optional --weight
sets its share of new placements, and an optional --capabilities names
the list it can be matched on.
Once the table has rows, it decides: a host in the profile's lane_hosts
but not in the table is not a lane host. cox host add and a launch with
--on print a note naming such hosts. A launch
takes the host's workspace from its own beat (cox host beat on that
machine), else from the same-named lane_hosts entry. cox setup doctor
--host reads only lane_hosts; cox host doctor box2 uses the table.
cox host drain box2 stops launching on the host while its live lanes
finish; cox host activate box2 makes it a lane host again. cox host
list prints one line per host: its state, capacity, beat age, and login.
Launch a lane
Launch an epic on a lane machine with --on:
cox route launch epic --initiative work/<id> --on box2
The command allocates the run id on the chair. It copies work/<id> to the
host with rsync. It then starts the lane there over ssh.
Watch the lanes
The docket, cox route status and cox runs top list lanes that other
machines hold. Each one reads like this:
<run> (on box2, since HH:MM, heartbeat Ns ago)
The docket is cox route context. Liveness comes from a lease in the shared
store. A second run of the same epic is refused on any machine.
A lane counts as lost only when its host has gone quiet and its own lease heartbeat has been quiet for 10 minutes; a lost lane is marked lost and relaunched on another host. Placement weighs each host's weight and capabilities when it fills free lanes.
Make a lane machine the resident chair
A lane machine can run the chair loop itself. cox chair service --install
--host <name> prints the steps that put the loop on that host as a
systemd user service; add --apply to run them instead of printing. cox
chair service --status --host <name> reports it.
Two steps stay with a person: loginctl enable-linger and systemctl
--user enable --now.
Land the work
When the lane ends, bring its work home:
cox runs fetch <run>
This brings the lane's task records and log to the chair. It also fetches
its branches, agents/<run>/*, and its phase branches, epic/<initiative>/*,
from the host's repos, and the fetched run's approvals reach the ticket
files. Then run cox runs land as usual.
The autonomous chair does the fetch itself. It runs
cox runs fetch before it lands a remote lane.
The chair's copy of a ticket stays at its old state while the lane runs. The land marks it done.
Shared storage
With no settings, traces live under runs/traces and the Iceberg lake
under runs/lake on each machine. One machine needs nothing more.
For several machines, point traces_url and lake_url in the provider
profile at a shared path. An NFS mount works, if every machine has it at
the same absolute path. No new service is needed.
For object storage, use s3:// URLs with a self-hosted S3-compatible
server, such as Garage. Add an object_store block to the provider
profile:
object_store:
endpoint: http://example.internal:3900
region: garage
access_key_env: GARAGE_ACCESS_KEY_ID
secret_key_env: GARAGE_SECRET_ACCESS_KEY
path_style: true
The profile names the environment variables that hold the key and secret.
It never holds the values. A literal key in the profile is refused.
cox lake doctor shows the endpoint and whether each variable is set.
A Garage server
A single-node Garage on the tailnet needs one profile block. Replace <host>
and <bucket> with your own:
object_store:
endpoint: http://<host>:3900
region: garage
access_key_env: GARAGE_ACCESS_KEY_ID
secret_key_env: GARAGE_SECRET_ACCESS_KEY
path_style: true
traces_url: s3://<bucket>/traces
lake_url: s3://<bucket>/lake
Garage 1.1 also needs two SDK settings. Without them the pyarrow S3 client
fails every upload with invalid checksum algorithm. Set both to
when_required:
AWS_REQUEST_CHECKSUM_CALCULATION=when_requiredAWS_RESPONSE_CHECKSUM_VALIDATION=when_required
Keep the key, the secret and these two settings in one env file. Write them
as export NAME=value lines and set the mode to 600:
export GARAGE_ACCESS_KEY_ID=<key>
export GARAGE_SECRET_ACCESS_KEY=<secret>
export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
chmod 600 <the EnvironmentFile path>
The file's path is the one cox chair service uses as EnvironmentFile.
Source it from the login shell's startup file on every machine. For zsh that
is ~/.zshenv:
. <the EnvironmentFile path>
Lanes started over ssh get their environment from that file and from nothing else. A file sourced only from an interactive startup file does not reach them.
A run reads the profile when it starts. It reads it again when it writes its
trace at the end. A run started before the file existed fails its trace write
with object_store names env var ... which is not set. The first trace in the
bucket comes from the first run started after the change.
Check the setup in three places:
cox lake doctorshows the endpoint and whether both variables are set.cox setup doctorhas a traces row that names the endpoint.- The bucket listing shows a trace after one run.
Other URL schemes and cloud-specific sign-in come from plugins in the
coxswain.storage entry-point group. The Plugins page lists
the groups. Core names no cloud vendor.
Traces and the lake still stay local unless these are set.
Land from any machine
By default the ticket files hold each task's state, which is
work_state: files. When the provider profile's storage_url names a
shared Postgres database that answers, the loop reads work items from the
store instead; cox route status then prints work state: store first. A
local SQLite store keeps the files as the board. Only the chair lands from
the ticket files; any machine can land once the store is the board.
With the store as the board, a land on any machine checks that the task is
approved in the store. It holds a lease named land:<task> while it
merges. It moves the task to done with a compare-and-set. If another
machine landed the task first, the land stops before it merges anything.
The ticket file's state: is updated after.
The ticket files become a cache. After editing them by hand, cox route
import writes them to the store, and cox route drift compares the two.
This command also shows how they differ from the store:
python -m harness.store_cli regenerate-states <work_dir> --initiative <id>
Add --apply to rewrite their state: lines.
When the shared store already holds the lane's task records,
cox runs fetch brings only the lane's branches.
Every machine that lands needs a checkout of the workspace. Ticket bodies stay in git.