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)
  Fac->>Lin: claim_next(): first ready, unblocked, by priority
  Fac->>Store: mirror ticket (contract snapshot), claim run, lease project
  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 n: 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 two rounds spent
    Fac->>Rev: terminal adjudication PASS/FAIL
  end
  Fac->>WT: verify gate again
  Fac->>Lin: re-read body, refuse on drift from the snapshot
  Fac->>Store: release run as merged, walk the ticket to merged
  Fac->>Fac: git merge --no-ff into main, regenerate FINDINGS.md
  Fac->>Lin: state Done, ledger comment
  Fac->>Fac: self-merge? re-exec factory.py from the new HEAD
  Op->>Op: git push (the factory never pushes)

Step by step

0. Filing

The operator writes TICKET.md in the template, validates it with ticket_template.py FILE --repo TARGET, and files it with factory.py TARGET --file-ticket FILE --priority high. The command validates again, creates the issue in the target's [board] project, 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 project'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. board.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 target 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.

Trace: tickets (status in_flight, activeRunId), runs (phase claimed), projects.activeRunId, a runEvents row, Linear state In Progress.

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

agents.agent() runs the configured implementer command (claude -p --model fable --effort medium 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 n: 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 a second review. After two rounds 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: rounds 1, 2 and the adjudication as reviewRounds rows; phases reviewing, addressing, verifying.

7. Merge gate

The verify command runs once more. board.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 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, findings.render() rewrites the FINDINGS.md window, and the provider pushes Done and a ledger comment.

Trace: runs.outcome = merged, tickets.status = merged, a merge commit whose message names the ticket, a fresh FINDINGS.md.

8. After the merge

If the target 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 supervisor notices the checkout's HEAD moved on its next pass and re-execs too. The daemon does not; it is stateless and the operator restarts its unit. 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, two failed rounds, 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.