Skip to content

Operating

Supervising a target and serving its state read-only. The operator commands (--requeue KO-n --note TEXT, --file-ticket TICKET.md [--update KO-n]) are described by factory.py --help, and the escalation ladder they sit on in the runbook. Back to the README.

Supervising

The loop watches itself only while it is alive. A crashed or hung run leaves a row in a work phase and a lease nobody gives back, and the supervisor is what notices: an acting sweep (--sweep --act) that fails any run with a dead heartbeat, a blown time box or a stuck review, releases its leases and leaves its branch and worktree for a human. --supervise runs that sweep every 60 seconds by default ([supervisor] sweep_interval_sec) as a long-lived process:

python3 factory.py --supervise /path/to/repo

Running it by hand is optional: the loop starts one itself. At startup, after its config and route checks and before its first claim, the loop reads the target's supervisor.lock, and when no live pid holds it spawns factory.py --supervise for the same target in its own session, with stdout and stderr appended to supervisor.log in the state directory, and prints [holo2] started a supervisor for TARGET as pid N; when a live supervisor already holds the lock it prints [holo2] supervisor pid N is watching TARGET and carries on. The spawned supervisor outlives the loop on purpose and takes the lock itself, so two loops starting at once resolve at the lock like two --supervises. [loop] spawn_supervisor = false turns the spawn off for an operator whose service manager runs the supervisor (Config).

It runs until SIGINT or SIGTERM, finishing the pass in hand and exiting clean. One supervisor per target: the first takes supervisor.lock in the target's state directory (beside the store) with an exclusive create and writes its pid into it; a second --supervise for the same target exits non-zero naming that pid. A lock whose pid is dead is a supervisor that was killed without the chance to clean up, and is reclaimed on the next start; reclaims take turns under an flock on the sidecar supervisor.lock.reclaim beside it, which is left in place. A lock that names no pid at all is not guessed about: the start refuses and says which file to look at.

Each pass bumps the process's row in the store's supervisorHeartbeats table, so whether the watcher is still watching is a query rather than a ps. The sweep also watches the loop's own restarts: a loop that merges a change to the factory itself writes a loopRestarts row and re-executes, and if no claim, heartbeat or "no ready tickets" exit follows within restart_grace_sec the next sweep prints loop did not return after re-exec from <sha> and records it, once per restart. The supervisor also watches the factory checkout it runs from: before each pass it compares that checkout's HEAD with the one it started on, and when they differ -- or when a pass finds the store stamped with a newer schema than its build understands -- it prints factory code moved from OLD to NEW; supervisor re-executing, releases its lock and replaces itself with the same command line, so a self-merge does not end the watch. Nothing else is relaunched. Process management (systemd, a tmux pane, nohup) is the operator's; the factory ships the invocation and nothing around it.

Serving

--serve PORT runs a read-only HTTP daemon for one target on loopback, so a drawer or dashboard can poll the factory over HTTP instead of reading the store:

python3 factory.py --serve 7710 /path/to/repo

A bare port binds 127.0.0.1, which is the whole setup on one machine. When the drawer runs on another machine, give the host too: --serve HOST:PORT binds this host's address on the private network between them (Across machines).

It answers three paths as JSON, every response Cache-Control: no-store, and opens the store through a read-only connection per request; it never holds a connection between requests and never writes. Any other path is 404 and any method but GET is 405, both as JSON.

Path Body
GET /status target, host, now, supervisor (state live/stale/none, pid, heartbeat_age_ms, host), thresholds (heartbeat_stale_ms, strikes), and runs: one {id, ticket, phase, heartbeat_age_ms, elapsed_ms, time_box_ms, host} per live run. 503 when the target has no store yet.
GET /runs?limit=N The --report table: rows of {ticket, actual_min, estimate_min, ratio, rounds, outcome, host, ended_ms}, the same rows in the same order as --report prints, oldest first, each with ended_ms (the run's end as epoch milliseconds, which the table does not print; a drawer ages the last merge from it against /status's now); ?limit=N keeps the first N and echoes limit (null when absent). A limit that is not a positive integer is 400 with an error.
GET /attention What needs the operator: level (none, working, attention, critical), items in the order to read them, and now. Items, each carrying its own level: {kind: "blocked", ticket, question} for every ticket parked blocked_on_operator; {kind: "stale_run", run, ticket, phase, heartbeat_age_ms} for a live run whose heartbeat age exceeds heartbeat_stale_ms; {kind: "failed", run, ticket, reason, ended_ms} for a run that ended failed in the last 24 hours and whose ticket is still in_flight (a --requeue or a later merge drops it); {kind: "supervisor", state: "stale"|"none", heartbeat_age_ms} when the supervisor is not live. With items, level is attention; with none, working while a run is live, else none. critical is reserved for a client to rank a daemon it cannot reach; the daemon never answers it itself. No acknowledgement or dismiss state: the window is time-based only. 503 when the target has no store yet.

Every host passes through [report] host_label, so a configured label is what the network sees rather than the machine name.

The boundary is the bind address and nothing else. The daemon has no authentication: it binds to the one address the command line names (loopback when it names only a port) and anyone who can reach that port can read run and ticket identifiers, phases, heartbeat ages and the estimate-vs-actual history. Keep the bare port unless another machine must reach it, and then name one private-network address. Binding to the wildcard address (all interfaces) publishes that history to every network the host is on, and there is no flag, token or allow-list in the factory that would narrow it back; whoever can reach the address is the whole access control, by design.

Serving standing

A daemon started by hand in a tmux session ends silently at the next reboot, and the drawer then reads the target as "attention needed". deploy/holophyte-serve@.service is a systemd user unit template that keeps one daemon per target standing: the instance name is the target slug, the unit restarts on failure, and an enabled unit comes back after a reboot or a supervisor re-exec, provided the operator's user manager itself starts at boot (lingering, below). It runs factory.py from the factory checkout named in its WorkingDirectory, so a self-merge is picked up on the next restart; the daemon reads the store per request and has no state to lose.

The unit reads three keys from ~/.holophyte/SLUG/serve.env:

Key Value
HOLOPHYTE_TARGET the target repository path
HOLOPHYTE_SERVE_ADDRESS 127.0.0.1 (what --serve PORT binds), or the host's private-network address
HOLOPHYTE_SERVE_PORT the port from the convention below

The address is 127.0.0.1 on one machine, or the host's address on the private network a remote drawer uses; never the wildcard address (see "Serving" above for what an open bind publishes).

Port convention: 7710 for the first target on a host, counting up by one per further target, so a client config is two lines per target: a host serving holophyte and lotuspod has them on 7710 and 7711.

An example ~/.holophyte/holophyte/serve.env:

HOLOPHYTE_TARGET=/path/to/holophyte
HOLOPHYTE_SERVE_ADDRESS=127.0.0.1
HOLOPHYTE_SERVE_PORT=7710

Install and enable, one instance per target:

sudo loginctl enable-linger "$USER"
mkdir -p ~/.config/systemd/user && cp deploy/holophyte-serve@.service ~/.config/systemd/user/
systemctl --user daemon-reload
systemctl --user enable --now holophyte-serve@holophyte
journalctl --user -u holophyte-serve@holophyte -f

The first line matters for an unattended reboot: a user unit is run by the operator's user manager, and without lingering that manager only starts when the operator logs in, so an enabled unit would wait for a login that never comes on a headless host. loginctl enable-linger starts the user manager at boot; run it once per host, and check with loginctl show-user "$USER" -p Linger (expect Linger=yes).

The unit's WorkingDirectory is %h-relative and names one checkout layout; adjust it before enabling if the factory lives elsewhere. A client finds a daemon at the bind address and the target's port from the convention, nothing else; splitting the drawer onto a second machine is Across machines.