The loop¶
What one run of the factory does, and the ticket-status and run-phase state machines it walks. Back to the README.
The loop¶
- Claim the first ready ticket — non-terminal and unblocked (Linear
blocksrelations are the only machine-checked dependencies). The lease is per ticket, not per project: the claim asserts that the ticket has no active run and points itsactiveRunIdat the new one, so two loops on one project each work a ticket of their own, and a ticket another live run holds is skipped in one line (ticket KO-n: lease already held by run N) for the next candidate rather than stopping the loop. The store lease is one writer's: a second writer host has a store of its own and cannot see it, so the claim leases the ticket on the board too. Once the store lease is taken the issue gets the labelholo:HOST, whereHOSTis this writer's[report] host_label(its hostname without one) --holo:writer-1-- and the team label is created on first use. The order is fixed: the store lease first, since it is the atomic one; then the label; then the issue's labels read back, because Linear has no compare-and-swap and the write is additive (addedLabelIds, never the whole label list). A ready issue carrying another writer'sholo:label is skipped at admission in one line (KO-n is leased by HOST on the board; skipping it) and nothing is leased in the store; another writer's label the read-back shows instead, taken between the listing and the write, backs the claim off the same way: this writer's own label comes off, the store lease goes back as aninfrafailure, no strike, and the loop takes the next ticket with the same skip line. A ready issue carrying this writer's own label with no live run under it in this store (a run that ended with the board down) is a stale lease a close-out never took off: the claim goes ahead, the stale label is removed and the fresh one written, both only once this loop's store lease is held, so two loops admitting the same stale label cannot strip the one the faster of them has just written. Each close-out -- a merge, a failure, a park for a human, a sweep -- and--requeuetake the label off, and only while the store names no other live run on the ticket, so a close-out that runs late cannot strip the label a fresh claim has since re-asserted. That look and the removal, like the claim's lease and label write, run under one per-store turn (a flock beside the store), so a claim of this store cannot land between them and have its fresh label stripped. A label the board will not take, or a read-back it will not answer, takes the label off once, best-effort -- a refusal is not proof the write did not land -- fails the claim as aninfrafailure and gives the store lease straight back, so a run never starts unlabelled.--requeuetakes the label off before the ticket is claimable again. Before the first claim the loop runs one read-only sweep of the store. The sweep's contract runs the other way too: a run the supervisor's sweep ends while the loop is inside a turn is over, and the heartbeat that keeps the run alive notices, whether at a beat mid-turn or at the last check when the turn returns -- it kills the turn's process group, the loop printsrun N was ended by the supervisor (REASON); stopping this turn, writes nothing more to that run, leaves the worktree and branch as the sweep preserved them, and goes on to its next claim. Then the loop asks GitHub about each pull request a parked run waits on (below, under pull request mode) -- before the mirror, so a ticket the merger also moved to Done ships its run rather than being walkedmergedwith the run left parked -- and then reconciles its mirror: every open mirrored ticket without an active run that Linear now holds completed or canceled (archived issues included; an archived issue still in an open state counts as canceled) is walked tomergedorabandoned, with areconcileintervention row on its most recent run and one printed line naming the move; a board that cannot be asked skips the reconcile in one line and the loop goes on. - Every project refreshes
mainfrom origin before every cut:git fetch originin the checkout, under the merge lock of step 6, then a fast-forward of localmainwhenorigin/mainis ahead. Local ahead or equal (a local-mode checkout's unpushed merges) changes nothing -- behind is fast-forwarded, never reset. Diverged -- neithermainnororigin/maincontains the other -- refuses the cut with an infra failure naming both shas, for a person to reconcile; a project with nooriginskips the step, and a failed fetch is the network's failure, not the ticket's strike. Then cut a per-task branch in a sibling worktree (<repo>.worktrees/), so the main checkout stays untouched, and run the project's configured[worktree] setupcommands there — a worktree that borrows the main checkout's environment tests something other than the branch it is on. After setup, a worktree containing a.githooksdirectory automatically enables it withextensions.worktreeConfig = trueand worktree-localcore.hooksPath = .githooks, before any agent turn. Hook configuration failure fails setup. - Implementer agent (Claude Code / Opus at high effort, write access)
implements and commits under a wall-clock budget from the ticket's estimate
(default 20 min). A budget that fires with uncommitted work in the tree
keeps it as a
WIP:commit on the branch — unverified, and named in the run's failure reason — so the requeue carries the work; only a tree with no changes at all is discarded. An implement or trim turn that crashed (killed by a signal), whose budget fired, or that raised also has the gitignored files and directories it created removed, recording anignored_leftovers_removedevent with the cause and the removed paths, so the next turn and verify start from the ignored files the worktree had before. Kept are the ignored entries that existed before the turn (a directory whole, even when the turn added files inside it) and anything at or under a[worktree] carrydirectory or the[merge] ui_capture_dir, and a directory holding any of those. A turn that exits normally, or exits non-zero below 128, removes nothing.
Trim step. Between the implement turn and the verify gate before the
first review round, a run that implemented trims its own diff once. The
step is skipped, recording a trim event naming why, when the worktree
is mid-merge with main, when the diff is under 50 changed lines, when
verify is red at the implementer's head, or when the run cap would refuse
a [trim] budget_min turn; [trim] enabled
= false turns it off. Otherwise one turn on the active implementer route
runs the vendored trim procedure: five passes (delete, merge, flatten,
comments, tests), one commit each, subject trim: and the pass name. The
factory then discards whatever the turn left uncommitted, removing only
the untracked files the turn created, and judges the new commits. A
timeout, a killed or failed turn, a route failure, a merge commit, rewritten
history, a subject that is not a pass or a pass made twice resets the
branch to the pre-trim head. A scope gate then reads each pass, oldest
first, against the run's diff (the files changed from the base to the
pre-trim head): a pass that changes a file outside it by anything but
added or removed single-line import or from lines resets the
branch to the pass before it, and the reason names the pass and the
file; that pass and every later one are reverted and never verified.
Verify green at the remaining head keeps every pass left; red keeps the
passes before the first one verify fails on. The trim event names the outcome
(skipped, nothing, kept, partial or reverted), and a failed trim
never fails the run: the review sees the untrimmed diff. Resumed runs, the
not-reproduced path, fix rounds, the merge gate and babysit passes do not
trim.
4. Verify gate: the ticket's mechanical verify command must pass before
each review round and again before merge. Every line of an instrumented
simple newline verify block must pass, and the run stops at the first
failure. Lines share one shell,
so exported variables and cd carry forward; an allowed failure must
say so explicitly with || true. The project's [verify] always
baseline runs after the ticket commands at every verify gate, including
fix rounds; before_merge adds a final tier at the merge gate. Each tier
runs in order and stops on failure. Both default to empty lists, and
timeout_sec defaults to 300 seconds per baseline command; see
verify configuration. A failure is fail-loud: a
parseable top-level && chain or instrumented simple newline block runs
clause by clause in one shell,
and the report names the clause that failed and its exit status, shows
the output of every clause that ran, and names the clauses the failure
short-circuited
— silence is reported as silence, never as a bare non-zero exit. Complex
shell programs (including semicolon-separated command lists) execute verbatim
with their own shell exit semantics; the instrumented first-failure and
clause-level reporting guarantees do not apply to them.
5. Local reviewer agent (Codex / GPT-6 Astra at high effort) reviews the
diff against the task inside the hardened container boundary described in
Reviewing;
findings go back to the implementer for one fix round. The number of
rounds is capped per run at
min(review_rounds_max, review_rounds + changed_lines // review_rounds_per_lines)
from [loop] (see Config): 2 by default, one more per 800
changed lines, never above 4.
6. Merge gate: the gate runs under a per-project merge lock (a file in the
project's state directory naming the run and when it took it), so two runs
reaching it together take turns and merges into main serialise; a gate
that waits out the bound parks the ticket naming the holder, and
--sweep --act removes a lock whose run has ended (judged and removed
under the same arbiter a gate takes it under, so a lock a live process
still holds is left in place and said so, and a fresh lock is never
displaced). Under the lock, main
is merged into the branch when it has moved past it -- a conflict
goes to the implementer first, the same resolution turn the claim path
runs on a leftover mid-merge worktree, and only a turn that leaves the
merge committed over a clean tree counts as a resolution; anything
less (a merge still open, or uncommitted edits the verify would read
but the merged sha cannot hold) is undone -- merge --abort, or a
reset to the pre-merge sha when the turn's staged leftovers refuse the
abort -- the branch left at its sha and the ticket parked
blocked_on_operator with the conflicting paths in the
question -- then
the verify command passes again on the result, and the ticket is re-read
from Linear and held against the snapshot the claim froze (title,
acceptance criteria, verify commands). A body edited while the run was
working refuses the merge and preserves the branch — the candidate answers
the ticket as it was claimed, not as it now reads. A ticket that cannot be
re-read (Linear down, issue gone) is not read as drift: the run records
that the check had no evidence and the merge goes ahead on the frozen
contract. On a clean gate: --no-ff merge to main, worktree and branch
cleaned up, ticket → Done. Under [merge] approve = "human" (see
Config) the clean gate parks instead of merging: the run
enters awaiting_merge_approval, the ticket goes blocked_on_operator
asking merge?, the ledger names the branch and candidate sha, the
branch and worktree are preserved, and the lease is released so the loop
claims the next ticket. The run stays open in that phase -- no ending, no
outcome, no heartbeat, and the sweep leaves it alone as it does a run
blocked on an operator -- so it is neither a failure nor counted as one;
main is untouched until an operator releases the run with
--approve KO-n (see CLI): that ends the parked run
with its resume point at the merge gate and returns the ticket to the
queue, and the next claim reuses the preserved worktree and branch,
re-runs the pre-merge verify against current main and merges --
claimed --> merge_gate below, with no implementer or reviewer turn.
Under [merge] mode = "pr" the clean gate leaves the machine instead of
landing on main: git push origin BRANCH, then a pull request against
main titled KO-n: TITLE whose body is the ticket body plus the run's
FINDINGS entry, in that order. Between the push and the create the run
asks GitHub once whether the branch is already the head of an open pull
request (KO-407): on a hit -- a requeued run's predecessor left one
open -- the run adopts that PR instead of opening a second, which
gh pr create would refuse; the run's prUrl is that PR and the
babysit passes below proceed as if the run had opened it. With
[merge] pr_draft = true the pull request is opened as a draft; at the
point a pass would merge it or park it ready to merge, the babysitter
marks it ready for review once (a pr_ready run event) and passes
again, waiting for the checks, and a draft it may not mark parks
instead. With pr_draft = "ui" it opens as a draft only when the diff
touches [merge] ui_paths, and the babysitter never marks it: it parks
held for the maintainer (a pr_draft_held run event) until the
reconcile reads it marked ready for review and wakes the run. A push the remote refuses or a PR create
that fails is an infra failure -- no strike, branch and worktree
preserved, no PR recorded. Then the loop babysits the pull request for
up to [merge] pr_rounds passes (see PR rounds):
each pass reads the unresolved review threads and the checks, the
adjudicator verdicts each thread ADDRESS, DECLINE or HUMAN, the
addressed ones get a fix round, a push and a reply naming the sha and
are resolved, the declined ones a reply and are left open. A thread
naming an existing function, helper or constant the diff duplicates is
a change request, not a preference: ADDRESS, the fix being reuse; the
repository's AGENTS.md or CLAUDE.md is quoted in the brief as the
reviewer's standard, and DECLINE is for a thread that asks for
nothing specific or for what the ticket puts out of scope. Before the
threads are judged, GitHub's mergeable answer is read: a pull
request reported CONFLICTING gets main merged in the way the gate
does it -- git fetch origin, then origin/main (the remote's
main, never the checkout's possibly stale local one) merged into
the branch in the resumed worktree, a push, a ledger note naming the
merge sha -- and the pass returns to waiting on checks. A merge that
stops in the tree is handed to the implementer the way a gate
conflict is; one left unresolved parks the run with the conflicting
paths in the question. The branch is never rebased and never
force-pushed, so review threads keep their lines; a pull request that
is merely behind but mergeable, and one GitHub has not judged yet
(UNKNOWN), are left alone. Every pass is
a reviewRounds row with route github:LOGIN. Green checks and no
open thread merge the PR through its merge API under approve = "auto";
under approve = "human", a decline, a HUMAN thread, red checks or
the cap park the run as above, with the PR's URL on the run
(runs.prUrl), in the ticket's question (PR open: URL, the open
threads listed) and in the ledger. --approve KO-n resumes the run on
the PR and merges it when green and quiet; --babysit KO-n resumes it
for another round of passes.
A babysit resume fast-forwards to the remote branch first (KO-379):
the pass fetches the branch from origin and compares it to the local
branch the way the merge gate compares main. A remote ahead by a
fast-forward moves the worktree and the local branch to its head, with
a ledger note (Fast-forwarded BRANCH to SHA from origin (N commit(s)
pushed by someone else)) saying why the reviewed delta grew, and the
pass treats the new commits as a fix round: they are held to the sha
the last independent judgement covered, so a person's commits on top
of a candidate are reviewed before anything merges. Branches that
diverged park the run with a question naming both shas and nothing
fetched into the worktree; equal branches change nothing and write no
note. A fetch that cannot resolve leaves the pass judging from the
local branch as before, where a head it did not push still parks as
"someone else pushed". A pull request merged on GitHub by a person
while the run waits is that approval: at startup and at the top of every
pass -- each serial claim, each scheduler tick, the timer's included --
the loop reads each parked pull request's state once, and one merged on
GitHub closes its run out as merged with the merge commit's sha
(awaiting_merge_approval --> done below), records who merged it in the
ledger, walks the ticket to merged (Done on the board), removes the
local worktree and branch, and renders the findings window so the run
appears in Shipped. One closed on GitHub without merging leaves the run
parked and makes the ticket's question PR closed without merge: URL,
which the skip line then reads; an open one changes nothing, and a
GitHub error is one printed line for that ticket and the pass goes on,
with the run still parked for the next pass to ask again. The startup
mirror reconcile leaves a ticket parked on a pull request alone even
when the board already says Done, since only GitHub's answer closes the
run out with its merge commit. One a person merges while a live run
still watches it in the merge gate ends that run merged the same way,
straight from the gate (merge_gate --> done below, KO-653): the
factory ran no merge, so the run records no merging step.
The same per-tick read also carries the pull request's updatedAt,
its review-thread count and the token's remaining GraphQL budget
(KO-362). Every park records what the pull request looked like after
the pass's own pushes and replies (runs.prSeenAt,
runs.prSeenThreads), and an open pull request whose updatedAt has
moved past that mark, or whose thread count has grown, is a reviewer's
comment nobody has answered: the tick sends the run back to the
babysitter exactly as --babysit KO-n does -- the intervention row
store.babysit() writes, with source supervisor, the run ended with its resume point at the
merge gate, the ticket ready -- and the next claim (the same pass, in
the serial loop) resumes the candidate on its pull request for another
round of passes. At most one such round per [merge] pr_poll_sec
(default 180 seconds) per pull request, measured from the park:
activity inside the interval is named in one line and waits for the
next tick. A run with no mark (parked by an older build, or after a
read that failed) has the mark recorded on the first tick and is not
sent back for it. When the read reports fewer than 500 GraphQL points
remaining, the tick reads no further pull request and prints one line
naming the reset time; reads resume once it has passed.
The factory still never pushes main, and never moves the local one
under this mode: the merge is GitHub's.
7. On failure (budget blown, no commits, verify stuck, 2 failed rounds):
the loop stops and leaves the branch + worktree behind for a human;
the ticket stays In Progress. A no-commit task is discarded outright —
there is nothing to preserve. A claim that reuses a preserved branch
does not work from the local copy alone: the reused branch is
fetched and fast-forwarded to what origin holds of it before
main is merged in and any implementer turn runs — a remote ahead
moves the local branch and worktree to its head (a ledger note names
the commit count), an equal or behind one changes nothing, and a
diverged one fails the run naming both shas. A project with no
origin skips the step. The one reuse that does not fetch is the
approved candidate's resume under --approve KO-n (item 6): the
approval is of the sha the park recorded, so that branch merges at
exactly that sha rather than being moved onto remote commits no
review saw.
8. On the second failed run of the same ticket (MAX_FAILED_RUNS), the
ticket is blocked instead of left open: its stored status becomes
blocked_on_operator and one Linear comment lists what each failed run
ended on. The claim path re-reads that count before every claim, so the
ticket is refused even when the board has been dragged back to Todo —
unblocking is a human's move, not the loop's. Failures are counted since
the last recorded human intervention on the ticket's runs
(store.record_intervention() / store.resume()): a recorded human
touch buys a fresh MAX_FAILED_RUNS, while a bare board drag records
nothing and forgives nothing. A refused ticket is skipped and the next
one is claimed: a blocked ticket still projects to Todo and still sorts
where it sorts, so stopping on it would starve every ticket behind it.
The pool¶
Under [loop] workers = N with N > 1 (see Config) the
process that ran factory.py PROJECT works no ticket itself: it is the
scheduler of a pool. It runs the startup checks, the read-only sweep, the
reconcile and the supervisor spawn once, for the whole pool, then ticks:
mirror the board's ready listing, count the tickets a worker could claim
(the store's own pickability: mirrored ready, under no live run's lease,
every dependency merged -- one listing and one store read per tick, then
the predicate per listed ticket), spawn factory.py PROJECT --worker
children until min(claimable, workers) are alive, block until any child
exits, read its status, repeat. While fewer than workers are alive the
block carries [loop] tick_sec (default 120 seconds) as a deadline: a
deadline that reaps nobody is a tick like any other, the listing and the
count run again, and a ticket filed while the pool was busy is spawned for
within a tick rather than at the next exit. The timer tick prints nothing
unless it spawns. With the pool full the deadline still holds: the tick
re-checks parked pull requests but reads no listing, since no count could
change what it spawns. A listing the board could not answer is
not an empty queue: nothing is spawned on it, a live pool recounts at its
next exit, and an empty pool ends the loop nonzero rather than reporting a
queue it never saw. A queue of one ticket is one worker, as with workers =
1; a queue of five under workers = 3 is three, and the fourth starts when
one of the three exits. Under [board] mode = "store" the scheduler counts
the store's queue (store.read.claimable()) instead of the listing: the
board is synced into the store at most once a tick_sec, however many
ticks and exits fall inside it, a listing the board could not answer leaves
the pool the queue the store already holds (with none, the pool stops
nonzero, as it does in mirror mode), and an empty queue parks nothing. A
claim that cannot read its candidate back from the board claims nothing
and exits nonzero rather than reporting an empty queue -- a worker with
5, on which the scheduler stops spawning, drains and exits 1 whatever
stop_on_failure says; an issue the board no longer has is skipped. Each worker is step 1 through 7 above for one
ticket -- claim, worktree, implementer, verify, review, merge gate -- and
exits with the run's status: 0 merged, 1 failed, 2 parked awaiting
merge approval, 3 nothing left to claim, 4 stopped for a human, 5
the board could not be read back (store mode). The
children share the scheduler's stdout, so one tee captures the pool, and
each worker prints [holo2 wN] in place of [holo2], N its slot. Merges
into main take turns under the merge lock of step 6, and so does a
worker's close-out -- the FINDINGS.md regeneration under [report]
findings = "repo", and its commit when the run merged -- so no worker
writes the checkout while a sibling merges
in it, whether the worker's own run merged or failed. stop_on_failure =
true stops the spawning at the first failed worker and waits for the
running ones; the scheduler exits nonzero, as the serial loop does. With
the listing empty and no child alive it exits 0, as the serial loop does
on an empty board. A worker that merged a change to the factory itself
stops the spawning too: the workers already running finish on the code they
started with, and the scheduler re-execs itself once the last one is in,
so no worker ever runs code newer than its scheduler. The supervisor is
spawned by the scheduler, never by a worker.
A story's witness pass never holds up a claim. Each tick spawns its
workers first; then, when no witness child of the scheduler is alive, the
scheduler is spawning, the project's witness.lock is free and an open
story's ledger lacks main's tip or the story's witness set is due again, it
spawns one factory.py PROJECT
--witness-step child, which runs the pass under that lock and exits. The
child counts against no workers slot and is not handed over across a
re-exec, but its exit is reaped like a worker's: the scheduler keeps
ticking, claiming and reaping while it runs, an empty pool with the child
alive waits on it with the tick deadline, and the loop returns once the
pool is empty and the child has exited. Whether a pass is pending is read
from the witness ledger, never from which children were spawned: a pass
runs at the tip it reads when it starts, so a child spawned after a long
set runs once at the newest tip however many commits landed meanwhile,
and a child that recorded nothing leaves its pass pending for a child a
tick after it exited at the same tip, or at once at a newer one. An empty
pool does not wait out that tick, so a pass that keeps failing at one tip
cannot hold the loop open, but a tip a worker merged while the child ran
gets its own child before the loop returns. A process
that finds the lock held -- a re-exec'd scheduler, a serial loop, a second
loop -- prints another witness pass holds the witness lock; skipping and
records nothing. The serial loop runs its witness step only once the queue
is empty.
State machines¶
Both diagrams below are generated from the code, not drawn:
store/tickets.py's TICKET_TRANSITIONS and store/operate.py's
RUN_PHASE_TRANSITIONS are the only authority for
which moves are legal, store.tickets.render_state_graph() renders them, and
tests/test_store_status_graph.py fails whenever the text between the
markers differs from what the tables render to. Regenerate with
python3 store/__init__.py --state-graph and paste the output over the
marked sections.
Ticket status (store.tickets.transition() refuses every edge not drawn here):
stateDiagram-v2
abandoned
blocked_on_deps
blocked_on_operator
in_flight
merged
needs_spec
ready
blocked_on_deps --> blocked_on_operator
blocked_on_deps --> ready
blocked_on_operator --> blocked_on_deps
in_flight --> abandoned
in_flight --> blocked_on_operator
in_flight --> merged
needs_spec --> ready
ready --> blocked_on_deps
ready --> in_flight
Run phase (the edges the loop writes; squashing is declared but never
entered by this loop, and awaiting_merge_approval is entered only under
[merge] approve = "human"):
stateDiagram-v2
addressing
awaiting_merge_approval
blocked_on_operator
claimed
done
failed
killed
merge_gate
merging
paused
rejected
reviewing
squashing
verifying
working
addressing --> blocked_on_operator
addressing --> failed
addressing --> killed
addressing --> paused
addressing --> verifying
awaiting_merge_approval --> done
awaiting_merge_approval --> failed
awaiting_merge_approval --> killed
awaiting_merge_approval --> paused
awaiting_merge_approval --> rejected
blocked_on_operator --> failed
blocked_on_operator --> working
claimed --> awaiting_merge_approval
claimed --> failed
claimed --> killed
claimed --> merge_gate
claimed --> paused
claimed --> working
failed --> addressing
failed --> reviewing
failed --> verifying
failed --> working
merge_gate --> awaiting_merge_approval
merge_gate --> done
merge_gate --> failed
merge_gate --> killed
merge_gate --> merging
merge_gate --> paused
merge_gate --> rejected
merge_gate --> verifying
merging --> awaiting_merge_approval
merging --> blocked_on_operator
merging --> done
merging --> failed
merging --> killed
merging --> merge_gate
merging --> paused
merging --> verifying
paused --> addressing
paused --> merge_gate
paused --> merging
paused --> reviewing
paused --> verifying
paused --> working
reviewing --> addressing
reviewing --> awaiting_merge_approval
reviewing --> failed
reviewing --> killed
reviewing --> merge_gate
reviewing --> paused
reviewing --> verifying
squashing --> paused
verifying --> awaiting_merge_approval
verifying --> failed
verifying --> killed
verifying --> paused
verifying --> reviewing
working --> blocked_on_operator
working --> failed
working --> killed
working --> paused
working --> verifying