Skip to content

The Farm

BoB’s night shift. The farm is a fleet machine that works while you sleep. It runs one bounded maintenance cycle, the tick, on a timer. It takes only the work you handed it, merges only what its CI proved green, and leaves a written record of the night. This page explains each of those choices. The A Night on the Farm tutorial hands a project to the farm for one night, and takes every command below from this page. The operational detail, step by step, is in Farm Tick (the night shift).

Four pieces, each with one job:

  • farm-01 is the machine: a Linux box on the tailnet, declared in the operator repo’s machines.json with the role farm-worker. It has its own BoB checkout, its own ~/.claude and its own GitHub login.
  • The runner is the self-hosted GitHub Actions runner on the same machine. It runs the CI that a farm pull request must pass before it merges. See CI Locus.
  • The tick is bob tick: one cycle that grooms, builds a plan of delegated work, runs one warp-drive session on it, reconciles the plan, publishes a fleet snapshot and writes a digest. A systemd user timer, rendered from the machine’s declared bob_workload, starts it once a night.
  • The lock is $BOB_HOME/.bob-tick.lock. A tick takes it first; a second tick that finds it held exits 3 at once. Two ticks never share a machine.

From any machine you can see the farm’s declaration and whether it is alive:

Terminal window
node ~/.claude/scripts/fleet/machine.js get farm-01
bob status --scope fleet --no-live

The first prints farm-01’s entry: its role, its tailscale_name and its bob_workload (the tick’s schedule, memory limit and whether ticks are still dry runs). The second reads the snapshots each machine publishes. The farm’s row is cached when its last snapshot is under a day old.

An unattended run has nobody to ask. So the question “may the farm take this?” is answered once, by one rule, before the night starts: eligibility.js. An issue is eligible when it is an approved req (or a bug with a reproduction) that carries the farm label. A project can drop the label requirement with _workflow.farm_eligible: true. Nothing serial-only, blocked, unapproved or narrative is ever eligible. The full rule is in What it may take.

The project itself must be live: its manifest must not declare _meta.active: false, because fleet readers skip an archived project. From the project’s directory:

Terminal window
gh issue edit <N> --add-label farm
node ~/.claude/scripts/warp-drive/eligibility.js check <N>
bob operator show
bob plan build --from farm
  • gh issue edit delegates one approved requirement. Only a human adds the farm label; tooling never does it on its own.
  • eligibility.js check answers for that one issue: exit 0 eligible, exit 3 excluded, with the reason.
  • bob operator show names the operator repo, where provisions/<project>.json lives. _meta.active must be absent or true there.
  • bob plan build --from farm builds the plan the farm would take, as a Flight plan: farm (unattended) issue. Every issue it leaves out is printed as [dropped] with its reason. The tick rebuilds the same plan when it starts, so this is a preview you can read before bed.

The farm is on the tailnet. You reach it over Tailscale SSH, from a laptop, with the transport BoB already has: audit.js pull. It runs one command on the farm over SSH with no prompt, an 8-second connect timeout and a 30-second limit. That suits a probe. Anything longer, such as a tick, runs on the farm inside tmux.

Terminal window
tailscale ping --c 1 --timeout 3s farm-01
node ~/.claude/scripts/fleet/audit.js pull farm-01
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'gh auth status'
ssh farm-01
bob ready <owner>/<project>
  • tailscale ping checks the tailnet path, and says whether it is direct or relayed.
  • audit.js pull farm-01 fetches the farm’s snapshot and caches it, so your fleet view is current. It reads the snapshot the farm’s own timer last published when that copy is fresh, and only otherwise runs a new one there (how a live pull reads a remote machine).
  • audit.js pull … --cmd 'gh auth status' checks that the farm has its own GitHub login. The farm never borrows yours: a secret is checked for presence on the machine that needs it, never copied there from another. If it is missing, log in on the farm itself.
  • ssh gives you a shell on the farm. The last line runs there: bob ready clones the project into the farm’s projects directory and makes it warp-ready, with no prompts.

Two prerequisites, both one-time and both a human’s to do:

  • SSH into the farm needs your laptop’s key in the farm’s ~/.ssh/authorized_keys (or Tailscale SSH enabled for the farm).
  • SSH from the farm into a Mac needs Remote Login on the Mac, which macOS ships off. The farm only needs this to audit your laptop. BoB reports it as a blocker and never turns it on for you. See tailscale-remote-ops.

The timer runs one tick a night on the project its declaration names. To give the farm a different project for one night, run one tick for it by hand, on the farm, in tmux, so it outlives your SSH session. See first what it would do:

Terminal window
ls ~/.claude/.bob-tick.pause
bob tick --dry-run --cwd ~/projects/<project>
tmux new-session -d -s <project>-night 'bob tick --cwd ~/projects/<project>'
  • The pause file is machine-wide. While it exists, every tick exits 3 and does nothing, the timer’s included. bob tick --resume removes it for all of them.
  • --dry-run runs the same pipeline read-only: it lists the work it would take and creates no issue, branch, PR or commit.
  • The last line starts the real tick and returns at once. The tick waits on its own warp-drive run, which works the plan at Level 3 and merges through pull requests that must pass CI.

