Skip to content

fix(dispatcher): keep a bind alive when reasoning fills the output cap - #2231

Merged
justinhelmer merged 1 commit into
mainfrom
plan/fix-switchboard-2099-the-661b8a/u1
Sep 22, 2026
Merged

justinhelmer merged 1 commit into
mainfrom
plan/fix-switchboard-2099-the-661b8a/u1

Conversation

@justinhelmer

@justinhelmer justinhelmer commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

The operator's output cap was sized for the visible answer alone, but on a reasoning wire the hidden reasoning is counted under the same ceiling, so a turn could be cut before it emitted any tool call — and a cut ended the pipeline. The cap now carries a reasoning allowance when the wire and the bound effort call for one, a cut retries once at a larger ceiling, and a second cut falls to a typed general bind instead of ending an owned pipeline.

Why: Closes #2099. It is the third gate on moving the model defaults to first-party OpenAI, with #2189 and #2190; after its rebase it now sits on main with #2189's evidence-gated schema re-ask in the same door loop, and it already cost pipelines today: two ship parents aborted with the operator failed: answer cut at the output cap (374 tokens). #2106 removed the request re-typing, so the length of the ask is close to irrelevant now — what spends the budget is reasoning, which is why this survived that fix and why the flip makes it systematic.

Where to look

  1. The allowance is added only when the turn actually asks for effort and the wire's cap field counts reasoning with visible output. ⚠ A custom cap field stays at the visible-answer cap rather than guessing the provider's contract.
  2. The retry makes a cut a typed recoverable fact: one materially larger ceiling, doubling if it was already reasoning-sized.
  3. The loop's handling retries once and then takes the typed general floor with reason output_cap. ⚠ This is the line that stops a cut ending a pipeline; the floor must stay a real bind, not a non_decision.
  4. The cap's derivation keeps the visible half constant and lets the wire and configured effort decide the allowance.
  5. Both tests pin the retry-then-accept path and the second-cut floor.

Feedback wanted: Two, both about the second attempt. The allowance is a flat 4,096 gated on effort being requested at all, not scaled by its level — is effort-presence the right gate? And should the retry lower the effort rather than raise the ceiling, given that the general floor behind it is a real failure for a write ask? See the comment below.

Risk: Too small an allowance and the door still gets cut — but now it retries and then floors to general rather than ending the pipeline, so the worst case is a mis-binding, not a dead run. Too large and a runaway turn costs more tokens before the twenty-second door timeout stops it. Blast radius is every operator turn on a reasoning wire; rollback is a revert, nothing persists.

Verified: Scoped operator tests, typecheck, hygiene:check and specs:check passed on the branch; full suite is CI-gated. The live proof on the real Responses wire is human-gated below.

Decisions (3)
  • The allowance is conditional, not unconditional. Adding it always would inflate the ceiling on wires whose cap counts visible output only, buying nothing and costing the runaway bound. It is gated on the cap field being one of the three that are documented to count reasoning, and on the turn actually requesting effort.
  • A cut is a typed error, not a string. route.ts previously threw a plain Error whose message the caller would have had to match. OutputCapError carries the ceiling that was hit, so the retry can compute the next one and the loop can tell a cut from a provider refusal.
  • The second cut floors rather than fails. The alternative was to surface the cut as a refusal. That is what ended two pipelines today. A typed general bind with reason output_cap keeps an owned pipeline alive and leaves the reason on the record.
Validation (5 criteria)
Criterion Proof
A cut answer retries once at a larger cap and the bind is accepted src/core/dispatch/operator.test.ts::an answer cut at the cap retries once at a larger cap and accepts the bind
A second cut never ends a pipeline; it floors through the typed general bind src/core/dispatch/operator.test.ts::a second cut floors through the typed general bind, so a cut answer never ends a pipeline
Merged routing-and-config item 29 carries the evidence-gated schema re-ask, output-cap recovery, and first-call cap conformance src/core/dispatch/operator.test.ts::operatorStage — the operator's effort from defaults.efforts.general::cap conformance: openai-responses at configured medium carries reasoning plus one bind
The retry ceiling is materially larger, never a nudge outputCapRetry returns max(current * 2, 4096)
A real operator turn at effort medium on the OpenAI Responses wire binds without a cut human-gated — needs the next release roll; no receipt yet
For agents

