Skip to content

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.