Skip to content

Hand-offs, stopping and resuming ​

Nobody watches a Run, so it has to end well when something goes wrong: a Ticket it cannot finish, a subscription or an API key that runs out, a human who wants it to stop, a machine that disappears. Each of these leaves the Ticket where a later Run can pick it up, on this Host or another. That Run does not pay again for Stages already done, and no human has to unpick labels first.

At a glance ​

EndingCaused byLeaves behind
Mergea green pass through every gatea closed issue; branch and state file gone
Hand-offa failure the fix budget cannot coverready-for-human, a comment, a draft pull request; work and state kept
Releasethe subscription or API rate limit, a transient API failure, an API key out of budget, or a failing base branchready-for-agent, no comment on the Ticket; work and state kept
Stopticket-runner stop (SIGTERM)nothing new
KillCtrl+C, SIGKILL, a vanished Hostthe claim; work as last pushed, and state

After a merge or a Hand-off the Run refills the Lane, and while another Lane is busy a free Lane also looks at the Frontier again at intervals (Running). After a Release or a Stop it claims nothing more, and the busy Lanes finish what they hold. A kill ends it on the spot.

Each Ticket that is still unfinished then comes back by one road. The board decides which:

Hand-off ​

A Hand-off gives a Ticket to a human. It is how every ending goes that is neither a merge nor a Release: a second failure after the fix budget is spent, a Stage that timed out, CI that never finished, a branch in the way at setup.

A Hand-off leaves you:

  • a draft pull request of the branch's work, where there is work to push; a pull request already open goes back into draft
  • the hand-off comment below, saying where the Ticket failed and why
  • ready-for-human in place of in-progress, and no assignee
  • each Stage's command line and transcript, kept under ticket-<n>/<runId>/ on the ticket-runner/state branch

Its state file is kept, so a relabel resumes the Ticket (Handing a Ticket back), except where no Stage of this Run could have left work (below).

The comment follows docs/templates/handoff-comment.md:

md
<!-- ticket-runner:handoff -->
**Handed off.** Failed at **verify**, after the fix budget was used.

- Failure: 1 of 6 criteria unmet
- Branch `agent/8-rate-limit-release` on the remote · worktree `/home/me/acme/.worktrees/ticket-8` · PR #31 (draft)
- Transcripts: `ticket-8/2026-09-17T09-00-00-000/` on the `ticket-runner/state` branch

<details><summary>Evidence</summary>
…
</details>

The Failure line says what stopped the Ticket. What it asks of you:

FailureWhat to change before handing it back
A Stage ran past its minute limit, or hit its turn limitRaise that Stage's maxMinutes or maxTurns in stages, or split the Ticket
implement left no commitsRead the reason the Stage gave in the hand-off comment, or its transcript when it gave none, then make the Ticket smaller or clearer
fix left no new commitsRead the reason the Stage gave in the hand-off comment, or its transcript when it gave none, then take the decision it asks for or mend the code by hand on the branch
A Check, a criterion or CI still failing after the fixRead the evidence in the comment, then mend the code by hand on the branch, or, for a criterion, rewrite it as the next row says
Criteria unmet after the fix, or no evidenceMend the code by hand, or rewrite the criteria so a session can check them by running or reading code (Acceptance Criteria)
CI timed outRaise ciTimeoutMinutes
No checksAdd a CI workflow, raise ciGraceMinutes if checks register slowly, or turn gates.ci off
The pull request is conflictingNothing: the next Run rebases it again, and the conflict Stage takes the conflict
The base branch could not be pulledClear what is in the way in the main checkout (Running)
The merge failedPut right the repository setting GitHub's refusal names, such as a branch protection rule
A branch in the way, or copies that have partedRun the command the Failure line names
A git or GitHub step failed: pushing, opening or un-drafting the pull request, the rebase's git, or setting up the worktreePut right what the error in the Failure line names, on the Host that ran it for git, then hand it back. A pull request someone closed by hand means the work should stop
Any other Stage failure, such as a non-zero exit or an answer its schema rejectedRead that Stage's transcript (What stays where)

When a Hand-off does less ​

A Hand-off at setup pushes nothing: there is either nothing to push, or what is in the way may be a human's work or another Host's.

CaseDraft pull requestState file
Started fresh, with nothing to resume, and failed before its worktree existednone: nothing to pushremoved: nothing was branched
Started fresh, and a local branch of that name existsnoneremoved: no Stage of this Run worked there
Resumed, this Host's branch has parted from the remote'snone opened; an open one goes to draftkept: both copies are the pipeline's
Any failure after the worktree was readyopened, or an open one back to draftkept

