fix(orchestrator): bound route_once's combined retry/failover wall-clock - #974
fix(orchestrator): bound route_once's combined retry/failover wall-clock#974seonghobae wants to merge 12 commits into
Conversation
…lock Diagnosed against contextual-orchestrator#946's noema-review TimeoutError failures (four consecutive commits, live job-log confirmed: run 33382752122, job 99458567351). Enumerated the real worst case on main: ModelClient's own internal transport retry (up to max_retries+1 attempts at `timeout` seconds each) stacks with _invoke's independent RETRY_SAME_AGENT layer for the identical retryable-transport-failure classification, before _invoke's cross-candidate failover even starts trying a different agent -- with no combined ceiling anywhere. A single candidate agent's worst case alone (~543s with the review sidecar's actual un-tuned serving configuration) is already 4.5x a fixed 120s external caller timeout. Adds an opt-in, additive `deadline_seconds` parameter to TaskOrchestrator.route_once(), threaded into _invoke() as an absolute `deadline`, bounding the combined wall-clock time across route_once's own next-candidate loop, _invoke's cross-candidate failover, and its same-agent tool retry. Left None (the default), every existing caller's behavior is unchanged -- the full test suite (2863 passed, 1 skipped) passes unmodified. Does not change ModelClient's or TaskOrchestrator's existing retry/timeout defaults (general-purpose, consumed by other callers), and does not by itself close the loop on #946: the actual review-sidecar fix (tuning its serving ModelClient/TaskOrchestrator construction, and/or raising noema_review_gate.py's external timeout) lives in scripts/ci/contextual_orchestrator_review_launcher.py and scripts/ci/noema_review_gate.py in ContextualWisdomLab/.github, which this session was blocked from writing to (push-access request denied by policy). See the PR description for the exact follow-up change needed there. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KPmJErfkcHer4UVEgrQxUX
|
Warning Review limit reachedNext included review available in 7 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (8)
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. Comment |
|
Picked up the This PR's own Generated by Claude Code |
|
Update from the
Your Separately: this PR's own Generated by Claude Code |
route_once's combined-deadline feature (854def2 and prior) left three paths able to exceed the caller's deadline or silently swallow its expiry, all found by Devin review on contextual-orchestrator#974: 1. ModelClient.chat() called _send_with_retry with a deadline but no explicit timeout, so _send_with_retry's own min(timeout, remaining) cap never activated -- one slow attempt could consume the entire remaining deadline instead of failing over on schedule. Same gap in _send_raw_with_retry (the proxy_send/passthrough path). Both now pass self.timeout explicitly so each attempt is capped at min(self.timeout, remaining deadline); the deadline-less path is untouched to stay byte-for-byte identical, including for test doubles overriding _send with a narrower signature than the deadline parameter. 2. _proxy_send's local-provider execution-slot wait always used self.timeout regardless of a shorter route deadline, so queue acquisition alone could exhaust a short deadline before any request attempt was dispatched. New ModelClient._local_slot_timeout() derives the wait from the active deadline (min(self.timeout, remaining)) and is used at both local-provider call sites in _proxy_send. 3. route_once's _ranked_agents call (including any semantic-affinity embedding request) runs before the deadline-checking loop and isn't itself deadline-bounded; if it alone exhausted the deadline, the loop attempted nothing and route_once returned its default "no candidate attempted" payload as an ordinary (if rejected) result instead of raising -- indistinguishable from a legitimately-judged rejection. route_once now raises the same explicit RuntimeError _invoke uses in this case. Propagating the deadline into ranking/embedding transport itself is a separate, larger change and is not attempted here. Added docs/adr/0001-tool-execution-fallback-policy.md's ADR amendment citing Dean & Barroso (2013), already used elsewhere in this repo for the same tail-latency/bounded-retry-budget concern. New regression tests for all three in tests/test_tool_execution_fallback.py. Full suite: 2870 passed, 1 skipped (was 2868 before the 3 new tests). interrogate: 100%.
.claude/worktrees/ holds full repository checkouts from past isolated sub-agent sessions (this environment's Agent tool worktree isolation) -- local, ephemeral, environment-specific state, not repository content. Matches this file's existing .venv/, .hypothesis/, .codegraph/ pattern for local tooling directories.
|
Follow-up on this round's 4 findings (job
New regression test ( Generated by Claude Code |
…aced type Second round of Devin review findings on contextual-orchestrator#974's deadline-propagation feature (first round: 28cdde8): - route_once's three direct deadline-exceeded raises (no candidate attempted, during provider invocation, during real-time judging) now raise the new RouteDeadlineExceededError(RuntimeError) instead of a bare RuntimeError. It carries a .trace attribute with whatever trace rows were already recorded, so a worker completion that landed just before the deadline -- and so already incurred real, billable provider cost -- is recoverable by the caller instead of disappearing silently along with the exception. Being a RuntimeError subclass keeps every existing `except RuntimeError` caller, including this repo's own tests matching on RuntimeError, working unchanged. - _invoke's own deeper cross-candidate/same-agent-retry deadline RuntimeErrors (surfaced through route_once unwrapped) are intentionally left alone: unifying those touches every other caller of _invoke, not just this opt-in deadline_seconds feature. - Replied on the PR that the pre-dispatch _ranked_agents/embedding-call gap (bounding ranking's own execution time, not just failing closed once it overruns) is a separate, larger change out of scope here, given the ADR's own "a deadline never interrupts an attempt already in flight" invariant and _ranked_agents' use by other routing paths. - Added an explicit redistribution-permissibility note to the Dean & Barroso (2013) citation added in 28cdde8: it's a Communications of the ACM article (ACM copyright), not an arXiv preprint, so cite+link+summary without vendoring a PDF, matching docs/papers/README.md's existing policy for the same situation. New regression test proving .trace survives the raise. Full suite: 2871 passed, 1 skipped (was 2870). interrogate: 100%.
Follow-up to 63a77fe per Devin's confirmation on contextual-orchestrator#974: route_once's real-time-judging deadline check raised before setting row["realtime_judge"], so a judge verdict that had already completed (and already recorded its own quality-ledger observation inside _realtime_route_judge) was missing from RouteDeadlineExceededError.trace. Moved the assignment before the check, mirroring the worker-row fix. New regression test. Full suite: 2872 passed, 1 skipped (was 2871). interrogate: 100%.
Signed-off-by: Seongho Bae <me@seonghobae.me>
Signed-off-by: Seongho Bae <me@seonghobae.me>
|
Synced to the last few rounds of commits here. Given the pace here (a new comprehensive commit roughly every 15-40 minutes, each closing several Devin findings faster than my own edit→12-minute-full-suite→push cycle can keep up with without repeatedly producing redundant, discarded work), I'm stepping back from racing to fix each new individual finding on this specific file myself. You're clearly driving this precisely and quickly. I'll keep watching CI status and stay available if a finding stalls or you want a second pair of hands on something specific. Generated by Claude Code |
|
Cross-PR integration contract: routing identity is provider-neutral |
|
Flagging a possible tension with the owner's cross-PR architectural contract (posted on This PR's Not asking you to change course mid-flight — just making sure this reaches you before Generated by Claude Code |
|
Superseded by #971 and the organization no-wall-clock-deadline policy. |
Draft PR body (contextual-orchestrator)
Root cause (verified against contextual-orchestrator#946's four consecutive
noema-reviewTimeoutError: timed outfailures)scripts/ci/noema_review_gate.py'scall_llm()(inContextualWisdomLab/.github)issues exactly one HTTP request per review,
opener.open(request, timeout=120),against the vendored
contextual-orchestratorreview sidecar's/v1/chat/completions. That sidecar's launcher(
scripts/ci/contextual_orchestrator_review_launcher.py) constructs itsserving
ModelClient/TaskOrchestratorwith zero explicit overrides:unlike the same file's deliberately-bounded preflight client a few lines
above it (
timeout=REVIEW_PREFLIGHT_TIMEOUT_SECONDS=10, max_retries=0). Thatmeans the serving path runs on
ModelClient's general-purpose defaults(
timeout=90,max_retries=2) andTaskOrchestrator's defaulttool_retry_attempts=1-- none tuned to fit inside the caller's fixed 120sbudget.
Enumerated worst case (current
main, exact line numbers)ModelClient._send_with_retry(orchestrator.py:1603): up tomax_retries + 1= 3 HTTP attempts perchat()call for any exceptionis_transient_error()(orchestrator.py:1102) classifies transient --which explicitly includes
TimeoutError/socket.timeout(
orchestrator.py:1118). Backoff between attempts is_backoff_delay(
orchestrator.py:1648):uniform(0, min(8.0, 0.5 * 2**attempt)), ≤1.5stotal across 2 backoffs. Worst case per
chat()call: 3×90 + 1.5 =271.5s -- already 2.26x the external 120s budget from this layer alone.
_invoke's same-agent tool retry (orchestrator.py:6502-..., thelayer
route_onceactually calls): aProviderUpstreamError(retryable=True)-- which is exactly what
classify_provider_failure(provider_errors.py:265)returns for
TimeoutError/URLError/ConnectionError-- is classified byclassify_provider_transport_failure(tool_fallback.py:124-159) asRETRY_SAME_AGENT. Bounded byretry_limit = min(self.tool_retry_attempts, MAX_TOOL_RETRY_ATTEMPTS)(
orchestrator.py:6606); with the launcher's unchanged defaulttool_retry_attempts=1,retry_limit=1, i.e. 2 fullchat()calls peragent before failover. Worst case per agent: 2×271.5 + ~0.25s backoff
≈ 543.25s.
_invoke's cross-candidate failover (orchestrator.py:6614,for agent in candidates:): tries every eligible agent in the pool withno combined ceiling. The review sidecar's own catalog is bounded to
ORCHESTRATOR_CATALOG_LIMIT/REVIEW_PREFLIGHT_MAX_TOTAL_ROUTES= 12(
contextual_orchestrator_review_launcher.py:47,contextual_orchestrator_review_sidecar.sh:38). Worst case for one_invoke()call, every candidate failing: 12×543.25 ≈ 6519s.Even without layers 2-3, layer 1 alone (a single agent, one retry) is
2×90 = 180s > 120s. This matches the observed symptom exactly: thesidecar's own preflight step uses a genuinely bounded client
(
timeout=10s, max_retries=0) and a tiny prompt, so it correctly reports aroute "ready"; the same route, hit with the real review's much larger
payload (diff up to
MAX_DIFF_CHARS=60000 chars + context up toMAX_REVIEW_CONTEXT_CHARS=24000 chars, vs. the preflight's ~60-characterprobe) under the serving client's un-tuned defaults, can legitimately need
more than one 90s attempt -- and every such retry silently consumes budget
the external 120s caller has no visibility into and no way to recover from.
Both hypotheses in the investigation prompt are real and compounding:
this is a genuine layered-retry-vs-external-timeout mismatch (confirmed), and
it is worsened by a real preflight-vs-serving payload-size gap (also
confirmed) that makes a single attempt more likely to approach or exceed the
90s per-attempt ceiling in the first place, triggering the retry cascade
above.
Live evidence (job logs, not speculation)
Pulled directly from the most recent
noema-reviewfailure on #946 (run33382752122, job99458567351, vendored pin8cd99f139915131ba0239bce12a5d6a5fd85394e):ready, 10 rejected (7
TimeoutError, 3HTTPError 404) -- matching theinvestigation prompt's "2-3 ready routes out of 12" exactly.
python3 -m scripts.ci.noema_review_gatestep started at2026-08-31T10:57:24.216Z; theTimeoutError: timed outtraceback (fromnoema_review_gate.py:656'sopener.open(request, timeout=120)) landedat
2026-08-31T10:59:31.681Z-- 127.46s total step wall-clock. Thattime also covers this step's own several
gh api/gh graphqlcalls(fetching the PR, diff, changed files, review-thread context) before
call_llm()ever opens the sidecar connection, so the actual servingrequest's own in-flight time was somewhat under 127.46s but consistent
with a single attempt (or one attempt plus the start of a second) running
into the 90-120s range -- the minimal end of the enumerated worst case
above, not the full 12-candidate cascade. This is exactly what "layer 1
alone already exceeds 120s" predicts, and does not require layers 2-3 to
explain this specific occurrence (though they make the tail far worse).
main()'s top-level handler only catchesRuntimeError(
noema_review_gate.py:826);TimeoutErroris anOSErrorsubtype, nota
RuntimeError, so this failure surfaces as a raw, uncaught Pythontraceback rather than the gate's own clean, redacted error path -- a
secondary, unrelated diagnosability gap in the same function (not fixed
by this PR; noted for whoever picks up the
.github-side change below).Related prior art
ContextualWisdomLab/.github#1415("fix(noema): batch sidecar routepreflight", open, blocked/unmerged, conflicts with #1422) already gives the
serving
ModelClienttimeout=120, max_retries=0-- removing layer 1'scompounding. That alone is not sufficient: with
tool_retry_attemptsleft at its default 1, layer 2 (
_invoke's own same-agent retry) stillproduces
2×120 = 240sworst case for a single agent, still 2x the external120s budget, before layer 3 (cross-candidate failover) is even considered.
The fix
This repository cannot itself force the review sidecar's actual behavior
inside the 120s window: the values that need to change
(
ModelClient(timeout=..., max_retries=...)andTaskOrchestrator(..., tool_retry_attempts=...)) are constructed entirelyinside
ContextualWisdomLab/.github'sscripts/ci/contextual_orchestrator_review_launcher.py, which this PR doesnot touch. I attempted to open a companion PR there and was blocked: my
session's write access to that repository was explicitly denied (see below).
What this PR does add, entirely inside
contextual-orchestrator, is themissing primitive that a caller with a fixed external deadline needs and
does not currently have: an opt-in, additive
deadline_secondsparameter onTaskOrchestrator.route_once()(threaded into_invoke()as an absolutedeadline) that bounds the combined wall-clock time across every layerabove (same-agent retry and cross-candidate failover) to a caller-chosen
ceiling, instead of leaving it unbounded. Left
None(the default), everyexisting caller's behavior is byte-for-byte unchanged -- confirmed by the
full existing test suite passing unmodified.
This intentionally does not touch:
ModelClient's orTaskOrchestrator's existing default values (timeout,max_retries,tool_retry_attempts) -- these are general-purpose productdefaults consumed by other callers (
gyeot,scopeweave) I have novisibility into changing unilaterally.
immediate_racecapability-equivalence branch inside_invoke, whichalready has its own independent
deadline_seconds=self.client.timeoutbound on
race_first_validand does not yet observe the new parameter(documented as a known gap in the docstring).
What still needs to happen in
ContextualWisdomLab/.github(blocked, not done here)For PR #946's actual CI symptom to stop recurring, a follow-up change is
needed in
scripts/ci/contextual_orchestrator_review_launcher.py's servingclient construction, e.g.:
and, since even a single 120s attempt with zero internal retry cannot
guarantee completion strictly under noema_review_gate.py's own 120s
transport read (there is no margin left for TLS/JSON/GC overhead once the
provider call itself is allowed to run the full 120s),
noema_review_gate.py's
opener.open(request, timeout=120)should also grow a small margin(e.g. 130-150s) to stop failing closed on legitimate near-the-limit
completions -- again derived from the (now much smaller, single-attempt)
internal worst case, not guessed.
I could not make this change myself: attaching
ContextualWisdomLab/.githubwith push access was explicitly denied by this session's policy classifier.
A maintainer or a session with write access to that repository should apply
it, ideally reviving/rebasing #1415 with the
tool_retry_attempts=0(ordeadline_seconds) addition and thenoema_review_gate.pytimeout bumpabove.
Verification
python -m pytest tests -q --ignore=tests/test_psychometric_routing.py(that one file has a pre-existing, unrelated missing-
numpyimport gap inthis sandbox): 2863 passed, 1 skipped in 733.83s.
coverage run -m pytest ...: same pass count under instrumentation(746.04s);
interrogate contextual_orchestrator/orchestrator.py: 100%(109/109).
tests/test_tool_execution_fallback.py: 11 new deadline cases(already-expired deadline before the first attempt, cross-candidate
cutoff, same-agent-retry cutoff, a generous deadline that doesn't
interfere, and 7 invalid-
deadline_seconds-value rejections).tests/test_measured_routing_evidence.py: 1 new case for route_once'sown outer judge-driven-failover loop (distinct code path from
_invoke's cross-candidate/same-agent loops above).git diff --check: clean.Refs: contextual-orchestrator#946, ContextualWisdomLab/.github#1415,
ContextualWisdomLab/.github#1455, ContextualWisdomLab/.github#1449.
Generated by Claude Code