Skip to content

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

  1. Claim the first ready ticket — non-terminal and unblocked (Linear blocks relations 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 its activeRunId at 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 label holo:HOST, where HOST is 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's holo: 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 an infra failure, 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 --requeue take 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 an infra failure and gives the store lease straight back, so a run never starts unlabelled. --requeue takes 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 prints run 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 walked merged with 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 to merged or abandoned, with a reconcile intervention 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.
  2. Every project refreshes main from origin before every cut: git fetch origin in the checkout, under the merge lock of step 6, then a fast-forward of local main when origin/main is ahead. Local ahead or equal (a local-mode checkout's unpushed merges) changes nothing -- behind is fast-forwarded, never reset. Diverged -- neither main nor origin/main contains the other -- refuses the cut with an infra failure naming both shas, for a person to reconcile; a project with no origin skips 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] setup commands 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 .githooks directory automatically enables it with extensions.worktreeConfig = true and worktree-local core.hooksPath = .githooks, before any agent turn. Hook configuration failure fails setup.
  3. 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 an ignored_leftovers_removed event 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] carry directory 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