The comment names a worktree wherever there is one: this Run's, or the one the branch in the way is checked out in. The failure line says what to do, for example delete it with git branch -D <branch> if the work on it is abandoned, or finish it by hand, then relabel the Ticket ready-for-agent.

What stays where ​

The Host a Ticket was worked on may be gone by the time a human looks, for example a cloud Host's VM. So everything a human or a later Run needs is on the Target's remote.

WhatWhereLasts until
The Ticket's commitsbranch agent/<n>-<slug> on the remotethe merge deletes it
State fileticket-<n>.json on ticket-runner/statethe work merges or leaves the pipeline (below)
Handed-off Stages' transcriptsticket-<n>/<runId>/ on ticket-runner/statethe state file goes
Worktree.worktrees/ticket-<n> on that Hostthe merge, or a human deletes it
Every Stage's full log (stdout, stderr too).ticket-runner/runs/<runId>/<n>/ on that Hosta human deletes it

The state branch is rewritten on every change, so read it from a fetch rather than a local branch:

bash
git fetch origin ticket-runner/state
git ls-tree -r --name-only FETCH_HEAD ticket-8/
git show FETCH_HEAD:ticket-8/2026-09-17T09-00-00-000/verify.transcript.jsonl

Every Stage that commits pushes the branch. .worktrees/ and .ticket-runner/ are safe to delete whenever no Run is running: everything a Run needs is on the remote, and the next Run remakes a worktree from it.

Handing a Ticket back ​

A handed-off Ticket is yours to end one of four ways:

YouThen
Change the Ticket, its criteria or the config, and hand it backThe next Run grades it against what the issue says then
Push commits to its branch on the remote, and hand it backThe next Run brings its copy up to that tip. A rewritten history parts the copies instead
Finish it yourself: mark the draft ready and merge itThe issue closes. Take ready-for-human off yourself, and delete the worktree and local branch on the Host that made them
Drop it: close the issueThe draft pull request and the branch stay until you close and delete them
bash
gh issue edit 8 --remove-label ready-for-human --add-label ready-for-agent   # hand it back

To hand a Ticket back, move its label from ready-for-human to ready-for-agent. The next Run finds it on the Frontier, reads its state file and carries on from what it reached. A Ticket that reached implemented goes straight to the Checks, so the implement Stage is not paid for twice.

A few things differ from the Run that handed it off:

  • Fix budget: fresh. The Ticket went through a human's hands, and whatever they did is what the new budget is for. The old comment still says the budget was spent, because it was.
  • Draft pull request: taken out of draft before CI is awaited, since a draft often runs no workflows and the wait would read "no checks". One that will not come out of draft, such as a pull request a human closed, is a Hand-off at pr: closing it said the work should not go on.
  • Hand-off comments: each is marked _Taken again by a later Run; this hand-off is history._ under its marker, which notifies nobody.
  • Progress comment: a new one. The comment the human read is left exactly as they read it.

Until the relabel, the state file is inert. No Frontier offers a ready-for-human Ticket, the sweep passes over it without a word, and a Run given its number skips it as not-ready. Once the issue closes, the next Run's sweep removes the file and its transcripts.

Release on a rate limit or an API failure ​

A Stage that hits the rate limit has failed at nothing, and neither has one the API stopped on its own side. Handing the Ticket off would make a human relabel a Ticket nothing was wrong with, and a fix Stage would only meet the same limit or the same outage. So the Ticket is released instead: it goes back on the board, and the next Run resumes it from the last Stage it finished, with its fix budget as it was.

On a Run started without --api-key the limit is the subscription's; on one started with it, the API's. A transient API failure is an API error with a 5xx status or none, such as 529 Overloaded, which the CLI had already retried, so the Stage is not retried in place; any other error is the Stage's own. How each is told from the session is under ClaudeAgentRunner.

What the Release does:

  • writes a ⏸ rate limited or ⏸ API failure row in the progress comment, which is the whole report
  • brings the state file up to date, to claimed if the implement Stage was stopped and implemented if a later Stage was, and keeps the fix budget as it was, except that a fix Stage stopped this way spends nothing
  • undoes the claim: adds ready-for-agent, removes in-progress, and unassigns
  • posts no comment of its own and opens no draft pull request. Its row notifies watchers only when it opens a new progress comment: when the stopped Stage is the first to report on the Ticket, or the Ticket was taken back from a human

The Run then claims nothing more, because what stopped one Stage would stop the next. The busy Lanes finish their own Tickets, and the Run ends without waiting for the limit to reset. Its last line says why, and when the limit resets where the CLI said (The summary). A released Ticket counts as taken, so the exit code is 0 unless something was handed off.

