Runner Connection Bootstrap

homeboy runner connect <runner-id> uses the first-class runner registry added for issue #2526.

Registry Contract

Runner configs are JSON files at ~/.config/homeboy/runners/<id>.json with the initial #2526 shape:

json
{
  "kind": "ssh",
  "server_id": "lab-box",
  "workspace_root": "/srv/homeboy-lab",
  "homeboy_path": "homeboy",
  "daemon": true
}

Only kind: "ssh", server_id, and optional homeboy_path are used by the Wave 1 connection commands. Registry CRUD owns creation, validation, and future execution metadata such as workspace_root, concurrency_limit, env, and resources.

Connection Shape

The remote daemon is started with homeboy daemon ensure-running --addr 127.0.0.1:0 and the reported address is rejected unless it is loopback. The local client reaches the daemon through an SSH -L 127.0.0.1:<local>:127.0.0.1:<remote> tunnel.

Session metadata is stored at ~/.config/homeboy/runner-sessions/<id>.json so status and disconnect can inspect or close the local tunnel later.

Before a direct-SSH connect is reported as successful, Homeboy captures the local tunnel process start identity and observes it through a bounded post-establishment window while the exact remote lease/PID continues answering /health. A failed observation cleans only that exact owned child. Connect failure evidence may add durability_stage; existing health attempt fields are preserved. health_attempt_count and health_attempts contain failed initial health probes and failed durability observations, not successful observations.

Binary Convergence

A plain homeboy runner connect <runner-id> first brings the runner’s configured Homeboy binary to the controller’s release (#16825), then attaches on the direct or service-managed path. --adopt-live-lease skips convergence, as do placement reconnects.

Convergence compares the configured binary’s self identity version with the controller’s build:

Situationbinary_convergence.status
Controller is not a clean release build (dirty, no commit, pre-release)skipped_dev_build (the runner is not probed)
Runner identity unreadableskipped_unverifiable
Runner already at the controller’s versionalready_current
Runner newer than the controllerskipped_remote_newer (never downgraded)
Runner older, daemon has active jobspending_active_jobs with blocking_job_ids; connect attaches to the current daemon and a later connect converges
Runner older, install succeededconverged
Runner older, install failed (network, checksum, target)failed with detail; connect proceeds with the existing binary

Installing reuses refresh-homeboy‘s verified release path pinned to the controller’s exact tag v<version> (never latest, never a source build) into _homeboy_binaries/homeboy-<sha256>/homeboy, selects it, and replaces the daemon with refresh-homeboy --reconnect‘s mechanism: an in-place restart, or the service unit repoint and restart. That refresh holds its own runtime promotion lease and finishes before connect takes its lease; its reconnect attaches through the non-converging connect, so convergence never recurses.

homeboy upgrade --upgrade-runner runs the same convergence decision, but targets the controller identity the upgrade just installed rather than the running (pre-upgrade) process’s build (#16845). See homeboy upgrade for how each status maps onto the runner upgrade entry.

A plain runner connect is therefore the normal way to update a runner, and operator advice for a runner binary older than the controller names homeboy runner connect <runner-id>. homeboy runner refresh-homeboy remains for explicit refs (--ref), selecting an existing binary (--select), downgrades (--allow-downgrade), dev builds (runner dev-sync), rotating a daemon that drifted from its configured binary, and as the manual fallback when convergence failed.

Daemon State Directory

Each (runner, controller) pair has exactly one daemon state directory on the runner host: ~/.config/homeboy/runner-daemons/<runner>/<controller>. Direct SSH launches, every remote daemon command, and the service unit’s HOMEBOY_DAEMON_STATE_DIR derive it from one function, so a restarted daemon reopens the job store its predecessor wrote.

Current Session

A controller tracks exactly one current session per runner. Jobs, runs, logs, cancellation, and artifacts route through that session’s daemon; there is no ledger of earlier daemons. Replacing the daemon publishes the new session under the runner state lock only while the recorded lease is still the one being replaced, and an in-place restart rebinds the same session to the restarted lease. A stop that cannot prove the remote daemon stopped keeps the session; a session whose daemon is proven dead is cleared. Controllers no longer read or write generations.json or retirement-verdicts.state; files left by earlier versions are ignored and not deleted.

Runner-Owned Service

A runner can own its daemon as a systemd user unit (#13881 step 3). The controller then never starts, replaces, rotates, or retires the remote daemon.

homeboy runner service install <runner-id> requires an idle runner. It:

  1. Writes ~/.config/systemd/user/homeboy-runner-<id>-<controller>.service. The unit runs homeboy daemon serve --addr 127.0.0.1:0 from the controller’s stable link ~/.local/share/homeboy/runner-service/<id>/controllers/<controller>/homeboy, in runner-daemons/<id>/<controller>, with Restart=always. The controller scope is a readable sanitized prefix plus the full SHA-256 of the raw controller ID, shared by service names, binary slots, and daemon state paths so sanitized IDs cannot alias.
  2. Stops the idle controller-started daemon so the unit takes the daemon owner lock.
  3. Waits for the unit’s lease, then marks the runner service_managed.

For a service-managed runner:

  • Connect waits for the service’s fresh lease, opens the tunnel, and writes the session. It takes no runtime promotion lease, so attaching never waits behind a running Cook’s runtime pin.
  • Disconnect closes the tunnel and removes the session. The daemon and its jobs keep running.
  • Refresh never rotates daemons. It builds the new binary, drains through the active-job guard, rewrites the unit, repoints the unit’s link, and restarts the unit. The daemon’s own startup recovers anything a previous owner left behind.

The service needs systemd user lingering on the runner host (loginctl enable-linger) so the unit keeps running without a login session.

Older releases used the unsuffixed homeboy-runner-<id>.service, shared binary link, and daemon-generations/<id>/primary; the intermediate controller-scoped scheme also used sanitized IDs without a hash. New installs never overwrite, stop, or adopt either legacy unit name/path. Such a unit may continue owning jobs while controllers move to hash-scoped units. Retire a legacy unit only after an operator verifies its exact unit and lease and confirms that it has no active jobs. Then stop and disable that specific unit; remove its unit file, binary link, or state directory only when no remaining controller depends on them. Do not use the new controller-scoped service commands to perform legacy cleanup.

Unreachable recorded host

Connect dials the configured server host. It does not discover a replacement address. SSH exit 255 with Host is down, or the same resolution/reachability failure, is transport_unreachable with that recorded host and the next check. That is not a missing Homeboy install.

A numeric DHCP address is not stable. Set a stable hostname, or a DHCP reservation, on the existing server, then reconnect:

sh
homeboy server set <server-id> --json '{"host":"<hostname>"}'

SSH host key verification stays on. Do not accept a new key.