The head 87ac6ebd is the coding child's own commit, unmodified. This pull request was opened from the operator session rather than by the runner: the ship parent aborted on a clean handoff without opening one (#2197), the fourth time today across three branches. The push is recorded by: "salvage". No code was written or amended from the session.

The commit subject is fix(routing): …, whose scope routing is not a code-map area and would fail check:pr-title. This pull request's title uses dispatcher, which is the area src/core/dispatch/ belongs to, and the squash subject comes from the title.

Merge order: this branch overlaps #2227 in src/core/dispatch/operator.ts. #2227 merges first and this branch rebases onto it before its review round, so an approval is not invalidated by a later rebase.

The defect's own mechanism is worth holding while reviewing: #2106 already made bind_preset carry the request by reference, so the visible answer no longer grows with the ask. What remained was reasoning inside the same budget. A fix that only enlarged the cap by a fixed amount would not be sufficient on its own, because reasoning has no fixed size — hence both the derivation and the retry.

@justinhelmer

Copy link
Copy Markdown
Contributor Author

Expanding the second Feedback-wanted question, because it is the difference between a fix and a better-labelled version of the outage it closes.

The general floor is not a safe landing for a write ask.

A second cut takes a typed general bind with reason output_cap. For a question that is fine — general answers it. For a write ask it is precisely yesterday's symptom: please review <PR> lands on general, the run replies that it cannot review pull requests, and the person watching sees the door fail. Compared to today's silent fall-through we would have gained an accurate record and changed nothing the user experiences. And it bites hardest exactly where the cap is most likely to be spent — a high-effort turn — so the escape hatch is most likely to fire in the case it serves worst.

That makes the retry, not the floor, the thing worth getting right. The question is what the second attempt should change.

Raising the ceiling is what this PR does. It converges only if reasoning happens to fit the next ceiling, and it costs more tokens each time.

Lowering the effort is the stronger answer. The operator's job is to pick a preset and a repository from a message. That is classification, not deliberation. A retry at low or no effort spends almost nothing on hidden reasoning and leaves effectively the whole budget for the visible answer, so it converges rather than escalating, and it costs less rather than more.

The design question underneath, which is why this is worth asking rather than just patching: the operator inherits defaults.efforts.general, and production sets that to medium. So the door reasons at the same tier as the general preset — not because anyone decided the door needs medium reasoning, but because it shares a key. If the operator pinned a low effort of its own, most of this class would not arise, and the retry would be a backstop for the rare case instead of the mechanism the fix depends on.

None of this is a request to hold the PR. It closes a real defect, the derivation and the retry are both right, and a typed floor with a named reason is strictly better than what is on main today. The ask is for the reviewer's judgement on whether the second attempt should lower effort instead of raising the ceiling, and whether the operator's effort should be its own rather than inherited — either now or as a named follow-up.

@coreplane-switchboard
coreplane-switchboard Bot force-pushed the plan/fix-switchboard-2099-the-661b8a/u1 branch from 87ac6eb to f2d668b Compare September 22, 2026 21:03
@justinhelmer

Copy link
Copy Markdown
Contributor Author

Live receipt for the defect this fixes, from production twenty minutes ago rather than from a fixture.

Door run 3bca2781 at 20:59Z recorded non_decision with reason "the operator failed: answer cut at the output cap (374 tokens)" — #2099 firing on 1.260.4, on the OpenRouter defaults, with #2106's by-reference binding already in force. That is the third occurrence today: a ship parent lost mid-pipeline at 11:29 PDT, a truncated review relay, and now this one.

