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 start --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.

Rolling Generations

The runner-layer rolling-generation primitive models daemon replacement as generations. A validated candidate starts on its own endpoint before it becomes the admission owner. Existing jobs remain owned by the draining generation, including their logs, cancellation, artifacts, and reconciliation records. A draining generation retires only after its authoritative active-job count reaches zero.

Its serializable status names the admission owner and each generation’s endpoint, active-job count, and admitting or draining state. A failed candidate startup is removed without changing the prior owner; repeating a refresh for the active generation is idempotent. Older single-generation records remain valid as one admitting generation.

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 daemon-generations/<id>/controllers/<controller>/primary, 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-generation paths so sanitized IDs cannot alias.
  2. Stops the idle controller-started daemon so the unit takes the daemon owner lock, and stops idle generation daemons left by earlier rotation.
  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 generation pin.
  • Disconnect closes the tunnel and removes the session. The daemon and its jobs keep running.
  • Refresh never rotates generations. It builds the new binary, drains through the active-job guard, 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.