Start a Run yourself, or through an Operator, once the limit has reset or the API has recovered, and the Ticket is back on the Frontier, resumed from its state file.

Release out of API budget ​

A Stage of a Run started with --api-key can be refused because the key's organization is out of credit, or because its workspace reached the spend limit set in the Console. Either comes back as a 400 whose message names the credit balance or the workspace's usage limits, and the two are one cause, since what you do next is the same. Neither is the Ticket's doing, so the Ticket is released as on a rate limit, with these differences:

  • The progress row is ⏸ out of API budget, and the Run's last line Out of API budget..
  • The summary does not say when access returns, though the spend limit's message does: look at the Console, then start the next Run.

Release over a failing base branch ​

A failing Check or red CI is inherited, not brought, when the pipeline runs the Check commands again in a clean worktree at the branch's merge-base with the base branch and one fails there too (A failure the base branch shares). The Ticket is released as on a rate limit, with these differences:

  • the progress row reads ⏸ `<command>` fails on the base branch, and the Run's last line names the command and the merge-base
  • the pipeline posts one Note on the standing Notes issue with the tail of the command's output at the merge-base, and beside it the branch's own failure; the Ticket itself gets no comment
  • the next Run rebases the branch onto the base branch before its Checks

The fix budget stays as it was, spent or not: a Ticket whose budget is already spent is released, not handed off. An open pull request stays open, and the next Run updates it.

Read the branch's part of the Note before mending anything: the merge-base and the branch failed the same command, not necessarily the same way. A branch whose failure is its own is released by every Run until the base branch passes the command, and mending the base branch's failure alone does not free it to merge, only to reach a fix Stage.

Mend the Check on the base branch, then start a Run. Each released Ticket is rebased onto the mended base branch before its Checks; a rebase that conflicts goes to the conflict Stage, and one it cannot resolve spends the fix budget, as at the Landing. A Run started before the base branch is mended releases the Ticket again.

Stop and kill ​

A Run can end early in two ways, and they leave opposite things behind (ADR-0006).

StopKill
Sent bySIGTERM, from ticket-runner stop or the OperatorCtrl+C, SIGKILL, out of memory, a vanished Host
Busy Lanesfinish, to merge, Hand-off or Releaseend with the Run
New Ticketsnone, from the Frontier or the Stranded Ticketsnone
Left behindnothing strandeda Stranded Ticket per busy Lane, and the Run lock
Written to the boardnothingnothing; the claims simply stay
Exit codethe outcomes', as usualnone

ticket-runner stop works only on the Run's own Host; on a cloud Host the Operator sends the signal. A lock a kill left behind is taken over by the next Run on the same Host by itself; from another Host it waits for a human (The Run lock).

A Stop cannot be taken back: the lock stays held until the Lanes are back, and starting a new Run is how to carry on. A narrowed Run stopped before it took any of its Tickets exits 2.

Ctrl+C stays a kill on purpose (ADR-0006): end a Run gently with ticket-runner stop.

Stranded Tickets and the sweep ​

A killed Run releases nothing. Its Tickets keep their claim, and their work stays on the remote as of the last push. That is why the state file is written as part of the claim, not by the Release: a killed Run would never get to write it.

A Stranded Ticket is one whose state file is still there while it still carries this pipeline's claim (assigned to the current gh user, and labelled in-progress). No Frontier offers it, because it is claimed. So every Run sweeps the state files before it looks at the Frontier:

The state file's TicketWhat the sweep does
still carries this user's claimstranded: queued and resumed in place. Claim untouched, nobody notified.
Claim has come off (ready-for-agent or ready-for-human)nothing. The Frontier picks up the first; the second waits for a human.
has closedremoves the state file and any kept transcripts
is assigned to someone elselogs #<n> is resumable, but <user> holds it now and leaves it
the tracker cannot be asked about itlogs it and leaves it. A Run never resumes, or forgets, a Ticket on a guess.
the state file cannot be readlogs #<n> has a state file this version cannot use, written by <version>; leaves the file and the claim alone

An unreadable state file was probably written by a newer pipeline.

Stranded Tickets fill free Lanes before anything the Frontier offers (Running), each resumed by whichever Host's Run swept it, from the copy Resuming into a worktree picks. A narrowed Run resumes only the Stranded Tickets among those it was given.

A kill still costs more than a Stop. Whatever the Stage had not yet committed and pushed is lost. A worktree left in the middle of a rebase is aborted back to the branch tip before the Checks grade it.

The state file ​

Without a record of how far a Ticket got, a released or stranded Ticket would start over when it came back: its implement Stage paid for again, and a spent fix budget fresh again. The state file is that record.

