Lifecycle of a ticket¶
One ticket, from a markdown file on the operator's disk to a commit on
main, with the module, the table and the process that owns each step.
The numbers match the loop description; this page adds where
each step leaves its trace.
sequenceDiagram
autonumber
participant Op as Operator
participant Lin as Linear
participant Fac as loop
participant Store as store.db
participant WT as worktree
participant Imp as implementer
participant Rev as reviewer (container)
participant Sup as supervisor
Op->>Lin: --file-ticket TICKET.md (validate, create, re-read, re-validate)
alt [board] mode = "mirror"
Fac->>Lin: claim_next(): first ready, unblocked, by identifier, or priority when configured
Fac->>Store: mirror ticket (contract snapshot), claim run, lease project
else [board] mode = "store"
Fac->>Lin: sync once a pass: the ready listing mirrored as revisions
Fac->>Store: claimable(): first queued row, admitted at its revision R
Fac->>Lin: read that one issue back; a change since R is admitted again
Fac->>Store: claim expecting revision R and column ready, one BEGIN IMMEDIATE
end
Fac->>WT: git worktree add, then [worktree] setup commands
Fac->>Imp: claude -p with the whole ticket body, budget = estimate
loop heartbeat thread
Fac->>Store: runs.lastHeartbeat every stale/2
Sup->>Store: sweep every 60 s: stale? over budget? review stuck?
end
Imp-->>WT: commits
Fac->>WT: verify gate: ticket's command, clause by clause
Fac->>Rev: staged export of base and candidate, read-only
Rev-->>Fac: CRITERION Cn: met — test · VERDICT
Fac->>Store: reviewRounds row (verdict, findings, fingerprint)
alt REQUEST_CHANGES with a fix round left
Fac->>Imp: findings, one fix round
else the run's cap spent
Fac->>Rev: terminal adjudication PASS/FAIL
end
Fac->>WT: verify gate again
Fac->>Lin: re-read body, refuse on drift from the snapshot
alt mode = "pr"
Fac->>Fac: git push the branch, open a pull request, babysitter it
Note over Fac: approve is read against the PR
else mode = "local", approve = "human"
Fac->>Store: park awaiting_merge_approval, release the lease
Fac->>Lin: ticket blocked_on_operator asking merge?
else mode = "local", approve = "auto"
Fac->>Fac: git merge --no-ff into main (FINDINGS.md rendered only under findings = "repo")
Fac->>Store: release run as merged, walk the ticket to merged
Fac->>Lin: state Done, ledger comment
end
Fac->>Fac: self-merge? re-exec factory.py from the new HEAD
Op->>Op: git push main (the factory never pushes main)
Step by step¶
0. Filing¶
The operator writes TICKET.md in the
template, validates it with
ticket_template.py FILE --repo PROJECT, and files it with
factory.py PROJECT --file-ticket FILE --priority high. The command
validates again, creates the issue in the Linear project the project's
[board] names, reads the stored body back and validates that, so a
transfer that rewrote the body is caught before the loop ever sees it.
Edits go the same way with --update KO-n.
Trace: nothing in the store yet; the issue in Linear, in Todo.
1. Claim¶
linear_provider.claim_next() lists the board's non-terminal, unblocked
issues (Linear blocks relations are the only machine-checked
dependencies), orders them by [loop] order (identifier or priority), and
returns the first the store will accept. projection.mirror_task() upserts the
ticket row with a contract snapshot: title, criteria, verify commands,
estimate. body_problem() runs the validator against the live body with
the project repository; a body that fails is mirrored as needs_spec and
skipped. store.claim() opens the run row and takes the project lease in
one BEGIN IMMEDIATE.
In [board] mode = "store" the store owns the queue instead
(holophyte/loop/claim_store.py). The board is read once a pass: the ready
listing is mirrored into the store, each changed board-owned field a new
revision, at most once a [loop] tick_sec. The claim takes the first row
of store.read.claimable() -- ready, in column ready, pickable -- and
admits it at its revision R with the same questions, then reads that one
issue back from the board. Another writer's holo: label, a stale
label, a gone or completed issue skips it; a board that cannot be asked
claims nothing. What it reads is mirrored, and an edit since R -- a new
title, a move to Backlog -- makes a new revision, so the ticket is admitted
again, at most twice an ask. store.claim() then asserts revision R and
column ready under its own transaction, so a run only ever works a
revision that passed admission, recorded as runs.revision.
Trace: tickets (status in_flight, activeRunId), runs (phase
claimed), projects.activeRunId, a runEvents row, Linear state In
Progress -- in store mode a push the host sweep delivers.
2. Worktree¶
gates.cut_worktree() adds <repo>.worktrees/<branch> detached from
main and checks out task/ko-n-slug. If a preserved branch from an
earlier failed run exists, the loop reuses it and merges the moved-on
main into it; a conflict is a clean refusal for a human. [worktree]
setup commands run there under their own cap, so the branch is tested
with its own environment and never the main checkout's.
Trace: runs.branch, phase working.
3. Implementer¶
roles.agent() runs the configured implementer command (claude -p
--model opus --effort high by default) in its own process
group with the whole ticket body as the prompt and a wall-clock budget from
the estimate. runs.heartbeat_while() beats runs.lastHeartbeat on a
daemon thread at half the supervisor's stale threshold for as long as the
agent runs; without it a long implementation read as a dead loop. Over
budget, the whole process group is killed.
Trace: heartbeats; commits on the branch.
4. Verify gate¶
gates.run_verify() runs the ticket's verify command in the worktree. A
top-level && chain is run clause by clause in one shell; the report names
the failing clause and its exit status, shows every clause's output, and
names the clauses the failure short-circuited. Zero tests discovered is
red, not green. The gate runs before every review round and again before
the merge.
Trace: the report text travels into the review prompt and the round row.
5. Review¶
review_runner.stage_candidate() exports the base and candidate commits
into a fresh, detached, zero-remote repository, fingerprints it, and
docker runs the reviewer image with that repository mounted read-only.
Inside, Codex is asked to account for every acceptance criterion with one
line each (CRITERION Cn: met — tests/file.py::Class::test, not met, or
unwitnessed) and end with a verdict. review.parse_findings() turns the
reply into structured findings keyed by (path, line, severity);
review.criteria_findings() resolves each named witness test against the
worktree and treats a missing one as unwitnessed. Any criterion not met or
unwitnessed makes the round changes_requested regardless of the verdict
line.
Trace: a reviewRounds row per round with the verdict, the findings, their
fingerprint, and the verify result the reviewer saw.
6. Fix rounds and adjudication¶
REQUEST_CHANGES sends the findings back to the implementer for one fix
round, then another review, for as many rounds as the run's cap allows.
The cap is per run: runs.review_round_cap() gives every run [loop]
review_rounds (2 by default), adds one round per review_rounds_per_lines
changed lines (800; 0 turns the scaling off) and stops at
review_rounds_max (4). Once the cap is spent a terminal adjudicator
answers PASS or FAIL. Meanwhile the supervisor's review_stuck check
compares the two rounds' finding sets; a Jaccard overlap at or above the
threshold with the same complaints twice ends the run, because the review
is circling rather than converging.
Trace: each round and the adjudication as reviewRounds rows; phases
reviewing, addressing, verifying.
7. Merge gate¶
The verify command runs once more. projection.merge_drift() re-reads the
ticket from Linear and compares it with the snapshot from step 1; a body
edited while the run worked refuses the merge and preserves the branch. A
board that cannot be read is recorded as "no evidence" and the merge
proceeds on the frozen contract. Then, under the defaults ([merge]
approve = "auto", mode = "local"), git merge --no-ff into main, the
worktree and branch are removed, store.release() ends the run as
merged and walks the ticket to merged, the FINDINGS.md window is
rewritten where [report] findings = "repo", and the provider pushes Done
and a ledger comment.
Under [merge] approve = "human" in local mode the clean gate parks the run instead:
phase awaiting_merge_approval, ticket blocked_on_operator, branch and
worktree preserved, lease released, until --approve KO-n sends it back
through this gate. Under [merge] mode = "pr" the clean gate pushes the
branch and opens a pull request against main, which the loop babysits
for up to pr_rounds passes before merging it through the API or parking;
PR mode is checked first, and approve is then read against the PR.
Both paths are described in the loop page.
Trace: runs.outcome = merged, tickets.status = merged, a merge commit
whose message names the ticket, and a fresh FINDINGS.md in a project that
opted in with [report] findings = "repo".
8. After the merge¶
If the project is the factory itself, reexec.reexec_self() replaces the
loop process with a fresh factory.py from the merged code and records a
loopRestarts row. The next sweep run starts from the new HEAD, and the
daemon, stateless, exits once it sees HEAD moved and is started again on
the new code by the next request. The operator pushes main to origin by
hand.
When it goes wrong¶
| Failure | Who notices | What happens | Operator step |
|---|---|---|---|
| Budget blown, no commits | loop | run failed, branch discarded (nothing to keep) |
none, or requeue after a contract fix |
| Verify red before merge, the cap's rounds failed, adjudication FAIL | loop | run failed, branch and worktree preserved |
read the ledger; --requeue KO-n --note … after fixing the contract |
| Heartbeat stale, time box blown, review stuck | supervisor | run swept failed, leases released, branch preserved; the loop stops itself on the ended run |
--requeue, relaunch the loop |
| Ticket body edited mid-run | merge gate | merge refused, branch preserved | restore the body or work it again |
| Second failed run of the same ticket | claim path | ticket blocked_on_operator with a Linear comment listing both failures |
a recorded intervention buys another attempt |
| Codex backend down | loop | run failed on the reviewer route |
wait, --requeue |
| Loop dies mid-review | supervisor + signal handler | the review container is removed on SIGTERM; --sweep lists strays |
--sweep --act |
Each row was a real day. The runbook has the commands.