The daemon's actions¶
--serve is a read daemon: every GET route in HTTP endpoints
opens the store read-only and closes it. Two opt-ins make it also an
operator's hand on the writer host: actions, below, and config_edit,
the project's configuration.
With
[serve]
actions = true
name = "holophyte"
the daemon answers three POST routes under /actions/, each mapped to a
step the operator ladder already allows: restarting the supervisor unit,
starting the loop unit, and requeue in the store. Without actions =
true every /actions/ path is 404, token or not, and the daemon writes
nothing. The routes are behind the same Authorization: Bearer token as
the JSON routes (Authentication) on every bind,
loopback included: the bind address guards reads, not a hand on the
units. So actions = true needs [serve] token_file whatever the bind,
and a daemon asked to bind without one exits naming the key. Without the
exact token the routes answer 401 before anything runs or is written.
The reply is always the same shape:
{"action": "restart-supervisor", "ok": true, "detail": "systemctl --user restart holophyte-supervise@holophyte exited 0", "unit": "holophyte-supervise@holophyte", "recorded": 42}
Every project action's 200 reply carries these four keys, whatever else
the route adds (ticket, run, unit, facts, ...). action is the route's
name. ok says whether the step did what was asked; detail says what
happened. A step that fails is ok: false with the reason in detail and
still status 200: the operator asked for a thing and is told the answer,
which is not a server error. Each action is an interventions row
(store.record_intervention(), the operator ladder's record-before-acting
call) written before the action runs; an action that cannot be recorded
does not run. recorded is that row's id, the one holo --json write
results cite: the row the action itself inserted, never another
writer's row of the same name (restart_supervisor, launch_loop,
requeue, operator_note for send-back, approve for merge,
babysit for ask, hold,
release_hold, pause, resume, abort or abort_close, and steer
or operator_note for steer). It is
null when the action wrote none: a refusal, ok: false before the
write, or a pause or abort of a run whose request is already
pending. A 400, 404 or 503 answers {"error": ...} instead.
On a host daemon¶
factory.py --serve with no project answers every route on this page
under the project's prefix, /projects/NAME/actions/... and
/projects/NAME/config, NAME its [serve] name (HTTP
endpoints). The opt-ins move: [serve] actions
in host.toml opens the actions for every registered project at once, and
config_edit stays in each project's own config. Both answer only to
host.toml's [serve] machine_token_file, on every bind, and a project's
own token_file under its own prefix, for one release (not merge,
which answers only to the machine token); the daemon refuses
to start with either opt-in on and no machine token, naming the key.
restart-supervisor is not a host daemon's route (404): a registered
project has no supervisor unit, and the host sweep's own action,
run-sweep, below, is at the root. The record of each project action is
written in that project's store, as on a project daemon; a store locked
when the action writes is that project's 503. Every read a host daemon
makes waits at most one second for a store's lock (HOST_READ_WAIT_S in
holophyte/serve/serve_host.py), not the store's own thirty, so an action can
answer 503 database is locked during a brief write lock where a project
daemon would have waited (HTTP endpoints).
POST /actions/run-sweep¶
At a host daemon's root, behind host.toml's [serve] actions and the
machine token. It runs systemctl --user start --no-block
holophyte-sweep.service: one host sweep run now, beside the timer's
(systemd never starts it twice at once, so a request while a run is in
flight joins that run). --no-block because a oneshot's plain start
would wait out the whole run.
Body: optional, a JSON object with note and author. No store owns the
sweep, and the stores may be what is down, so the record is not an
interventions row: before systemctl is asked, one JSON line is appended
to HOLOPHYTE_HOME/host-actions.jsonl and synced, with at, action
(run_sweep), unit, via, pid, author (default maintainer) and
note. A line that cannot be written runs nothing:
{"action": "run-sweep", "unit": "holophyte-sweep.service", "ok": true,
"detail": "systemctl --user start --no-block holophyte-sweep.service exited 0",
"recorded": "/home/op/.holophyte/host-actions.jsonl"}
recorded is the ledger's path, null when the line could not be written;
ok and detail are as on the unit actions below. The console's
"Run sweep" on a stale sweep or supervisor row is this route.
POST /actions/restart-daemon¶
At a host daemon's root, behind host.toml's [serve] actions and the
machine token. Body: optional, a JSON object with note and author.
Before restarting, one JSON line is appended to
HOLOPHYTE_HOME/host-actions.jsonl and synced, with at, action
(restart_daemon), via, pid, author (default maintainer) and note.
If the ledger cannot be written, the daemon keeps running and answers 200
with ok: false, recorded: null and detail naming the ledger.
{"action": "restart-daemon", "ok": true,
"detail": "the daemon drains its requests and restarts",
"recorded": "HOLOPHYTE_HOME/host-actions.jsonl"}
recorded is the ledger's resolved path. After writing the answer, the
daemon stops accepting requests and drains active requests for at most
DRAIN_SEC, logging restart requested over HTTP. With an adopted socket
it exits 0; the service manager starts the next daemon when a request
arrives on that socket. Otherwise it re-executes itself. It comes back on
the checkout's code; a greater root /status daemon.started_ms
identifies the new daemon. Requests while draining answer 503
daemon restarting.
This route does not restart the supervisor, sweep or loop. It is 404 on a
project daemon and under /projects/NAME/actions/ on a host daemon.
POST /actions/restart-supervisor¶
Runs systemctl --user restart holophyte-supervise@NAME, where NAME is
[serve] name: the instance the deploy unit templates are enabled under
(the project slug, see Supervising and serving), the
project directory's name when the key is absent. systemctl gets 20 s to
answer. A non-zero exit is ok: false with its stderr in detail; an
absent systemctl or one that outlives the cap is ok: false saying so.
The reply also carries unit, the instance addressed.
The record is a human restart_supervisor intervention on the store's
newest run, its narrative naming the unit and the route: interventions are
keyed by run, and a supervisor restart is about the runs it watches over.
A project with no store, or a store with no run yet, has nothing to record
against and the unit is left alone: ok: false saying so, recorded
null, systemctl not called. No body is read.
POST /actions/launch-loop¶
Runs systemctl --user start holophyte-loop@NAME: one pass of the loop as
the deploy template defines it, inactive again once the queue is down.
Everything else is as restart-supervisor, the intervention a
launch_loop row, except that a store with no run yet links the
launch_loop intervention to the project instead
(store.record_project_intervention()), and recorded is that
interventions row's id, not the project's; only a store with neither
leaves the unit alone.
POST /actions/requeue¶
Body: a JSON object with ticket (required, the Linear identifier, KO-n),
note (optional, why the ticket goes back in the queue; a fixed note
saying it came from the console when absent) and force (optional
boolean, default false; true is --force and requires a non-blank
note, the written diagnosis, with no console default). The daemon does
exactly what factory.py PROJECT --requeue KO-n --note TEXT does: the store's one
requeue transaction, a requeue interventions row carrying the note on
the failed run and the ticket walked to ready. The reply carries
ticket and, on success, run, the failed run it was requeued after.
A ticket the store never mirrored, or one the store refuses to requeue
(a live run, a ticket not in_flight, a last run that did not fail, a
ticket already relaunched twice against one failure since a human last
acted on it), is 200 with ok: false and the refusal in detail; nothing
is written. The relaunch refusal asks for a diagnosis: "force": true
with the diagnosis as the note requeues the ticket anyway, its note
recorded as forced past N relaunches: NOTE. A body that is not a JSON
object, one with no ticket, a force that is not a boolean, or
"force": true without a non-blank note is 400 naming it. A project with no store is 503.
POST /actions/merge¶
Body: a JSON object with run (required, the run parked for a human's
merge), note, author and bypass_review (all optional). Under [merge] mode = "pr"
and approve = "human" a green and quiet pull request parks its run in
awaiting_merge_approval; this route is a console's "merge" on it, and
it merges only an approved, green, quiet pull request.
First it computes the readiness GET /runs/N/merge
answers, again, at call time: the run parked on its pull request under
approve = "human", then GitHub read afresh through the babysitter's own
pull request read for an APPROVED review decision, passing required
checks, MERGEABLE, no open review thread, and the branch head on
origin and the pull request still the run's candidateSha or
approvedSha. Not ready is 200 with ok: false and nothing written:
{"action": "merge", "ok": false, "run": 52, "reason": "review_not_approved",
"detail": "GitHub's review decision is REVIEW_REQUIRED", "facts": [...],
"recorded": null}
reason, detail and facts are the read route's. A
review_bypassable run is refused so too unless the body carries
"bypass_review": true; with it the run is released as a ready one is.
The flag on a ready run is a plain merge that records no bypass, and on
any other reason it changes nothing. Ready, the daemon
calls the store's approve release, the one --approve KO-n makes: in
one transaction an approve interventions row on the run, the run
released abandoned with resumePhase merge_gate, and the ticket
walked to ready. Its note reads AUTHOR via the console: merge at head
SHA; FACTS held, then the body's note when one is given; author
defaults to maintainer. A bypass lists every fact but review_approved
as held and adds bypassing the required review: DETAIL, the
review_approved fact's detail naming the bypassed ruleset, and the
answer's detail reads approved at SHA, bypassing the required review;
.... The loop merges a bypass release as any other: GitHub grants the
merge to a user its ruleset lets bypass, and a merge GitHub still
refuses takes the loop's existing MergeRefused path. The daemon does not merge: the loop's next
claim resumes the candidate on its pull request and the babysitter
merges it, re-checking green and quiet under the merge lock and parking
it again otherwise.
{"action": "merge", "ok": true, "run": 52, "ticket": "KO-219",
"head_sha": "5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f",
"detail": "approved at 5acc138e0c2b4d7f9a1e6b3c8d0f2a4e6c8b0d1f; the loop's next claim merges the candidate on https://github.com/example/repo/pull/31",
"recorded": 87}
The release names the run it checked: a ticket that moves between the
read and the write, a newer run parked in its place included, is the
store's ApproveRefused, ok: false with reason not_parked and the
refusal in detail, and nothing is approved. On a host daemon
/projects/NAME/actions/merge answers only to the machine token; a
project's own token is 401. A run that is not a positive integer is
400, and so is a bypass_review that is not a boolean, before GitHub is
read; a project with no store is 503. --approve and holo approve keep
releasing without reading GitHub.
POST /actions/ask¶
Body: a JSON object with run (required, a run parked on its pull
request), question (required) and author (optional, default
maintainer). The console's question about the pull request: the loop's
next claim answers it there, without waiting for the host sweep to see new
pull request activity.
A run that is not a positive integer is 400, a run the store does not
hold is 404 no such run, and a project with no store is 503. Other
refusals are 200 with ok: false, a reason and a detail, and write
nothing; they are checked in this order:
reason |
When |
|---|---|
empty_question |
question is missing, not a string, or blank |
no_pull_request |
the run has no pull request URL, as under [merge] mode = "local" |
ask_pending |
an earlier console ask on the same ticket and pull request URL has no answer yet |
finished |
the run has ended: merged, failed, abandoned or rejected |
not_parked |
the run is open but is not its ticket's newest run parked in awaiting_merge_approval: it is live in the loop, or paused |
{"action": "ask", "ok": false, "run": 52, "reason": "ask_pending",
"detail": "console ask event 812 on https://github.com/example/repo/pull/31 is not answered yet",
"recorded": null}
Accepted, one transaction records a console_ask event on the run (detail
level, payload {"question", "author"}), writes a babysit interventions
row with source human and the note AUTHOR via the console: ask:
QUESTION, and releases the park as send-back does: the run ended
abandoned with resumePhase merge_gate, and the ticket walked to
ready. event_id is the ask's event and recorded the interventions
row. The daemon calls no GitHub API.
{"action": "ask", "ok": true, "run": 52, "ticket": "KO-219",
"event_id": 812, "recorded": 88,
"detail": "console ask event 812 recorded; the loop's next claim answers it on https://github.com/example/repo/pull/31"}
On that claim the babysitter treats each unanswered console ask on the
ticket's pull request as an @holophyte ask: on its conversation, whatever
[merge] mention_accounts lists: the adjudicator reads the checkout and the
ticket and changes nothing. One conversation comment is posted, starting
> [Asked from the console by AUTHOR](PR_URL) with the question quoted,
then the answer under the factory's comment header; the babysitter never
reads that comment back as an instruction. The answer records the usual
instruction event (outcome asked) and a console_ask_answered event
with payload {"event_id", "url", "answer"}, and the run parks again as
before. GET /runs/N/asks lists the asks and
their answers. A failed or empty answer fails the run, as an
@holophyte ask: does, and the ask stays unanswered.
The project's configuration: GET /config and PUT /config¶
A second opt-in, separate from actions:
[serve]
config_edit = true
opens the project's own config.toml -- the file the loop reads at
startup, Configuration -- to the console, behind the same
bearer token on every bind, loopback included, so config_edit = true
needs [serve] token_file as actions does and a bind without it exits
naming the keys. Without config_edit = true both routes are 404, token
or not. It is off by default because the file is command execution on the
writer host: [worktree] setup and [agents] name programs the next loop
start runs, so a client that can write the file can run what it likes as
the loop's user.
GET /config answers
{"text": "[serve]\ntoken_file = \"...\"\n...", "values": {"serve": {"token_file": "..."}, "loop": {"workers": 2}}, "path": "/home/.../config.toml", "applies": "next loop start", "revision": "<sha256>", "modified_ms": 1791969120000, "saved": {"at_ms": 1791969120000, "actor": "maintainer"}}
text is the file as written, except that the value of every key whose
name ends in token or key (api_key, token, "api key") is
replaced by "[redacted]"; token_file, a path, stays. The key may be
bare, quoted or dotted, under a [table] or [[array]] header or inside
an inline table, and the value is replaced whole whatever its shape --
a multi-line string, an array, an inline table -- with a comment beside
it left in place. A table whose own name ends so ([extra.api_key],
api_key.value = ..., api_key = { ... }) is a secret whole: every
value under it is replaced, whichever way the table is written. The daemon checks its own work against the parsed
document and answers 500 rather than serve a text in which a secret is
still readable. A project with no file yet has text "". values is the same redacted
text parsed with tomllib, as JSON: a quoted table name or a triple-quoted
string is an ordinary key or value here, so a client reads settings from
values and never parses TOML itself; null when the text does not parse. applies says when a change takes effect: the loop reads
the file once at startup, so a written change waits for the next start
(POST /actions/launch-loop, or the supervisor's), and a running loop is
not touched.
revision is the SHA-256 hex digest of the file's text before redaction,
as read by the daemon; an absent file has the digest of empty text.
modified_ms is the file's modification time in epoch milliseconds, or
null with no file. saved names the newest config_edit intervention on
this project's runs: at_ms is its timestamp and actor is maintainer,
operator, supervisor, or null for an older row with no recorded actor.
With no such intervention, saved is null. A manual file edit changes the
revision and modification time but does not create a save record.
Send the revision read in If-Match when saving either text or patch.
Under the write lock, the daemon compares it with the current file before
restoring secrets, validating, recording or writing. A mismatch answers
409, without creating a backup or intervention:
{"ok": false, "error": "config.toml changed since this revision was read; read it again", "current": "<current-sha256>"}
The error names the project's own file. Omitting If-Match preserves
unconditional writes for existing clients.
PUT /config takes {"text": "..."}, the whole new file. Every
"[redacted]" value in it -- any TOML string reading [redacted],
however quoted, a comment beside it or not -- is replaced by the current
file's value for the same key in the same table after the revision check, so
a round trip through the page never blanks a secret; a [redacted] under
a key the current file does not hold is 400 naming it. The text is then parsed as
TOML and run through the loader's startup checks -- unknown keys, every
constrained value, the shape of [board], [agents] and [worktree] --
exactly as
factory.py does before it claims anything; what the daemon does not do
is probe the host (whether a program is on PATH, whether Docker answers),
which is the loop's question at its next start. A document the loader
refuses is 400:
{"ok": false, "error": "[holo2] /home/.../config.toml: [loop] workers must be an integer >= 1, got 0", "line": 2}
and nothing is written. An accepted document is recorded first, a human
config_edit interventions row on the store's newest run naming the file
and the backup (a project with no store or no run has nothing to record
against and is 503, the file untouched); then the previous text is copied
to config.toml.bak-STAMP beside the file, STAMP the UTC time to the
second (-2, -3 when that second already has one), and the new text
lands by rename from a staging file of its own, so a reader sees the old
file or the new one and never a torn one. Writes are taken one at a time,
from the read of the current file to the rename. Clients sending If-Match
cannot overwrite an intervening save with a stale revision. The reply is
{"ok": true, "path": "/home/.../config.toml", "backup": "/home/.../config.toml.bak-20260910T120000Z", "applies": "next loop start", "recorded": 42, "probe": null, "revision": "<written-sha256>"}
revision names the written file. backup is null when there was no file
to keep. Backups are not pruned.
PUT /config with {"text": "...", "check": true} checks without saving.
It restores redacted values from the current file and runs the same TOML
parser and loader checks. Success is 200 {"ok": true, "checked": true};
it takes no save lock, writes no file or backup, records no intervention,
and probes no implementer. Checks ignore If-Match and do not need a run
in the store. The config_edit opt-in and token gate still apply.
check must be a boolean; any patch body containing check is 400
(check takes a text body). With check false, a text body saves normally.
Every 400 for a text body, whether a check or a save, includes line:
the 1-based line from a malformed-TOML message, or the first assignment
of the named [table] key under that header (including a top-level
table.key assignment). It is null when the refusal cannot be located.
Only the first refusal is returned.
PUT /config also takes {"patch": {...}} in place of text: a flat
object of dotted table.key to a string, an integer, a boolean or a list
of strings,
{"patch": {"loop.workers": 3, "worktree.setup": ["make deps", "make lint"], "agents.implementer": "claude -p"}}
The daemon loads the current file with tomlkit, sets each key -- creating
a [table] the file lacks, editing a multi-line array item by item so its
lines and comments stay -- and serialises it, so comments, order and the
formatting of everything but the patched values survive byte for byte;
the result is then held, recorded, backed up and written exactly as a
text is, with the same reply. A key with no table part, a table this
version does not read, a value of another shape or a [table] that is
not one is 400 naming the key and nothing is written; a value the loader
refuses is the same 400 a text gets. The interventions note names the
patched keys. tomlkit is one of the factory's two dependencies
(requirements.txt; the other, mcp, only holo mcp needs); a daemon
started without it exits naming the module and the install line.
probe is the implementer probe the loop runs at startup, run here when
the write changed [agents] implementer (config.md): the
command as written, asked for the word ready in an empty directory under
the loop's cap, after the file is replaced. It reports and does not gate
-- the write is ok with the file and backup in place whatever the route
said -- so a route that does not answer is found at the write, not by the
next loop start refusing to run. Null when the key did not change or now
names no route. Otherwise
{"ok": false, "command": ["my-harness", "--fast", "Reply with the single word: ready"], "returncode": 1, "timed_out": false, "timeout": 90, "output": ["my-harness: unknown option --fast"]}
with returncode null and timed_out true when the cap ended it, and
output the last lines the route printed.
What a no-commit turn leaves on the run¶
An implementer turn that ends without a commit, whatever its exit code,
normally fails the run. A PR fix turn that is not cut off, leaves a clean
candidate, and answers every addressed thread with a THREAD n: summary
instead parks as blocked_on_operator with the implementer's reasons, so
an operator can send it back with a corrected instruction. Dirty no-commit
fix turns retain the failure and recovery path.
The loop records one detail-level runEvent of kind implementer_output:
its summary is the last non-empty line of the implementer's final message
and its payload the message's last 4000 characters
(OUTPUT_TAIL in holophyte/loop/implement.py), passed through the prose redactor
(redact_prose() in holophyte/redact.py): every credential the config
and the environment hold, and every name = value pair with a secret's
name wherever it sits in the text, read [redacted]. It sits beside the
stream's other kinds (phase_change, crash, carried_candidate,
merge_gate, pull_request) and is what tells a contract the implementer
judged unwinnable from a crash or a refusal. GET /runs/N answers it among
the run's events by its summary, the one detail kind the route shows;
the payload is read from the store.
What a pr_open item's action is not¶
/attention (HTTP endpoints) sends a run parked
on its pull request as kind pr_open, with the PR's URL and the reason it
parked. The console's one action on it, "Open PR", opens that URL in a
new tab and posts nothing: the pull request waits on a review or a merge
by a person. For a run parked for a human's merge ([merge] approve =
"human"), GET /runs/N/merge says whether it
may merge now and POST /actions/merge releases it
to the loop once GitHub shows it approved, green, mergeable, quiet and at
the parked head. The console draws a Merge button when it is ready, and a
"Merge (bypass review)" button when its readiness is review_bypassable,
each behind an in-row confirm; the bypass confirm names the bypassed
ruleset and posts "bypass_review": true.
Beside it the console draws Ask from GET /runs/N/asks:
an in-row question box whose Send posts POST /actions/ask
with the row's run and the question. While the latest ask is unanswered
the row shows it as waiting and offers no Ask; once answered, the row links
the answer comment and shows the answer.
--approve KO-n on the writer host releases a parked candidate without
reading GitHub.
Errors¶
| Status | When |
|---|---|
| 400 | the body is not a JSON object, or requeue has no ticket, a non-boolean force, or "force": true without a non-blank note, or merge has no positive integer run; PUT /config whose text is not a string, is not TOML, the loader refuses, or holds a [redacted] with no current value; a patch that is not an object, or with a key the daemon cannot apply |
| 401 | no exact bearer value, on any bind; body {}, nothing run or written |
| 404 | [serve] actions is not true, or the action is not one of this page's; /config without [serve] config_edit = true; on a host daemon, restart-supervisor, a project name outside the registry, and any root action but run-sweep or restart-daemon; restart-daemon on a project daemon or under /projects/NAME/actions/ |
| 405 | POST on any path outside /actions/; PUT on any path but /config |
| 503 | any request while the daemon drains for restart (daemon restarting); requeue or merge against a project with no store yet; PUT /config with no store or no run to record against; on a host daemon, a project whose store is stamped newer than the build can read, or is locked or corrupt when the action reads or writes it |