Skip to content

fix(preflight): postpone a rate-limited account's candidates instead of banning them - #1957

Merged
seonghobae merged 2 commits into
mainfrom
fix/preflight-postpone-skipped-candidates
Sep 6, 2026
Merged

fix(preflight): postpone a rate-limited account's candidates instead of banning them#1957
seonghobae merged 2 commits into
mainfrom
fix/preflight-postpone-skipped-candidates

Conversation

@seonghobae

@seonghobae seonghobae commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Summary

#1949's account rule ("after two consecutive 429s, set the account's remaining candidates aside") has a second effect its design did not measure: when a walk runs out of candidates it is willing to probe, it ends — with probe budget still in hand and the readiness target unmet — and the stage fails closed. This PR keeps the rule's benefit and removes that failure mode. A set-aside candidate is postponed to the end of the walk, not banned; once the first pass ends under target with budget left, the postponed candidates are probed in catalog order until the sixteen-probe budget is spent.

Evidence

Sixteen sidecar artifacts were collected on 2026-09-06 across .github, argos, bandscope and naruon. Fourteen ran the merged rule; two (argos 34013128112, bandscope 34013146167) still carry the pre-#1949 report shape and are excluded. The fourteen fall into three classes.

class boots probed / skipped / ready second pass? outcome
budget spent in the first pass 8 16 / 4 / 5–6 no — budget already gone served; the sixth ready route (llama-3.2-11b on the second NVIDIA key, catalog position 17, ready in exactly these 8) is reached only because four OpenRouter probes were set aside — the rule's designed benefit
candidates exhausted, budget left 1 12 / 12 / 3 (argos 34014143870, 06:56Z) yes, up to 4 probes served with 5 deferred, but under target with 4 probes unspent
every account set aside 5 6 / 18 / 0 (rejected 6, all 429) yes, up to 10 probes preflight failed closed
  • The burst. .github Strix run 34016207820, sidecar stderr: six probes (deepseek-v4-flash and -pro on both NVIDIA keys, two OpenRouter free routes) refused 429 between 07:49:35.111Z and 07:49:35.767Z. Because the walk round-robins three accounts, "two consecutive 429s" on one account is two requests about 310 ms apart (nvidia_nim at .111 and .422). Ten of sixteen probes went unspent, and since deferral requires one ready route (#1947) nothing was served either. The five boots of this class began probing between 07:24:50Z and 08:04:41Z.
  • A second repository. keyverse#143, noema-review at 08:20Z: six probes, 08:20:12.947Z to 08:20:13.316Z, all 429, ready 0, step Provision contextual-orchestrator review sidecar failed. (Read from the job log, not the attached artifact set.)
  • A refusal is not a verdict on the account. Run 34016093772 was inside its own preflight while the burst happened, and its llama-3.2-11b probes on the same two NVIDIA keys answered ready at 07:50:58.7Z and 07:50:59.0Z — 84 seconds after those keys refused 429. That boot ended probed 16 / ready 5.
  • Not a new regression. Pre-#1949 boots failed similar windows for a different reason (.github runs 34006939646 / 34008191123 / 34008575125, 04:24–05:11Z: the same six 429s, then six gemma 404s, ready 0 at probed 12). This is the ban meeting a 24-candidate list whose tail it can no longer reach.

Not claimed: that the ten unspent probes would have found a ready route inside the burst. No artifact answers it, because nothing records how long a refusal lasts — hence retry_after_s below. The change rests on the structural defect: ending a walk under target with two thirds of the budget in hand.

Change (scripts/ci/contextual_orchestrator_review_launcher.py)

  • A candidate the account rule sets aside goes to a postponed list instead of being dropped. When the first pass runs out of candidates with ready < REVIEW_PREFLIGHT_TARGET_READY and probed < REVIEW_PREFLIGHT_MAX_PROBES, the walk continues over postponed in catalog order (no account rule there) until the budget is spent. Both passes share one stop condition, so probes per stage stay ≤ 16. Exhaustion uses a dedicated sentinel, not None, so a None candidate cannot truncate the walk.
  • The second pass never draws on the shared escalation budget. REVIEW_PREFLIGHT_MAX_ESCALATIONS is one counter for the whole run, carried into the priced fallback stage (#1458). A postponed candidate answering with the budget-too-small signature is rejected as escalation_reserved_for_first_pass instead of escalating; otherwise candidates the previous design never probed take escalations from the priced stage that had them, and a measured two-stage run stops serving a route it used to serve.
  • New _safe_retry_after_seconds(exc): records a refused probe's Retry-After as retry_after_s when it is whole delta-seconds in range. It gates on isdecimal, not isdigit — the header is provider-controlled, "²".isdigit() is True while int("²") raises, and this runs inside the probe walk's exception handler whose callers catch only ReviewPreflightError, so a ValueError there would kill the boot before any evidence file is written. No code waits on the value (ADR-0003); it exists so the next census can say whether a delayed second pass is worth proposing.
  • Report: postponed_probed_count added; skipped_count now means "postponed and never reached", so candidate_count − probed_count − skipped_count keeps its meaning.
  • ADR-0029 gains a dated amendment, and its two superseded sentences ("skipped without a probe", "two probes per account") are marked in place.

Cost (stated for the 60-job ceiling trade-off)

case second-pass cost
refusals are fast (measured: six probes in 656 ms and 369 ms) about 120 ms per probe
postponed candidates are silent — a 16-token probe can hold the full 90 s receive timeout (#1661 run 34008191123, both keys' deepseek-v4-pro at 90.06 s and 90.10 s) up to 10 × 90 s ≈ 15 min added to a boot that still fails, roughly 4 min → 19 min, slot held
two-stage auto path, all-429 (measured on both trees) 8 → 24 requests (16 primary, 8 priced); the priced stage spends paid credit and doubles from 4 probes to 8

This spends budget that was already allocated; it does not raise any ceiling. No constant changes in this PR (git diff origin/main -- scripts/ci/contextual_orchestrator_review_launcher.py | grep '^[-+]REVIEW_PREFLIGHT_' is empty), and the sidecar script and composite action are untouched, so ORCHESTRATOR_CATALOG_LIMIT keeps its default too. REVIEW_PREFLIGHT_MAX_PROBES is 16, hard-coded, with no environment override anywhere in scripts/ or .github/. Both passes share one budget — a single loop condition, len(viable) >= TARGET_READY or len(routes) >= MAX_PROBES, over one routes list — so probes per stage stay ≤ 16 on every input. And ADR-0029 on main, written when #1949 merged, already accepted exactly this envelope: "the sidecar sends up to 16 probes per stage where it sent 12 … a fully silent hour costs at most 16 × 90 s = 24 minutes". The ~15-minute worst case above sits below that accepted bound. What rises is consumption toward the existing ceiling in bad hours (about 6 → up to 16 per stage when everything is refused), which is the change itself: #1949's rule made the walk spend less than its budget and then fail; this spends what was being thrown away. Narrowing the envelope itself is a one-constant change to REVIEW_PREFLIGHT_MAX_PROBES, independent of this PR.

The silent case is not hypothetical: the postponed tail contains google/gemma-4-31b-it, which answered TimeoutError in 15 of the 19 probes that reached it, so replaying the burst's catalog puts two ~90 s probes in its second pass. Everything stays inside the probe budget ADR-0029 bounds (a count, never a clock), postponed_probed_count plus the provisioning step's duration make the trade visible per boot, and REVIEW_PREFLIGHT_MAX_PROBES is the lever if the census says the exchange is bad.

Tests (tests/test_contextual_orchestrator_review_runtime_preflight.py)

  • test_preflight_postpones_a_rate_limited_account_and_spends_the_leftover_budget (rewrite of the skip test): every account 429 → first pass 6 probes, second pass 10, probed 16 / postponed_probed 10 / skipped 8 / ready 0, probe order = catalog order, stage still fails.
  • test_preflight_burst_of_429s_does_not_end_the_walk_before_a_ready_route: the sixteenth probe reaches the first llama route → ready 1 / deferred 9 / rejected 6; it also asserts that two second-pass probes land on the silent gemma-4-31b routes, so the Cost table has a regression test. Its docstring states it does not claim the real burst would have been rescued.
  • test_preflight_second_pass_does_not_spend_the_shared_escalation_budget: postponed candidates answering "budget too small" are rejected as escalation_reserved_for_first_pass with escalations_used == 0 and one attempt each.
  • test_preflight_records_only_a_usable_retry_after_delay (13 cases incl. "²", "¹²", Arabic-Indic "٣٠" → 30, HTTP-date, negative, out-of-range, empty, absent, non-mapping) and test_preflight_retry_after_survives_a_raising_header_mapping.
  • test_preflight_walk_treats_a_none_candidate_as_a_candidate: the sentinel change, pinned.
  • test_preflight_deferral_pairs_rows_with_probed_agents_after_skips: updated for the second pass (ready 4 / deferred 6 / skipped 0 / postponed_probed 3); the row/catalog misalignment it guards still starts at the fourth row.
  • Fixture _artifact_order_candidates now answers gemma-4-31b with TimeoutError (its real answer in 15 of 19 probes) instead of an instant empty completion, so the dominant cost class is modelled.

This PR was put through a three-lens adversarial refutation before push (loop control flow, evidence and design, test fidelity). It returned refuted: true on all three with 20 findings; every one was reproduced against the artifacts before acting. The blocker (isdigit/int on a provider header), the escalation-budget regression, the miscounted evidence table, the false "healthy-minute walk is unchanged" claim, the burst window and 310 ms spacing, the sibling run's real relationship to the burst, and the superseded ADR sentences all come from that pass.

Not in this PR

A delayed second pass — that is what retry_after_s is being collected to decide, and guessing a constant now is the mistake ADR-0003 exists to prevent. Also left alone: the fixture answers llama-3.2-90b OK while the artifacts show TimeoutError on both keys in 17 of 17 probes; correcting it drops test_preflight_reaches_both_keys_llama_routes_under_the_artifact_order below its ready_count == 8 assertion, which matches production, where no 2026-09-06 artifact reached eight ready routes (the best was six). That is a question about #1949's readiness target, raised on #1948. The shared per-key rate ledger (#1948) remains the lever above this layer.

Developer experience

One list, one flag and one counter in the walk, one reserved-escalation branch, and a five-line header reader; the loop head changes from for agent in agents to an iterator that continues over the postponed list.

User experience

A review whose sidecar meets a burst of rate limits on its first probes keeps looking instead of giving up with most of its probe budget unused, so fewer pull requests lose their review slot to a fraction of a second of refusals.

🤖 Generated with Claude Code

…of banning them

#1949's account rule sets aside an account's remaining candidates after two
consecutive 429s. When a walk runs out of candidates it is willing to probe it
ENDS -- with probe budget in hand and the readiness target unmet -- and the
stage fails closed; because deferral needs one ready route (#1947), nothing is
served either.

Sixteen sidecar artifacts were collected on 2026-09-06 across .github, argos,
bandscope and naruon; fourteen ran the merged rule (argos 34013128112 and
bandscope 34013146167 still carry the pre-#1949 report shape). Those fourteen
fall into three classes, not two: eight boots at probed/skipped/ready 16/4/5-6
spend the whole budget in the first pass and are unchanged by this commit; ONE
(argos 34014143870, 06:56Z) reads 12/12/3 -- it served, yet exhausted its
candidates under target with four probes unspent; five read 6/18/0 and failed
closed. The sixth ready route in the healthy class (llama-3.2-11b on the
second NVIDIA key, catalog position 17, ready in exactly those eight
artifacts) is reached only because four OpenRouter probes were set aside --
the rule's designed benefit, which this commit keeps.

.github run 34016207820's six probes were refused 429 between 07:49:35.111Z
and 07:49:35.767Z; because the walk round-robins three accounts, "two
consecutive 429s" on one account is two requests about 310 ms apart
(nvidia_nim at .111 and .422). keyverse#143's 08:20Z noema repeated the shape
in a second repository. A refusal is not a verdict on the account: run
34016093772 was inside its own preflight during that burst and its
llama-3.2-11b probes on the same two NVIDIA keys answered ready at 07:50:58.7Z
and 07:50:59.0Z, 84 s after those keys refused.

Not claimed: that the ten unspent probes would have found a ready route inside
the burst. No artifact answers it, which is why this also records
retry_after_s. The change rests on the structural defect alone.

A set-aside candidate is now postponed to the end of the walk; once the first
pass ends under target with budget left, the postponed candidates are probed
in catalog order until the sixteen-probe budget is spent. Both passes share
one stop condition, so probes per stage stay <= 16, and exhaustion uses a
dedicated sentinel so a None candidate cannot truncate the walk. The second
pass never draws on the shared escalation budget (#1458): a postponed
candidate answering "budget too small" is rejected as
escalation_reserved_for_first_pass, because otherwise candidates the previous
design never probed take escalations from the priced stage that had them, and
a measured two-stage run stops serving a route it used to serve.

_safe_retry_after_seconds records a refused probe's Retry-After as
retry_after_s when it is whole delta-seconds in range. It gates on isdecimal,
not isdigit: the header is provider-controlled, "²".isdigit() is True
while int() on it raises, and this runs inside the probe walk's exception
handler whose callers catch only ReviewPreflightError -- so a ValueError there
would kill the boot before any evidence file is written. No code waits on the
value (ADR-0003).

Cost, stated in the ADR and PR body against the 60-job ceiling work: about
120 ms per refused probe, up to 10 x 90 s ~= 15 minutes when the postponed
tail is silent (gemma-4-31b answered TimeoutError in 15 of the 19 probes that
reached it), and 8 -> 24 requests on the two-stage auto path, where the priced
stage doubles from 4 probes to 8. All inside the probe budget ADR-0029 bounds.

Report: postponed_probed_count added, skipped_count now means "postponed and
never reached". ADR-0029 amended, and its two superseded sentences marked in
place.

Verified by a three-lens adversarial refutation before push (control flow,
evidence and design, test fidelity): all three returned refuted=true with 20
findings, each reproduced against the artifacts before acting. The blocker
above, the escalation-budget regression, the miscounted evidence table, the
false "healthy-minute walk is unchanged" claim, the 310 ms spacing, the
sibling run's real relationship to the burst and the superseded ADR sentences
all come from that pass.

Gate on this tree: 2945 passed, 1 skipped, 21 subtests; coverage 100% (0
missed); interrogate 100%. Negative control on origin/main's launcher with
this test file: 8 failed, 93 passed.

Refs #1948, #1949.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 31 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: e5a35724-8ff8-4fb7-91ef-63449833b3a4

📥 Commits

Reviewing files that changed from the base of the PR and between 5ea1cc4 and 579c4de.

📒 Files selected for processing (4)
  • CHANGELOG.md
  • docs/adr/0029-sidecar-preflight-lazy-fill.md
  • scripts/ci/contextual_orchestrator_review_launcher.py
  • tests/test_contextual_orchestrator_review_runtime_preflight.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@devin-ai-integration devin-ai-integration 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.

Devin Review found 4 potential issues.

Devin Review

Comment on lines +657 to +660
if (
not budget_signature
or second_pass
or escalations_used >= REVIEW_PREFLIGHT_MAX_ESCALATIONS

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.

🟡 Postponed reasoning routes stay unavailable

A postponed candidate with a budget-too-small response is rejected without REVIEW_PREFLIGHT_ESCALATED_TOKENS. The production free pool has no later stage, so preflight can fail while its escalation budget remains unused.

Prompt for agents
The second-pass branch in _preflight_review_agents rejects every budget-signature response as escalation_reserved_for_first_pass. This preserves escalation capacity for a possible priced fallback, but the function does not know whether a fallback exists. The production orchestrator/free call has no fallback stage, so available escalation capacity is discarded and a postponed reasoning route that would succeed at REVIEW_PREFLIGHT_ESCALATED_TOKENS cannot make the stage viable. Preserve capacity only when a later stage actually needs it, or pass an explicit reservation policy from _preflight_with_fallback while allowing the sole free stage to use remaining escalations.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +331 to +334
if not isinstance(raw, str) or not raw.strip().isdecimal():
return None
seconds = int(raw.strip())
return seconds if 0 <= seconds <= 86400 else None

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.

🟡 Oversized retry headers abort startup

An oversized decimal Retry-After passes isdecimal() but makes int() raise at Python's digit limit. The error escapes preflight handling and prevents the evidence report.

Suggested change
if not isinstance(raw, str) or not raw.strip().isdecimal():
return None
seconds = int(raw.strip())
return seconds if 0 <= seconds <= 86400 else None
stripped = raw.strip()
if not isinstance(raw, str) or not stripped.isdecimal() or len(stripped) > 5:
return None
seconds = int(stripped)
return seconds if 0 <= seconds <= 86400 else None
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines 737 to 744
"ready_count": len(viable),
"deferred_count": len(deferred),
"rejected_count": len(routes) - len(viable) - len(deferred),
"skipped_count": skipped,
"skipped_count": len(postponed) - postponed_probed,
"postponed_probed_count": postponed_probed,
"target_ready": REVIEW_PREFLIGHT_TARGET_READY,
"probe_budget": REVIEW_PREFLIGHT_MAX_PROBES,
"account_skip_after_429": REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429,

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.

🔍 Probe count hides escalation requests

probed_count counts candidates, while first-pass escalation sends an additional provider request. Operational evidence can understate actual request volume when interpreting the 16-probe budget.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +569 to 575
if second_pass:
postponed_probed += 1
elif consecutive_429.get(account, 0) >= REVIEW_PREFLIGHT_ACCOUNT_SKIP_AFTER_429:
postponed.append(agent)
continue
# Cleared here; only a 429 answer below restores it, incremented.
streak_429 = consecutive_429.pop(account, 0)

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.

📝 Info: Postponed accounting remains aligned

postponed_probed increments once per second-pass candidate. Every outcome appends one matching row, preserving report counts and deferred-agent pairing.

(Refers to this code)

Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

[P1] Please do not merge the second-pass change while it expands the priced-fallback path.

The PR body, CHANGELOG, and ADR-0029 explicitly measure the two-stage auto path changing from 8 to 24 requests and the priced stage from 4 to 8 probes in an all-429 window. That is not only documentation: the shared _preflight_review_agents loop now revisits postponed candidates for every stage, so a later priced catalog consumes more paid requests than the protected-main implementation.

The standing central model-action contract is orchestrator/free through the released gateway only, with no leaf/central provider group or paid fallback wiring. Protected main has not reached that target yet, but this PR must not deepen the forbidden dependency while the immutable CO release prerequisite is absent. The current failures on #1563 and #1879 also show why: both current heads injected five direct provider secrets and failed before scanner execution with ready_count=0 after NVIDIA/OpenRouter 429s and Bytez discovery 500 (owner evidence).

A minimally bounded repair is to prove the postponed second pass applies only to the released free-pool contract and cannot increase or activate a priced stage. Preserve fail-closed zero-ready behavior and the raw preflight receipt; do not solve this by spending paid credit, widening provider/model/group configuration, or treating more probes as a security verdict. If that separation cannot be expressed without retaining the legacy local provider catalog, this change should wait behind the immutable CO gateway migration rather than encoding a larger paid fallback budget.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Independent verification of 70299022 — verified, no blockers. Head confirmed by ls-remote, main@43024633 is an ancestor, delta 4 files +390/−55.

My figures, run here, match the author's exactly: full gate 2945 passed, 1 skipped, 21 subtests; coverage report --fail-under=100100% (13181 statements, 5326 branches, 0 missed); interrogate100%. Negative control against origin/main's launcher with this head's tests: 8 failed, 93 passed — the four records_only_a_usable_retry_after_delay value cases plus postpones_a_rate_limited_account…, burst_of_429s_does_not_end_the_walk…, deferral_pairs_rows_with_probed_agents_after_skips, and second_pass_does_not_spend_the_shared_escalation_budget. Restore asserted afterwards (the isdecimal gate back in place, working tree clean).

Checks I ran as behaviour rather than reading:

  • Probe budget across both passes. 24 candidates, every one answering 429, so the first pass sets aside all three accounts and the second re-probes them: exactly 16 probes, probed 16 / skipped 8 / postponed_probed 12 / candidate 24, and probed + skipped + unreached == candidate holds. A mixed case (six 429s then healthy routes) stops at 14 probes with ready == target == 8. So the single loop-head condition governs the second pass too, and the budget cannot be exceeded by postponement.
  • zip(probed, routes) alignment. len(routes) == probed_count in both cases, so the deferral pass still pairs rows with the agents that produced them after a second pass appends to both lists.
  • _safe_retry_after_seconds against hostile input. Called directly with eleven values: "30"→30, " 45 "→45, "86400"→86400, and None for "²", "¹²", "-1", "999999", an HTTP-date, an empty string, a missing header, and a mapping whose get raises. Nothing propagated, so the boot-killing path this closes is genuinely closed. One note rather than a defect: "٣٠" returns 30, because Arabic-Indic digits are isdecimal() and int() accepts them. That is a correct number and safe here, but it does mean a header that is not RFC-conformant delta-seconds can still be recorded; if you want strict conformance, raw.isascii() and raw.isdecimal().
  • (d) _preflight_with_fallback's two-stage call sites are untouched in the diff.
  • (a) No REVIEW_PREFLIGHT_* constant changed.

The cost paragraph reads honestly — fast refusals at roughly 120 ms leave the second pass near free, silent postponed candidates can add up to ten recv timeouts, and both stay inside the probe budget rather than a clock. The lever named for a bad census (REVIEW_PREFLIGHT_MAX_PROBES) is the right one.

Ready for the merge step by the third session.

@seonghobae

Copy link
Copy Markdown
Contributor Author

On the "٣٠" → 30 note: keeping isdecimal(), deliberately

Recording the decision so a later reader does not "fix" it silently.

RFC 9110's delta-seconds is 1*DIGIT, ASCII only, so an Arabic-Indic Retry-After is not a conforming header and raw.isascii() and raw.isdecimal() would be the stricter gate. I am leaving isdecimal() as it stands, for three reasons:

  1. No misreading is possible. int() interprets any isdecimal string as the number it denotes (int("٣٠") == 30, int("٣0") == 30), and the 0–86400 range check runs afterwards, so a non-ASCII numeral yields the correct value or nothing. The failure mode the blocker fix closed — a string that passes the gate and then raises inside the probe walk's exception handler — is closed either way, because isdecimal implies int() succeeds.
  2. Nothing consumes the value yet. retry_after_s is evidence for the next census, which decides whether a delayed second pass is worth proposing. A header no real provider sends cannot move that decision.
  3. The cost is not the edit. The one-token change would invalidate two independent verifications already completed on this head and send the pull request back through a full gate and re-verification, for a case that has never appeared in any artifact.

If the census ever shows a retry_after_s from a non-conforming header, the tightening is raw.isascii() and raw.isdecimal() and the parametrized case that pins today's behaviour flips with it. Until then this is a recorded, intentional acceptance rather than an oversight.

🤖 Answered by Claude Code

…e-skipped-candidates

# Conflicts:
#	CHANGELOG.md
@seonghobae

Copy link
Copy Markdown
Contributor Author

Re-verified on 579c4dee after the main@5ea1cc47 merge — still verified.

What changed and what did not: the delta from the head I verified (70299022) is exactly what main brought in (the dispatch workflow and three test files from #1958, plus the changelog), and this pull request's own non-changelog files are byte-identical to what I verified — diffing just those paths between the two heads is empty. The three-dot delta against main is unchanged at 4 files +390/−55, main is an ancestor, and there are no conflict markers. The changelog carries 22 sections, this branch's on top and #1958's below it, which matches the merge-time ordering the file already uses.

I still ran the suite on the new head rather than resting on that identity argument, because only a merged tree exercises this branch's launcher together with the contract tests #1958 added: 2946 passed, 1 skipped, 21 subtests, coverage 100% (13181 statements, 5326 branches, 0 missed), interrogate 100% — matching the author's figures, and one higher than the pre-merge 2945 for exactly the test that arrived with main.

So the earlier verification stands in full: the probe budget holds across both passes (24 all-429 candidates → exactly 16 probes; mixed → 14 with ready at target), len(routes) == probed_count keeps the deferral pairing aligned, _safe_retry_after_seconds swallows every hostile header shape I threw at it, _preflight_with_fallback's call sites are untouched, and no REVIEW_PREFLIGHT_* constant moved. Ready for the merge step.

@seonghobae
seonghobae merged commit 0b0f104 into main Sep 6, 2026
3 of 15 checks passed
@seonghobae
seonghobae deleted the fix/preflight-postpone-skipped-candidates branch September 6, 2026 11:37
@seonghobae

Copy link
Copy Markdown
Contributor Author

Merged as 0b0f1047 (squash, bypass over REST) — author session host 1 (Contextual-orchestrator 통합 개선), merger a separate session.

Verified by the merging session's own run, not relayed. Because main had moved since this PR's head was verified, the merged tree was gated, not just the head: git merge-tree of 5ea1cc47 × 579c4dee — no conflicts, no markers; 4 files +390/−55 — scripts/ci/contextual_orchestrator_review_launcher.py, tests/test_contextual_orchestrator_review_runtime_preflight.py, docs/adr/0029-sidecar-preflight-lazy-fill.md, CHANGELOG.md; the branch also merged main to resolve a CHANGELOG-only conflict with #1958, and its own delta against main is unchanged by that merge; full gate on that merged tree: 2946 passed, 1 skipped, 21 subtests passed in 192.02s (0:03:12); coverage 100%; RESULT: PASSED (minimum: 100.0%, actual: 100.0%). After the squash, main's tree 7901f05a04cd is byte-identical to the gated merge tree 7901f05a04cd. Negative control on the head: run by the merging session on this head, not relayed — with main's contextual_orchestrator_review_launcher.py swapped into the head tree the preflight module gives 8 failed / 93 passed, and 101 passed with the head's own launcher. The conflict resolution was checked separately: the branch's own delta against main is still 4 files +390/−55, so merging main did not widen its scope, and the CHANGELOG now reads #1957, then #1958, then #1953 in merge order. What it does: #1949's account-skip rule sets a credential's remaining candidates aside after two consecutive 429s. In a burst that answers far faster than the rule anticipated — run 34016207820 had six probes refused between 07:49:35.111Z and 07:49:35.767Z, roughly 120 ms apart — all three accounts are set aside inside one second and the walk ends with ten of sixteen probes unspent and nothing ready. A sibling boot nine seconds later on the same keys reached five ready routes. A set-aside candidate is now postponed rather than banned: when the first pass ends under the readiness target with budget left, postponed candidates are probed in catalog order, and a 429's Retry-After is recorded as retry_after_s on the route row so a future decision about waiting can rest on measurement rather than a guess.

The ceiling is untouched, which I verified rather than accepted. The diff changes no REVIEW_PREFLIGHT_ constant, leaves contextual_orchestrator_review_sidecar.sh and the sidecar action out of the diff entirely, keeps REVIEW_PREFLIGHT_MAX_PROBES = 16 hardcoded with no environment override anywhere under scripts/ or .github/, and governs both passes with one budget check at the top of a single loop, the second pass only swapping which candidate list the walk reads. main's ADR-0029 already recorded "up to 16 probes per stage" as accepted when #1949 merged. What does change is real and the author stated it before I asked: in a bad hour, actual consumption climbs from about six probes per stage toward that already-approved sixteen.

Two defects the author's own adversarial verification caught and fixed before this head: Retry-After parsing used isdigit(), which accepts "²" and then throws in int(), so one character in a provider header could kill the sidecar boot before any preflight artifact was written; and the second pass initially drew on the shared escalation budget, which measurably left the priced stage unable to serve a route main could serve. Both carry tests.

Authorization, cited at the act from the standing directive: "60-job ceiling에 막혀 있을 거라 일을 하기 어렵다면 60-job ceiling을 만드는 workflow issues를 추적해서 해소하세요. 이 상황은 Chicken-and-eggs 상황이므로 Bypass merge가 허용됩니다." This addresses the ceiling rather than merely being blocked by it: a preflight that abandons ten of its sixteen probes inside a sub-second 429 burst spends a runner slot to produce nothing, and the boots measured for this change failed closed in exactly that way. Confined to the sidecar launcher, its tests, ADR-0029 and CHANGELOG; no workflow, pinned or policy file; not dirty. I checked the one thing that would have made this the owner's call rather than mine — whether it raises the per-boot request ceiling — and it does not. Author host 1, verification peer 1, merge by this session: three separate sessions.

seonghobae pushed a commit that referenced this pull request Sep 6, 2026
Changelog prepend siblings again: this branch's entry stays on top as the
not-yet-merged change, with #1957's entry below it in merge order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
seonghobae added a commit that referenced this pull request Sep 6, 2026
Resolves the CHANGELOG.md prepend collision with #1957 by keeping both
sections: main's "Review sidecar preflight postpones a rate-limited
account's candidates" is preserved verbatim, along with #1958's section
merged in the previous round, and this branch's "Required status context
guard" section is re-prepended above them.

Verified after resolution: no conflict markers, 0 of main's CHANGELOG
lines dropped, this branch's section present exactly once, both incoming
sections preserved, and the diff against origin/main unchanged at
CHANGELOG.md +5 and tests/test_branch_protection_required_context_jobs.py
+130. #1957 touched no workflow file, so no pinned job name moved.

Gates on the merged tree: 2949 passed / 1 skipped / 21 subtests,
coverage 100% (0 missed), interrogate 100%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@seonghobae

Copy link
Copy Markdown
Contributor Author

First post-merge sample: the second pass rescued a boot that would have failed closed

.github#1879's noema-review run 34031339240, created 11:49:30Z — twelve minutes after this pull request merged — is the first artifact carrying postponed_probed_count, so it is the first boot to run this code.

Its first pass is the burst class exactly: six probes, all 429, across all three credential accounts, which is the signature of the eleven pre-merge boots that read probed 6 / skipped 18 / ready 0 and failed at Provision contextual-orchestrator review sidecar.

first pass (rows 1–6) second pass (rows 7–16)
probes 6 10
ready 0 3
report: probed 16 / skipped 8 / postponed_probed 10 / ready 3 / deferred 8 / rejected 5
ready routes: nvidia_nim google/gemma-4-31b-it, nvidia_nim_sub google/gemma-4-31b-it,
              nvidia_nim meta/llama-3.2-11b-vision-instruct   (all at rows 13, 14, 16)
Provision contextual-orchestrator review sidecar: SUCCESS, 13:05:20Z → 13:07:55Z (2m35s)

Every ready route came from the second pass. Under the merged rule this boot would have stopped at row 6 with the budget two-thirds unspent, and — because deferral requires one ready route — would have deferred nothing either. Instead it served three ready routes with eight deferred behind them, and the provisioning step passed. The run still failed, but downstream of preflight, which is the capacity class rather than this one.

This is one boot, not a rate. What it does settle is the thing this pull request explicitly declined to claim. The body said: "Not claimed: that the ten unspent probes would have found a ready route inside that burst. No artifact answers it." One now does, for this boot: they did.

The cost did not materialize here. The Cost table bounded the worst case at ten silent probes ≈ 15 minutes. These ten answered fast (429, 404, and three ready), so the whole provisioning step took 2m35s. Note google/gemma-4-31b-it answered ready on both keys here despite answering TimeoutError in 15 of the 19 probes that reached it in the pre-merge artifacts — one more reason a refusal or a timeout is an answer about an instant.

retry_after_s: the providers publish nothing

Of the 8 rows with http_status: 429, 0 carry retry_after_s. So the follow-up this pull request deferred — spending the second pass after the delay the provider names — has no data to stand on: there is no published delay to wait for. That question can be closed unless a later sample shows otherwise, and the field stays as cheap evidence.

Still open

The healthy-minute invariant (c) is untested: this sample is a burst boot, and no healthy boot has yet run this code. That check is probed 16 / skipped 4 / ready 5–6 with postponed_probed_count absent or zero, and it is the one that would show the change costing something in ordinary minutes.

🤖 Measured by Claude Code

Copy link
Copy Markdown
Contributor Author

Independent pre-merge specimen of the exact defect this fixes, plus a triage caveat for anyone reading post-merge failures.

.github#1916@bf6e0477cb, noema-review preflight artifact (run 34027314566, job 101477373524):

candidate_count 24  probe_budget 16  probed_count 6   skipped_count 18
ready_count 0       rejected_count 6  deferred_count 0
escalation_budget 4 escalations_used 0  account_skip_after_429 2
target_ready 8

Six 429s in 678 ms (12:00:31.883 → 12:00:32.561), two per account across nvidia_nim / nvidia_nim_sub / openrouter, then the walk stopped with 10 of 16 probes and all 4 escalations unspent. Same shape as the 34016207820 sample in your docstring. (bytez died separately and earlier: provider_discovery_failed provider=bytez code=http_status_500.)

The caveat. That run executed at 12:00Z — 23 minutes after this PR merged (11:37:30Z) — so it reads at a glance like the fix not working. It isn't. The run was created at 10:24:07Z and waited ~94 minutes for a runner. pull_request_target pins the trusted base-branch scripts at event-creation time, so it ran the pre-#1957 launcher.

The report itself distinguishes the two versions unambiguously: a pre-#1957 report has no postponed_probed_count key and stops at probed_count 6; a post-#1957 report carries postponed_probed_count and spends the full 16.

So for the next few hours of triage: classify a preflight failure against the launcher version implied by the run's created_at, not its started_at. A merged fix only reaches runs created after the merge, and today's queue depth puts ~1.5 h between those two timestamps. I'm applying that rule to my own signature census and will report post-#1957 preflights separately once my currently-queued runs (created after 13:16Z) land.

One adjacent datum on why the pool state is genuinely volatile rather than dead: deepseek-ai/deepseek-v4-flash-0731 was 429-rejected on both NVIDIA keys at 12:00:31Z, and at 12:05:50Z the same model on the same repository (#1946@790ef33ea) was admitted and served for 1688.2 s before the gateway returned 503 (phase=response_error, caller attempts=1). Five minutes apart, opposite outcomes — the pool alternates on a minute scale, which is what makes spending the whole probe budget worth it.


Generated by Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

Discriminator (c) answered: the healthy-minute walk is unchanged in production

The last open check on this change was whether an ordinary-minute boot costs anything under the new code. .github run 34033351322, created 12:31:23Z (54 minutes after this merged) and carrying postponed_probed_count, is that boot.

candidate 24 / probed 16 / skipped 4 / postponed_probed 0 / ready 6 / deferred 2 / rejected 8
first pass = 16 - 0 = 16 probes        second pass: DID NOT RUN
ready: deepseek-v4-flash x2 keys, deepseek-v4-pro x2 keys, llama-3.2-11b x2 keys

postponed_probed_count is 0, so the second pass never started, and the shape is identical to the pre-merge healthy boots (probed 16 / skipped 4 / ready 5–7). The pull request body claimed "the healthy-minute walk is unchanged, because its sixteen probes are spent before a second pass could start". That is now measured rather than argued.

The cost in this boot is real but not from this change

Provisioning took 14:06:21Z → 14:21:17Z (14m56s), with probes spanning 14:11:57.781Z to 14:20:52.245Z. The reason is visible in the rows: four probes answered TimeoutErrorgemma-4-31b-it and llama-3.2-90b-vision-instruct, on both keys — which is the silent class this pull request's Cost table bounded at about 90 s each.

All four are in the first pass. The old launcher probed the same sixteen candidates in a healthy boot, so this cost is pre-existing and would have been paid identically before this change. What the Cost table warned about was ten additional silent probes in a second pass; here the second pass did not run at all.

Cross-checks from the same artifact

  • gemma-4-31b-it timed out on both keys here, while it answered ready on both keys in the burst boot at 11:49Z. A timeout, like a refusal, is an answer about an instant.
  • One escalation was spent (escalations_used: 1, row 2 at attempts: 2), so the escalation budget still reaches the first pass, which is the behaviour the reserved-escalation branch was written to protect.

Where the after-samples stand

Five boots now carry the field: ready 3, 1, 1, 1 in four burst boots whose second pass ran, and ready 6 in this healthy boot where it did not. Reporting only the best of them would overstate the effect; the honest summary is that the change converts ready 0 into ready 1–3 in bursts and leaves ordinary minutes untouched, while the target of 8 remains unmet in every boot observed.

🤖 Measured by Claude Code

Copy link
Copy Markdown
Contributor Author

Post-merge production result, as promised. The provisioning shape this PR targets is gone from the window; what remains is a different class.

All noema-review.yml runs created after the merge (created:>2026-09-06T11:37:00Z), read at 14:30Z:

42 runs   4 success   3 failure   18 cancelled   14 queued   3 in_progress

The 18 cancelled are concurrency supersedes, not outcomes. Of the 7 that reached a terminal verdict, 4 succeeded — the first being 34030785256 on this PR's own branch at 11:37:33Z.

The three failures, by annotation:

run duration served_model outcome
34031339240 637.0 s google/gemma-4-31b-it HTTP 502, phase=response_error, caller attempts=1
34031968160 1830.0 s google/gemma-4-31b-it HTTP 502, phase=response_error, caller attempts=1
34032092309 2043.5 s deepseek-ai/deepseek-v4-flash-0731 HTTP 502, phase=response_error, caller attempts=1

Zero of the three are the ready 0 provisioning shape. Every one got a route, spent 10–34 minutes on it, and died at the gateway. Preflight is no longer the thing that fails.

That matters for where the next fix goes. The residual class is the serving stall: one route admitted, caller attempts=1, no failover, and a 502/503 only after the full attempt burns. It is the same shape as the pre-fix #1946 sample (1688.2 s → 503 on the same deepseek-v4-flash-0731), which means that sample was never really a preflight problem either. Read against contextual-orchestrator at pin 414f2297, the mechanism is that a bare TimeoutError on the tool-bearing passthrough is re-raised as 500 internal_error before _record_failure (orchestrator.py:8048) runs, so the per-agent breaker never counts it — contextual-orchestrator#1082's scope. My four Strix samples recorded 0 of 21, 0 of 48, 0 of 63 and 0 of 65 timeouts as circuit_failure on that path, against 9/14 and 10/15 on the no-tools _invoke route-walk.

One caveat on the sample: 14 of the 42 are still queued and 3 in progress, so this is the first ~50 minutes of post-fix evidence, not a settled rate. I'll re-read once my own queued heads (#1913@d06e8ea02, #1938@37f7b0dc3, both created after 13:16Z) land, and will say so if the shape distribution changes.


Generated by Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

Mechanism note: the second pass gains breadth, not time

Worth pinning before anyone reasons about this later, because the two explanations look alike and only one is possible.

Measured on the burst boot (.github strix run 34032092337):

first pass, last probe   13:42:16,278   openrouter dots-3-note-preview   (candidate 6)
second pass, first probe 13:42:16,344   nvidia_nim google/gemma-3-12b-it (candidate 7)
                                gap =  0.066 s

All sixteen probes finish inside one second. Against that, the refusal window measured on a single route lasted 3 m 32 s: nvidia_nim deepseek-v4-flash-0731 answered 429 at 13:42:15 in this boot and ready at 13:45:48 in its sibling (noema run 34032092309, same pull request, created the same second). The second pass therefore begins about 3,200× too early to be waiting anything out.

So when a burst boot ends with one to three ready routes, that is not the burst having passed. It is the second pass probing different candidates — the ones the account rule had set aside, which in these artifacts are the gemma and llama rows rather than the deepseek and OpenRouter rows that were refused. The gain is breadth within the burst, not elapsed time.

This also states more precisely why the delayed second pass stays out of scope. A delay would add a second, different benefit that this change does not provide, and the measured burst length says such a delay could plausibly help. What blocks it is unchanged: retry_after_s is empty in every 429 row across three artifacts, so there is no provider-published interval to key on, and inventing a constant is what ADR-0003 and ADR-0005 forbid. If that follow-up is ever reopened, the thing to measure first is whether any provider begins publishing the header.

Credit for the correction to the lane that measured the 66 ms gap; my earlier figure of about 1.2 s for ten refused probes was the cost of the second pass, not its offset, and the two are easy to conflate.

🤖 Measured by Claude Code

Copy link
Copy Markdown
Contributor Author

Correction to my earlier comment, and the first post-merge preflight artifact — which confirms this fix works.

The correction first. I wrote that the residual failures are "one route admitted, caller attempts=1, no failover". The "no failover" part is wrong. caller attempts=1 counts sidecar→gateway calls, not gateway-internal routing. A sidecar log I captured at 15:00Z shows roughly twenty provider attempts across four distinct agents inside a single caller attempts=1 request. The gateway does fail over. Anyone reading my earlier comment as evidence that failover is broken should disregard that sentence.

Related, and it affects how those annotations should be read: served_model in a failure annotation names the last route attempted, not one that served. In the run below the annotation says served_model=deepseek-ai/deepseek-v4-flash-0731 while the ready route was meta/llama-3.2-11b-vision-instruct — deepseek was only the terminal failover target. So the served-model column in my earlier table identifies terminal candidates, not servers.

I am also downgrading the "~1800 s clustering" reading I recorded privately and never posted: since those durations span an internal failover walk rather than one model call, they are not evidence of an upstream per-call ceiling.

Now the artifact. noema-sidecar-evidence from .github#1913@d06e8ea02, run 34035364476 (created 13:11:59Z, executed 14:56–15:01Z), against my own pre-fix #1916 sample:

#1916 12:00Z (pre-fix) #1913 15:00Z (post-fix)
probed_count 6 16 (full budget)
postponed_probed_count (absent) 10
deferred_count 0 8
rejected_count 6 7
skipped_count 18 8
ready_count 0 1

Every one of those moved the way this PR intended: the postponed tail is probed, transient 429s defer instead of banning the account, the full budget is spent, and the walk finds a ready route where the pre-fix walk stopped at zero. This is the direct A/B I said I would produce.

Where it still falls short is target_ready: 8 versus ready_count: 1. With a single ready route, the serving phase attempted llama-3.2-11b eleven times over 105 s, recorded circuit_failure … failures=1.0 threshold=3, then failed over into the deferred set — candidates already known to be rate-limited — and ended request_failed status=429 code=rate_limit_exceeded. Deferring rather than banning is right; the deferred pool is not yet a usable failover target when it is the only place left to go.

One measurement from the same log, offered because it pins a number this discussion keeps estimating. Preflight probes of google/gemma-4-31b-it on both NVIDIA keys ended in TimeoutError at 90.090 s and 90.115 s. That is a real per-attempt cost, and it is what makes TaskOrchestrator's circuit_reset_seconds = 30.0 too short to keep a stalled route excluded — the counter clears well before the next attempt on that route finishes.

Updated tally, terminal noema-review outcomes for runs created after the merge, read at 15:10Z: 4 success, 5 failure. Still provisional — a good many runs from the window are unfinished — and, per the correction above, I am no longer characterizing the failures as failover-less.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant