State Machine
Sortie maintains two layers of state for every issue it processes. The orchestration state tracks whether the orchestrator has claimed the issue and what it is doing with it. The run attempt phase tracks where a single agent invocation stands within its lifecycle. These are independent from tracker states (To Do, In Progress): they are Sortie’s internal bookkeeping.
See also: WORKFLOW.md configuration for active_states, terminal_states, handoff_state, and in_progress_state; error reference for error kinds that trigger retries; CLI reference for --dry-run mode that simulates dispatch without launching agents; dashboard reference for real-time visibility into orchestration state.
Orchestration states
Every issue known to the orchestrator is in exactly one of five states. The orchestrator is the single authority for these transitions: no other component mutates scheduling state.
| State | Description |
|---|---|
Unclaimed | The issue is not running and has no retry scheduled. Eligible for dispatch if it meets candidate selection rules. An unclaimed issue can still be held out of dispatch by a park record or an exhausted effort budget; both are dispatch gates rather than claim states. |
Claimed | The orchestrator has reserved the issue to prevent duplicate dispatch. A claimed issue is always either Running or RetryQueued. |
Running | A worker is running for this issue, moving through the run attempt phases below. |
RetryQueued | No worker is running, but a retry timer exists. The issue remains claimed until the timer fires and either re-dispatches or releases. |
Released | The claim has been removed. The issue is no longer tracked. This happens when the issue reaches a terminal tracker state, leaves the active state set, is missing from the tracker, or exhausts its retry path. |
flowchart TD
UC([Unclaimed]) --> RN
subgraph Claimed
RN[Running] --> RQ[RetryQueued]
RQ --> RN
end
Claimed --> RL([Released])
RL --> UC
classDef idle fill:#f0f0f4,stroke:#8b8fa3,color:#3a3d4a
classDef active fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f,stroke-width:2px
classDef waiting fill:#fef3c7,stroke:#d97706,color:#78350f
classDef released fill:#f0f0f4,stroke:#8b8fa3,color:#3a3d4a,stroke-dasharray:5 5
class UC idle
class RN active
class RQ waiting
class RL released
style Claimed fill:none,stroke:#3b82f6,stroke-width:2px,rx:8,color:#3b82f6Transition details
Unclaimed → Claimed. Occurs during the dispatch phase of a poll tick. The issue must pass all candidate eligibility checks and a global or per-state concurrency slot must be available. Dispatch claims and launches in one step, so this path never leaves an issue claimed without a worker; RetryQueued and the retry entries rehydrated at startup are the two ways an issue is claimed with no worker running.
Running → RetryQueued. Five worker exit outcomes lead here (the first two do not apply when a soft-stop signal is active; see Claimed → Released below):
- Normal exit, issue still active, no soft-stop: continuation retry after 1 000 ms fixed delay.
- Normal exit, handoff fails, no soft-stop: continuation retry after 1 000 ms.
- Normal exit, handoff withheld by the evidence policy, no soft-stop, and the issue not found terminal by the read that outcome performs: exponential backoff retry (see backoff formula), the only one of these five outcomes that takes exponential backoff from a normal exit rather than the fixed continuation delay.
- Error exit, retryable: exponential backoff retry (see backoff formula).
- Stall timeout: worker is killed; exponential backoff retry is scheduled.
A worker exit is not the only way an entry gets queued: a reaction reconcile pass writes one directly for an issue whose session has already ended, which is how a CI fix, a review response, or a rebase reaches the dispatch path.
An issue holds at most one queued retry. When any of these outcomes finds one already queued (a reaction continuation scheduled while the session was still running, for example), the queued entry is left in place and the claim is kept, rather than the queued work being replaced. The queued entry runs on its own timer, and the outcome that deferred to it takes no further action.
RetryQueued → Running. The retry timer fires. The orchestrator reads that one issue from the tracker by ID (not the candidate list), confirms it is still eligible, acquires a slot, and launches a new worker.
If no slot is available, the entry is rescheduled at the next attempt number, so its delay grows by one backoff step rather than repeating, and its error becomes no available orchestrator slots. A fire that a retry now request brought forward is the exception: the entry keeps its attempt number and its earlier due time.
The worker runs the agent kind and template recorded on the entry, unless the configuration in force no longer reaches that kind or no longer holds that template; then the current dispatch rules route the issue again and the worker starts a new session instead of resuming the earlier one. An entry whose agent kind has no adapter is rescheduled with backoff and keeps its claim.
While an instance spend ceiling holds, the fire dispatches nothing and the entry is re-armed one polling.interval_ms ahead, unchanged: it keeps its attempt number, its error, and its stored row, and the wait adds no backoff step. An entry that then finds no slot or no SSH host also keeps its attempt number, where other entries advance to the next attempt.
Claimed → Released. The claim is removed and no retry is scheduled:
- Reconciliation detects the tracker state is terminal or no longer in
active_states. On a label-modelled tracker, an issue that reads no state label counts only at the second consecutive reading. - The retry timer fires and the per-issue tracker read reports the issue missing, terminal, or no longer in an active state. A reaction-kind entry is rescheduled instead of released.
- The
max_sessionsbudget is reached. - The
max_tokenstoken budget is reached, either at a dispatch decision, or by the in-flight check when its cancellation is what ends the running session. - The
max_cost_usdmoney budget is reached, at the same two points as the token budget. - The worker error is classified as non-retryable.
- A
handoff_statetransition succeeds (the tracker now owns the issue). A run that declared no change was needed targetstracker.no_change_stateinstead where that field is configured; see handoff evidence. - Soft-stop
blocked: worker exits normally, claim released. No handoff transition, no continuation retry. Where the dispatch drives issue state, the issue is also parked with the escalation label and held out of dispatch until a release gesture. See the parked-issue release rules. - Soft-stop
needs-human-review, handoff succeeds: worker exits normally, handoff transition performed, claim released. - Soft-stop
needs-human-review, handoff fails: worker exits normally, handoff fails, claim released without retry. - The consecutive handoff-absence ceiling is reached: the claim is released and the issue is held out of dispatch until a release gesture. See park issues stuck in a loop of empty runs for the ceiling and the three release gestures.
- A stage hop is made: the claim is released with no handoff transition, and the issue stays in its active state for the next stage.
Two of these release only when the issue has no retry already queued: a successful handoff_state transition, and a normal exit on an issue that has since left the active states. A queued retry keeps the claim in both cases, so work queued while the session was running is not stranded. A stage hop does the opposite: it cancels the queued retry and the issue’s pending reactions along with the claim.
Released → Unclaimed. A released issue can be re-dispatched on a future poll tick if its tracker state returns to an active state. The orchestrator does not remember previous releases: each poll tick evaluates eligibility from scratch. The one exception is a stage hop record, which holds the issue until a read shows the label the hop added (see candidate eligibility).
Run attempt phases
Each worker attempt progresses through a linear sequence of phases. Terminal phases end the attempt and report the outcome to the orchestrator.
| Phase | Description |
|---|---|
DispatchTransition | Optional. When tracker.in_progress_state is configured and the dispatch drives issue state, the worker transitions the issue to that state before workspace preparation. If the issue is already in the target state, the call is skipped (debug log only). Failure is non-fatal: the worker logs a warning and continues. A dispatch that does not drive issue state, such as a label-command session, skips the phase entirely. |
DispatchComment | Optional. When the dispatch drives issue state, the worker publishes the session.started event. If a tracker_comment entry in notifications lists it, or the deprecated tracker.comments.on_dispatch is true, the worker posts a tracker comment acknowledging that Sortie has claimed the issue. Other entries that list the event receive it without delaying the worker. Fires after the dispatch transition and before workspace preparation. Failure is non-fatal: the worker logs a warning and continues. |
PreparingWorkspace | Workspace directory is created or reused. after_create and before_run hooks execute. |
BuildingPrompt | The text/template prompt body is rendered with issue data, attempt number, and turn context. |
LaunchingAgentProcess | Before the agent adapter starts the working session, the credential-verification step opens a session of its own, sends one fixed request, and closes it again, on every agent kind and every host, including one from worker.ssh_hosts, unless Sortie proved the same agent setup within the last 15 minutes. A runtime that cannot complete that request ends the attempt here with error kind credential_unverified, or with port_exit when it exits before responding (the early exit report), before the working session ever starts. Once that check passes or is skipped, the agent adapter starts the working session (subprocess or API call). |
InitializingSession | Waiting for the session_started event from the agent adapter. |
StreamingTurn | The agent is actively working. Token usage, tool calls, and status events stream in. |
SelfReviewing | Optional. Entered only when self_review.enabled is true and the coding turn loop finished successfully, not on turn failure. Runs review iterations until the turn budget is exhausted or the agent signals completion. |
Finishing | The turn ended. after_run hooks execute. The worker checks whether to loop for another turn. |
Succeeded | Terminal. The worker completed all turns without error. |
Failed | Terminal. An error occurred during any earlier phase. |
TimedOut | Terminal. The turn exceeded agent.turn_timeout_ms. |
Stalled | Terminal. No agent event arrived within agent.stall_timeout_ms. Detected by reconciliation. |
CanceledByReconciliation | Terminal. The worker was cancelled because the issue’s tracker state became terminal or left the active set. |
flowchart TD
DT[DispatchTransition] --> DC[DispatchComment]
DC --> PW[PreparingWorkspace]
PW --> BP[BuildingPrompt]
BP --> LA[LaunchingAgent]
LA --> IS[InitializingSession]
IS --> ST[StreamingTurn]
ST --> FN[Finishing]
ST --> SR[SelfReviewing]
SR --> FN
FN --> ST
FN --> OK([Succeeded])
ST --> TO([TimedOut])
SR --> TO
ST --> SL([Stalled])
ST --> CR([Canceled])
classDef phase fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
classDef active fill:#bfdbfe,stroke:#2563eb,color:#1e3a5f,stroke-width:2px
classDef success fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px
classDef failure fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
class DT,DC,PW,BP,LA,IS,SR,FN phase
class ST active
class OK success
class TO,SL,CR failureAny phase from PreparingWorkspace through StreamingTurn can also transition to Failed on error; SelfReviewing can also transition to TimedOut, but only when a review or fix turn exceeds agent.turn_timeout_ms.
Multi-turn behavior
A single worker attempt can execute multiple agent turns. After each turn:
- The worker checks the tracker for the issue’s current state.
- If the state is still active and the turn count has not reached
agent.max_turns, the worker loops back toStreamingTurn. - The first turn uses the full rendered prompt. Continuation turns send only continuation guidance to the existing agent thread.
Tracker writes the orchestrator makes
A write belongs in the table below when it decides where the issue goes next: it moves the issue to a single named state drawn from configuration, or, for a stage label, selects the dispatch rule that runs on the issue next. A stage label is a label, not a tracker state, and writing it leaves the issue’s state unchanged.
| Write | Trigger | Configured by |
|---|---|---|
| In-progress state | At dispatch, before workspace preparation. | tracker.in_progress_state |
| Handoff state | On a normal worker exit, with the issue still in an active state, the dispatch driving issue state, not a blocked soft stop, not already reported terminal, not withheld by the handoff-evidence verdict, and not replaced by a stage hop. | tracker.handoff_state, or tracker.no_change_state for a run that declared no change was needed |
| Stage label | In place of the handoff write, when the run’s rule carries next. Adds the next stage’s label and removes the issue’s other stage labels. See stage hop. | next on a dispatch rule, naming a rule that carries stage |
| Terminal state | When a Sortie-managed pull request merges while the linked issue is still parked in the handoff state. | reactions.merge_completion.target_state |
Any other write the orchestrator makes decides nothing about where the issue goes: the dispatch comment, the completion and failure comments, the auto-merge success comment, and a reaction’s escalation label or comment are not transitions.
The governing boundary is not between reading and writing, it is between a write that reports an event the orchestrator observed and a write that expresses a judgment about the work; the orchestrator makes only the first kind.
The stage-label write is of the first kind as well: it follows a successful exit the orchestrator observed, and the stage it selects is the one you named in next. A case that turns on judgment, such as choosing between a completion state and an abandonment state for a pull request closed unmerged, is left to you or to the coding agent.
The coding agent has a path of its own, separate from these writes: the tracker_api tool performs a transition_issue the agent asked for, within the configured project scope.
Each write stays off until its field is configured, and the stage-label write until a rule carries next. The terminal write needs reactions.merge_completion.provider on top of its target state; a deployment that does not configure it sees no terminal write from the orchestrator at all.
The handoff write carries one extra guard, because it is the write most likely to race a person. If you close or cancel an issue while its final turn is still finishing, the handoff write would otherwise overwrite your decision with handoff_state.
So the exit path tests the issue against terminal_states using the freshest observation it has, preferring one made by reconciliation, then one made by the worker’s own per-turn refresh, then the state last recorded for the run. A terminal observation suppresses the write, releases the claim, and cancels any pending retry.
Because that observation can itself go stale while the worker tears down, Sortie performs one more state read immediately before the write and applies the same test. If that read fails, the write proceeds: an unreachable tracker is not evidence that the issue is closed.
The read is skipped entirely when terminal_states is empty, since with no terminal state configured no value could classify as one and the call would cost a tracker request without ever suppressing anything.
Only a terminal state suppresses. Any other state, including handoff_state itself when the agent already applied it through the tracker_api tool, leaves the write and everything downstream of it unchanged. A stage hop is decided only after this read, so a terminal result suppresses the hop as well.
Handoff evidence
The handoff write is subject to one further condition beyond those in the table above: the run’s handoff-evidence verdict, governed by tracker.handoff_evidence (default observed). Sortie inspects the workspace the run used and returns one of three verdicts: work was observed, absence of work was observed, or evidence was not determinable. Work observed never withholds the write. Absence observed always withholds it, unless the policy is off. Not determinable withholds it only under strict.
| Policy | Work observed | Absence observed | Not determinable |
|---|---|---|---|
observed (default) | Handoff proceeds | Withheld | Handoff proceeds |
strict | Handoff proceeds | Withheld | Withheld |
off | No verdict is computed; the conditions in the table above stand | n/a | n/a |
The default withholds only on a positively observed absence and abstains everywhere it cannot measure the workspace: a workspace that is not a Git work tree, and a workspace inspection that itself fails, which includes one still waiting on output a descendant process kept open past a bounded wait.
A deployment whose workspaces are never version-controlled trees sees no behavioral change under the default and configures nothing. strict has no partial form: in such a deployment it withholds every transition and stops the pipeline, which is the operator’s own choice to make.
One legitimate configuration is misread by the default. A primary dispatch whose entire product is a write to the tracker presents a measurable workspace and no movement in it, so it reads as an absence. A write made through the tracker_api tool goes straight to the tracker, not to the workspace, so it is invisible to the inspection above and cannot rescue the case. That is the case off exists for.
The verdict describes what survived the run, not whether the agent acted: it can withhold a handoff write and it can never cause one, and it cannot see work that was produced and then reverted.
A verdict that withholds the handoff is checked against the tracker once before it takes effect, the same guard the handoff write itself carries. Immediately before recording anything, Sortie reads the issue’s state and tests it against terminal_states. A terminal result discards the verdict and routes the exit into the terminal disposition instead: the claim is released, any pending retry is cancelled, no failed run is recorded, no failure comment is posted, no retry is scheduled, and the consecutive-absence count does not advance.
Any other state leaves the withheld outcome exactly as it was, and so does a read that fails, because an unreachable tracker is not evidence that the issue is closed. Like the write path’s read, this one is skipped entirely when terminal_states is empty, since no value could then classify as terminal.
It is what keeps Sortie from reporting a failure and promising a retry on an issue that finished during the run (through the agent’s own tracker_api tool, through an after_run hook, or through a person moving it).
A withheld handoff that survives that check records the run as failed, naming the verdict as the reason, even though the agent process exited normally.
This bears on what a recorded run status of succeeded asserts on the handoff path. It means the worker exited without error and the evidence policy did not withhold the transition, or withheld it and the read above then found the issue terminal.
It does not assert that work was positively observed: an undeterminable verdict under observed, every normal exit under off, and a withheld verdict discarded by that read all record succeeded. So does a no-change declaration that stood, which asserts a claim the agent made and self-review checked where enabled, not a positive workspace observation.
Rows written before the evidence policy took effect keep the older exit-kind-only meaning and are not rewritten, so a success rate computed across the whole history spans three definitions of succeeded rather than one.
Declaring that nothing needed changing
A run can also state the verdict directly instead of leaving it to the workspace inspection above. Writing no-change-needed to .sortie/status declares that the requested outcome already held and the agent changed nothing. The declaration is admitted to the self-review phase on the same terms as needs-human-review.
If that phase does not confirm it (anything other than exactly one iteration ending on a pass verdict, with no failing verification result), the declaration is retracted and the run falls back to the ordinary evidence-based verdict above, as if it had made no declaration. On a deployment with self-review disabled, no such check runs and the declaration stands unverified.
A declaration that stands always yields work observed, ahead of and bypassing the workspace inspection above entirely: it cannot be withheld and cannot be undeterminable. Under observed and strict, it resets the consecutive-absence count and releases a park held for consecutive absences.
Under off, no verdict is computed and neither the reset nor the park release happens; resolving the transition target is the declaration’s only effect there. In every case, including off, the target is tracker.no_change_state where that field is configured, falling back to tracker.handoff_state otherwise; see the tracker.no_change_state field.
On a rule that carries next, that target applies only when no stage hop is made. Where no handoff path applies (the dispatch does not drive issue state, or tracker.handoff_state is unset), the declaration changes no issue state.
A run that produces nothing and declares nothing keeps the ordinary absence outcome above in full: handoff withheld, failure recorded, consecutive-absence count advanced. An undeterminable run that declares nothing keeps its policy-dependent outcome: proceeds under observed, withheld under strict. A terminal state observed at exit is not a declaration and is tested first, so a declaration on an already-terminal issue changes nothing.
A continuation turn dispatched by a reaction runs as an ordinary agent session and so performs the same in-progress, handoff, and stage-label writes, while a session dispatched by a label command performs none of them.
Stage hop
A rule that carries next makes a stage hop in place of the handoff write (see dispatch configuration for stage, next, and dispatch.max_consecutive_hops). A hop is due on exactly the exits that would otherwise take the handoff write, after the terminal tests and the evidence verdict above have let the exit through.
A needs-human-review soft stop and a no-change-needed declaration that stood both qualify. A blocked soft stop, a failed, timed-out, stalled, or cancelled run, a terminal exit, a withheld verdict, a label-command session, and an issue no longer in an active state never hop.
A hop that is made:
- Adds the stage label of the rule
nextnames. - Records the hop in the database and increments the issue’s consecutive hop count.
- Cancels the issue’s queued retry, including its persisted entry, and its pending reactions, and releases the claim. The run that hopped seeds no new reaction.
- Removes every other configured stage label the issue carried when the run was dispatched. A stage label added to the issue during the run is not removed.
The hop writes no tracker state. The issue stays in its active state, tracker.handoff_state and tracker.no_change_state are not written, and sortie_handoff_transitions_total is not incremented. The next stage’s dispatch performs the in-progress write like any dispatch. From the hop until a run of the issue is dispatched, the status API lists the issue under between_stages; the stored hop record keeps that listing across a restart.
The run that reaches a hop decision records the target rule and one of these results on its run history row:
| Result | Label add | Handoff write | Event |
|---|---|---|---|
advanced | Succeeded, and every other stage label was removed. | Not made | stage.advanced |
partial | Succeeded, but removing at least one other stage label failed; those labels stay on the issue. Logged as stage hop made, stage labels left on the issue. | Not made | stage.advanced |
failed | Failed. Logged as stage hop not made with reason=add_failed. | Made | stage.not_advanced |
ceiling | Not tried, because one more hop would take the count past dispatch.max_consecutive_hops. Logged as stage hop not made with reason=ceiling. | Made | stage.not_advanced |
A hop that is not made leaves the exit on the handoff write, with that write’s own outcomes: a failed write schedules a continuation retry, or releases the claim after a soft stop. When the configuration in force at the exit no longer holds the run’s rule or no longer gives it a next, or the rule next names no longer exists or no longer carries stage, no hop is due: the exit takes the handoff write, records no result, and publishes no stage event.
The consecutive hop count is kept per issue and survives a restart. It resets when:
- an exit writes
tracker.handoff_stateortracker.no_change_state, or finds the issue terminal or no longer in an active state; - reconciliation, a retry timer’s read, or the stage-hop hold’s direct read finds the issue terminal or outside the active states;
- the issue is parked, or its park is released.
Each reset logs stage hop count reset with a trigger of handoff, terminal, not_active, parked, or unparked. Moving a stage label while the issue stays active does not reset the count, and neither does a failed handoff write, a withheld verdict, or a reload.
Transition triggers
These events drive state transitions. Each is processed in order, one at a time.
| Trigger | What happens |
|---|---|
| Poll tick | In this order: run preflight validation, which forces a defensive workflow reload, and apply the resulting config to runtime state whether or not it passed; reconcile running issues; run the periodic workspace sweep when it is due. Dispatch is the only step gated on preflight success: a failed preflight returns here. Then fetch candidates, sort them, rebuild the budget-exhausted and parked sets from the candidate list, and dispatch eligible issues until slots are exhausted, then delete the stored acknowledgement of every attention item that no longer exists. Dispatched workers perform the optional in-progress transition (via tracker.in_progress_state) and optional dispatch comment (when session.started reaches the tracker_comment destination) as their first steps. |
| Worker exit (normal) | Remove the issue’s running-session entry. Persist run history to SQLite (a withheld handoff is recorded as failed, naming the verdict, unless its own verification read routed the exit into path 4 below). Update token totals. Outcome paths: (1) no soft-stop, issue active, dispatch drives issue state: schedule continuation retry or perform handoff transition (retry on handoff failure); (2) soft-stop blocked: release claim, no handoff, no retry, and park the issue with the escalation label where the dispatch drives issue state; (3) soft-stop needs-human-review, or a no-change-needed declaration that stood through self-review: perform handoff transition (if configured, issue active, and the dispatch drives issue state) to tracker.handoff_state, or to tracker.no_change_state for the declared case where that field is set, release claim (no retry on handoff failure); (4) issue already reported terminal: no handoff, release claim, no retry, no reactions enqueued; (5) handoff eligible but withheld by the evidence policy, with the read that outcome performs finding no terminal state: no handoff transition, exponential backoff retry, or the issue is parked once the consecutive-absence ceiling is reached; (6) none of the above, meaning the issue is no longer in an active state: cancel any pending retry and release the claim; (7) stage hop, when the run’s rule carries next and the exit reaches the handoff transition of path 1 or path 3: add the next stage’s label, remove the issue’s other stage labels, cancel any queued retry and pending reactions, release the claim, no handoff transition, the issue stays active; when the label add fails, the hop count is at dispatch.max_consecutive_hops, or the configuration in force no longer links the rule to a stage through next, the exit takes the handoff transition of path 1 or path 3 instead. Path 4 is tested ahead of paths 1, 3, 5, and 7 and overrides them, and a withheld verdict whose own verification read reports a terminal state is routed into path 4 as well; path 5 is tested ahead of paths 1, 3, and 7 and overrides them, except a no-change-needed declaration that stood, whose verdict is always work observed and so is never diverted into path 5; path 7 is tested after the read immediately before the handoff transition finds no terminal state (a terminal result there ends in path 4) and ahead of the handoff transition of paths 1 and 3, which it replaces when the label add succeeds; path 2 is tested first of all; path 6 is the fallthrough and is tested last. Publish the session.completed event, or session.stopped for a soft stop, to every destination that lists it, among them the tracker_comment destination, which posts the completion comment (asynchronous; does not block). A withheld handoff publishes session.failed instead. A made hop also publishes stage.advanced, and a due hop that is not made publishes stage.not_advanced. |
| Worker exit (error) | Remove the issue’s running-session entry. Persist run history. Classify error. If retryable, schedule exponential backoff retry, or defer to the queued entry when one already holds the retry slot. If not retryable, release claim. Publish the session.failed event to every destination that lists it, among them the tracker_comment destination, which posts the failure comment (asynchronous; does not block). |
| Worker exit (cancelled) | The worker was cancelled by reconciliation, by stall detection, by the agent.max_tokens or agent.max_cost_usd in-flight check, or by shutdown. Remove the issue’s running-session entry. Persist run history, under status budget_stopped when the token or money ceiling’s own cancellation ends the run and cancelled otherwise. See how to control agent costs. Release the claim only when no retry is already queued: a retry pre-scheduled by stall detection keeps the claim so nothing else can dispatch the issue. No handoff transition, no new retry. |
| Agent update event | Update the issue’s running-session entry: token counters, session ID, thread ID, agent PID, last activity timestamp. It also updates the agent kind’s usage windows when the event carries them. This still applies to a run whose worker has already exited, as long as that exit has not yet been handled, so the event never lands on a later run of the same issue. An event carrying token usage evaluates the issue against agent.max_cost_usd, then agent.max_tokens, and cancels the worker once the cost or the sum reaches its ceiling, unless the event is still waiting when that issue’s worker exit is taken up: then the usage reaches the next dispatch decision through the run’s recorded total instead. During shutdown drain, no event is evaluated against the ceiling at all. |
| Retry timer fired | Read that one issue from the tracker by ID. If it is still eligible and slots are available, dispatch, re-selecting the agent kind and template first when the configuration in force no longer reaches the recorded ones. If no slots, or the read fails, or the entry’s agent kind has no adapter, reschedule at the next attempt number, with the reason as the entry’s error and timer as its trigger; a worker for the issue that is still running, or a read that returns no state label, reschedules at the same attempt number instead. A reaction-kind entry whose issue is blocked or in a state that is neither active nor the handoff state is rescheduled at the next attempt number too, and is dropped once it has stayed paused for 30 minutes. If the tracker reports the issue missing, terminal, or no longer active, release the claim and delete the persisted entry, except for a reaction-kind entry, which is rescheduled instead of released. Enforce the agent.max_sessions, agent.max_tokens, and agent.max_cost_usd budgets here: an exhausted budget releases the claim rather than dispatching. |
| Reconciliation: tracker state refresh | For each running issue: terminal state → cancel worker, clean workspace. Still active → record the state and labels the tick read as the run’s current ones. Neither active nor terminal → cancel worker, no cleanup here; the periodic sweep may still remove that workspace later on age. |
| Reaction reconcile passes | Part of the same poll tick, after the tracker state refresh. Eight reaction kinds each get one pass in a fixed order: CI failure, review comments, bot review, merge conflicts, auto-merge, review label command, fix label command, merge completion. A pass can dispatch a continuation session for the issue, which claims it exactly as a primary dispatch does. See the reactions reference for what each pass observes. |
| Reaction: managed PR observed as merged | The merge-completion pass. When reactions.merge_completion is configured, transition the linked issue to the configured terminal state, once per merge commit. No workspace or source-control side effect. |
| Retry now request | POST /api/v1/retries/{identifier}/run re-arms the queued entry in place with a zero delay, so the timer fires at once and the entry takes the Retry timer fired path. When that fire puts the entry back, it keeps its attempt number and the due time it had before the request, instead of the next attempt number and backoff step. Answered 409 Conflict during shutdown drain. |
| Check reaction now request | POST /api/v1/reactions/{identifier}/{kind}/check moves the pending reaction’s next_poll_at to the time Sortie takes the request, when that time is later, and changes nothing else about the reaction. The next poll tick or refresh then checks it under the rules of its kind. When that check keeps the reaction pending and starts before the due time the request replaced, the reaction gets that due time and its earlier backoff count back. Answered 409 Conflict during shutdown drain. |
| Acknowledge request | POST /api/v1/attention/{id}/acknowledge matches the id against the attention items derived now. It saves the acknowledgement to SQLite first and records it in memory only when the save succeeds. An unknown id or a failed save changes nothing, and a repeat request for an acknowledged item changes nothing. Answered 409 Conflict during shutdown drain. |
| Refresh request | POST /api/v1/refresh runs a full poll tick out of band, identical to a tick the timer fired. Discarded during shutdown drain. |
| Self-review progress | Marks the running entry as self-reviewing and records the iteration number, or clears both when the review loop ends. A progress message for a run whose worker has already exited is applied to that run before the exit is handled, so it never lands on a later run of the same issue. Live session bookkeeping only; no claim or retry transition. |
Candidate eligibility
An issue is eligible for dispatch when all conditions are true:
| Condition | Details |
|---|---|
| Required fields present | id, identifier, title, and state must be non-empty. |
| State is active | state is in tracker.active_states (case-insensitive). |
| State is not terminal | state is not in tracker.terminal_states. |
| Not running | The issue is not already Running. |
| Not claimed | The issue is not already Claimed. |
| Not budget-exhausted | The issue is not already held by a per-issue agent.max_sessions, agent.max_tokens, or agent.max_cost_usd ceiling, rebuilt each poll tick from run history. |
| Not parked | The issue is not already parked. A park holds the issue until a later poll tick observes a release gesture. After a person’s release, the issue’s next dispatch reads its comments and holds the issue, with the reason park_comments_unread, while that read fails. |
| Stage hop label observed | After a stage hop, the issue dispatches on no rule until a read has shown the label the hop added. When the candidate list lacks that label, Sortie reads the issue once by ID that tick. A read that shows the label releases the hold. A read that shows the issue active without the label also releases it, logs stage hop target label absent, hold released, and routes the issue by the labels that read returned. A read that finds the issue terminal or outside the active states resets the hop count and dispatches nothing. A read that shows no state holds the issue for that tick and logs stage hop target read showed no state, holding issue; reconciliation resets the count at the second consecutive reading with no state. A failed read holds the issue until the next tick. A released hold stays released across a restart. |
| Instance spend ceiling not reached | The estimated cost of the current UTC day is below agent.max_daily_cost_usd, that of the current UTC week (Monday 00:00 UTC to the next Monday 00:00 UTC) is below agent.max_weekly_cost_usd, and that of the current UTC month is below agent.max_monthly_cost_usd, for each that is set. Dispatch stays held until every window that has reached its ceiling clears. A held candidate is counted under daily_spend_ceiling, weekly_spend_ceiling, or monthly_spend_ceiling: the reason of the holding window that resets latest, the longer window when two reset together. |
| Global slots available | running_count < agent.max_concurrent_agents. |
| Per-state slots available | Running count for this state < agent.max_concurrent_agents_by_state[state] (if configured). |
| No blocker is still active | Every entry in blocked_by has a non-empty state that is in tracker.terminal_states. An entry with an empty state, or a state outside terminal_states, holds the issue. |
| Blocker list is authoritative | The issue’s blocked_by must be resolved, not merely absent of active blockers. On a tracker whose candidate fetch cannot carry blockers (currently GitHub and Gitea), each candidate’s list is read separately, bounded by a small budget shared across the whole poll (see blocker resolution below). A candidate whose read hasn’t happened yet this poll, or whose read failed, is held rather than dispatched on an unread list. |
Issues are sorted for dispatch: priority ascending (issues with no priority sort last), created_at oldest first, identifier lexicographic tiebreaker.
Blocker resolution
Jira and Linear return each issue’s blockers together with the candidate list, so nothing extra is read. GitHub and Gitea do not: a candidate from either tracker is held until a separate per-issue read resolves its blocker list, and that read is bounded to four per poll, shared across every candidate that needs one. GitHub’s candidate payload can prove an issue has zero dependencies without spending a read; Gitea’s cannot, so every Gitea candidate needing resolution costs one.
GitLab declares that it has no blocking relation to read at all, so its issues carry an authoritative empty list from the candidate fetch and are never held for this reason. See the GitHub and Gitea adapter references for the read cost and how a failed read is handled.
A held candidate is not silent: it increments the sortie_candidate_holds_total counter with a reason label, and a candidate held by a blocker also logs one record. The reason is one of:
| Reason | Meaning |
|---|---|
blocked_by | At least one blocker has a non-terminal or unknown state. |
blockers_unresolved | The blocker read for this candidate was attempted and failed, or this poll already gave up on further reads after an earlier failure. Retried on a later poll. |
blockers_not_read | This poll’s read budget was already spent on other candidates before reaching this one. Retried on a later poll. |
blockers_incomplete | The candidate’s producer marked the list unresolved and nothing was available to complete it. |
daily_spend_ceiling | The instance’s spend in the UTC day has reached agent.max_daily_cost_usd. Logged once when the hold begins, not per candidate. |
weekly_spend_ceiling | The instance’s spend in the UTC week, which starts on Monday at 00:00 UTC, has reached agent.max_weekly_cost_usd. Logged once when the hold begins, not per candidate. |
monthly_spend_ceiling | The instance’s spend in the UTC month has reached agent.max_monthly_cost_usd. Logged once when the hold begins, not per candidate. |
sortie --dry-run reports the blocker reasons (blocked_by, blockers_unresolved, blockers_not_read, and blockers_incomplete) per candidate as skip_reason (see the CLI reference) and does not evaluate the spend ceiling, and the Prometheus metrics reference documents the counter in full.
The dashboard’s Not dispatched block and the held list of GET /api/v1/state show each held candidate with its reason and what releases it. They also cover holds the counter does not count: a full agent slot limit, no free SSH host, and an unconfirmed stage hop.
Backoff formula
Sortie uses two retry delay strategies depending on the exit type.
Continuation retry (normal worker exit, issue still active):
Error retry (worker failure, stall timeout):
Default max_retry_backoff_ms: 300 000 (5 minutes). Configurable via agent.max_retry_backoff_ms.
| Attempt | Delay |
|---|---|
| 1 | 10 s |
| 2 | 20 s |
| 3 | 40 s |
| 4 | 80 s |
| 5 | 160 s |
| 6+ | 300 s (cap) |
When a retry fires but no concurrency slot is available, the entry is rescheduled at the next attempt number (one backoff step longer, not a repeat of the same delay) with error no available orchestrator slots. A failed tracker read for the issue reschedules the same way.
Reconciliation
Reconciliation runs at the start of every poll tick, before dispatch, as one fixed sequence.
Part A: Overdue retry re-arm. A retry timer event can be dropped if too many arrive at once. An entry whose due_at lags the current tick by more than 60 seconds is re-armed with a zero delay, so an undeliverable entry cannot hold the retry slot for the life of the process.
Part B: Stall detection. For each running issue, compute elapsed time since the last agent event (or started_at if no event has arrived). If elapsed exceeds agent.stall_timeout_ms, the worker is killed and an exponential backoff retry is scheduled. Disabled when stall_timeout_ms is zero or negative.
Part C: Tracker state refresh. Fetch current tracker states and labels for all running issue IDs, for every issue carrying a pending reaction, and for every issue holding a stage hop record, running or not.
| Tracker reports | Action |
|---|---|
| Terminal state | Cancel worker. Mark workspace for cleanup after worker exits. |
| Still active | Record the state and labels this tick read as the run’s current ones, unless a newer read is already recorded. Worker continues. |
| Neither active nor terminal | Cancel worker. No workspace cleanup here; the periodic sweep may remove that workspace later on age. |
| No state, first reading (GitHub, Gitea, GitLab: an open issue with no state label) | Keep the worker running and log deferred stop for issue without a state label. A person’s label edit can pass through zero state labels for a moment. |
| No state, second consecutive reading | Treated as neither active nor terminal: cancel worker, no workspace cleanup. |
| Fetch fails | Keep all workers running. Retry on next tick. |
An issue with no worker whose pending reaction holds a standing escalation is also acted on when the tracker reports it outside tracker.handoff_state in a non-terminal state while Sortie does not run, retry, claim, or park it. A direct read of the issue confirms the state, and then every standing escalation of the issue ends and its entries and attempt counters drop. A reading with no state ends them at the second consecutive reading. The same read runs just before the dispatch of a poll candidate that holds a standing escalation. A terminal state releases the issue’s escalation records with its pending reactions.
A terminal state, or one that is neither active nor terminal, also resets the hop count of an issue holding a stage hop record. A reading with no state resets it only at the second consecutive such reading. A queued retry whose per-issue read shows no state is likewise rescheduled at the first reading and released at the second.
Part D: Reaction passes. The eight reaction kinds each get one pass, in this order: CI failure, review comments, bot review, merge conflicts, auto-merge, review label command, fix label command, merge completion. The order is load-bearing in two places: merge-conflict detection runs before auto-merge so a fresh conflict is acted on before auto-merge re-confirms its deferral, and merge completion runs last so a merge performed earlier in the same tick is observed on the same pass. See the reactions reference for what each pass does.
Periodic workspace sweep. Separately from the per-tick reconciliation above, a sweep runs once every 60 poll ticks and applies two grounds in one pass. The terminal check runs first: it asks the tracker for the state of every workspace key on disk that does not belong to in-flight work, and removes those reported terminal. Whatever it leaves is then evaluated against workspace.retention_days, an opt-in age bound that is off by default and needs no answer from the tracker, so it still removes on a pass where the tracker read failed.
Recovery at startup
When Sortie starts (or restarts after a crash), it reconstructs orchestration state from SQLite and the tracker.
- Open SQLite database and apply schema migrations.
- Load persisted retry entries. Reconstruct retry timers from stored
due_attimestamps. Each rehydrated entry marks its issue claimed, so the first poll tick cannot dispatch it a second time. - Load the cumulative token and runtime totals, the park records that hold issues out of dispatch, and the stage hop records, which restore each issue’s hop count and whether its stage-hop hold has been released. A read failure for any of these is logged as a warning and startup continues with none.
- Enumerate workspace directories on disk and map directory names to issue identifiers.
- Query the tracker for the states of those identifiers and remove the workspace directories of issues reported terminal. Only keys whose state is both known and terminal are removed, so a workspace whose issue is missing from the response or sits in a non-active, non-terminal state survives the pass. No age-based removal runs at startup.
- Rebuild the pending reaction set from recent run history, so a watch that was in flight when the process stopped is not lost. Runs whose recorded activity is older than the recovery lookback are skipped. A failure here is logged as a warning and startup continues.
- Restore the stored escalations onto the rebuilt entries, and rebuild an entry from its stored escalation for an issue that step 6 did not reach. A stored escalation whose reaction kind is not configured, or whose issue has no successful run, ends. A failure here is logged as an error and startup continues.
- Begin the normal poll loop. The first tick fires immediately, and it is that tick, not a separate recovery step, that reads the tracker’s active issues and reconciles them with the restored state.
If the workspace listing or the terminal-state query fails at startup, Sortie logs a warning, cleans nothing on that pass, and continues. For an issue with no running worker, terminal cleanup then waits for the periodic sweep, which is also where the opt-in age bound in workspace.retention_days applies.
Was this page helpful?