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. scout writes no interventions row, so its recorded is always
null: its record, written before it starts anything, is the scout's own
row and that row's first event, scout_queued. scout-file writes none
either: its record is the filed ticket and the action item's filed key.
scout-stop writes none either: its record is the scout's
scout_stop_requested event. scout-message writes none either: its record is
the ask's or steer's thread message. 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/abort¶
Body: a JSON object with run (required, the run to end), note
(required, why), close (optional boolean, default false) and
pr_comment (optional text). The daemon does what
factory.py PROJECT --abort KO-n --note TEXT does, with close: true as
--close-pr and pr_comment as --pr-comment TEXT. With close: true
the run's pull request is closed with no comment, or, with pr_comment,
after one comment holding pr_comment alone under the factory's comment
header. pr_comment is posted only with close: true. The abort note is
never posted to GitHub.
A run that is not a positive integer, a missing or blank note, a
close that is not a boolean, a pr_comment that is not text or is blank,
or a pr_comment without close: true is 400 naming it; nothing is
written. A null pr_comment is no comment.
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 in the console, without waiting for the host sweep to
see new pull request activity. Nothing from a console ask is posted to GitHub.
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 in the console"}
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. No comment, review reply or issue comment is
posted: the answer, redacted, is recorded as a console_ask_answered event
with payload {"event_id", "url", "answer"} and a null url, and the run
parks again as before. An ask answered before console answers stayed in the
console keeps its posted comment's url, and such a comment, found on the
pull request, still settles its ask. 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.
POST /actions/scout¶
Body: a JSON object with ask (required, non-blank text: what the scout
looks into; links, ticket keys and pull request URLs go in its text),
kind (required, bug, research, recon or guardrails), lead (optional, a
[scout.leads.NAME] route; default [scout] lead), helper (optional, a
[scout.helpers.NAME] route; default [scout] helper), swarm (optional
positive integer, helpers at once; default [scout] swarm), minutes
(optional positive integer, the wall-clock budget; default [scout]
budget_min), subject (optional, KIND:REF: what the scout is about)
and author (optional, default maintainer). The helper
total and the wave count are always the config's helper_turns and
waves. holo scout "ASK" --kind K, holo bug, holo idea and the
holo mcp scout tool are the same launch; --about KIND:REF and the
tool's subject send subject.
subject names one thing the project's store holds: run:N, run N;
defect:N, run N stopped on its ticket's wording, by a contradiction
event or a supervisor sweep that failed it as a stuck criterion or a stuck
review, as the Findings page counts it; ticket:KEY, a ticket, recorded
under the store's key for it; or pr:URL, a pull request one of the
project's runs opened, the URL compared and recorded as given. The scout
records it as subjectKind and subjectRef, and the scout reads serve it
as subject.
One transaction records the scout in phase queued with its ask, kind,
routes, swarm, helper total, waves, budget and subject, and its first
event, scout_queued (detail level, payload {"author", "via",
"subject"}, subject the string as given or null, via being
POST /actions/scout, holo scout or holo mcp). After that commit the
daemon runs the start half of the host sweep's scout step once: while the
host's live scouts are fewer than host.toml's [scout] max_parallel, it
starts the host's oldest queued scouts, each moved to planning at
main's sha and spawned as factory.py --scout ID PROJECT, so a new
launch never jumps the queue. Launches and the host sweep count and start
under one host-wide lock, so launches at once never start more scouts than
the slots. A scout not started stays queued, with
ok: true and why it did not start in detail, and a later host sweep
starts it. That includes a scout whose process could not be spawned: the
step records a scout_start_failed event and moves it back to queued
for the next pass, however often it fails; so does a launch whose start
lock in the host home cannot be opened. A launch whose
scout another pass started answers ok: true with queued false; one
whose scout has ended answers ok: false with its phase and reason.
The reply adds scout, the new scout's id, and queued, true when it was
not started; recorded is null.
{"action": "scout", "ok": true, "scout": 7, "queued": true,
"detail": "scout 7 queued: 2 of 2 scout slots in use", "recorded": null}
A body that is not a JSON object, a missing or blank ask, a kind
outside the four, a lead or helper that is not a string, or a swarm
or minutes that is not a positive integer is 400 naming the field. So is a
subject that is not a string, has no colon, has a kind outside the four,
a blank ref, a run or defect ref that is not a positive integer, or a
pr ref that does not start http:// or https://. A
lead or helper the config does not define (detail names the
[scout.leads] or [scout.helpers] table), a swarm above the
config's helper_turns, and a subject the store does not hold (a run or
ticket it lacks, a defect run with no such stop, a URL no run opened;
detail names its ref) are 200 with ok: false. None of these writes
anything. A project with no store is 503.
POST /actions/scout-file¶
Body: a JSON object with scout (required, a positive integer: the
scout's id), item (required, a positive integer: the action item's
position), note (optional) and author (optional, default
maintainer). It files one action item of a done scout as a Backlog
ticket, never Ready. holo scout file ID N and the holo mcp
scout_file tool are the same filing. On a host daemon
/projects/NAME/actions/scout-file answers only to the machine token; a
project's own token is 401.
An unknown scout is 404 no such scout, an unknown item 404 no such
action item. A scout not done is 200 with ok: false naming its
phase, and nothing is checked or written. An item already filed is 200 with ok: false, ticket the
key it was filed as, and detail scout 14 item 2 is already filed as
KEY; nothing is checked or written. A revise item is 200 with
ok: false, ticket null and detail scout 14 item 2 revises NAT-3 at
revision 2; save it on NAT-3's edit page: a revision is saved on its
ticket's edit page, so nothing is checked or filed and the item stays
unfiled, and holo scout file exits 1 printing that detail. A project with no [board] is
ok: false. A blank body is ok: false, and so is a body with no H1
whose item has a blank title; nothing is written. A body with no H1 is
filed as # TITLE (the item's title) and a blank line before it,
placed after a leading +++ header so the header keeps its fields, and
that filed text is what the checker judges, so a free-text body is never
refused for its missing title.
The filed text is judged by the checker
POST /tickets/check answers with, for the
backlog column. Only what refuses a Backlog filing on the board refuses
it: the check's blocks (a Depends on key the project does not hold, or
text the leak guard keeps out of the store), or the board refusing it.
Then the answer is 200 with ok: false, problems (every problem, in the
checker's order), advisories, and detail, the first problem; nothing
is filed, and the item stays unfiled.
Otherwise the text is filed to Backlog through the project's board, with
the title, estimate and Depends on read from it, and the item records
the filed key and time. Any other problem files it as a Backlog draft:
its first board note opens Filed from scout 14 item 2 as a Backlog
draft; the ticket checker reports: with each problem on its own -
line, in the checker's order. A problems note that cannot be posted
leaves ok: true and detail says so. A project's filings run one at a
time, from check to note, so two posts of one item file it once. With a
note, the filed ticket's next board note (its first, on an item with
no problems) reads AUTHOR: NOTE; a note that cannot be posted leaves
ok: true and detail says so. The reply adds scout, item,
ticket, the new key, problems (empty for a clean item) and
advisories; recorded is null.
{"action": "scout-file", "ok": true, "scout": 14, "item": 2,
"ticket": "KO-412", "problems": [], "advisories": [],
"detail": "scout 14 item 2 filed as KO-412 in Backlog", "recorded": null}
A draft's detail reads scout 14 item 2 filed as KO-412 in Backlog as
a draft; the checker's problems are its first note, and its problems
are every problem, in the checker's order. holo scout file exits 0 and
prints the detail, then each problem.
A body that is not a JSON object, a scout or item that is not a
positive integer, or a note or author that is not text is 400 naming
the field. A project with no store is 503.
POST /actions/scout-stop¶
Body: a JSON object with scout (required, a positive integer: the
scout's id), note (required without task, non-blank: why), task
(optional, below) and author (optional, default maintainer). It stops
one scout. holo scout stop ID "note"
and the holo mcp scout_stop tool are the same stop. On a host daemon
it is /projects/NAME/actions/scout-stop, admitted as the other project
actions are.
Every stop the route accepts first records a scout_stop_requested
event on the scout, at the detail level, its payload {"author",
"note", "via", "pid"}, pid the process the stop acts on or null. Then,
by the scout's phase:
queued: the scout endsstoppedat once, its reasonstopped by AUTHOR: NOTE. Nothing is signalled, and the host sweep never starts it.- live (
planning,swarming,synthesizing,answering) on this host, with a recordedpidwhose command line (ps -o command= -p PID) names--scout ID: that process is sent SIGTERM. The scout process ends itself: it kills the process groups of its running lead turn and helper turns, each such helper task endingstopped, runs no further turn, ends the scoutstoppedwith the reasonstopped by AUTHOR: NOTEread from its newestscout_stop_requestedevent, records ascout_stoppedevent and exits 0. The reply comes before that end. A process gone before the signal reaches it is treated as no running scout process (below), unless the scout has ended meanwhile: that end is kept, and the reply names its phase,oktrue only when it isstopped. A signal the process's owner refuses is 200 withok: falsesaying so, the scout left live. - live with no running scout process (no
pid, or one whose command line does not name--scout ID): the scout endsstoppedat once, its reasonstopped by AUTHOR: NOTE; no scout process was running, and nothing is signalled. - live on another host: 200 with
ok: falsenaming that host, whose daemon must stop it; nothing is written. - already ended (
done,failed,stopped): 200 withok: falsenaming the phase; nothing is written.
An unknown scout is 404 no such scout. The reply adds scout and
pid, the process signalled or null; detail is stop sent to scout N's
process P or scout N stopped, and recorded is null.
{"action": "scout-stop", "ok": true, "scout": 14, "pid": 48213,
"detail": "stop sent to scout 14's process 48213", "recorded": null}
With task (optional, a positive integer: a helper task's id as GET
/scouts/ID serves it in tasks) the route stops that one helper task,
not the scout, and note is optional. holo scout stop ID --task N
["note"] and the scout_stop tool's task field send it. It is taken
when the scout is swarming on this host with a recorded pid that runs
it, and the task is the scout's and queued or running. The route then
records a scout_task_stop_requested event at the detail level, its
payload {"task", "author", "note", "via"} (note null when none was
given) and its summary helper task T stop asked by AUTHOR, signals
nothing, and answers ok: true with task, pid null and detail
scout N stops helper task T. The scout process reads those events at
most two seconds apart while a wave runs: a running task has its turn's
process group killed (a read helper's turn, or a run helper's setup step
or turn), a queued one is never launched, and either way the task ends
stopped, its excerpt what its turn or setup step printed before it was
killed (empty for one never launched). A task asked to stop whose turn
ends on its own before it is killed ends stopped too, its excerpt its
answer. The
other helpers run on, and the lead reads the stopped task as any other.
Refusals write nothing: a task that is not the scout's is 404 no such
task; a task already ended is ok: false naming its phase; a scout not
swarming is ok: false naming its phase; a scout on another host is
ok: false naming that host; and a scout with no running process is ok:
false with detail scout N has no running process to stop task T.
{"action": "scout-stop", "ok": true, "scout": 14, "task": 52, "pid": null,
"detail": "scout 14 stops helper task 52", "recorded": null}
A body that is not a JSON object, a scout or task that is not a
positive integer is 400 naming the field; without task a note missing
or blank is 400 note must say why, with it a note that is not text is
400, and an author that is not text is 400. A project with no store is 503.
POST /actions/scout-message¶
Body: a JSON object with scout (required, a positive integer: the
scout's id), mode (required, ask or steer), text (required,
non-blank: the ask or the steer), answers (optional, a positive
integer: the id of the scout's card the message answers) and author
(optional, default maintainer). An ask asks a done scout about its
report, and its lead answers in its thread, which
GET /scouts/ID serves as thread. A steer
tells a done scout to look further: it runs one more pass on the steer
and appends that pass's report. holo scout ask ID "TEXT" and
holo scout steer ID "TEXT", each with --answers MSG, and the
holo mcp scout_ask and scout_steer tools are the same messages. On a
host daemon it is /projects/NAME/actions/scout-message, admitted as the
other project actions are.
The message is recorded first, on the scout's thread. Then the route runs
the start half of the host sweep's scout step once, which takes the
oldest waiting work while the host is under its [scout] max_parallel:
for an ask the scout goes to answering and its lead's session is
resumed to answer; for a steer the scout goes to planning and runs its
next pass. A message not taken waits for the sweep's next pass.
A running scout (queued, planning, swarming, synthesizing or
answering) takes a message too. The route records it and starts nothing:
the reply is ok: true, queued: true and detail scout N is PHASE;
message M waits for its next wave or its end. A steer waiting when the
lead's synthesis turn begins, the lead turn after a wave that plans the
next one, is taken there without moving the scout's phase: a
scout_steer_taken event, at the detail level, with payload
{"message": M, "wave": W} and summary steer taken for wave W, consumes
it; the synthesis turn reads the steer and plans wave W to follow it, and
the report turn reads it and says what it changed. An ask, and a steer
that arrives with no synthesis turn left, waits until the scout is done
and is then taken as a message to a done scout is.
An answer turn's ANSWER and a pass's REPORT may end with a proposals
list, each {"question": "...", "choices": [{"label": "...", "mode":
"ask"}, {"label": "...", "mode": "steer"}]} with two to four choices,
each choice's mode ask or steer.
After the turn's own record, at most MAX_PROPOSALS (2) of them, in the
order given, are posted to the thread as cards: author scout, mode
answer, answers the message the turn took, the question as text and
one block {"type": "proposal", "question", "choices"}. A malformed
proposal, or one past the cap, is dropped and named in the turn's
scout_answered or scout_pass_done event, under dropped_proposals.
A person answers a card with an ask or a steer whose answers is the
card: a client posts a choice as its label with that choice's mode, and a
typed answer as its text; the daemon does not match the text to a label. The turn that takes the message reads the card's question
beside it.
An unknown scout is 404 no such scout. A failed or stopped scout is
200 with ok: false, reason not_done and detail naming its phase; a
scout with an ask or steer still waiting is 200 with ok: false, reason
ask_waiting and detail naming the waiting message's id. An answers
that is not a card of this scout, or a card a person's message already
answers, is 200 with ok: false, reason card_refused and detail
naming the card's id; the store holds that rule, so two messages cannot
both answer one card. None of these writes anything. The reply adds
scout, message, the new message's id, and queued, true when the
message was not taken and waits for a scout slot; detail is scout N is
answering message M, scout N runs pass P for message M, message M
waits for a scout slot: ... or, on a running scout, scout N is PHASE;
message M waits for its next wave or its end, and recorded is null.
{"action": "scout-message", "ok": true, "scout": 14, "message": 3,
"queued": false, "detail": "scout 14 is answering message 3",
"recorded": null}
A body that is not a JSON object, a scout that is not a positive
integer, a mode missing or not ask or steer, a text missing or
blank, and an answers that is not a positive integer are 400 naming the
field. A project with no store is 503.
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 three dependencies
(requirements.txt; of the others, only holo mcp needs mcp and nothing
imports claude-agent-sdk yet); 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 shows
the question and the answer, with no link to GitHub.
--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 |