The file is JSON a human can read without the pipeline:

json
{
  "ticket": 8,
  "branch": "agent/8-rate-limit-release-and-resume",
  "state": "implemented",
  "fixUsed": false,
  "pullRequest": 31,
  "title": "feat: release a rate-limited Ticket",
  "rebaseFirst": true,
  "runId": "2026-09-17T09-00-00-000",
  "version": "0.5.2",
  "updatedAt": "2026-09-17T10:14:02.511Z"
}
FieldMeaning
ticketthe Ticket, repeated so the file reads on its own
branchwhere the work is, used as-is rather than derived from the title again
stateclaimed (the implement Stage has not finished) or implemented (its work is on the branch)
fixUsedwhether the fix budget is spent. Resuming buys no second chance, except after a Hand-off.
pullRequestthe pull request already open, so a resumed Run does not open a second one
titlethe latest title the implement or fix Stage gave for the whole branch, which names the pull request
rebaseFirstpresent only after a Release over a failing base branch: rebase onto the base branch before the Checks
runId, version, updatedAtwhich Run and which version wrote it, and when

There are only two states. Everything after the implement Stage (Checks, verify, rebase, pull request, CI, merge) is run again from the Checks by a Run that resumes at implemented.

The file is written as part of the claim and updated as the Ticket advances. It goes when the Ticket merges, its issue closes, or its branch is in neither the worktree here nor on the remote, and at a Hand-off at setup that leaves no work of the pipeline's.

The ticket-runner/state branch ​

The state lives on the Target's remote, off the board, so a Run on any Host can resume a Ticket another Host left (ADR-0004). Each change rewrites the branch as one snapshot commit (GitWorkspace). Never commit to it by hand while a Run is running.

The Run lock ​

One Run at a time holds a Target, whichever Host it is on, through a lock on the Target's GitHub repository: off the board, but where every Host and every human can see it (ADR-0008). A Run holds it from start to end, and it covers the whole Target, where a claim covers one Ticket.

It is lock.json at the tip of the ticket-runner/lock branch. That branch always exists once a Run has started. The file names the holder: host (kind, id, name), pid, command, runId, startedAt, and the process's start time as the OS reports it. Otherwise it reads { "held": false }. The commit message says the same thing in words, for example Held by run 2026-09-17T09-00-00-000 on the workstation `desk`: ticket-runner run or Free.

Unlike the state branch, the lock keeps its history, so GitHub shows who held the Target and when. Each take and each release is a new commit on the tip the Run last read, pushed with a lease on that tip, so nothing is ever deleted and a take succeeds only from the state its taker last saw. How two Runs racing for it are told apart is under GitWorkspace.

What a Run makes of a held lock depends on where the holder is:

HolderStandingThe new Run
on this Host, its process alive, start time matchingrunningrefused: Another ticket-runner is running on this Host: …
on this Host, its process gone (or its pid reused by another program)abandonedtakes the lock over by itself, so a Run ended by Ctrl+C costs nothing
on another Hostelsewhererefused, never presumed dead

Nothing on one Host can see another Host's processes, so a lock left from another Host stays until a human releases it. The lock's last commit on GitHub names its holder, Held by run <id> on <host>: …; look on that Host, and release the lock only once its Run is gone. Ask an Operator, or commit a lock.json that reads { "held": false } to the branch on GitHub, and only when no Run is running. A cloud VM reclaimed mid-Run costs exactly one such release.

A cloud Host is told apart by its session id; one that names no session gets a random id, so even its own later Runs see its lock as a stranger's. A Run that cannot release the lock when it ends says so, and keeps its own exit code.

Resuming into a worktree ​

The branch on the remote carries a Ticket's work between Hosts, so a resumed Ticket starts from it rather than from whatever this Host has.

Before resuming, a rebase that a killed Run left in progress is aborted. Then the pipeline compares this Host's copy with the remote branch:

"Parted" means each side holds commits the other lacks: another Host moved on while this one held work it never pushed. The pipeline does not pick a side silently. The Hand-off names both copies and the two ways to make them one: throw this Host's copy away with the command it gives, or force-push it over the remote's from its worktree (git push --force-with-lease origin <branch>). Then relabel the Ticket. Both copies are the pipeline's work, so the state file stays, and the next Run resumes from whichever side the human kept.

  • From Ticket to merge: the lifecycle, the fix budget and the Landing that these endings interrupt
  • Running: run, stop, a narrowed Run, the summary and exit codes, and the Operator on a cloud Host
  • Configuration: Stage limits and timeouts, which decide when a Stage "did not finish"
  • Internals: the Workspace port that keeps the state and the lock, and the ADRs behind this page

References ​