You do not need to stay on the farm. From the laptop:

Terminal window
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'tmux ls'
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'node ~/.claude/scripts/warp-drive/state-machine.js status ~/projects/<project>'
bob viz
  • tmux ls on the farm lists the tick’s session and the warp-drive session it started.
  • state-machine.js status on the farm prints the project’s run state as JSON: its phase, the requirement it is working and its chunk. It reads the same state file bob warp status reads on your laptop.
  • bob viz shows the farm’s tiles from its last snapshot. The tick publishes a new snapshot near its end, so the tiles catch up then.

A tick leaves four records, each in its own place:

Terminal window
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'cat "$(ls -t ~/.claude/fleet/ticks/*.md | head -1)"'
gh issue list --label flight-plan --state all --search "farm (unattended) in:title"
gh issue list --label session-report --state all --limit 3
gh pr list --state merged --limit 5
node ~/.claude/scripts/fleet/audit.js pull farm-01
bob status --scope fleet --no-live
git -C ~/projects/bigbrain tag --list 'nightly-*' --sort=-creatordate
  • The digest. $BOB_HOME/fleet/ticks/<date>.md on the farm: the verdict and one row each for the plan, the work taken and merged, todos filed, the release, rituals, tokens and the budget. The same digest is commented on the plan issue and sent to your phone with bob notify.
  • The run’s issues. The farm plan carries the digest comment. The warp-drive run filed its chunk reports and its session summary. Each merged PR has fixes #N and passed CI.
  • The fleet snapshot. The tick’s last act is a snapshot, so after a pull the farm’s row is cached and fresh.
  • The nightly tag. When the tick maintains BoB itself, it tags BoB’s master nightly-YYYY-MM-DD if master moved and CI is green. Your laptops update to it with bob update --channel nightly. A tick on any other project cuts no tag.

The /status report in the project reads the same tracker, so the farm’s merged work and filed todos appear in its catch-up next to your own.

An unattended run has floors that no level, flag or tick loosens. Each is declared in one place, and you can read each one:

Terminal window
jq -r '.permissions.deny[]' ~/.claude/profiles/autonomous.json
node ~/.claude/scripts/promotion/promotion.js decide --unattended --json
node ~/.claude/scripts/fleet/deploy-pending.js status
  • No force-delete. The tick’s run works at Level 3, under the autonomous permission profile. Its deny list refuses git push --force, git reset --hard, git branch -D and rm -rf, and a branch is only ever removed with git branch -d, which refuses an unmerged one.
  • No deploy past the promotion ceiling. promotion.js decide --unattended prints what an unattended run may do in this project: merge is always pr (merged only when CI is green), and deploy never goes past the ceiling in the project’s dev.json. A deploy above it is refused by the shared deploy entrypoint and by the promotion-ceiling-guard hook.
  • No redeploy under a live session. Rewriting ~/.claude/settings.json would silence the hooks of every Claude Code session already running on the farm. So deploy.sh and bob update refuse while a session is live, and queue the deploy in $BOB_HOME/.deploy-pending instead. The queue drains itself when the last session ends, at the next tick, or at the next fleet snapshot, under the tick lock. deploy-pending.js status exits 0 when nothing is queued and 3 while a deploy waits; bob doctor shows it as deploy queued since <time>. See Queued deploys.

The eligibility rule is the floor under all three: work you did not delegate never reaches the run at all.

A tick that could not finish says why, in its digest and in its exit code. The four ways a night most often goes wrong, and the one command that tells you which:

Terminal window
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'tmux ls'
gh issue list --label todo --state open
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'bob doctor'
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'bob forge queue status'
node ~/.claude/scripts/fleet/audit.js pull farm-01 --cmd 'systemctl --user list-timers bob-tick.timer'
What the morning shows What happened What to do
Digest verdict exit 3, another tick holds …/.bob-tick.lock A tick was still running, or the timer’s own tick had started first. The lock is released when its tick ends. tmux ls on the farm shows the tick that holds it. Wait for it to end, then start yours again. Never delete the lock file.
The run stopped in blocked, a new todo filed The farm’s preflight gate failed: the farm’s BoB is unusable, or the project is not ready on the farm. The run filed one todo per cause, in the repo that owns it. Read the todo. For a host cause, bob doctor on the farm names the fix. Do what the todo says and add completed; the next tick retries.
Merged row empty, writes waiting GitHub was unreachable or rate-limited overnight. The run kept working, queued the writes it could not make, and parked at merging. bob forge queue status on the farm lists the waiting writes. The next tick replays them; nothing is lost.
The farm’s fleet row is cached(stale) or the pull fails The farm is off, off the tailnet, or its timer stopped, so it has not published a snapshot for over a day. tailscale ping farm-01 first. If it answers, systemctl --user list-timers on the farm shows whether the tick timer is still scheduled.

None of these needs you to repair the farm by hand from the laptop. Each one either fixes itself on the next tick or files a todo that says what only you can do.