Worth stating for the reviewer, since it bears on how hard to push on the two Feedback-wanted questions: this is not a latent defect being fixed prophylactically. It is firing on the current production build several times a day, and it fires harder after the provider change this release is gating, because the Responses wire counts reasoning tokens inside the same max_output_tokens budget the answer has to fit in.

The release is being held for this pull request on Justin's instruction to get every open pull request into it, so the review round here is the last gate before both this fix and the provider switch reach production together.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes requested: The retry path is sound, but unset effort still leaves reasoning-capable Responses calls on the undersized initial cap.

Warning

Changes requested · head f2d668b · 1 finding: 1 minor

Severity Finding Where
minor F1 Reasoning allowance disappears when effort uses the model default src/core/dispatch/route.ts:60
Full review

F1 — providerStructuredModel explicitly delegates to the model’s default when effort is unset, so a reasoning-capable OpenAI Responses model can still consume hidden reasoning while outputCapWithReasoning leaves its initial ceiling at 374 tokens. That guarantees an avoidable first truncation and may spend the shared 20-second timeout before the larger retry runs. Gate the allowance on the reasoning-counting wire/model capability rather than explicit effort presence, and add an unset-effort Responses regression.

  1. Keep the flat 4,096-token allowance rather than scaling it by effort level, but effort presence is the wrong gate because unset does not mean no reasoning.
  2. Keep raising the retry ceiling. Lowering effort changes classification semantics and is not reliable truncation recovery. Giving the operator its own low-effort configuration may be worthwhile separately, but should not replace this retry behavior.

Co-Authored-By: coreplane-switchboard[bot] <318072483+coreplane-switchboard[bot]@users.noreply.github.com>
@justinhelmer

Copy link
Copy Markdown
Contributor Author

Re-review requested at 74a8ed03 — the round-1 finding is addressed, CI green on every check.

F1, the reasoning allowance disappearing when effort is unset. outputCapWithReasoning no longer takes an effort argument at all. The allowance hangs on the capability alone — the wire's cap field counting reasoning together with visible output — so a reasoning-capable Responses model gets the larger ceiling on its first call whether or not an effort was requested. The reasoning is stated in the code: an unset effort delegates to the model's default and does not prove the model spends no reasoning tokens.

The regression is a second row on the existing cap-conformance table — openai-responses, max_output_tokens, effort unset — asserting the bind lands on the first call, one request with no retry, at a ceiling at or above the allowance plus the visible answer. Under the old gate that row fails twice over: the allowance is skipped and the first call truncates. Spec item 29 moved with it.

Both answers from the last round were taken as given. The allowance is still a flat 4,096 rather than scaled by effort level, and the retry still raises the ceiling rather than lowering the effort. Only the gate changed. The separate question — whether the door should have its own effort instead of inheriting the general preset's by sharing a key — is filed on its own and deliberately not in this pull request.

About the head. One commit. It differs from the previously reviewed f2d668b4 by the gate change, its regression, the spec line, and one unused type import removed after CI caught it as a lint error on the intermediate head. The branch was moved onto this commit by a leased ref update rather than a fresh push, so the tree is exactly the one the fix produced.

@coreplane-switchboard coreplane-switchboard Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM: The round-one reasoning-cap finding is fully addressed, with regression and spec coverage for unset effort on the first call.

Note

Approved · head 74a8ed0 · no findings

Full review

The prior finding is fully resolved; no new findings remain.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-approved: coreplane-switchboard[bot] reviewed this PR and posted an LGTM verdict (see its review). This repository opted in through its REVIEW_BOT_LOGIN and REVIEW_BOT_ID variables.

@justinhelmer
justinhelmer merged commit 7216cbf into main Sep 22, 2026
30 checks passed
@justinhelmer
justinhelmer deleted the plan/fix-switchboard-2099-the-661b8a/u1 branch September 22, 2026 22:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant