Skip to content

Processes

Six kinds of process touch a target. Three are long-lived and live beside the store; two are spawned per run; one polls the daemon over HTTP.

The loop

python3 factory.py /path/to/repo

One process per target. Claims, works and closes out one ticket at a time, then claims the next; exits when the board has no ready ticket, when a run fails ([loop] stop_on_failure, the default), or when it merges a change to the factory itself, in which case it re-executes factory.py from the new main and carries on. It is the only writer of runs.phase (through runs.set_phase()) and of main.

At startup it validates every config table, live-probes the configured agent routes, runs a read-only sweep and prints it, and refuses to start without a [board] table. It holds one write connection to the store for its lifetime; the heartbeat thread opens its own.

A run's phases in order: claimed → working → verifying → reviewing → (addressing → verifying → reviewing) → merge_gate → merging → done, or failed from any of them. The store refuses any edge the diagram in The loop does not draw, and set_phase() on a run the supervisor already ended raises RunEnded, which is how the loop stops cleanly instead of advancing a corpse.

The supervisor

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

One per target, enforced by supervisor.lock in the state directory. Every [supervisor] sweep_interval_sec (60 s) it runs an acting sweep: every live run is sighted; a heartbeat older than heartbeat_stale_min counts a strike, and stale_strikes consecutive strikes end the run; a run past its time box plus grace ends; two review rounds whose findings overlap at or above review_overlap_threshold end the run as review_stuck. Ending a run means store.release() with the leases freed and the branch kept. It also watches for a loop that re-executed and never came back, and for stray review containers whose scratch directory is gone.

Each pass writes a supervisorHeartbeats row, so "is the watcher watching" is a query. Before each pass it compares the factory checkout's HEAD with the one it started on and re-executes itself when they differ, or when the store is stamped with a schema newer than its build; this is what keeps a self-merge from silently ending supervision.

It is deliberately dumb about the work: it reads the store and nothing else, never talks to Linear except to post the one comment a swept run gets, and never touches a worktree.

The serve daemon

python3 factory.py --serve 7710 /path/to/repo (loopback; HOST:PORT to bind elsewhere)

One per target, as a systemd user unit (holophyte-serve@SLUG). A ThreadingHTTPServer bound to the one address given, loopback when only a port is; every request opens the store read-only, answers, closes. It imports store.read and nothing from store, so it cannot write. Endpoints in the HTTP reference. It has no authentication; the bind address is the boundary. Stateless, so a code change is picked up by restarting the unit.

The implementer

Spawned by the loop per run: the [agents] implementer command (default claude -p) with the ticket body as its prompt, in its own process group, in the worktree, with a wall-clock budget. Killed as a group on budget. It sees the repository and the ticket; it does not see the store, Linear, or the reviewer's output except as text the loop feeds back in a fix round.

The reviewer

Spawned by the loop per round: docker run of the pinned reviewer image with a staged export of the candidate mounted read-only at /workspace, a disposable copy of the Codex auth, no host home, no Docker socket, no capabilities. Codex inside gets the ticket, the verify report and the criteria checklist instruction. The container is removed when the review ends, when the loop takes SIGTERM, or by --sweep --act if it outlived its loop. The same image and prompt shape serve the terminal adjudicator. Reviewing has the boundary in full.

The drawer

contrib/swiftbar/holophyte.10s.py, run by SwiftBar on the operator's Mac every ten seconds. Reads ~/.holophyte/drawer.toml for one daemon per target, fetches each daemon's JSON with a two-second timeout, and prints a menu: a "needs you" section when anything needs the operator, then one block per target. The glyph is the two-leaf mark; a green, amber or red dot inside it is the worst level across daemons. It has no state and no write path.

How they restart

Process Restarts itself when Restarted by hand when
loop it merges a factory change (re-exec) queue was empty and new tickets are filed; after a failed run
supervisor the factory checkout's HEAD moves; the store schema is newer never, in normal operation
serve daemon Restart=on-failure in the unit after a merge that touches serve.py, report.py or store/read.py
drawer every 10 s by SwiftBar after pulling a new script version (SwiftBar refresh)

The loop and supervisor both go through holophyte/reexec.py, which replaces the process image with the same command line through an injectable EXEC seam so tests can watch it happen without exec'ing.