Reactions
Reactions are feedback loops that respond to events on a Sortie-created pull request after the initial agent run hands off. Each reaction kind watches one external signal (failing CI, requested review changes, automated review-bot comments, a merge conflict against the PR’s base branch, a mergeable approved PR, or a merged pull request) and responds in one of three ways: it dispatches a continuation turn so the agent can respond, or, for auto-merge, performs the merge directly, or, for merge-completion, transitions the linked tracker issue.
Reactions are opt-in: a kind is inactive until its provider is set, and omitting the reactions block disables all of them.
Reaction kinds at a glance
| Kind | Watches | Action | Budget field (default) | Runtime kind |
|---|---|---|---|---|
ci_failure | CI status on the PR branch | Dispatches a continuation turn | max_retries (2) | ci |
review_comments | Review comments from human reviewers requesting changes | Dispatches a continuation turn | max_continuation_turns (3) | review |
bot_review | Automated review-bot comments | Dispatches a continuation turn | max_continuation_turns (5) | bot-review |
merge_conflicts | PR mergeability against the base | Dispatches a rebase-and-resolve continuation turn | max_retries (1) | merge-conflict |
auto_merge | Merge preconditions on an approved PR | Merges the PR directly | max_retries (2) | merge |
merge_completion | Merge state of a managed PR | Transitions the linked issue to a terminal state | max_retries (2) | merge-completion |
Reaction lifecycle
Every reaction kind moves through the same pipeline. The orchestrator records a pending reaction for an issue when a worker exits normally and SCM metadata is available, and it reconstructs eligible pending reactions at startup, with the escalations that stood when the process stopped, so feedback survives a restart. On each reconcile tick, after tracker-state refresh, the orchestrator runs the pipeline for each pending reaction in a fixed order: CI failure first, then review comments, then bot review, then merge conflict, then auto-merge, and merge completion last.
- Poll. The orchestrator queries the kind’s provider for the current signal, throttled by the kind’s
poll_interval_ms. A transient fetch error re-enqueues the entry for the next tick. - Fingerprint.
review_comments,bot_review,merge_conflicts, andauto_mergehash their salient state into a SHA-256 fingerprint stored in thereaction_fingerprintsSQLite table. The review fingerprint is the sorted set of non-outdated comment IDs; the bot-review fingerprint is the sorted set of non-outdated bot comment IDs under its own kind row; the merge-conflict fingerprint is the PR head SHA; the merge fingerprint is the PR head SHA combined with the review decision.merge_completionalso occupies a row of its own, but stores the merge commit identifier reported by the forge verbatim rather than hashing anything.ci_failureoccupies a row too, and likemerge_completionstores its value verbatim rather than hashing it. What it stores is the pull request’s head as resolved on that pass, never a recorded SHA and never a branch name. - Deduplicate. When the fingerprint matches the last value already marked dispatched, the tick takes no action. For the hashing kinds, a new push or a changed comment set produces a new fingerprint and clears the dispatched mark. For
review_commentsandbot_review, a changed set must then pass the already-given check before it dispatches.merge_completionis the exception on the far side: its dispatched row is retained after the transition rather than cleared, so the same merge is never observed as new.ci_failureruns both mechanisms: the head fingerprint dedups a head that has already dispatched, and that fingerprint moves with the pull request, so a commit landing on the pull request re-arms the reaction. Apendingstatus re-enqueues under backoff. Apassingstatus clears the attempt counter and ends a standing CI escalation, and the reaction keeps watching. - Dispatch. The reaction action runs. Where the kind carries a
triageblock, an operator-owned command runs first and can close the subject or hand it to a person instead, in which case none of the actions in this step happen. Forci_failure,review_comments,bot_review, andmerge_conflictsthe orchestrator schedules a fix continuation turn, injecting the signal into the prompt through a continuation context variable. An issue holds at most one queued continuation at a time, so a kind that finds one already queued defers and re-checks on a later tick rather than replacing it; the queued work is never discarded, and the deferring kind takes none of the other actions in this step on that tick. Forauto_mergethe orchestrator merges the pull request directly, since no code change is needed. Formerge_completionit calls the tracker transition directly, for the same reason. Each dispatch increments the per-issue, per-kind attempt counter and uses a fixed 1-second delay rather than exponential backoff. - Escalate. When the attempt counter reaches the kind’s retry budget, and when a triage command answers
escalate, the orchestrator applies the configuredescalationaction once for the subject it judged and records a standing escalation. The entry stays pending, dispatches nothing, and keeps watching the pull request until a fact it observes ends the escalation.review_commentsandci_failurerelease the claim unless a worker is running for the issue.auto_merge,bot_review, andmerge_conflictskeep the claim. When a worker is running, its exit decides the claim. Forreview_commentsandbot_review, a spent budget escalates only when a new comment arrives; see retry budgets.
flowchart TD
EX[Normal worker exit] --> PE[Pending reaction recorded]
PE --> PL{Poll provider}
PL -->|signal not actionable| PL
PL -->|actionable| FP{Fingerprint changed?}
FP -->|no| PL
FP -->|yes| BUD{Within retry budget?}
BUD -->|yes| DI[Dispatch: continuation turn or merge]
DI --> PL
BUD -->|no| ES([Escalate])
classDef start fill:#dbeafe,stroke:#3b82f6,color:#1e3a5f
classDef decision fill:#fef3c7,stroke:#d97706,color:#78350f
classDef action fill:#d1fae5,stroke:#059669,color:#064e3b,stroke-width:2px
classDef terminal fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
class EX,PE start
class PL,FP,BUD decision
class DI action
class ES terminalRetry budgets
The attempt counter is tracked per issue and per kind. It is cleared when the issue reaches a terminal tracker state, and it is held in memory, so a restart clears it. The exception is the counter of a kind with a standing escalation: Sortie stores it with the escalation and restores it at startup. A person returning the issue to work ends that escalation and clears the counter; see how a standing escalation ends. Dropping an entry at the end of its watch window also clears it, except for a spent review_comments or bot_review budget (see below). auto_merge clears it too when it finds its pull request already merged (see reactions.auto_merge). ci_failure also clears it, while the entry keeps watching, when CI returns to passing.
The budget field differs by kind: ci_failure and auto_merge use max_retries (default 2), review_comments uses max_continuation_turns (default 3) as its hard cap, and bot_review uses max_continuation_turns (default 5) as its hard cap. merge_conflicts uses max_retries (default 1), the lowest of the kinds, and its counter is episodic, resetting when the conflict clears.
A budget of 0 escalates ci_failure and merge_conflicts on the first actionable signal with no fix attempt. auto_merge is the exception: its escalation check requires max_retries greater than zero, so a budget of 0 turns count-based escalation off entirely instead of making it immediate.
Polling is bounded, for every kind, by watch_window_ms, a shared and configurable field: once it elapses, the orchestrator drops the entry and logs a warning instead of escalating, so the reaction goes silent with no tracker-visible signal. The window never drops an entry that holds a standing escalation, and never drops a review_comments entry whose pull request is waiting on a required approval. review_comments, bot_review, merge_conflicts, and auto_merge default to thirty minutes. For auto_merge the window is measured from the entry’s creation. For the other three it is measured from the last time the entry waited for the issue’s retry slot, or from the entry’s creation when it never has.
ci_failure defaults instead to twenty-four hours, measured from the last recorded head change rather than from entry creation. Every kind’s value must be non-negative and must not exceed 9223372036854 (about 292 years); 0 removes the bound entirely, and where the bound does apply, the entry drops with the same silent warning and no escalation. A pending reaction’s watch_ends_at in the HTTP API reports when its window ends.
An authentication-class or payload-class merge error still escalates auto_merge immediately regardless of the budget. To get near-immediate escalation on a failed merge, set auto_merge.max_retries: 1, the lowest budget its count-based check honors, which escalates after the first failed attempt.
Spent budget on review_comments and bot_review. These two kinds judge a spent budget after the poll, the fingerprint check, and the retry-slot check, and before any triage command. A spent budget escalates only when the poll returns a comment that no earlier run of the issue was given; see comments already given.
review_comments: any new comment ID counts, including a review summary.bot_review: only a comment on the code counts, meaning one tied to a file. Items a bot posts only in its review summary, with no file attached, never escalate on their own and stay visible on the pull request.
A poll that finds only comments a run was already given, or finds the review resolved, does not escalate. The entry keeps polling at poll_interval_ms and logs a debug line, until a new comment arrives, watch_window_ms drops the entry (a pull request waiting on a required approval is held past it), or the issue reaches a terminal state. A spent counter survives the watch window dropping the entry, so an entry created later for the same issue starts with the budget still spent.
While turns remain, nothing changes: a summary-only bot re-review still dispatches a continuation turn, and a triage command that answers handled is the way to keep a re-review known to be clean from spending one.
A value that fails these bounds is not caught the same way for every kind. reactions.ci_failure.watch_window_ms is validated while WORKFLOW.md itself is loaded, so an out-of-range edit fails a dynamic reload outright: the previous configuration remains active, and the failure is logged.
watch_window_ms for review_comments, bot_review, merge_conflicts, and auto_merge is validated only when the orchestrator builds those reactions, which happens once at startup and is not repeated by a reload; an out-of-range edit to one of them is accepted by a reload with no error and simply has no effect, same as any other change to those blocks, until the next restart. At that restart, and in sortie validate run ahead of one, the same value fails at startup and the process exits 1.
merge_completion uses max_retries (default 2) to bound retryable transition failures, and a budget of 0 escalates on the first failed transition rather than turning the count-based check off, which is what the same value does for auto_merge.
Its pending entry carries no time-to-live at all. It is bounded instead by the issue leaving the configured handoff state, because a merge waits on human review for an unbounded time, and by a fixed 30-minute grace period that starts only once the forge reports the pull request merged without a merge commit identifier. max_retries does not bound that grace period.
A pending entry also keeps its issue’s workspace from being swept, but only when the entry’s kind carries an expiry. The five kinds that carry an expiry pin the workspace: ci, review, bot-review, merge, and merge-conflict. The kinds that carry no expiry do not pin: label-review, label-fix, and merge-completion.
A kind that waits on a human gesture keeps its entry indefinitely, and an entry that never expires would exclude its workspace from every bound in the system. See the workspace configuration for the age bound a pin defers.
Comments already given
review_comments and bot_review start a continuation turn only for a set that holds a comment no earlier run of the issue was given. A fingerprint change alone does not start one.
When a poll finds a changed set whose comments were all given before, because a fix made some of them outdated or a review was dismissed, or because the set matches one seen earlier, the pass starts no turn, spends none of max_continuation_turns, runs no triage command, and escalates nothing. The entry keeps polling at poll_interval_ms.
A comment the agent received but left unaddressed is not sent again when its siblings go away; it comes back with the next new comment. A turn that does start carries every remaining comment, the ones the agent already has included.
What counts as given. A review-fix run is given every comment it carries, whether its prompt comes from the template or from the built-in prompt. A new run is given the comments its first prompt shows. That prompt shows them when rendering it without the kind’s template variable changes the text, so a template that never prints review_comments (or bot_review_comments) hands a new run nothing and the same comments can start a later turn. Either way, Sortie records the comments when the run exits normally. A run that fails or is cancelled records nothing.
What else is not new. A comment carried by the issue’s turn that is still running is not new. For bot_review, a comment an earlier spent-budget pass or triage escalation already reported to a person is not new either; that record is forgotten together with the kind’s attempt counter (see retry budgets) and is never stored, so a restart clears it.
Storage. The record is the reaction_handoffs SQLite table, one row per issue, kind (review or bot-review), and comment ID. It survives restarts, so a restart starts no turn that only repeats earlier comments. Rows are only added: an escalation, an elapsed watch window, and a terminal tracker state leave them in place. A database upgraded to this table starts with it empty, so a comment given before the upgrade can be given once more.
Fingerprint and failures. When the stored record already covers every comment in a set, the fingerprint is marked dispatched. A set covered only by the running turn stays unmarked and is judged again once that run has exited. When the record cannot be read, the pass logs a warning, waits one poll interval, and dispatches, escalates, and marks nothing.
Budget. With max_continuation_turns spent, review_comments escalates when a new comment arrives, and bot_review escalates only when the new comment is on the code. A set with no new comment does nothing. While turns remain, a new review summary from a bot still starts a bot_review turn.
A new run of an issue. A new run whose issue already has a pull request is given the comments no run was given, so the first turn does not have to wait for a later poll. The orchestrator fetches them just before it dispatches, when all of these hold:
- The dispatch comes from the poll candidate pass, not from a reaction or a retry. A reopened issue is one case.
- The kind is configured and has no
triageblock, since triage decides whether an agent receives the comments. - The workspace’s
.sortie/scm.jsonholdspr_numberabove zero and non-emptyowner,repo, andbranch. Herebranchis required for both kinds, althoughreview_commentspolling does not need it. - The pull request holds at least one comment no run was given.
The run then receives every actionable comment through review_comments or bot_review_comments on its first turn. The comments are used only when the prompt rendered with them contains every line of the prompt rendered without them, in the same order. A template that swaps its text when the variable is set therefore renders its normal prompt, and nothing is recorded as given.
A fetch or record-read failure logs the warning fresh run not given review comments with the kind and the error, and the run starts with its normal prompt. The failure concerns only the kind that failed, and the pass after the run handles the comments as usual. To write a template that takes the comments, see what the agent sees.
State eligibility
Reaction continuations dispatch even while the issue sits in the tracker’s handoff_state, the state Sortie transitions to for human review after a successful run. This differs from fresh-work retries (stall recovery and transient agent errors), which dispatch only when the issue is in an active_state. An issue that has moved to any other state runs no further reactions.
When the tracker reports the issue in a terminal state, that release is immediate rather than deferred to the next retry: on the reconcile tick that observes it, every pending reaction entry and every attempt counter for that issue is dropped, its pending retry is cancelled, and its claim is released.
This happens whether or not a worker is still running for the issue, so the two label-command kinds, which carry no expiry, stop polling the pull request’s label journal as soon as the issue closes instead of continuing for the life of the process, and the issue is available for a fresh dispatch the moment it is reopened into an active state.
The stored escalation records of the issue are deleted with the entries. Fingerprint rows and the stored record of comments already given are not deleted by this path. See the state machine reference for the claim and retry model.
Cross-kind isolation
Each kind owns its own pending entry, fingerprint row, and attempt counter. A successful auto-merge, or escalation of any one kind, scopes its cleanup to that kind alone and leaves the other kinds’ state on the same issue intact.
Escalation scopes to its kind the same way. review_comments and ci_failure release the claim when they escalate, unless a worker is running for the issue, and auto_merge, bot_review, and merge_conflicts keep it. Every kind keeps its own pending entry while its escalation stands, and the escalation of one kind leaves the others on the same issue running.
merge_completion scopes its cleanup the same way and keeps the claim: a successful transition clears only its own pending entry and attempt counter, so the other kinds’ state on that issue is untouched, and an escalation on any other kind leaves merge-completion tracking in place.
Escalation actions
An escalation fires when a kind exhausts its budget, when a triage command answers escalate, when the forge refuses the merge call of auto_merge or the tracker refuses the issue move of merge_completion with an authentication or payload error, and when merge_completion gives up on a merge whose commit identifier never arrives. The escalation field chooses the action Sortie takes on the issue:
label(default): addsescalation_label(defaultneeds-human) to the tracker issue.none: applies no label and posts no comment of its own.comment(deprecated): posts a plain-text tracker comment naming the PR, the attempt count, and the outstanding signal. A deprecation warning names the replacement,escalation: noneplus a subscription to the event.
Whatever the value, the escalation also emits one event: escalation.ci_failure, escalation.review_comments, escalation.bot_review, escalation.merge_conflicts, escalation.auto_merge, or escalation.merge_completion. Each entry in notifications that lists the event receives it, so Slack or a webhook can hear about an escalation whether the reaction labels or not. A tracker_comment entry that lists the event posts the comment on the issue. To get a comment and no label, write escalation: none and list the event on a tracker_comment entry. To get both, keep escalation: label and list the event.
escalation | Label | Comment on the issue |
|---|---|---|
label (default) | Applied. | None, unless a tracker_comment entry lists the event. |
none | None. | None, unless a tracker_comment entry lists the event. |
comment (deprecated) | None. | Posted. |
A reaction block that omits escalation resolves to label, auto_merge included, and draws no warning. A comment on the issue, from escalation: comment or from a subscription, is posted each time an escalation applies its action, which is once for each subject, while a label is applied idempotently.
The action runs asynchronously with a 30-second timeout. A failed escalation is logged and counted but does not block cleanup. Escalation outcomes are recorded by the per-kind escalation counters, such as sortie_ci_escalations_total; see the Prometheus metrics reference.
Standing escalations
An escalation stands from the moment a kind escalates until a fact Sortie observes ends it. While it stands, the entry stays pending and appears in pending_reactions with escalated_at and escalation_trigger, dispatches nothing, applies no second action, and keeps reading the pull request. No watch window ages it out. ci_failure, review_comments, bot_review, merge_conflicts, auto_merge, and merge_completion escalate this way; the label commands never escalate.
Sortie stores each standing escalation with its trigger, its subject, the kind’s attempt counter, and the pull request, so a restart restores it. The restart spends no second fix budget and applies no second action. A restored merge_completion escalation makes one transition call on its first pass: a success ends it, and a failure leaves it standing without a second action. An escalation that was standing when a database was upgraded to the release that stores escalations is not restored, and the next escalation of its kind records one.
Every escalation has a trigger, served as escalation_trigger:
| Trigger | Meaning | Kinds |
|---|---|---|
budget_exhausted | The kind used up its tries. | Every kind that escalates |
triage | The kind’s triage command answered escalate. | ci_failure, review_comments, bot_review, merge_conflicts |
refused | The forge refused the merge call, or the tracker refused the issue move, with an authentication or payload error. | auto_merge, merge_completion |
missing_merge_commit | The forge reports the pull request merged but gives no merge commit after the 30-minute grace period. | merge_completion |
The subject is what the escalation judged. An escalation applies its action once for each subject: when an escalation ends and one for the same subject begins again, Sortie records the new time and applies no second action, so a subject gets one label and one event however often its escalation ends and begins again. A triage escalation is not applied again for a subject already escalated.
| Kind | Subject |
|---|---|
ci_failure, merge_conflicts | The pull request head. |
review_comments, bot_review | The set of comments. |
auto_merge | The merge call the forge refused. An escalation on an exhausted budget has no subject and applies its action every time it begins. |
merge_completion | The merge commit, or the pull request for missing_merge_commit. |
How a standing escalation ends
An escalation ends when any of these holds, and on no other fact:
- The pull request merges, closes, or no longer exists. For
merge_completionthe pull request has already merged, so only a pull request that no longer exists ends it. - The issue reaches a terminal tracker state.
- A person answers it by moving the issue out of
tracker.handoff_state, or by releasing its park so Sortie runs the issue again. This ends the escalation of every kind that stands on the issue, drops the entries and their attempt counters, and lets the next run start the reaction with a fresh budget. Sortie acts on this only when no run, retry, claim, or park of its own holds the issue, and only after a direct read of the issue shows a state that is neither the handoff state nor terminal; a search that trails Sortie’s own handoff write ends nothing. An issue the tracker reads with no state ends the escalations at the second reading in a row. Withouttracker.handoff_stateset, no state marks a person’s answer. - A fact of the kind itself, in the table below.
| Kind | The escalation also ends when |
|---|---|
ci_failure | A check of the pull request passes, or a new commit lands on it. For a budget_exhausted escalation, a reloaded max_retries rises above the CI fixes used. |
review_comments, bot_review | The pull request holds no actionable comment. For a triage escalation, the set of comments changes. For a budget_exhausted escalation, no comment is left that no run was given (for bot_review, on the code), or a restart finds max_continuation_turns above the turns used. A comment that arrives while a budget escalation stands joins it. |
merge_conflicts | A check finds no merge conflict, or a conflict appears on a different head. For a budget_exhausted escalation, a restart finds max_retries above the rebases used. |
auto_merge | For a budget_exhausted escalation, a restart finds max_retries above the merge calls made, or at 0. |
merge_completion | For missing_merge_commit, the forge reports the merge commit. For a budget_exhausted escalation, a restart finds max_retries at or above the failed transitions. |
Only ci_failure is re-read on every tick, so a raised CI budget ends its escalation at the next failing check, and every other budget change takes effect at the next restart, as the configuration note describes.
Common fields
Every reaction kind shares these four fields.
| Field | Type | Default | Description |
|---|---|---|---|
provider | string | (required) | SCM or CI adapter kind that activates the reaction: github, gitea, or gitlab. Must match a registered adapter. Absent or empty disables the kind, and all other fields in the sub-object are ignored. |
max_retries | integer | 2 | Fix continuation dispatches per issue before escalation. Must be non-negative. |
escalation | string | label | Action taken when the kind hands the subject to a person, either because the budget is spent or because a triage command answered escalate. One of label, none, or the deprecated comment. See escalation actions. |
escalation_label | string | needs-human | Label applied to the tracker issue when escalation is label. |
Keys other than these four are kind-specific and listed under each kind below.
Note
Environment variable overrides for reactions fields are not supported. Reaction configuration comes from WORKFLOW.md, and it is captured once when the orchestrator starts. A dynamic reload does not rebuild it: changing any field of any kind, or adding or removing a kind’s block, takes effect only on the next restart. The one exception is ci_failure, which is folded into the CI feedback configuration and re-read on every tick.
Three things sit outside that exception and still need a restart: provider, which selects the CI provider built at process start (a reload leaves the running provider in use, and neither adds nor removes CI feedback), max_log_lines, because the CI provider is constructed with it, and the triage block, which every kind that offers it freezes at process start.
Triage command
ci_failure, review_comments, bot_review, and merge_conflicts accept an optional triage block. It names a command that runs in the issue workspace once the reaction has found a new subject and before the reaction dispatches a continuation turn for it. The command answers handled, dispatch-agent, or escalate, so work with a deterministic fix can be resolved without an agent session.
auto_merge, merge_completion, and label_commands do not accept the block. The first two dispatch no agent, so there is nothing for a pre-dispatch gate to gate, and the label commands carry no escalation field, so one of the three answers would have nothing to apply. A triage block under any of them, or under any other key of reactions, is a configuration error.
| Field | Type | Default | Description |
|---|---|---|---|
script | string | (required) | Shell script body, run the same way a workspace hook is. Must be a non-blank string. |
timeout_ms | integer | 60000 | Bounds one run. Must be between 1 and 600000. The ceiling sits below the shortest default watch_window_ms, so an entry whose command hangs still ages out. |
Both fields are read once when the orchestrator starts, ci_failure included, so an edit to either takes effect on the next restart rather than on a dynamic reload.
reactions:
merge_conflicts:
provider: github
max_retries: 1
escalation: label
escalation_label: needs-human
triage:
script: |
./scripts/merge-conflict-triage.sh
timeout_ms: 120000Execution environment
The command runs with the per-issue workspace directory as its working directory, through the same machinery as a workspace hook: sh -c on POSIX and cmd.exe /C on Windows, the same restricted environment, and the same 8 KiB captured output tail. It receives the variables every hook receives and three of its own. See the hook subprocess environment for the allowlist and the triage command variables for the three.
Sortie terminates every process still in the command’s process group whenever the command ends: on a timeout, on a superseding fingerprint, when the episode ends, when Sortie shuts down, or when the command exits on its own with any status. A command that exits on its own and leaves such a process behind logs one INFO record, leftover processes terminated after the command exited, carrying reaction_kind. See hook process lifetime for the routes a process meant to outlive the command can use instead.
Sortie never creates the workspace directory for a triage run. A directory that is absent, or a path that is not a directory, ends the run before any subprocess starts, and the reaction dispatches exactly as it would with no block.
Input document
SORTIE_REACTION_INPUT names a JSON file describing the subject. Both that file and the result file live in a temporary directory created for the run and removed when it returns, outside the workspace, so a stale answer from an earlier run cannot be read as this one’s and externally authored text stays out of the tree the agent reads. Review comment bodies, check names, and branch names reach the command only inside this document, never through an environment variable or a shell word.
{
"schema_version": 1,
"reaction_kind": "merge-conflict",
"issue": { "id": "10432", "identifier": "MT-649", "display_id": "MT-649" },
"attempt": 3,
"workspace": "/var/sortie/workspaces/MT-649",
"fingerprint": "9f2c7d1a4b6e08c3f5a2d9b7e14c6083a5d2f9b1c7e340a86d5b2f9c1e7a4308",
"attempts_used": 0,
"max_attempts": 1,
"subject": {
"pr_number": 128,
"branch": "sortie/MT-649",
"head_sha": "5c1f0b7a9d3e46281af7c40b9e2d6538ca10b7f4",
"base": "main"
}
}| Key | Type | Value |
|---|---|---|
schema_version | integer | Version of this document’s shape. 1 today. |
reaction_kind | string | Runtime kind of the reaction that armed: ci, review, bot-review, or merge-conflict. Same value as SORTIE_REACTION_KIND. |
issue | object | id, identifier, and display_id for the tracker issue. |
attempt | integer | The issue’s run attempt number, the same value as SORTIE_ATTEMPT. |
workspace | string | Absolute path to the per-issue workspace, the same value as SORTIE_WORKSPACE. |
fingerprint | string | The value this kind stores for the current subject, as described under that kind above. |
attempts_used | integer | The kind’s attempt counter for this issue at the moment the run starts. |
max_attempts | integer | The kind’s budget field: max_continuation_turns for review_comments and bot_review, max_retries for ci_failure and merge_conflicts. |
subject | object or array | The same value the continuation prompt template receives for this kind, so the command sees what the agent would have seen. |
subject is the .ci_failure map for ci, an array of .review_comments maps for review, an array of .bot_review_comments maps for bot-review, and the .merge_conflict map for merge-conflict. Each is described under its kind above and in the continuation context variables.
Result document
The command writes one JSON object to the path in SORTIE_REACTION_RESULT. The file does not exist when the command starts. Unknown keys are ignored, so a later field cannot break a script written today.
{ "disposition": "handled" }| Disposition | Effect |
|---|---|
handled | The command resolved the subject. Sortie marks the reaction fingerprint dispatched and keeps watching, spending no attempt, scheduling no continuation, and writing nothing to the tracker. |
dispatch-agent | The reaction proceeds to the continuation turn it would have scheduled anyway. Every counter, fingerprint, and entry is left as it would have been with no triage block. |
escalate | Sortie applies the kind’s configured escalation immediately, with no attempt spent. A comment on the issue, from escalation: comment or from a tracker_comment subscription to the escalation event, carries copy naming the triage command as the reason rather than an exhausted budget. |
A handled answer marks the subject dispatched in the same durable row the reaction’s own deduplication uses, so the reaction takes no further action on it until the fingerprint moves. Within the episode, a pass that recomputes an already-answered fingerprint replays the stored answer instead of running the command again, and a replayed escalate posts no second escalation.
Sortie also records every answer it applies, once per run, against the reaction kind and the subject the command answered. Sortie never replays a recorded answer; it only reads the record back to show what the newest answer for a subject was. A bot_review subject whose newest answer is handled reads as handled on the issue page, on Pipeline, and in the issue’s status sentence, also after a restart, until the bot review’s comments change. The handled member of the pending reaction’s last_check serves the same fact.
Fallback to dispatch-agent
Exit status 0 together with a result file naming one of the three dispositions is the only path to an answer other than dispatch-agent. Every other outcome writes one warning record naming the reason and falls back to dispatch-agent:
- The workspace directory is absent, or the path is not a directory.
- The subprocess fails to start.
- The command exceeds
timeout_ms. - The command exits non-zero. A non-zero exit is never honored, even when the result file holds a valid answer.
- The result file is missing, larger than 64 KiB, unreadable, or not valid JSON.
dispositionholds a value other than the three above.
A broken script therefore costs one warning record and one agent turn. It cannot strand a reaction.
Timing and concurrency
The command runs off the reconcile pass. The pass that starts it re-enqueues its entry and makes no further provider call for that subject, and a later pass reads the answer, so a reaction carrying a triage block acts no sooner than one of that kind’s poll_interval_ms after the subject is first seen. For ci_failure, which has no poll_interval_ms, the wait is the pending backoff already in force.
Runs in flight are capped at agent.max_concurrent_agents, and never below 1, across every issue and kind. The cap adds to agent concurrency rather than sharing slots with it, so a host configured for four agents can run four agent processes and four triage commands at once. An entry that finds the cap reached starts nothing and is reconsidered on the next tick. Unlike the triage block itself, the cap follows a reloaded agent.max_concurrent_agents.
A run is killed, with its whole process group, when timeout_ms elapses, when a pass computes a different fingerprint for the subject, when the episode the run belongs to ends, and when the process shuts down. In each of those cases a fresh run follows for the next subject Sortie sees, so the same command can be invoked more than once for work it has already done.
A run in flight at restart is lost, and the subject is triaged again from scratch. The answers Sortie applied before the restart stay recorded, but a recorded answer is never replayed: a subject whose deduplication mark never landed is triaged again.
review_comments and bot_review test their continuation-turn cap before the command runs, so a subject arriving on a spent budget never invokes it: the reaction escalates when the subject carries a new comment (see retry budgets) and otherwise keeps polling. A set that holds no comment an earlier run was given never invokes it either, whatever the budget. ci_failure and merge_conflicts test their retry budget after, so the command runs first and the budget escalation follows only on a dispatch-agent answer.
Normalized mergeability states
merge_conflicts and auto_merge both gate on a normalized mergeability classification rather than on a forge field. Each SCM adapter maps its own platform’s mergeability signal onto these five values, and the reaction machinery reads only the normalized result.
| State | Meaning |
|---|---|
clean | Every required check passes and the pull request is ready to merge. |
unstable | The pull request is mergeable, but some non-required checks are failing. |
blocked | A protection rule prevents the merge, such as a missing review, a stale base, or a draft. |
dirty | The pull request has merge conflicts against its base. |
unknown | No usable classification is available yet. Every consumer defers and re-reads on the next poll. |
No adapter is obliged to produce every state, and two of them do not. Which platform signal yields which state is documented per forge: GitHub adapter reference, Gitea adapter reference, and GitLab adapter reference.
| Provider | States it can report |
|---|---|
github | clean, unstable, blocked, dirty, unknown |
gitea | clean, blocked, unknown |
gitlab | clean, blocked, dirty, unknown |
Two consequences follow, each restated under the kind it affects. merge_conflicts arms on dirty alone, so it never arms on gitea. auto_merge proceeds on clean or unstable, so its unstable arm is reachable on github alone and the mergeability precondition is effectively clean on the other two.
Reaction kinds
reactions.ci_failure
Polls CI status for Sortie-created branches and dispatches a continuation turn when CI fails.
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
max_log_lines | integer | 50 | Maximum number of log lines in the CI failure excerpt injected into the prompt. The [sortie] lines described under the excerpt format below are not counted, so the excerpt holds at most this value plus three lines. 0 disables log injection. |
watch_window_ms | integer | 86400000 | Milliseconds the watch keeps following a pull request since its last recorded head change (twenty-four hours by default). Must be non-negative and must not exceed 9223372036854 (about 292 years). 0 removes the bound. A standing escalation is never aged out. |
Activation: active when provider names a registered CI status provider and an SCM adapter is also configured. provider must match the provider named by every other active SCM reaction; a mismatch fails startup and is reported by sortie validate under the reactions.scm_provider_conflict check.
The agent or an after_run hook must write pr_number (positive integer), owner, repo, and branch (all non-empty) to .sortie/scm.json in the workspace; all four are required, and a workspace whose metadata names a branch but no pull request seeds no CI watch. The orchestrator resolves the pull request’s head live through the SCM adapter on every due pass rather than reading a ref once when the pending entry is recorded.
Behavior: the reconcile loop resolves the pull request’s current head live on every due tick, through the SCM adapter, and fetches CI status for that head; no ref is captured once and held for later polls. Two check conclusions are failing, failure and timed_out; a completed check that concludes cancelled withholds a passing verdict without asserting a failing one, holding the aggregate at pending rather than passing or failing.
A pending status re-enqueues under capped exponential backoff. A passing status clears the attempt counter and ends a standing escalation, and the reaction keeps watching, so a commit pushed to the pull request afterward is still observed. A failing status dispatches a continuation turn carrying the failing checks through the .ci_failure template variable and increments the attempt counter, while the counter is below max_retries. At max_retries the reaction escalates instead and the counter stays there, so the count the escalation reports is the number of fixes dispatched.
The reaction keeps watching after it escalates and records a standing escalation on the head. A recheck of a head that stays red dispatches nothing, repeats nothing, keeps the time the escalation began, and records no further ci_failed run, which would count against agent.max_sessions. A passing check or a new head ends the escalation. The action applies once per head: a later escalation of the same head records a new time and applies no second action.
The watch ends on merge, on close without merging, when the pull request is not found on the forge, when the watch window elapses (never while an escalation stands), or when the tracker issue enters a tracker.terminal_states state; a later normal worker exit for the same issue, or startup recovery rebuilding the entry from the workspace metadata, begins a fresh watch. See the .ci_failure template variable for its schema.
Example:
reactions:
ci_failure:
provider: github
max_retries: 2
max_log_lines: 50
watch_window_ms: 86400000 # optional; shown at its default (24h)
escalation: label
escalation_label: needs-humanExcerpt format: .ci_failure.log_excerpt holds the output of the failing step of the first failing check. When Sortie cannot find that step, it holds the end of the job log instead. An excerpt built from a job log opens with one [sortie] line that says which case applies. The step name in the first two forms is the step’s name on GitHub and the runner stage’s name on GitLab.
| Opening line | Case |
|---|---|
[sortie] Output of the failing step "<name>"; output from the rest of the job is left out. | The step was found. |
[sortie] Output of the failing step "<name>", mixed with output from steps that ran at the same time; output from the rest of the job is left out. | The step was found, and other steps ran at the same time, so their lines are interleaved. |
[sortie] The failing step could not be located, so these are the last lines of the job log. | The step was not found. |
[sortie] The failing step could not be located and the job log was not read to its end, so these are the last lines that were read. | The step was not found and the read stopped before the end of the log. |
A found step longer than max_log_lines shows its opening lines, which carry the command the step ran, followed by its last lines. The opening lines take at most one fifth of max_log_lines, rounded down.
Each span left out is replaced by [sortie] N lines omitted. ([sortie] 1 line omitted. for a single line), so the excerpt holds at most two such lines. A step without opening lines, such as one that ran at the same time as others, shows one omission line followed by its last lines.
Every line is cleaned before it is counted. Escape sequences and control characters are removed, trailing spaces and tabs are trimmed, and a line left empty is dropped. A line longer than 4096 bytes as the forge delivers it is cut and ends with [sortie: line cut]. Sortie reads at most 16 MiB of a log.
A Gitea excerpt is the status description and has no [sortie] line. How each provider finds the failing step is described in the GitHub, GitLab, and Gitea adapter references. See Configure CI feedback for an example of what the agent receives.
reactions.review_comments
Polls review comments left by human reviewers who have requested changes on Sortie-created PRs and dispatches a continuation turn so the agent can address the feedback. Each forge spells the changes-requested state differently, and each adapter selects against its own platform’s spelling. This kind reads review state only; it does not create PRs, approve reviews, or resolve comments.
Bot-authored comments are excluded when the forge marks their author as a bot account. The gitea provider carries no such marker, so nothing is excluded there and a bot’s changes-requested review reaches this kind alongside the human ones; see the Gitea adapter reference.
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
poll_interval_ms | integer | 120000 | Minimum interval between review API polls per issue. Minimum: 30000. |
debounce_ms | integer | 60000 | Wait after the newest detected comment before dispatching. Must be non-negative. |
max_continuation_turns | integer | 3 | Hard cap on review-triggered continuations per PR. Must be positive. |
watch_window_ms | integer | 1800000 | Milliseconds a pending entry is kept (thirty minutes by default), measured from the last time the entry waited for the issue’s retry slot, or from its creation when it never has. Must be non-negative and must not exceed 9223372036854 (about 292 years). 0 removes the bound. A standing escalation is never aged out, and neither is an entry waiting on a required approval. |
Activation: active when provider names a registered SCM adapter. Pull requests are watched for review comments only while this block is present; another reaction kind alone does not start review polling. The agent or an after_run hook must write pr_number (positive integer), owner, and repo to .sortie/scm.json in the workspace. When any field is missing or zero, review polling is skipped for that workspace with no error.
Behavior: comments newer than debounce_ms defer dispatch until the reviewer’s batch settles. The fingerprint is the SHA-256 of the sorted non-outdated comment IDs; an unchanged set is skipped, and a changed set starts a continuation only when it holds a comment no earlier run was given (see comments already given). Dispatch injects the comments through the .review_comments template variable (a list of maps with keys id, file, start_line, end_line, reviewer, body).
Escalation fires when the attempt counter has reached max_continuation_turns and a comment arrives that no earlier run was given; see retry budgets. The entry then stays pending under a standing escalation and ends it when the review is resolved, as described there. See the .review_comments template variable for its schema.
Example:
reactions:
review_comments:
provider: github
max_retries: 2
escalation: label
escalation_label: needs-human
poll_interval_ms: 120000
debounce_ms: 60000
max_continuation_turns: 3Waiting on a required approval
A review_comments entry is waiting on approval when a check finds no comment to answer and the forge reports that an approval the branch rules require is still missing. This is the state of a pull request that passed its checks and waits only for a person to approve it, as in a workflow that leaves the merge to people.
Each provider reports the missing approval from its own data:
| Provider | Reports a missing required approval when |
|---|---|
github | GitHub’s review decision for the pull request is REVIEW_REQUIRED. |
gitlab | The merge request’s approvals payload reports approvals left above zero. GitLab Community Edition omits that field, so a gitlab pull request there never waits on approval. |
gitea | The base branch protection requires more approvals than the pull request holds. Only official approvals count, each reviewer counts by their latest review, and a dismissed review does not count. A base branch whose protection cannot be read (HTTP 404) requires none. |
A reviewer who is requested or assigned but not required by a rule does not put the entry in this state, and neither does a standing request for changes: its comments are answered under the usual rules.
While the entry waits on approval:
watch_window_msdoes not drop it. The window still applies to a check that has not recorded the wait: an entry whose window elapsed before a check found the approval missing is dropped as usual.- Every check reads the pull request, its review comments, and the review decision at
poll_interval_ms, for as long as the approval takes. A longerpoll_interval_mslowers the forge API cost. - A check that cannot read the review decision keeps the previous reading.
The wait ends at the first check that finds any of these:
- The pull request merged, closed, or no longer exists. The entry is dropped and an info line is logged.
- The forge no longer reports the approval missing. The entry follows
watch_window_msagain, and is dropped at that check when the window has already elapsed. - A comment to answer. The entry handles it as it does any comment, except that a window that has already elapsed drops the entry instead, with the usual warning and no dispatch.
The tracker issue reaching a terminal state also ends the entry, as it does for every kind.
An operator sees the wait on the dashboard as the “waiting on approval” reading of the Review station (see status word and mark), and in the HTTP API as last_check.awaiting_approval on the review row of pending_reactions, with watch_ends_at null and a status_text of Waiting on approval of #<number>.
reactions.bot_review
Polls comments authored by automated review tools (linters, static analyzers, security scanners, and AI reviewers) on Sortie-created PRs and dispatches a continuation turn so the agent can address them. This is the complement of review_comments: that kind routes comments from human reviewers requesting changes and excludes bot-authored ones, while bot_review routes the bot-authored ones. The runtime and persisted kind value for this reaction is bot-review, not bot_review.
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
poll_interval_ms | integer | 60000 | Minimum interval between bot-comment polls per issue. Minimum: 30000. Tighter than review_comments (120000) because bot comments arrive in bulk right after a push rather than at reviewer pace. |
max_continuation_turns | integer | 5 | Hard cap on bot-review continuations per PR. Must be positive. Higher than review_comments (3) because bot fixes are mechanical. |
bot_usernames | list of strings | (empty) | Allowlist of bot logins, matched case-insensitively. Extends classification to review tools that comment under a regular user account. Empty by default, so only accounts the forge marks as bots match, and on gitea nothing matches. |
watch_window_ms | integer | 1800000 | Milliseconds a pending entry is kept (thirty minutes by default), measured from the last time the entry waited for the issue’s retry slot, or from its creation when it never has. Must be non-negative and must not exceed 9223372036854 (about 292 years). 0 removes the bound. A standing escalation is never aged out. |
Activation: active when provider names a registered SCM adapter, on its own, with no other reactions block required. The agent or an after_run hook must write pr_number (positive integer), owner, repo, and branch (all non-empty) to .sortie/scm.json in the workspace. When any field is missing or zero, bot-review polling is skipped for that workspace with no error.
Classification: bot authorship is deterministic author metadata, not comment content. A comment is selected when the forge marks its author as a bot account, or when its author login matches a bot_usernames entry (case-insensitive). No changes-requested review state is required, because review bots commonly post comment-only reviews.
The bot_usernames allowlist covers review tools that comment under a regular user account rather than a bot account. Sortie ships no built-in list of such logins: an account that the forge does not mark as a bot matches only when it is named here.
Warning
On the gitea provider, bot_usernames is the only classification signal there is. Gitea accounts carry no bot marker, so an empty allowlist selects nothing and this kind routes no comments at all. Name every bot account in bot_usernames to make it fire. sortie validate accepts the empty allowlist, because the shape is valid. See the Gitea adapter reference.
No debounce: bot comments dispatch on the tick they are detected. There is no debounce_ms field. This differs from review_comments, which waits out debounce_ms so a reviewer’s batch settles; bot comments arrive in bulk on a push, so there is nothing to wait for.
Fingerprint and dedup: the fingerprint is the SHA-256 of the sorted non-outdated comment IDs, stored in reaction_fingerprints under a kind distinct from review_comments. A push that changes the bot comment-ID set produces a new fingerprint, and a continuation starts when the set holds a comment no earlier run was given (see comments already given); an unchanged set that has already dispatched is skipped within the poll interval.
Continuation context: dispatch injects the comments through the .bot_review_comments template variable, a list of maps with keys id, file, start_line, end_line, reviewer, and body, where reviewer is the login of the bot that authored the comment. This is the same shape as .review_comments.
Cross-kind isolation: bot_review and review_comments never interfere on the same PR. Each owns its own pending entry, fingerprint row, and attempt counter.
Escalation: fires when the attempt counter has reached max_continuation_turns and a bot adds a comment on the code that no earlier run was given; see retry budgets. The action is label (default), none, or the deprecated comment, with escalation_label defaulting to needs-human.
The escalation is scoped to the bot-review kind and does not release the issue claim. The entry stays pending under a standing escalation and keeps its attempt counter. It dispatches nothing and applies no second action while the escalation stands, and it ends when the pull request ends, when no comment on the code is left that no run was given, or on the other facts under how a standing escalation ends. A new bot comment on the code that arrives while it stands joins it. If the escalation ends and a different set of comments escalates later, that escalation applies its action again, so a comment on the issue, whether from escalation: comment or from a tracker_comment subscription to escalation.bot_review, can accumulate on a long-lived PR; prefer label.
Bot-review checks and escalations are recorded by the sortie_bot_review_checks_total{result} and sortie_bot_review_escalations_total{action} counters when the HTTP server is enabled; see the Prometheus metrics reference.
Example:
reactions:
bot_review:
provider: github
escalation: label
escalation_label: needs-human
poll_interval_ms: 60000
max_continuation_turns: 5
bot_usernames: # only for review tools that comment under a user account, not a bot account
- example-review-botreactions.merge_conflicts
Polls the mergeability of open Sortie-managed PRs on every reconcile cycle. While a PR remains conflicted, the orchestrator dispatches one rebase-and-resolve continuation turn per distinct conflicting head commit, subject to the retry budget; re-observing the same head dispatches nothing further, and a return to no-conflict is not required between attempts.
The turn runs on the existing workspace and carries the PR’s real base branch, read live from the PR object on the tick, so the agent rebases the head branch onto the PR’s current target rather than an assumed default branch.
The WORKFLOW.md key is merge_conflicts (plural); the runtime and persisted kind value is merge-conflict (singular, hyphenated); and the continuation context variable is .merge_conflict (singular).
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
poll_interval_ms | integer | 60000 | Minimum interval between mergeability checks per issue. Minimum: 30000. |
watch_window_ms | integer | 1800000 | Milliseconds a pending entry is kept (thirty minutes by default), measured from the last time the entry waited for the issue’s retry slot, or from its creation when it never has. Must be non-negative and must not exceed 9223372036854 (about 292 years). 0 removes the bound. A standing escalation is never aged out. |
Two common fields take kind-specific defaults here. max_retries defaults to 1, not the common 2, because a conflict that survives one rebase is unlikely to clear on a retry. max_retries: 0 does not disable the kind; it escalates on the first detected conflict with no rebase attempt. To disable merge-conflict handling, omit the merge_conflicts block.
Activation: active when provider names a registered SCM adapter, on its own, with no other reactions block required. The agent or an after_run hook must write pr_number (positive integer), owner, repo, and branch (all non-empty) to .sortie/scm.json in the workspace. When any field is missing or zero, merge-conflict polling is skipped for that workspace with no error.
Episodic retry: the attempt counter is per episode. A resolved conflict (the PR returns to a non-conflicted state) resets the counter, so a later independent conflict opens a fresh episode and starts from zero rather than counting against the earlier budget. The default max_retries of 1 is the lowest of any kind for that reason.
Detection: conflict detection reads the normalized mergeability state. Only dirty is a conflict and arms a rebase turn. clean, unstable, and blocked each close the episode and reset the counter, and unknown defers to the next tick, logging merge conflict deferred: mergeability unknown, while the provider finishes computing mergeability.
Warning
This kind never arms on the gitea provider. Gitea reports mergeability as a single boolean with no conflict value, so its adapter classifies a conflicted pull request as unknown, never dirty. The entry defers on every tick until watch_window_ms (thirty minutes by default) drops it with a warning and no escalation, so the operator gets no rebase turn and no tracker-visible signal. sortie validate accepts provider: gitea here, because the shape is valid. Resolve conflicts on Gitea manually, and see the Gitea adapter reference.
Fingerprint and dedup: the fingerprint is the SHA-256 of the PR head SHA, stored in reaction_fingerprints under a kind distinct from the other reactions. One conflicted head dispatches exactly one rebase turn. After the agent rebases and pushes a new head, the new head yields a new fingerprint and re-arms a fresh attempt bounded by max_retries; when the conflict clears, the row is deleted, so the next conflict observation dispatches again.
Continuation context: dispatch injects the .merge_conflict template variable, a map with keys pr_number, branch (the PR head branch the agent rebases), head_sha (the latest commit SHA on the head), and base (the PR’s real target branch, read live, the rebase target).
Coexistence with auto-merge: merge_conflicts and auto_merge run independently on the same PR. Auto-merge defers while the PR is conflicted, merge-conflict drives the resolution on a provider that reports dirty, and once the PR is clean and approved auto-merge proceeds.
Cross-kind isolation: merge-conflict detection runs independently of every other reaction kind. Each owns its own pending entry, fingerprint row, and attempt counter.
Escalation: fires when the episode’s attempt count exceeds max_retries. The action is label (default), none, or the deprecated comment, with escalation_label defaulting to needs-human. The escalation is scoped to the merge-conflict kind: it removes the fingerprint, keeps the pending entry under a standing escalation with the counter at the rebases dispatched, and does not release the issue claim. Other reaction kinds on the same issue are preserved. The entry dispatches nothing while the escalation stands, and watch_window_ms never drops it. A check that finds no conflict ends it, as does a conflict on a different head, which then proceeds as a new head.
Merge-conflict checks and escalations are recorded by the sortie_merge_conflict_checks_total{result} and sortie_merge_conflict_escalations_total{action} counters when the HTTP server is enabled; see the Prometheus metrics reference.
Example:
reactions:
merge_conflicts:
provider: github
max_retries: 1
escalation: label
escalation_label: needs-human
poll_interval_ms: 60000reactions.auto_merge
Polls merge preconditions on Sortie-created PRs and merges directly through the SCM adapter once they hold. Auto-merge is off by default and activates only when provider is set. There is no separate enabled flag; the presence of provider is the activation key, matching the other reaction kinds. The runtime and persisted kind value for this reaction is merge, not auto_merge.
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
strategy | string | squash | Merge strategy. One of merge, squash, or rebase. |
require_ci | boolean | true | When true, every CI check must pass before the merge. When false, CI is advisory only. |
delete_branch | boolean | true | When true, the PR head branch is deleted after a successful merge. A delete failure does not roll back the merge. |
poll_interval_ms | integer | 60000 | Minimum interval between precondition checks per issue. Minimum: 30000. |
watch_window_ms | integer | 1800000 | Milliseconds a pending entry is kept, measured from the entry’s creation (thirty minutes by default). Must be non-negative and must not exceed 9223372036854 (about 292 years). 0 removes the bound. A standing escalation is never aged out. |
Activation: active when provider names a registered SCM adapter. The agent or an after_run hook must write pr_number, owner, repo, and branch (all non-empty) to .sortie/scm.json.
provider must match the provider named by every other active SCM reaction; a mismatch or an unknown provider fails startup, and sortie validate reports the mismatch offline under the reactions.scm_provider_conflict check. At startup the orchestrator runs a one-shot token-scope preflight through the SCM adapter, asking whether the credential can reach the merge endpoint and, when delete_branch is true, the branch-delete endpoint.
The scope names and how much an adapter can verify differ by forge; see the GitHub adapter reference, the Gitea adapter reference, and the GitLab adapter reference.
An auth-class scope failure disables auto-merge for the process lifetime; a transport-class failure schedules one retry on the next tick before disabling. An adapter that cannot read the credential’s scopes fails open, so auto-merge proceeds and a real gap surfaces instead as an auth failure on the first merge attempt.
Merge preconditions: the orchestrator merges only when all of the following hold. While any is unmet, the entry re-enqueues at the poll interval.
| Precondition | Requirement for merge |
|---|---|
| Ownership | The PR is Sortie-created, identified by .sortie/scm.json. |
| Draft state | The PR is not a draft. |
| Mergeability | The normalized mergeability state is clean or unstable. |
| Review | The review decision is APPROVED, or reviews are not required (NOT_REQUIRED). |
| CI | The CI conclusion is success when require_ci is true; ignored when false. |
The unstable arm of the mergeability precondition is reachable on github alone. Neither gitea nor gitlab ever reports unstable, so on those two providers this precondition is effectively clean. The arm also does not decide the merge on its own: the CI precondition is evaluated separately from mergeability, so require_ci: true can still hold a merge that unstable let past. An unknown state defers the entry rather than failing it, and on gitea that is where a merge conflict lands as well.
Behavior: a read that finds the pull request already merged, whoever merged it, ends the watch at once: the entry is dropped without waiting for watch_window_ms, and the attempt counter and fingerprint are cleared. Sortie records the merge as the issue’s outcome. The drop posts no tracker comment and deletes no branch.
For a pull request not yet merged, the merge fingerprint is the SHA-256 of the PR head SHA combined with the review decision, so a new push or a change in review decision allows a fresh attempt. The merge call is made with the expected head SHA to close the time-of-check to time-of-use window between the precondition read and the merge.
A rejection from the merge endpoint sends the adapter back to re-read the pull request, and only a re-read confirming the pull request merged is dispatched as success. No adapter matches the provider’s rejection wording, so a reworded response does not change the outcome.
Escalation fires when the attempt counter reaches max_retries, but only when max_retries is greater than zero: a max_retries of 0 disables the count-based check instead of making it immediate. The entry then stays pending under a standing escalation: it makes no further merge call and applies no second action, and it ends when the pull request merges, closes, or no longer exists, or on the other facts under how a standing escalation ends. Without an escalation, the pending reaction expires after watch_window_ms (thirty minutes by default, measured from the entry’s creation, configurable up to 9223372036854 milliseconds or removed with 0); when it does, the orchestrator drops the entry and logs a warning rather than escalating, so the reaction goes silent with no tracker-visible signal. The window never drops an escalated entry, and an escalated entry keeps reading the pull request after the auto-merge preflight has failed.
An authentication-class or payload-class merge error still escalates immediately regardless of the budget, with the trigger refused.
Safety: auto-merge acts on Sortie-created PRs only and never merges a draft. The merge is performed directly rather than through an agent turn.
Warning
A merge is irreversible. Sortie does not roll back on tail-step failures such as branch deletion or the confirmation comment. Auto-merge stays off unless reactions.auto_merge.provider is set, and enabling it is a conscious opt-in.
Example (conservative opt-in):
reactions:
review_comments:
provider: github # SCM provider; must match auto_merge below
auto_merge:
provider: github # activates auto-merge; no separate "enabled" flag
strategy: squash # squash | merge | rebase
require_ci: true # never merge on failing or pending CI
delete_branch: true # remove the head branch after a successful merge
max_retries: 2 # merge attempts before escalation
escalation: none # no label; a tracker_comment entry listing escalation.auto_merge posts the comment
poll_interval_ms: 60000 # 60s between precondition checksreactions.merge_completion
Observes the merge state of Sortie-managed pull requests and transitions the linked tracker issue to a single configured terminal state, exactly once per merge. It is the only reaction kind whose action is a tracker write: it never calls an SCM write method, never merges or pushes anything, and never dispatches a continuation turn.
It is off by default and activates on provider alone, so a deployment that omits the block behaves exactly as it did before. The runtime and persisted kind value for this reaction is merge-completion, distinct from merge (auto-merge) and merge-conflict.
Fields (beyond the common fields):
| Field | Type | Default | Description |
|---|---|---|---|
target_state | string | (required) | The single terminal state the linked issue moves to once its pull request merges. It has no default and is never inferred from tracker.terminal_states. |
poll_interval_ms | integer | 60000 | Minimum interval between merge-observation polls per issue. Minimum: 30000. A lower value is rejected, not clamped. |
One common field behaves differently here. max_retries keeps its default of 2 and bounds retryable transition failures, but max_retries: 0 escalates on the first failed transition rather than turning the count-based check off, which is what the same value does for auto_merge. escalation and escalation_label carry their usual defaults.
Tracker prerequisites: two tracker fields must be set whenever this block is active, each reported as its own configuration error when it is missing. tracker.handoff_state must be non-empty: it is the state a merge waits in, and the reaction stops re-enqueueing an entry once the issue leaves it. tracker.terminal_states must be written out in front matter, because the reconcile pass reads the list exactly as configured and no tracker supplies a default.
Activation: active when provider names a registered SCM adapter, on its own, with no other reactions block required. provider must match the provider named by every other active SCM reaction; a mismatch is reported by sortie validate under the reactions.scm_provider_conflict check. The agent or an after_run hook must write pr_number (positive integer), owner, and repo to .sortie/scm.json in the workspace. Unlike the checkout-bearing kinds, no branch is required, because this reaction performs no checkout and reads no branch.
Any merge, by anyone: the pass reads the pull request live from the forge on every due tick and acts on the forge’s own merged flag. It consults no record of a merge Sortie performed, and it does not require reactions.auto_merge to be configured. A merge performed by a person in the forge UI, by a forge automation rule, or by Sortie’s own auto-merge all reach the same transition.
The pending entry carries only the pull request number, the owner, and the repository; the merge commit is observed live rather than taken from a value stored when the entry was created. On GitHub that identifier is read through the GraphQL API, so the configured token must be able to reach it; see the GitHub adapter reference.
Target-state rule: the issue moves to the state named by target_state, applied verbatim. The target is never derived from tracker.terminal_states, because a terminal list routinely mixes a completion state with one or more abandonment states, and neither the ordering nor the vocabulary carries a guaranteed meaning. Three rules constrain the value, all compared case-insensitively and evaluated in this order:
- It must not equal
tracker.handoff_state. - It must not be a member of
tracker.active_statesas written. - It must be a member of
tracker.terminal_statesas written.
The order decides what you are told: a value that is both the handoff state and non-terminal is reported against the first rule, not the third. All three are configuration-shape checks that need no network access, so sortie validate reports them offline under the check name reactions.merge_completion, at error severity, which fails validation and exits non-zero.
No validator catches the mistake that matters most. Naming an abandonment state where a completion state was meant is valid configuration and closes finished work under the wrong label. That is a judgement about the issue rather than a configuration shape, and the orchestrator does not reverse the transition.
Idempotency latch: the fingerprint row for this kind, keyed by the issue and the kind merge-completion in the reaction_fingerprints table, holds the merge commit identifier reported by the provider, not the pull request number. A pull request reported as merged with no commit identifier never latches this row, and no sentinel, pull request number, or branch is written in its place; that condition is bounded on its own clock, described next.
Before transitioning, the pass writes the observed commit into the row; when the stored value already equals the observed one and is marked dispatched, the transition is skipped, which dedups the same merge across repeated poll ticks and across a process restart between them. A different commit identifier, meaning the issue produced a second managed merge, re-arms the latch for exactly one further transition.
On a successful transition the row is marked dispatched and retained, never deleted, because deleting it would let the next tick observe the same merge as new. That is the opposite of what the sibling kinds do with their fingerprint rows.
Merge reported with no commit identifier: a forge that reports the pull request merged while supplying no merge commit identifier does not latch the row above, and the entry that observes it is not polled indefinitely. The first tick that sees this condition on a given pull request starts a fixed 30-minute grace period, which is not configurable and which max_retries does not bound.
The condition is recorded as a second row in reaction_fingerprints, under the kind merge-completion-missing-sha, holding the normalized owner/repo#number identity and the time that identity was first seen in this state. Re-seeing the same pull request preserves both values; a different pull request replaces them and starts a fresh observation.
During the grace period the entry re-enqueues under exponential backoff floored at poll_interval_ms, and each tick logs a warning naming the repository, the pull request, how long it has waited, and the grace period it is waiting against. The row is persisted, so a restart does not restart the clock.
Expiry is evaluated on the first tick at or after the deadline. If a real identifier arrives before then, the normal latch and transition run unchanged, and the observation row is cleared once that transition is latched. If the identifier is still absent, Sortie begins a missing_merge_commit standing escalation: the entry stays pending and keeps polling at poll_interval_ms, the configured escalation is applied, and the escalation is logged at error level. No transition is attempted and no merge fingerprint is written while the commit is missing, so the issue stays in the handoff state until the forge reports the commit, which ends the escalation and runs the normal transition, or until a person moves the issue.
A comment on the issue, from the deprecated comment posture or from a tracker_comment subscription to escalation.merge_completion, names the repository, the pull request, the elapsed wait, the reason, and the configured target state; the label posture adds escalation_label instead, and the escalation log carries the same identifying and manual-action context under every posture.
Delivery is recorded only after both the tracker write and the follow-up write that marks it delivered succeed, and the two share one 30-second deadline.
The tracker write is the label under label, and the comment on the issue under comment or under none with a tracker_comment subscription to the event. Under none with no such subscription there is no tracker write to confirm, so delivery is recorded at once. A Slack or webhook send never confirms delivery and never blocks it, so it never suppresses the retry of a failed tracker write.
A failure in either leaves the observation recorded as undelivered, and the escalation keeps standing. The first pass after a restart, or a fresh pending entry that a later worker exit or startup recovery seeds, retries delivery once without restarting the grace period or the polling loop. When only the marker write failed, the notification already reached the tracker, so that retry delivers a second time. label repeats harmlessly, because re-applying a present label is a no-op; a comment posts a duplicate.
Once delivery is marked, a later pass keeps watching without repeating the signal. The observation row is also cleared whenever the issue is missing from the tracker’s state response, is already terminal, or has left the handoff state, and when the pull request is gone from the forge.
No expiry: the pending entry carries no time-to-live. review_comments, bot_review, merge_conflicts, and auto_merge each bound their entry with watch_window_ms, defaulting to thirty minutes, and ci_failure bounds its own with the same field, defaulting instead to twenty-four hours, because each waits on a signal that either arrives shortly after the agent finishes or does not arrive at all. The one exception is a review_comments entry waiting on a required approval, which the window does not drop.
A merge waits on human review for an unbounded time, so this kind takes the same posture as the label commands and carries no expiry.
The entry is bounded another way: it stops being re-enqueued once the issue leaves the configured handoff state, and it is dropped outright when the issue is already terminal, when the issue is missing from the tracker’s state response, or when the pull request is gone from the forge. For an entry with a standing escalation, a missing issue or one outside the handoff state ends the entry only after a direct read of the issue confirms it. One post-merge condition carries a clock of its own: a pull request reported merged with no commit identifier escalates after 30 minutes and keeps watching, as described above.
Failure matrix: a failed transition is routed to one of four dispositions.
| Transition outcome | Posture |
|---|---|
| Transport failure, API failure, or an unclassified error | Retry with backoff, bounded by max_retries, then escalate. An unclassified error routes here, the non-destructive default. |
| Authentication failure | Escalate immediately, no retry. |
| Payload failure, such as a target state the tracker will not accept | Escalate immediately, no retry. |
| Issue not found | Stop. Mark the latch dispatched, drop the entry, and log a warning, with no escalation, because no issue is left for a later attempt to reach. |
The retry bound is a strict over-limit comparison against a per-issue counter scoped to this kind. The counter is incremented after every transition call regardless of outcome, so the attempt count an operator reads in a comment escalation is truthful even on the two paths that escalate immediately.
Escalation: the postures are the ones the sibling kinds already use. label (default) adds escalation_label (default needs-human) to the tracker issue. none applies no label.
The comment, from the deprecated comment or from a tracker_comment subscription to escalation.merge_completion, is plain text naming the number of attempts, the target state, the pull request number, and the repository. The same postures serve the missing-identifier escalation above, where the comment names the elapsed wait and the manual follow-up rather than an attempt count.
Both run asynchronously with a 30-second timeout, so a slow tracker does not block the reconcile tick, and an escalation that itself fails is logged without changing the entry. On an escalation the pending entry and the attempt counter stay while it stands, and the entry makes no further transition call. The fingerprint row is deliberately left undispatched.
That residue lets the one transition call that a restart resumes go through: a success ends the escalation, and a failure leaves it standing without a second action.
Restart to apply: this block, target_state included, is captured once at startup and is not rebuilt on a dynamic reload. A change to any field here, or to either tracker prerequisite, takes effect only on the next restart. sortie validate runs the same startup path, so its offline verdict cannot diverge from what a restart would produce.
If tracker.terminal_states is edited while the process runs so that the captured target_state is no longer a member of it, the reaction logs one warning naming both values, suppresses repeats while the condition persists, and keeps transitioning issues to the frozen target; the terminal workspace sweep meanwhile stops collecting the workspaces of the issues this reaction closes, until the two agree again. A restart rejects that same configuration offline before the process starts.
Request cost: each parked issue costs one tracker issue-state read and one pull-request read per poll interval, plus one tracker write per observed merge. Tracker state is fetched for all due entries in one batched call per tick, not one call per issue.
On a forge tracker the tracker and the SCM adapter share one credential against one host, so the steady-state cost approaches two requests per parked issue per poll interval. A deployment with many simultaneously parked issues should raise poll_interval_ms above the default rather than accept it.
Warning
The transition is irreversible by the orchestrator, and enabling this block grants the tracker credential write authority it did not need before: on the forges, moving an issue to a terminal state closes the native issue. Nothing checks that authority in advance. There is no startup scope preflight and no validator check for it, so an insufficient scope surfaces only at runtime, as an authentication failure on the first transition attempt, which escalates immediately.
Example:
reactions:
merge_completion:
provider: github # activates the kind; must match other active SCM reactions
target_state: done # required; a member of tracker.terminal_states
poll_interval_ms: 60000 # 60s between merge-state polls; minimum 30000
max_retries: 2 # retryable transition attempts before escalation
escalation: label # "label", "none", or deprecated "comment"
escalation_label: needs-humanScope boundary: a pull request closed without merging leaves the issue in the handoff state, and an issue with no managed pull request leaves the issue in the handoff state. This reaction closes neither, and promises nothing about either.
Validation rules
Rules marked startup only are not reachable by sortie validate. They are enforced when the orchestrator builds the reaction at startup, so a workflow that breaks one passes validation cleanly and the process exits 1 on the first run.
- Reaction kind keys must match
[a-z][a-z0-9_-]*. Invalid keys are rejected with a configuration error. max_retriesmust be non-negative for all kinds.watch_window_msmust be non-negative and must not exceed9223372036854(about 292 years) forci_failure,review_comments,bot_review,merge_conflicts, andauto_merge.escalationmust belabel,none, orcommentfor all kinds.commentis deprecated.poll_interval_msmust be at least30000forreview_comments. Startup only.poll_interval_msmust be at least30000forauto_merge.debounce_msmust be non-negative, andmax_continuation_turnsmust be positive, forreview_comments. Startup only.poll_interval_msmust be at least30000forbot_review.max_continuation_turnsmust be positive forbot_review.bot_usernamesmust be a list of strings forbot_review.poll_interval_msmust be at least30000formerge_conflicts. Startup only.strategyforauto_mergemust bemerge,squash, orrebase.triageis accepted only underci_failure,review_comments,bot_review, andmerge_conflicts. Under any other key ofreactions, includingauto_merge,merge_completion, andlabel_commands, it is rejected.triagemust be a map,triage.scriptmust be a string that is not blank after trimming, andtriage.timeout_msmust be an integer between1and600000.require_cianddelete_branchforauto_mergemust be boolean.- Every active SCM reaction must declare the same
provider. The set spansci_failure,review_comments,bot_review,merge_conflicts,auto_merge,merge_completion, and thelabel_commandsblock, and any two of them naming different providers is reported under thereactions.scm_provider_conflictcheck. poll_interval_msmust be at least30000formerge_completion.target_stateis required formerge_completion. Compared case-insensitively, it must not equaltracker.handoff_state, must not be a member oftracker.active_states, and must be a member oftracker.terminal_states, both as written.tracker.handoff_statemust be non-empty andtracker.terminal_statesmust be written out in front matter wheneverreactions.merge_completion.provideris set. Each missing field is its own configuration error.
sortie validate reports every unmarked rule above offline, before dispatch, at error severity, which fails validation and exits non-zero. A startup-only rule surfaces instead as an invalid review reaction config or invalid merge_conflicts reaction config log line naming the offending field, and the process exits before its first poll. See the CLI reference for the validate subcommand.
Was this page helpful?