Written against the hook and poll design, which the daemon replaced
(How a post reaches an agent); what it says
of today's code is not current.
Part of the proposal, for version 2's node layer. Nothing of it is built. Revised after an adversarial review (three reviewers, 46 findings merged into 18) of its first text. It answers the backlog's "Who owns a run and its repository on a host" (docs/backlog.md): the races fixed on 2026-10-18 each came from two short-lived processes owning one decision or one write. The maintainer, 2026-10-18: "i never opposed the idempotent daemon", and "do try to keep event driven scheduling, rather than polling, for any event that has a clear trigger. try to keep clear layers and data-driven design." The daemon here is the one their earlier direction named: "No automatic relaunch loop, the daemon started from the CGI or the hook under Fossil's sandbox" (HISTORY.md, the list kept).
The invariant
On each host, for each release root (ROOT), at most one process decides
about the runs of every repository that root acts on, and writes OCX's
answers to them: the root's owner, OCX (layers 2 and 3 of
2.0). A repository is acted on from one
release root, as hook's status already requires. Everything else wakes
it. A tool still writes its caller's own message or post, as decision 39
has it, and takes turns with the owner there, one fossil command, or the
commands of one forum write, at a time (Fossil.turn).
Why one for each root, not for each repository: refusal 7 checks a
directory against every run in OCX's care, whatever its repository, and
ROOT/care belongs to one account.
Why a lock, when the record already settles which started counts (2.4,
"the first that counts, counts")? The record settles it only after both are
written: two owners could each launch a process before either started
reached the server. The lock stops the second launch.
| Part | Owns | Never |
|---|---|---|
| Fossil (layer 1) | storage, the server's order, receipts, sync | decides about a run |
| The owner | every decision about the root's runs; every answer OCX writes to them; each run in its care, its workspace steps and its process, as that process's parent, until its last publication | runs as any account but the root's |
| A run's process | its command, in its sandbox and limits | holds the owner's lock, inbox or notes |
A trigger (Fossil's hook, poll, and each tool that writes: create, post, stop, publish, start and the rest) |
its caller's message or post, or poll's pull; then one wake |
decides about a run; waits for the owner |
Events, each with its trigger
Nothing polls where an event has a trigger. A wake is one line, written in
one write of at most 4096 bytes (Linux's PIPE_BUF, so lines from many
triggers never mix): the repository, by its path with links followed, and
the first and last receipt its trigger acts on; or check and the
repository. The owner keeps in memory the receipts it has acted on, and acts
on each receipt once, so a receipt two lines name (a tool's own write, which
Fossil's next hook lists again; two polls whose pulls overlap) is decided
and given once. It decides by the record, read in the server's order, and
never reads a receipt that no line names (decision 39).
| Event | Its trigger | Its line | The owner then |
|---|---|---|---|
| artifacts arrive at the server's repository | Fossil's after-receive hook | the receipts of the artifacts Fossil listed | reads them through the envelope (layer 2) and decides each by a run's life |
| a tool writes on the server's repository itself | that tool, after its write (HISTORY.md, decision 39) | its write's receipt | the same |
| artifacts arrive at a clone | poll, after its pull; Fossil cannot push to a clone, so this trigger is whoever runs poll: a person, or a timer the host's owner sets |
the receipts its pull made | the same |
| a person starts a manual run | start, writing a start message (2.3) where the run is |
its receipt | the same: the run decided as one that is not manual, from the start's sender |
| a person asks the deliberate check | hook --login on the server's host, poll --all on a clone's, and every accept or rollback |
check |
reads every open run naming the repository's clone and decides each by 2.13 |
| a run's process exits, by itself or past a limit | the kernel: the owner, its parent, reads its exit status | none | writes ended, then the run's publications |
| a stop, a publish or a delivery arrives | as artifacts arrive | as they do | ends the run's process, publishes again, or writes the delivery to the process's input |
Starting, idempotently
- The owner holds
ROOT/care/owner.lock, which names its pid, for as long as it lives: a lock the kernel frees when its holder dies. Its inbox isROOT/care/inbox, a pipe made once and never removed or replaced. Their names and the line's form are fixed for version 2, so a trigger of any release finds an owner of any other. - A trigger, once its arrival is on the record, opens the inbox for writing
without waiting. Opened: it writes its line and exits; an inbox with no
room already holds wakes, and the trigger says on standard error that its
arrival is left to the deliberate check. Not opened, since no one has it
open: it starts the owner detached (setsid, as
Session.startdoes), with its line, and exits. No trigger waits for the lock or becomes the owner itself, so a hook, a timer'spoll, a tool at a terminal and a request from the CGI each return. - The owner, started, makes the inbox if it is not there, opens it for reading and writing and keeps it open for life, writes its own line into it, then tries the lock without waiting. Taken: it is the owner, and reads its line with the others. Held: it closes the inbox and exits. Every owner opens the inbox before it tries the lock, so a held lock with the inbox open by no one means only an owner dying, and an owner started at the same moment as the winner leaves its line in the pipe for it.
- A trigger or an owner running as another account than the root's refuses, saying so, and starts nothing.
- An owner that dies takes with it the process of each run whose sandbox
has
ends_with_parent(bwrap --die-with-parent); the process of a run without it, or run unconfined, outlives it. Nothing restarts the owner. What it leaves is what decision 39 calls a node's going down: a line it had read and not acted on, a line written into its inbox as it died, astartedwith noended, and each process that outlived it. The deliberate check finds each: it ends each process that outlived its owner, by the run's note, then answers each countedstartednot in carelost. Until then a later arrival decides only what its own line names: a stop listed for such a run answers itlostthe same way. - A write of the owner's that meets a writer outside OCX (Fossil's
"database is locked"; a hook's wake meets one every time, since Fossil
starts the hook inside its own write transaction) is tried again after
OCX's busy timeout, and at each wake, until it is written. The run stays
in care meanwhile, and its process is never launched before its
startedis written. An owner that dies first leaves the run to the deliberate check. - A tool waits for its turn at fossil in the kernel (flock without
LOCK_NB), never by Crystal's
flock_exclusive, which sleeps a tenth of a second and tries again. The owner's own fossil commands take turns among themselves within it. - Within the owner, one loop reads the inbox and decides, one line at a
time. Each run in care has one task of its own for its steps, its process,
its deliveries and its publications, and only that task writes the run's
started,endedand publications, so a stop and an exit never both write anended.
Publication
The owner writes ended when a run's process is over, with outputs (the
hash of each file the run will publish), then each publication. A run stays
in its care until the last one is written; a publish for it meanwhile
does nothing and says so. If the owner dies first, 2.3 stands: nothing
publishes by itself, and a publish has the owner publish again from the
kept workspace.
What OCX keeps on the host
| Kept | Where | For |
|---|---|---|
| the owner's lock, naming its pid | ROOT/care/owner.lock |
one owner for each release root; accept and rollback end it |
| its inbox | ROOT/care/inbox, a pipe, mode 0600 |
wakes |
| each run's note: the process group and pid the owner launched, and the run's workspace | ROOT/care/RUN.note, written before launch, removed after its last publication or by the deliberate check |
the deliberate check, to end what a dead owner left before answering lost; refusal 7, which counts every note's workspace |
| the log | ROOT/hook.log, one line for each wake, as Hook::Run |
status |
| each run's workspace | as 2.15 | what the run made, read and published again |
| the passwords file, on a clone's host | as 2.15 | the clone's user's password |
What goes
| Today | Why it goes |
|---|---|
| the round's lock | one owner decides |
each run's lock and each user's, and the probes of them (take, held?, care, busy?) |
the owner knows the runs in its care |
poll.lock |
the owner acts on each receipt once, whatever lines name it |
the watcher as a process of its own (--watch, Watcher.hand, Watcher.start), and its deciding again what the round decided (decided, the second decision in prepared, halted, following) |
the owner runs each run's steps and process itself |
each session's inbox (RUN.inbox, Relay) |
the owner writes a delivery to its process's input |
stop_program, TERM to a watcher |
the owner ends its own child |
RUN.workspace, and the pids in RUN.lock |
the run's note |
run's decision by hand (DecideRun, --by-hand) |
a start message, decided like any arrival |
| 2.3's watcher, keeping a run in OCX's care to its last publication under the run's lock and OCX's own | the owner keeps it |
Kept: Fossil.turn, its wait made the kernel's; Node.end_left, run by
the deliberate check from the run's note; Node.mend, which indexes each
post a failed write left unindexed and on a clone sends what a failed push
left, run by the deliberate check alone; Session.fifo, without its
delete, for the inbox.
What comes
| New | Its cost |
|---|---|
| the owner's loop | reading the inbox, parsing a line, the receipts acted on, one task for each run, the retry of a write that met another writer |
| a trigger's wake | open without waiting and write one line, or start the owner detached; one call, in the hook, poll and each writing tool |
| the owner's start | make and open the inbox, write its line, try the lock |
start, a message kind (NEW TERM) |
a row in 2.3 and in 2.13's table, its check in layer 3, the start tool writing it |
| the run's note | written before launch; read by refusal 7 and the deliberate check |
| accept and rollback | end an owner with no run in its care, by TERM to the pid its lock names, then run the deliberate check |
What callers lose
| Caller | Today | Then |
|---|---|---|
create, stop |
answer what the node did at once (Hook::Acted) |
answer that the message is written and the owner woken; what the owner did is on the record and in ROOT/hook.log |
run, now start |
refuses at once with the node's reason | writes start; a refusal is the run's ended on the record |
poll |
answers what it answered, handed, ended, gave and could not give | answers what its pull brought, and that the owner was woken |
status |
reads hook.log's last line |
the same, written by the owner |
Demonstrations owed
- Concurrent arrival: with no owner, two triggers at once, each finding the
inbox open by no one, so two owners start; one takes the lock, the other's
line reaches it, and each receipt is decided once. Again with a tool's
write that the hook lists again, and with two
polls whose pulls overlap: one decision and one delivery each. - A run's exit: by itself, past a limit, and with a stop arriving as it
exits, which gives one
ended;endedbefore any publication, then each; a write that meets Fossil's own, written when tried again. - An owner's death: killed with a run in its care and a line unread; a
later arrival decides only its own line and launches nothing else; the
deliberate check ends the run's process by its note, an unconfined one
included, and answers it
lost. - Accept: an idle owner ended and the deliberate check run; a line written in between is found by that check.
Open
- Accepting a release while runs are in the owner's care: whether the owner
goes on with the old release until they end, or is ended and its runs are
answered
lost. - Whether an owner's death should be found without a person asking. That needs a durable record of the receipts acted on, read at each owner's start, which reverses decision 39's "never swept up by a later arrival".