Millhouse is the human-interactive surface around the frostyard mill: it
supervises mill runs across projects so that a run's lifecycle — start,
liveness, phase, stop — is owned by one place instead of being hand-scripted
per run. Its core rule is that every process operation targets a pid/pgid
recorded in a registry (never a pattern match, never a global "stop
everything" verb), and that a run is considered alive only when its recorded
PID exists and /proc/<pid>/cwd still resolves to its recorded worktree.
See DESIGN.md for the product design, the motivating incidents,
and the substrate probe results these packages preserve.
Scope of this repository today: the core supervision library, the HTTP
control plane over it, and the millhouse binary — the Go packages listed
under Packages. millhouse serve runs the daemon, and spawn,
list, status and stop drive it over that API. Anything else described in
DESIGN.md is design, not code.
Platform: Linux only, deliberately. /proc, setsid, and process groups
are the substrate, not an implementation detail to be abstracted away.
Requires Go 1.26.
make build # compile all packages; the daemon lands in build/millhouse
make test # go test ./...
make ci # mirrors CI exactly: tidy, vet, gofmt, test, race, buildmake ci is the single source of truth for what CI checks — if it is green
locally, CI is green.
Run the daemon, then drive it:
build/millhouse serve & # binds 127.0.0.1:7777, logs to stdout
id=$(build/millhouse spawn --project myproj --source local \
--worktree /path/to/worktree -- sleep 300)
build/millhouse list # ID PROJECT SOURCE PID PHASE GATE STATE
build/millhouse status "$id" # the same run as indented JSON
build/millhouse stop "$id" # stopped <id>Stopping the daemon does not stop the runs: they are spawned detached, and a restarted daemon re-attaches to them from the registry.
⚠ There is no authentication. The daemon binds
127.0.0.1:7777by default, and that default is the only thing protecting it. Anyone who can reach the address it is listening on can start processes on this machine, read their logs, and kill them. A non-loopback--addr—0.0.0.0:7777, a LAN address, a container's external interface — is an explicit operator choice to publish that capability to the network, and nothing in this phase will ask for a credential. Put it behind an SSH tunnel or a reverse proxy that does authenticate, or leave it on loopback.
Seven endpoints, all served by millhouse serve. Every JSON error is the
object {"error":"<message>"}; <run> below is the run document described
under State directory. A request to a known path with the
wrong method is answered 405 by the router itself.
| Method and path | Success | Errors |
|---|---|---|
GET /healthz |
200 {"ok":true} |
none — it reads neither the registry nor /proc, so a state-dir problem cannot make it fail |
GET /runs |
200 [<run>, …], started_at ascending (ties broken by id); [] when empty |
none |
GET /runs/{id} |
200 <run> |
404 no such run "<id>" |
POST /runs — body {"project":…,"source":…,"worktree":…,"cmd":[…]} |
201 <run> (the created run, as recorded) |
400 malformed JSON, any of the four fields absent or empty, an empty argv[0], or a worktree that is not an existing directory; 500 any other spawn failure |
POST /runs/{id}/stop |
200 {"id":"<id>","escalated":<bool>} — escalated says whether SIGTERM had to be followed by SIGKILL |
404 no such run "<id>"; 409 run <id> is not alive — nothing is signalled in either case; 500 when the run was alive but signalling its recorded process group failed |
GET /runs/{id}/log?tail=N |
200 text/plain, the last N bytes of that run's log (tail defaults to 4096, is clamped to 1 MiB, and a shorter log comes back whole) |
400 invalid tail for a non-positive or unparseable N; 404 no such run "<id>", or run <id> has no log file yet; 500 when the log file exists but cannot be read. This endpoint's errors are text/plain, not the JSON error document |
GET /runs/{id}/events?source=journal|conductor&tail=N |
200 application/x-ndjson, the last N events of that stream as the producer's own JSONL lines, verbatim, oldest first and newest last (tail counts events, defaults to 50 and is clamped to 1000; conductor events are ordered by each event's own timestamp, never by filename). A known run that has produced no events yet is 200 with an empty body, not a 404 |
400 invalid source when source is absent or is anything but journal/conductor; 400 invalid tail for a non-positive or unparseable N; 404 no such run "<id>"; 500 when a stream's files exist but cannot be read. This endpoint's errors are text/plain, not the JSON error document |
alive, phase, gate_pending, last_event_at and schema are recomputed
for every response — from /proc, from the worktree's journal and
.mill/meta.json, and from the run's conductor event log — and are never
served as read from disk. POST /runs
passes cmd to execve as a list; no shell is involved at any point, and no
endpoint accepts a pattern, a glob or an "all" selector.
millhouse serve keeps everything in one directory, resolved from
--state-dir, else $MILLHOUSE_DIR, else ~/.millhouse:
~/.millhouse/
├── registry.json # every run record; the single source of truth for
│ # what may be signalled. Written atomically
│ # (temp file + rename), never appended to.
├── engine/
│ └── <version>/ # the three mill engine files this binary
│ ├── mill.yaml # embeds, written out at `serve` startup;
│ ├── mill-copilot.yaml # <version> is read from mill_state.py's own
│ └── mill_state.py # ENGINE_VERSION. Nothing reads them yet.
├── logs/
│ └── <run-id>.log # that run's stdout and stderr, as the process wrote
│ # them; served by GET /runs/{id}/log
└── runs/
└── <run-id>/
└── tmp/ # that run's private $TMPDIR, created before the
# process starts; conductor's event log lands in
# tmp/conductor/*.events.jsonl
Every run is started with TMPDIR set to its own runs/<run-id>/tmp — the
daemon's environment plus that one variable — so the scratch files its tools
write, conductor's event log above all, land inside the state dir keyed by run
id instead of in a shared /tmp that is swept out from under them.
Nothing under runs/<run-id>/ is deleted by this phase: not when a run exits,
not when a spawn fails after the directory was created (a failed spawn still
gives its registry reservation back and removes its log file, but leaves that
directory and anything the short-lived process wrote into it), and not on any
sweep, age check or size cap — there is none. Retention is a later concern;
today those directories, and the log files beside them, accumulate until an
operator removes them.
engine/<version>/ holds the three mill engine files this binary carries
embedded — mill.yaml, mill-copilot.yaml and mill_state.py — written out
once at serve startup, before the listener is opened; if they cannot be
written the daemon exits non-zero with a one-line message rather than serving
from a half-written directory. Files already present with the right content
and mode are left untouched; anything absent, edited or left with the wrong
mode is rewritten atomically, and a path occupied by something that is not a
regular file is an error rather than something to replace. Nothing under
engine/ is deleted by this phase — not on startup, not on shutdown, and not
on any sweep — so upgrading the binary adds a directory beside the old ones
and every version materialized on this machine accumulates until an operator
removes them. Retention is a later concern, exactly as for runs/<run-id>/
above. Nothing reads these files yet: materializing them is all this phase
does with them.
registry.json is a JSON object of run id → run document:
{
"9f3a1c2b": {
"id": "9f3a1c2b",
"project": "myproj",
"source": "local",
"worktree": "/path/to/worktree",
"cmd": ["sleep", "300"],
"pid": 40127,
"pgid": 40127,
"started_at": "2026-07-24T11:02:15.481Z",
"exit_observed_at": "2026-07-24T11:07:15.502Z",
"alive": false,
"phase": "gate_chunk",
"gate_pending": false,
"schema": ""
}
}The value of each key is the <run> document the API serves.
exit_observed_at is absent while a run is alive. alive and phase are
persisted as advisory snapshots for a human reading the file — no code path
trusts them from disk.
gate_pending (bool), last_event_at (RFC3339 UTC) and schema (string) are
advisory snapshots on the same terms, derived from files outside the registry:
gate_pending and last_event_at from the run's conductor event log under
runs/<id>/tmp, schema from the worktree's .mill/meta.json.
last_event_at is omitted while empty (empty means "no event with a usable
timestamp", which today includes every run with no conductor events, since the
mill's journal lines carry no timestamp); the other two are always present,
because false and "" are their real values for "no gate pending" and "no
schema recorded".
All five derived fields are recomputed together on every read (GET /runs,
GET /runs/{id}) and on every poller tick, for every run and regardless of
liveness — the files they come from outlive the process, so a stopped run keeps
serving what its journal, event log and meta.json still say. What was stored
is never returned: a value that reached registry.json some other way is
overwritten by the recompute, not passed through.
A run whose .mill/meta.json reports a schema other than 1 gets "schema?"
as its phase, while schema keeps reporting the version verbatim. That is a
warning about how far to trust everything else derived from that run's files —
never an error, and never a reason to stop tracking, listing or stopping the
run.
-
internal/run— the run model and the registry that persists it.Run— one supervised run (id, project, source, worktree, command argv, pid/pgid, start/exit times) and its persisted JSON shape.Registry— run records keyed by run ID under an injected state directory, written atomically (temp file + rename);LogPathderives a run's log file from its ID, andRunTmpPathits per-run temporary directory (runs/<id>/tmp). Both are pure path functions — they take no lock, touch no disk, and create nothing.Alive/Phase/GatePending/LastEventAt/SchemaOf— derived state, recomputed on every read rather than trusted from disk: liveness is pid and/proc/<pid>/cwdmatching the recorded worktree; phase is the last event in the worktree's.mill/journal.jsonl; the last three read the run's conductor event log and.mill/meta.jsonthroughinternal/events. OnlyAliveconsults the process; the rest are functions of files that outlive it, so they are recomputed for stopped runs too. Each degrades to its safe zero value (false,"") when a source cannot be read — this is the read path, which never logs and never fails a caller over a file another process is writing.- The one judgement made on top of them: a
schemathat is neither""nor"1"replaces the run's phase with"schema?", leavingschemaitself verbatim. It is warn-only — no error, no dropped run.
-
internal/events— read-only parsers for the two event streams a mill run produces: the mill's semantic journal (<worktree>/.mill/journal.jsonl, lines keyedevent) and conductor's execution event log (<run-tmp>/conductor/*.events.jsonl, lines keyedtype,timestamp,data). AScannertakes both paths as values and re-reads them on every call — nothing cached, no read offsets persisted, exactly likePhase.- Each source has one parsed accessor —
JournalEvents,ConductorEvents— which alone decides which lines survive and in what order, plus a raw-line accessor —JournalLines,ConductorLines— derived from it. The raw text is never filtered or ordered by a second implementation, so it cannot disagree with the parsed events. - Journal order is file order: one append-only file, so it is already
chronological (and today's journal lines carry no timestamp at all).
Conductor events are sorted by their own
timestampfield, never by filename — a conductor filename embeds a workflow name as well as a time, so filenames do not sort chronologically across workflows. - Malformed lines are skipped, never fatal, and dropped from the raw view as well as the parsed one; a missing worktree, run-tmp, directory or file reads as empty rather than as an error.
- Three derived signals read on top of those accessors:
GatePending(is the newest gate-type conductor event agate_presentedrather than agate_resolved?),LastEventAt(the newest conductor timestamp as a UTC time, with anokflag so "no events yet" stays distinct from "an event at the epoch"; journal lines have no timestamp, so they never contribute), andSchema(theschemafield of<worktree>/.mill/meta.json, which the engine writes as a JSON number, normalized to its canonical string form — both1and"1"read as"1"; an absent file or field reads as"").Schemaonly normalizes the value; deciding what a given schema means for a run belongs to the layer that owns a run's phase. internal/runis its only consumer today: the registry's recompute path reads these three signals for every run. Nothing aboveinternal/runimports it yet.
- Each source has one parsed accessor —
-
internal/supervisor— the process operations over that model.Spawn— starts a run detached (setsid, its own session and pgid), exec'ing argv directly, with output to the registry's log path andTMPDIRset to the registry'sRunTmpPathfor that run (this process's environment plus that one appended variable — constructing a child's environment, not reading configuration out of one). The run id is reserved first — apid=0registry entry, plus the log file claimed withO_CREATE|O_EXCL— and only then are the run's tmp directory created and a process started, so a colliding id is retried before anything exists to undo and no existing run's record or log can be overwritten. Any failure after the reservation removes it again (and kills and reaps the process group if one had already started); worktree-validation failures wrapErrInvalid, so a caller can tell a bad request from a failure of the machine. The one thing no failure removes is the run's tmp directory once it exists — failing to create it is an ordinary spawn failure that leaves nothing behind, but a failure after that point rolls back the registry entry and the log file only.Stop— SIGTERM to exactly one run's recorded process group, bounded wait, then SIGKILL that same group.Poller— periodically recomputes every run's derived state and stamps the exit time of newly-dead runs; it signals nothing.- It is also the logging seam for process lifecycle actions (
spawned,stopped,exit-observed), written to the stdlib logger the daemon shares;internal/runstays silent.
-
internal/logfmt— how any value the binary did not author (run id, project, source, worktree, argv word, journal-derived phase) is rendered into a log line: Go-quoted, so it cannot split the line or forge another, and with the daemon's lifecycle words (started,shutdown) escaped, so it cannot forge one of those either.internal/supervisorandinternal/apiboth log through it, because they log to the same stream. -
internal/api— the HTTP control plane over a registry, and the thin client for it. Both halves live in one package so the request shape, the status codes, and the{"error":"..."}error document stay a single reviewable contract. The seven endpoints and their response shapes are the table under HTTP API; what that table does not say:- The read endpoints recompute all five derived fields (
alive,phase,gate_pending,last_event_at,schema) at the moment of the call, so a run's state is never served as it was last written to disk. POST /runschecks all four fields itself before callingsupervisor.Spawn, and an emptycmdmeans an unusableargv[0]—[""]is rejected exactly as[]is, while["sleep",""](an empty argument) is passed through. A worktree that is not an existing directory arrives assupervisor.ErrInvalid, which is what makes it a 400 rather than a 500.POST /runs/{id}/stopre-derives liveness from/procfirst, sosupervisor.Stopis not even called for the 409 — nothing is signalled on either error path.GET /runs/{id}/logreads its tail by seeking from the end rather than buffering the file, and clamps an oversizedtailrather than refusing it: the parameter says how much of the log the caller wants, not how big the log is allowed to be.GET /runs/{id}/eventspasses the producer's JSONL lines through verbatim — nothing is parsed and re-serialized — and takes its ordering frominternal/eventsrather than re-deriving one: "the last N events, newest last" is exactly the last N elements of the slice the scanner returns. There is no CLI subcommand for it yet;api.Client.GetEventsis how a caller reaches it.Client—Healthz,ListRuns,GetRun,SpawnRun,StopRun,GetLog,GetEvents; any non-2xx response becomes a*StatusErrorcarrying the status and the daemon's message, whether it arrived as JSON or as plain text.Serve— the daemon lifecycle. It logs what it inherited (onere-attachedline per still-alive run, plus a count of the dead ones it holds), then onestartedline, serves the API until its context is cancelled, and on cancellation logs oneshutdownline and then drains in-flight requests, stops the poller and joins it, and saves the registry once — in that order. A poller that fails is logged and restarted rather than taking the daemon down with it, and no supervised run is ever signalled: stopping the daemon does not stop the runs. The listener is injected rather than an address bound here, so the caller owns the bind (loopback by default) and tests can serve on port 0. Translating SIGINT and SIGTERM into that cancellation belongs to the caller, as does pointing the stdlib logger at stdout —Serveonly writes to it. Values it did not author (run ids, project names, journal-derived phases, a poller's error text) go throughinternal/logfmt, as dointernal/supervisor's lifecycle lines — the two packages share one log stream, so registry, request or journal content can neither split a log line nor forge a secondstartedorshutdownanywhere in it.
- The read endpoints recompute all five derived fields (
-
cmd/millhouse— the binary (make build→build/millhouse). The subcommand isos.Args[1], with no flags before it: each subcommand owns its own flags, so--addris the listen address forserveand the daemon address for the clients. A missing or unknown subcommand prints usage and exits 2;millhouse help(or-h) prints the same text and exits 0.millhouse serve [--addr ADDR] [--state-dir DIR] [--poll-interval D]— resolves the state dir (--state-dir, else$MILLHOUSE_DIR, else~/.millhouse), points the stdlib logger at stdout, opens the registry, starts the poller, listens onADDR(default127.0.0.1:7777, used exactly as given) and serves the API until SIGINT or SIGTERM, which it turns into the context cancellationinternal/api.Serveshuts down on. Stopping the daemon does not stop the runs. There is no authentication in this phase: a non-loopback--addrexposes full control of the runs on this machine to anyone who can reach it.millhouse spawn --project P --source S --worktree WT [--addr ADDR] -- CMD [ARG...]— starts a run through the daemon and prints its id. All three flags are required, and so is the literal--: the command is exactly the words after it (at least one), passed as a list and exec'd directly, never as a shell line. Leaving the--out is a usage error even when a command follows it, becausespawn --project P ... sleep 30andspawn --project P ... -- sleep 30are otherwise indistinguishable to the flag package.millhouse list [--addr ADDR]— a fixed-width table of every run the daemon knows about, oldest first: ID, PROJECT, SOURCE, PID, PHASE, GATE, STATE. STATE isrunningorstopped, derived from the liveness the daemon recomputed for that request and from nothing else. GATE is!when that run's recomputedgate_pendingis true — it is waiting at a gate — and an empty cell when it is not; it says only that a run is waiting, never what for, and nothing in this phase acts on it.millhouse status <id> [--addr ADDR]— that one run's full record as indented JSON, exactly as the API reports it.millhouse stop <id> [--addr ADDR]— stops exactly the one run named, printingstopped <id>orstopped <id> (escalated to SIGKILL). One id, always: there is no way to stop more than one run with one invocation.- Every client subcommand takes
--addr(default127.0.0.1:7777) as the daemon's address, and they share one exit-code contract: 0 on success, 1 on any API or network error with the message on stderr, 2 for a usage error — which sends nothing at all. A client's flags may be written anywhere among its arguments, somillhouse status <id> --addr Aworks as documented: the clients split their raw arguments into flags and positionals themselves rather than lettingflag.FlagSet.Parsestop at the first bare word and drop the flags after it.spawn's literal--is the one boundary that holds: everything after it is the command's argv, flag-shaped or not. - This is also the only place that reads
$MILLHOUSE_DIRor the home directory, and the only call in the program that sets the stdlib logger's output — everything belowcmd/takes what it needs as a value.
internal/run and internal/supervisor take their state directory as a
value, and internal/api is constructed from the very same Registry — no
globals, no environment or home-directory reads — so tests run fully isolated
in temporary directories (and against httptest, never a fixed port).
prototype/ is the frozen lifecycle probe that validated this model. It is
kept compiling (go build ./... covers it) but is historical reference
material only: it is byte-frozen, carries no tests, and must not be imitated —
its probe scripts deliberately contain the hazardous pattern-matching process
ops that the registry exists to replace.
AGENTS.md holds the binding repository conventions (stdlib-first, the
process-supervision invariants, testing rules), and
docs/agents/skills/ holds durable lessons from previous runs. Read both
before changing anything here.