Coordinator on folknet #
A design for moving the spec-watch coordinator off the development box onto folknet:
the scheduled tick (review, writer launches, landing through bench/land.sh), the
primary checkout it lands into, and the scripts it calls. Afterwards the development box
is only where a person connects from. It serves the standing rule that heavy work runs
on folknet hardware (the roster handoff.md on main). Nothing here is built or moved
yet. Hosts are named by role; real addresses stay in $HOME/rig-notes/rig-access.md.
In one paragraph. Create one new small unprivileged LXC, the coordinator guest,
with the existing lane scripts (bench/lane-create-lxc.sh, bench/lane-provision.sh).
Clone the primary checkout there, fetching from the rig forge's pull mirror. Recreate the
gitignored files by hand with rig-network addresses and no jump host. Install the same
cron entry with spec-watch-schedule.sh. Only one coordinator can tick because the
development box's cron entry is removed and its primary checkout renamed, so a leftover
entry or a hand-run tick fails at the tick script's first check. The tick, pi-tracks,
bench/land.sh and the lane scripts need no code change. Pushing to the public forge
stays a person's step at first; an automatic push with a deploy key is a later, separate
decision.
Today and after #
flowchart LR
subgraph today["today"]
direction LR
p1["person"] --> dev1["development box<br/>cron tick, primary checkout,<br/>/tracks, status line"]
dev1 -- "ssh via jump host<br/>(~18.6 KB/s up)" --> lanes1["2 build lanes<br/>gates + writers"]
end
subgraph after["after"]
direction LR
p2["person"] --> dev2["development box<br/>or laptop: ssh only"]
dev2 -- "ssh via jump host" --> coord["coordinator guest<br/>cron tick, primary checkout,<br/>/tracks, status line"]
coord -- "rig LAN" --> lanes2["2 build lanes<br/>gates + writers"]
end
A tick uses little CPU: one claude -p session talking to a remote API, git, and
node scripts. The heavy parts (gates, landing checks, writers) already run on the lanes
through bench/on-lane.sh and bench/lane-writer-launch.sh. Moving the tick matters
because every gate and writer launch now crosses the development box's slow uplink as a
bundle (docs/infrastructure.md → "Build lane"). A tick on the rig LAN does not.
Where the coordinator runs #
| New LXC (chosen) | One of the two lane guests | |
|---|---|---|
| Isolation | Only the tick, the primary and its local-only files | Shares a user and home with unattended claude --bg writers that run acceptEdits |
| CPU contention | None with gates; the tick is light | A 96-thread gate or batch competes with the tick, and a busy tick slows a gate |
| Blast radius | A writer or a pkill -f on a lane cannot touch the primary (the pkill incident in docs/infrastructure.md → "Rig change log") |
bench/lane-apply.sh runs git clean -fdx in copies under the same home; a lane restart stops ticks and gates together |
| Cost | One more guest to provision and back up | None |
The coordinator guest is created with bench/lane-create-lxc.sh's own sizing variables
(LANE_CORES=8 LANE_MEMORY_MB=16384 LANE_DISK_GB=60). It goes on a node that carries no
lane, so losing one node never takes a lane and the coordinator together. It is
provisioned with bench/lane-provision.sh and the writer-host section (LANE_PI_TRACKS_URL,
plus a LANE_WRITER_SETUP command that installs the tick's own harness: its CLI,
configuration checkout and policy hook). That installs everything the tick needs:
git, Node 22, python3, jq, the harness, and pi-tracks at
~/.local/share/pi-tracks, the path the cron entry names. It also installs the Rust
toolchain and browsers, which the coordinator uses only when a lane is unreachable (see
"Gates when both lanes are down"). LANE_WRITER_DIRS names the primary checkout and its
.worktrees instead of the lane roots. Add a weekly backup job beside the two lane jobs,
because the primary can hold landed commits no forge has yet.
The primary checkout and its remotes #
flowchart LR
pub["public forge"] -- "pull mirror, every 10 min" --> rig["rig forge"]
rig -- "fetch (LAN, no rate limit)" --> prim["primary on the<br/>coordinator guest"]
prim -- "push main + spec/* tags<br/>(a person, at first)" --> pub
pub -- "lane mirrors fetch<br/>(NEV_LANE_GIT_URL)" --> lanes["lane mirrors"]
prim -- "bundle of commits<br/>not on origin/main" --> lanes
- Clone.
git clonefrom the rig forge's pull mirror of the current repository. Then setremote.origin.pushurlto every push URL the development box's primary has (git remote -v; during the rename's step 4 there are two). Fetching from the rig forge avoids the public forge's rate limit. - Mirror lag is harmless. The mirror is up to 10 minutes behind a push, so a fetch
can move
origin/mainback to an older commit.bench/remote-lane.shbundlesorigin/main..HEAD, so the bundle just gets larger. After a push, the forge'sPOST /repos/{owner}/{repo}/mirror-syncshortens the lag.bench/land.shreads only localmain, and neverorigin. - Who pushes.
bench/land.shsays "Nothing is pushed", and a person pushesmainfrom the development box; the one exception isjobs/scripts/poll-and-land.sh, which pushes thespec/<name>/<sha>tag of each job it lands toJOBS_TAG_REMOTE(defaultorigin). The first cutover keeps that, with no forge credential on folknet. A person pushes from the laptop or the development box withgit fetch <coordinator remote> mainfollowed bygit push origin main 'refs/tags/spec/*'. The alternative isgit pushon the guest overssh -A, using the person's own key through a forwarded agent. - Automatic push, later. If landings should reach the public forge unattended:
- Generate a deploy key on the guest (
ssh-keygen). The private half never leaves it. - A person adds the public half to the repository as a write deploy key in the forge's web interface. That is the only provisioning step, and nothing is committed.
- To keep the key away from writers, it belongs to a second unix account that runs no
writer and no session. The tick account can only reach it through one sudo rule for a
fixed script. That script pushes
mainfast-forward only, plusspec/*tags. - The key is in the guest's backups, so the backup storage must not be the NAS export that is open to the whole subnet.
- Whether landed code goes public with no person in between is a person's decision.
- Generate a deploy key on the guest (
Exactly one tick owner #
The tick's lock (spec-watch-tick.sh, flock on
~/.local/state/spec-watch/<repo basename>.tick.lock) exists only on one machine.
Two coordinators would both see the same free slots and launch duplicate writers into
the same lane copies: the lane path is <root>/<worktree basename> on both. They
would also land into two diverging main branches. Three measures keep one owner, and
none of them needs code:
- Remove the development box's cron entry. Run
spec-watch-schedule.sh remove <primary>. It deletes only the marker line and the entry for that repository, so the other repositories' entries stay. - Retire the development box's primary. Rename the directory
(
mv <primary> <primary>.retired). A leftover cron entry or a tick run by hand then stops atspec-watch-tick.sh's first check (not a primary checkout). A tick already running ends normally, since it holds open files, not the path. Renaming it back is the rollback. - Wait out a running tick before the first folknet tick. Both conditions must hold
on the development box:
flock -n <lock> truesucceeds, and<repo basename>.tick.pidnames no live process.
A lease that both machines can see, for example an owner ref on the rig forge that
spec-watch-tick.sh checks, would enforce this by mechanism instead of by procedure.
It is not needed while one person does the cutover. It is listed under open questions.
Writers and gates from the coordinator #
No script changes. The ssh routes to the lanes change, and they live only in local-only files:
bench/lane.local.json. Copy the development box'slanes[]entries (lanes[0]on the first guest,lanes[1]on the second) with the rig-network lane addresses. Changessh_optsto drop-J <jump host>and name the coordinator's own key.rootstays the same, and so doesgit_url, whatever the rename's step 4 has reached by then.- The primary's
.env. ItsGENERA_LANE_SSH,GENERA_LANE_SSH_OPTSandGENERA_LANE_GIT_URL(or theNEV_*names) take precedence overlane.local.jsoninbench/on-lane.shandbench/remote-lane.sh. They must name the same jump-free route, or be left out. Every worktree links this file (tracks.json→install). - Lane access. Generate an ssh key on the coordinator guest and add its public half
to
~lane/.ssh/authorized_keyson both lanes. A lane writer's liveness check (bin/spec-status.mjswriterFor()) readssshandsshOptsfrom the per-track runtime file, whichbin/spec-watch-launch.mjsfills from the launch script's output. New launches therefore carry the new route automatically. - Faster transfers. Bundles,
--pulldirectories and writer fetch-backs (bench/lane-writer-fetch.sh) move over the rig LAN instead of through the jump host.
Gates when both lanes are down #
bench/on-lane.sh falls back to a local run on the coordinator guest. That still runs on
folknet hardware, but with 8 cores a cold cargo test --workspace is slow. A cold run
peaks at about 1.1 GB more memory. This is acceptable: nothing needs a fast turnaround,
and a gate that fails on a reachable lane is never retried locally. Local writers are
the same case: bin/spec-watch-launch.mjs launches one on the guest only when every
lane refuses.
Authentication #
- Claude. The tick runs
claude --add-dir ... -p '/spec-watch'(spec-watch-tick.sh, withANTHROPIC_MODEL=claude-opus-5) on the same subscription as the lane writers. A person signs in once over ssh withclaude auth login, the step already used for the lanes. ~/.config/pi-tracks/env. ContainsTRACKS_TOOL=claude, the same as on the development box.- Model gateway.
NEV_MODEL_URL(orGENERA_MODEL_URL) in the primary's.envnames the gateway's rig-network address, the one each lane keeps in~lane/.lane-model-env. The development box's value is a loopback port forward and does not work on the guest. The key is copied as it is. - Metered key. Leave
ANTHROPIC_API_KEYout of the guest's.env. No unattended step may spend metered credit (the roster's decision rule), and a key that is absent cannot be spent. Add it back only while a person runs an approved metered spec. $HOME/.config/folknet-writer/env(mode 600) is copied only when stage 3 ofdocs/folknet-writer-design.mdneeds a writer launched from the guest.
Secret files move by scp from the development box, one file each, mode 600, and never
through git.
How a person sees state #
The status line, /tracks (tracks-status.sh), /waiting (waiting.mjs) and
spec-status.mjs all read local files: the primary, ~/.local/state/spec-watch/ and its
stages/. They therefore run on the coordinator guest. A person runs
ssh <coordinator> from the development box or the laptop, then either starts claude
in the primary (the ~/.claude checkout supplies the same status line and hooks) or
runs the scripts directly. For a quick look without a session:
ssh <coordinator> tail -n 40 .local/state/spec-watch/spec-watch.log, or
ssh <coordinator> .local/share/pi-tracks/bin/spec-status.mjs <primary>. Hand-started
interactive tracks run there as well, so that main moves in only one checkout.
~/.claude and pi-tracks on the guest #
Both are anonymous, read-only clones made by bench/lane-provision.sh, as on the lanes.
Changes are still made on the development box or the laptop, then committed and pushed.
The guest only pulls. A pull must not rewrite scripts under a running tick, because bash
reads a script as it runs. So the guest's crontab gets a second entry that takes the same
lock before pulling:
flock -n <lock> sh -c 'git -C ~/.claude pull -q --ff-only; git -C ~/chook pull -q --ff-only; git -C ~/.local/share/pi-tracks pull -q --ff-only',
every 15 minutes. ~/.claude/settings.local.json is per machine and gitignored, so it
is written by hand.
Cutover #
Preconditions.
- No spec track is live: every spec is landed or disabled.
- No hand-started track is live in the development box's
.worktrees. Each one has landed, or its branch has been pushed and its worktree removed, so it can be recreated on the guest. - The development box's
mainhas been pushed.
The per-track runtime state is then not needed. Its files are keyed by a hash of the
worktree's absolute path (bin/spec-status.mjs runtimeFile()), so they would not carry
over to a new path anyway. Cut over in the daytime, with a person watching the first
tick.
sequenceDiagram
participant D as development box
participant C as coordinator guest
participant L as lanes
C->>C: 1-4 create, provision, clone, local-only files
C->>L: 5 check: lane ssh + on-lane gate
D->>D: 6 remove cron, wait for lock, rename primary
C->>C: 7 install cron, watch first tick
C->>L: 8 enable one spec, it writes, reviews, lands
- Create and provision the guest. Run
bench/lane-create-lxc.shwith the sizes above, thenbench/lane-provision.shwithLANE_PI_TRACKS_URL,LANE_WRITER_SETUPandLANE_WRITER_DIRSset to the primary and its.worktrees. Check: a rerun prints onlyunchanged:lines. - Sign in. Run
claude auth loginover ssh. Check:claude -p 'reply ok' --model claude-opus-5printsok. - Clone the primary. Clone from the rig forge mirror and set the push URLs.
Check:
git -C <primary> rev-parse mainequals the development box'smain. - Write the local-only files. These are
.env(lane keys, gateway address, no metered key),bench/lane.local.json,~/.config/pi-tracks/envand~/.claude/settings.local.json. Then run thetracks.jsoninstallstep once in the primary iforacle/node_modulesis needed there (bench/oracle-install.shafter a landing expects it). Check:git -C <primary> status --porcelainis empty. - Check the lane routes. Add the guest's key on both lanes. Check:
ssh -o BatchMode=yes <lane> truesucceeds for each entry, andbench/verify.shin a throwaway worktree printson-lane: ... running on the build lane. - Stop the development box. Run
spec-watch-schedule.sh remove <primary>, wait until the lock is free, thenmv <primary> <primary>.retired. Check:crontab -lhas no entry for genera, and no=== tickline for it appears in the development box's log after this point. - Start the guest. Run
TRACKS_TOOL=claude spec-watch-schedule.sh install <primary>. Check: within 5 minutes the guest'sspec-watch.loghas a=== tickline, thenskipped: idleor an=== exit 0. - First real run. Enable one small spec. Check: it launches on a lane, is fetched
back, reviewed and landed, a
spec/<name>/<sha>tag appears, and the floors ran on a lane. - Push. A person pushes
mainand the tags. Check:git ls-remoteon the public forge shows the landed commit.
Rollback #
On the guest, stop ticking. On the development box, restore the checkout and its cron entry:
ssh <coordinator> .local/share/pi-tracks/bin/spec-watch-schedule.sh remove <guest primary>
mv <primary>.retired <primary> && TRACKS_TOOL=claude ~/.local/share/pi-tracks/bin/spec-watch-schedule.sh install <primary>
That restores today's setup exactly as long as the guest landed nothing. If it did,
first wait for the guest's lock to be free, then run git -C <primary> pull --ff-only <guest primary over ssh> main and fetch its spec/* tags. A spec track that is live on
the guest has its worktree and runtime state only there. Let it land first, or push its
branch and accept that the development box's tick starts it again from that branch.
Other repositories #
The development box's crontab also ticks the sandbox and two other repositories, each with its own marker line. Each one moves by the same steps, one repository at a time, after genera has run cleanly on the guest. The sandbox has no remote, so it is cloned over ssh from the development box.
Open questions #
- Does the public forge offer per-repository deploy keys? If not, an automatic push needs a dedicated forge account's key, or stays a person's step.
- Should landings be pushed without a person? This is a person's decision (see "Who pushes").
- Should there be a lease both machines can see, so that a second coordinator is refused
by mechanism and not only by the rename? A pi-tracks change to
spec-watch-tick.sh. - Which node carries the guest? Pick one with no lane and spare memory. Record the choice in the local rig notes.
- Should the guest also hold the gateway port forwards the development box keeps today? On the rig LAN it reaches the gateway directly, so probably not.
- Once genera has moved, the "Two machines" paragraph in
~/.claude/CLAUDE.md(projects live at~/workspace/<name>on the development box) needs a line for repositories whose primary is on the coordinator guest.
Files and symbols this design relies on #
nev: bench/lane-create-lxc.sh, bench/lane-provision.sh (writer-host section,
LANE_WRITER_DIRS), bench/lane.example.json, bench/lane-writer-launch.sh,
bench/lane-writer-fetch.sh, bench/remote-lane.sh, bench/lane-apply.sh,
bench/on-lane.sh, bench/verify.sh, bench/land.sh, bench/oracle-install.sh,
bench/web-features-update.sh, tracks.json (install, specWatch.lanes,
landScript, landChecks), docs/infrastructure.md,
docs/folknet-writer-design.md.
pi-tracks: bin/spec-watch-tick.sh (the lock, pid file and not a primary checkout
check), bin/spec-watch-schedule.sh (install, remove, the cron marker line),
bin/spec-status.mjs (runtimeFile(), writerFor()), bin/spec-watch-launch.mjs
(lane launch and local fallback), bin/spec-watch-due.mjs, bin/tracks-status.sh,
bin/waiting.mjs, prompts/spec-watch.md.
Elsewhere: ~/.claude/docs/working-modes.md → "Mode 3", ~/.claude/statusline.sh
(load_stages), $HOME/rig-notes/rig-access.md.