Skip to content

Commit cc157f9

Browse files
authored
Merge pull request #943 from sortie-ai/feat/936-budget-stop-visibility
feat(orchestrator): surface per-issue budget holds to operators
2 parents c7a2e8c + 1c6448e commit cc157f9

29 files changed

Lines changed: 2431 additions & 335 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
- `sortie validate` now reports a warning when an agent block sets `mcp_config` for an agent kind that never receives the generated MCP configuration file. `claude-code`, `codex`, `copilot-cli` and `opencode` receive it; `kiro` does not, so an `mcp_config` value in a `kiro` block had no effect and nothing said so. The reference documentation now states which kinds consume the file. It is a warning and not an error: such a configuration stays valid, the run proceeds, and the exit code is unchanged.
1616
([#928](https://github.com/sortie-ai/sortie/issues/928))
1717

18+
- An issue that Sortie has stopped dispatching because it reached its `agent.max_sessions` or `agent.max_tokens` ceiling now says so. Previously such an issue stayed in an active tracker state and was simply never picked up again, with no log line at any level, no marker on the tracker, and `GET /api/v1/{identifier}` reporting it as unknown; the only way to explain the stall was to count the issue's rows in the run history against the ceiling configured in `WORKFLOW.md`. The orchestrator now logs a warning naming the issue, which of the two ceilings stopped it, and its usage against that ceiling, once when the hold begins rather than on every poll, and the tick summary reports how many issues are held. `GET /api/v1/state` lists them with the same numbers, the per-issue endpoint answers for a held issue instead of reporting it unknown, and the dashboard shows a card and a table. Two metrics, `sortie_budget_exhaustions_total` and `sortie_budget_exhausted_issues`, report how often a hold begins and how many issues are held right now. What the ceilings count, and when they stop an issue, is unchanged.
19+
([#936](https://github.com/sortie-ai/sortie/issues/936))
20+
1821
### Fixed
1922

2023
- A malformed end-of-turn notification from the `codex app-server` no longer leaves the turn outcome reported as the bare word `turn` followed by a trailing space. A payload that fails to parse carries no status word, so the turn now reports the shared failure message instead: the status API's `last_message` field and the recorded run history both read `turn failed`.

docs/architecture/05-workflow-specification.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,20 @@ Fields:
221221
- Default: `0` (unlimited; no effort budget enforced).
222222
- Maximum number of completed worker sessions for a single issue before the orchestrator
223223
stops re-dispatching it. Counted from `run_history` entries.
224-
- When the count reaches `max_sessions`, the claim is released and a warning is logged.
224+
- When the count reaches `max_sessions`, a warning is logged. The retry handler releases the
225+
claim it holds; the poll tick's own rebuild of the exhausted-issue set writes the candidate
226+
into that set instead, because a held candidate never held a claim to release.
225227
- `0` disables the budget (unlimited retries).
226228
- Changes are re-applied at runtime and affect future retry timer evaluations.
227229
- `max_tokens` (integer)
228230
- Default: `0` (unlimited; no token budget enforced).
229231
- Cumulative per-issue token ceiling. The orchestrator sums `total_tokens` across the
230232
issue's `run_history` entries and stops re-dispatching once the sum reaches `max_tokens`.
231-
- When the sum reaches `max_tokens`, the claim is released and a warning is logged, exactly
232-
as `max_sessions` does. A failed token query fails open: dispatch proceeds.
233+
- When the sum reaches `max_tokens`, a warning is logged, on the same two lanes and for the
234+
same reason `max_sessions` states above. A failed token query fails open, but not the same
235+
way on both lanes: the retry handler's check is skipped and dispatch proceeds for that
236+
issue, while the rebuild folds the prior set's entries for the failing axis forward
237+
unchanged and keeps the other axis fresh.
233238
- A run whose coding agent reported no token usage is recorded unmeasured and contributes
234239
nothing to the sum. A sum below `max_tokens` that includes at least one unmeasured run
235240
allows the dispatch and logs a warning naming the issue, the sum, the ceiling, and the

docs/architecture/06-configuration-specification.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,10 +118,11 @@ Validation checks:
118118
Effort-budget and notification config are validated outside this preflight, by design:
119119

120120
- The per-issue token ceiling (`agent.max_tokens`) is not a scheduler preflight check. It is a
121-
re-dispatch gate evaluated on the retry path alongside `agent.max_sessions` (Section 8.4), so
122-
the ceiling stops a blocked issue from being re-dispatched rather than failing startup or a
123-
poll tick. Config-level validation rejects a negative `agent.max_tokens` when the config is
124-
parsed, which both startup validation and the live-reload fail-safe path consume.
121+
re-dispatch gate evaluated on both the retry path and the poll tick's own rebuild, alongside
122+
`agent.max_sessions` (Section 8.4), so the ceiling stops a blocked issue from being
123+
re-dispatched rather than failing startup or a poll tick. Config-level validation rejects a
124+
negative `agent.max_tokens` when the config is parsed, which both startup validation and the
125+
live-reload fail-safe path consume.
125126
- The `notifications` backend list (Section 5.3.11) is structurally validated when the config is
126127
parsed: the value must be a sequence, every entry must carry a non-empty string `kind`, and
127128
`max_per_session`, when present, must be a non-negative integer. `max_per_session` is optional:

docs/architecture/08-polling-scheduling-and-reconciliation.md

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -130,37 +130,55 @@ Retry handling behavior:
130130

131131
Per-issue effort budget (defense-in-depth):
132132

133-
- When `agent.max_sessions > 0`, the retry handler counts completed sessions for the issue
134-
from `run_history` before fetching candidates.
135-
- If the count reaches `max_sessions`, the claim is released and a warning is logged instead
136-
of re-dispatching.
137-
- If the count query fails, the budget check is skipped (fail-open) and dispatch proceeds
138-
normally.
133+
- When `agent.max_sessions > 0`, two lanes evaluate it: the retry handler counts completed
134+
sessions for the issue from `run_history` before fetching candidates, once per retry; the
135+
poll tick's rebuild runs the same count as one batch query over the whole candidate set,
136+
once per tick, before dispatch (see the rebuild bullet below).
137+
- If the count reaches `max_sessions`, a warning is logged on both lanes. The retry handler
138+
releases the claim it holds and does not re-dispatch; the rebuild writes the candidate into
139+
the exhausted-issue set instead, because a candidate the rebuild holds never held a claim to
140+
release.
141+
- If the retry handler's count query fails, its check is skipped (fail-open) and dispatch
142+
proceeds normally for that issue. The rebuild's own fail-open, which differs because it
143+
rebuilds a whole set rather than deciding one dispatch, is described in the rebuild bullet
144+
below.
139145
- `max_sessions = 0` (default) disables the budget entirely.
140146

141147
Per-issue token budget (cost ceiling):
142148

143-
- When `agent.max_tokens > 0`, the retry handler sums `total_tokens` across the issue's
144-
`run_history` entries on the same pre-dispatch path, after the session check.
145-
- If the sum reaches `max_tokens`, the claim is released and a warning is logged instead of
146-
re-dispatching, identical in mechanism to the session ceiling.
149+
- When `agent.max_tokens > 0`, two lanes evaluate it: the retry handler sums `total_tokens`
150+
across the issue's `run_history` entries on the same pre-dispatch path, after the session
151+
check, once per retry; the poll tick's rebuild runs the same sum as one batch query over the
152+
whole candidate set, once per tick, after the session-count query (see the rebuild bullet
153+
below).
154+
- If the sum reaches `max_tokens`, a warning is logged on both lanes. The retry handler
155+
releases the claim it holds and does not re-dispatch; the rebuild writes the candidate into
156+
the exhausted-issue set instead, for the reason the effort-budget bullet above states.
147157
- The session and token ceilings are independent hard ceilings. A re-dispatch is blocked when
148158
either is reached, so whichever fills first across polling cycles is the one that fires. When
149159
a single evaluation finds both exhausted, the reported and logged reason names the token
150160
budget; the block itself is identical regardless of which ceiling triggered it.
151161
- The per-tick rebuild of the exhausted-issue set accounts for these two ceilings only: it runs
152162
the session-count batch query when `max_sessions > 0` and the token-sum batch query when
153-
`max_tokens > 0`, and unions the results. Each blocked issue carries a machine-readable reason
154-
(`token_budget` or `session_budget`, token taking precedence over session) surfaced in the
155-
runtime snapshot beside the exhausted set. The rebuild is skipped entirely when both budgets
156-
are disabled.
163+
`max_tokens > 0`, and unions the results, token taking precedence over session when one issue
164+
reaches both. On the tick an issue enters the set, the rebuild emits one warning record naming
165+
the issue, the reason, and the used and budgeted numbers, and increments a counter for that
166+
reason; a tick that re-observes an issue already in the set under the same reason emits
167+
neither, so the record and the counter fire once per hold rather than once per tick. Each
168+
blocked issue's reason and numbers travel with its own entry, keyed by issue, rather than in a
169+
map alongside the set. On a query error for one axis, the rebuild folds the prior set's
170+
entries for that axis forward unchanged and keeps the other axis fresh, which is a different
171+
fail-open mechanism from the retry lane's own skip-and-proceed; dispatch still proceeds for
172+
every candidate not in the set. The rebuild is skipped entirely when both budgets are
173+
disabled.
157174
- The consecutive handoff-absence ceiling (§14.2) is evaluated separately, by the same
158175
mechanism that parks a `blocked` soft stop, after the release-evaluation step below so a
159176
park released this tick is not immediately re-parked. It shares the `parked` reason
160177
vocabulary with the `blocked` park rather than the exhausted-issue set's own reasons, and is
161178
skipped entirely under `tracker.handoff_evidence: off`, which records no absence.
162-
- If the token query fails, the check fails open and dispatch proceeds, matching the session
163-
check. A token sum recorded before the token columns were added reads as zero.
179+
- If the retry handler's token query fails, its check fails open and dispatch proceeds for that
180+
issue, the same fail-open shape the session check above uses. A token sum recorded before the
181+
token columns were added reads as zero.
164182
- A sum below the ceiling that includes at least one unmeasured run allows the dispatch and
165183
logs a warning naming the issue, the sum, the ceiling, and the unmeasured count, matching the
166184
visibility of the query-failure fail-open case above. The retry path warns on every occurrence

docs/architecture/18-logging-status-and-observability.md

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ message `"park label write failed"`, at `Warn`, and does not suppress the parkin
4242
a park emits one `Info` record, message `"issue unparked"`, carrying `trigger`
4343
(`state_changed`, `label_removed`, or `evidence_observed`) and `reason`.
4444

45+
An issue entering the per-issue budget-exhausted set, on either the poll tick's rebuild or the
46+
retry lane, emits exactly one `Warn` record, message `"candidate held by budget ceiling"`, carrying
47+
the standard issue context fields plus `reason` (`token_budget` or `session_budget`),
48+
`used_sessions`, `budget_sessions`, and, when the token ceiling was evaluated for that issue,
49+
`used_tokens` and `budget_tokens`. The record fires once per hold: a tick that re-observes an
50+
already-announced hold under the same reason emits nothing further, and whichever lane discovers a
51+
hold is the only one that announces it.
52+
4553
The periodic workspace sweep emits exactly one summary record per pass, at `Info` level, message
4654
`"sweep: pass complete"`, on every pass that produced a candidate set, including a pass over zero
4755
keys, a pass whose tracker read failed, and a pass that removed nothing. This is deliberate: a
@@ -66,7 +74,9 @@ removed expired workspace"`, carrying `workspace_key`, `last_activity` (RFC3339)
6674

6775
The `"tick completed"` `Info` record carries four integer attributes for the blocker gate in
6876
addition to its existing ones: `held_by_blockers`, `blockers_unresolved`, `blockers_not_read`, and
69-
`blockers_incomplete`, each counting the candidates the tick held for that reason.
77+
`blockers_incomplete`, each counting the candidates the tick held for that reason. It also carries
78+
`budget_exhausted`, the number of this tick's candidates in the per-issue budget-exhausted set
79+
after the rebuild.
7080

7181
A candidate the dispatch gate holds for a blocker reason produces exactly one of five per-issue
7282
records, and a pass whose reads were refused by the forge produces exactly one pass-level record in
@@ -122,12 +132,12 @@ should return:
122132
- `seconds_running` (aggregate runtime seconds as of snapshot time, including active sessions)
123133
- `rate_limits` (latest coding-agent rate limit payload, if available)
124134
- `budget_exhausted_count` (number of issues currently blocked by a re-dispatch budget; always present)
125-
- `budget_exhausted` (sorted list of blocked issue IDs; omitted when the set is empty)
126-
- `budget_exhausted_reason` (map from blocked issue ID to the gate that fired, `token_budget` or
127-
`session_budget`; `token_budget` takes precedence over `session_budget` when one issue reaches
128-
both gates; omitted when the set is empty). The exhausted set and its reasons are rebuilt per
129-
tick from those two gates (Section 8.4); an issue's reason entry exists exactly when that issue
130-
is in `budget_exhausted`.
135+
- `budget_exhausted` (list of blocked-issue records, sorted by identifier; always present, empty
136+
when the set is empty). Each record carries the issue's ID and identifier, the reason
137+
(`token_budget` or `session_budget`; `token_budget` takes precedence over `session_budget` when
138+
one issue reaches both gates), the used and budgeted session and token counts, and the time the
139+
hold began. The set is rebuilt per tick from those two gates (Section 8.4) and also updated by
140+
the retry lane when it discovers a hold between ticks.
131141
- `parked_count` (number of issues currently held out of primary dispatch; always present)
132142
- `parked` (sorted list of parked issue IDs; omitted when the set is empty)
133143
- `parked_reason` (map from parked issue ID to the park's reason, `agent_blocked` or
@@ -281,7 +291,8 @@ Minimum endpoints:
281291
"generated_at": "2026-02-24T20:15:30Z",
282292
"counts": {
283293
"running": 2,
284-
"retrying": 1
294+
"retrying": 1,
295+
"budget_exhausted": 1
285296
},
286297
"running": [
287298
{
@@ -317,6 +328,19 @@ Minimum endpoints:
317328
"error": "no available orchestrator slots"
318329
}
319330
],
331+
"budget_exhausted": [
332+
{
333+
"issue_id": "ghi789",
334+
"issue_identifier": "MT-651",
335+
"reason": "session_budget",
336+
"used_sessions": 3,
337+
"budget_sessions": 3,
338+
"used_tokens": null,
339+
"budget_tokens": 0,
340+
"unmeasured_sessions": null,
341+
"exhausted_at": "2026-02-24T20:12:00Z"
342+
}
343+
],
320344
"agent_totals": {
321345
"input_tokens": 5000,
322346
"output_tokens": 2400,
@@ -360,6 +384,7 @@ Minimum endpoints:
360384
}
361385
},
362386
"retry": null,
387+
"budget_exhausted": null,
363388
"logs": {
364389
"agent_session_logs": [
365390
{
@@ -381,8 +406,13 @@ Minimum endpoints:
381406
}
382407
```
383408

409+
- `status` is `"running"` when the issue has a running session, otherwise `"retrying"` when it has
410+
a pending retry, otherwise `"budget_exhausted"` when it is held out of dispatch by a per-issue
411+
budget ceiling; `budget_exhausted` in the response body carries the same record shape as the
412+
`budget_exhausted` array on `GET /api/v1/state` and is `null` when the issue is not in that set.
384413
- If the issue is unknown to the current in-memory state, return `404` with an error response
385-
(for example `{"error":{"code":"issue_not_found","message":"..."}}`).
414+
(for example `{"error":{"code":"issue_not_found","message":"..."}}`); an issue held by a budget
415+
ceiling is not unknown to it, so this case is distinct from that one.
386416

387417
- `POST /api/v1/refresh`
388418
- Queues an immediate tracker poll + reconciliation cycle (best-effort trigger; implementations
@@ -451,6 +481,8 @@ Defined metrics (label sets and buckets are specified here; see ADR-0008 for his
451481
| `sortie_issue_parks_total{reason}` | Counter | Issue park events, partitioned by reason (`agent_blocked`, `handoff_absence`). Incremented once per park, whichever trigger produced it. |
452482
| `sortie_dispatch_rule_match_total{layer,rule}` | Counter | Dispatch rule match outcomes, partitioned by resolution layer (`rule`, `default`, `fallback`) and matched rule name. Empty rule names report as `<none>` to bound label cardinality. |
453483
| `sortie_candidate_holds_total{reason}` | Counter | `IncCandidateHolds`. Candidates the dispatch loop held, partitioned by reason (`blocked_by`, `blockers_unresolved`, `blockers_not_read`, `blockers_incomplete`). Incremented once per held candidate; never incremented for a candidate rejected by an eligibility or capacity gate, and never incremented a second time for the pass-level `blocker reads halted for this tick` ERROR that accompanies a run of `blockers_unresolved` holds. |
484+
| `sortie_budget_exhaustions_total{reason}` | Counter | `IncBudgetExhaustions`. Issue entries into the per-issue budget-exhausted set, partitioned by reason (`token_budget`, `session_budget`, open to a later value). Incremented once per hold, from whichever lane discovered it; never incremented on a tick that merely re-observes an already-announced hold. |
485+
| `sortie_budget_exhausted_issues{reason}` | Gauge | `SetBudgetExhaustedIssues`. Issues currently held out of dispatch, partitioned by reason. Recomputed from the full budget-exhausted set on every gauge update, including every declared reason at zero when nothing is held, so a reason that clears reports zero rather than freezing at its last value. |
454486
| `sortie_tool_calls_total{tool,result}` | Counter | Agent tool call completions, partitioned by tool name and result (`success`, `error`). |
455487
| `sortie_ci_status_checks_total{result}` | Counter | CI status check outcomes, partitioned by result (`passing`, `pending`, `failing`, `error`). |
456488
| `sortie_ci_escalations_total{action}` | Counter | CI escalation actions when fix retries are exhausted, partitioned by action (`label`, `comment`, `error`). |

docs/architecture/22-test-and-validation-matrix.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,21 @@ Unless otherwise noted, Sections 17.1 through 17.7 are `Core Conformance`. Bulle
162162
- The worker-exit absence park records the same tracker state the run's own terminal observation
163163
resolved, not an unrecorded state, and is releasable by a later state change without an
164164
intervening backfill tick
165+
- An issue entering the per-issue budget-exhausted set, on either the poll tick's rebuild or the
166+
retry lane, produces exactly one log record naming the issue, the reason, and the used and
167+
budgeted numbers
168+
- The record and its counter increment fire once per hold: repeated ticks over the same held
169+
issue produce neither, and an issue that leaves the candidate set and returns still held under
170+
the same reason produces neither either
171+
- Whichever lane, poll tick or retry timer, discovers a hold is the only one that announces it;
172+
the other lane's rebuild or block leaves the announcement memory alone
173+
- The budget-exhausted gauge reports a per-reason level derived from the current set, seeded to
174+
zero for every declared reason on each recompute, so a reason that clears reports zero rather
175+
than freezing at its last published value
176+
- Both `GET /api/v1/state` and `GET /api/v1/{identifier}` carry the budget-exhausted record's
177+
identifier, reason, and numbers, and the per-issue endpoint answers for a budget-blocked issue
178+
instead of reporting it unknown
179+
- The dashboard renders a budget-blocked card and table only when the exhausted set is non-empty
165180
- A dispatch that does not drive issue state performs neither the dispatch-time transition nor the
166181
handoff transition, and enqueues no reaction on its own exit
167182
- Abnormal worker exit increments retries with 10s-based exponential backoff

0 commit comments

Comments
 (0)