Skip to content

fix(batch): raise on download failure instead of a silent empty result - #957

Merged
seonghobae merged 11 commits into
mainfrom
fix/batch-retrieval-failure-and-usage-honesty
Sep 1, 2026
Merged

seonghobae merged 11 commits into
mainfrom
fix/batch-retrieval-failure-and-usage-honesty

Conversation

@seonghobae

Copy link
Copy Markdown
Contributor

Summary

Two related "hollow path" bugs found while auditing the batch/cost-review hub for cases where a request path reports success or a plausible-looking cost without the underlying evidence actually existing (per this repo's own Honest metrics convention).

Bug 1 — a batch download failure was indistinguishable from a legitimately empty result.
PgLlmBatchBackend.retrieve() and PgLlmBatchEmbeddingBackend.retrieve() returned a bare [] whenever the injected client reported {"success": false, ...} — the same shape returned by a batch that genuinely completed with zero items. For embeddings this was severe: CostRoutingCoordinator.embeddings_batch_document() cached that fabricated zero-vector result under status: "completed", and the cache short-circuits every later poll/retrieve call before ever touching the backend again — so one transient download failure permanently poisoned that batch_id with no way to recover except an out-of-band cache clear.

New BatchDownloadError(job_id, reason), raised instead of []. retrieve_batch() lets it propagate (mirroring how an unknown/unowned job id's KeyError already propagates); server.py maps it to 502 batch_download_failed. embeddings_batch_document() catches it, returns status: "failed" with an error field, and deliberately does not cache the result — a failed retrieval stays retryable.

Bug 2 — LocalBatchBackend (the default backend for every standalone/self-hosted deployment without pg-llm-batch) discarded real usage.
orchestrator.complete()'s result has no top-level usage key — real provider usage is nested per workflow step in result["trace"][i]["usage"]. LocalBatchBackend.submit() never read it, so every local-batch result carried the dataclass's 0/0 token defaults. BatchResultItem now aggregates real prompt_tokens/completion_tokens from trace. Separately, retrieve_batch()'s heuristic-estimate fallback (triggered whenever a batch item reports no usage) always estimated from a hardcoded blank "" prompt regardless of what was actually asked — every such row was labeled measurement_status="estimated" while being a constant ~3-token estimate, misrepresenting what "estimated" means to a buyer reading the ledger. BatchResultItem now carries the original request messages through so the fallback estimates from the real prompt.

Developer experience

  • BatchDownloadError exported from the package (contextual_orchestrator/__init__.py).
  • New/extended tests: tests/test_batch_routing.py, tests/test_batch_routing_boundaries.py, tests/test_cost_router.py, tests/test_cost_router_boundaries.py.
  • python -m pytest tests -q: 2833 passed, 1 skipped, 1 pre-existing unrelated failure (test_psychometric_routing.py::test_fast_mlsirm_fit_uses_judge_acceptance_item_for_context_scoreModuleNotFoundError: No module named 'fast_mlsirm', the same pre-existing environment gap already confirmed unrelated in #955 and #956's own validation).
  • Rebuilt on current origin/main (c6c3a0c9), no conflicts.
  • No open PR touched batch_routing.py::retrieve/cost_router.py::retrieve_batch/embeddings_batch_document before this one (checked before opening).

User experience

  • A batch caller now gets a 502 batch_download_failed (with the job id and reason) on a real download failure instead of a misleading 200 with an empty/zero-item result, and can retry — previously an embeddings batch could get stuck permanently reporting zero-vector "completed" results.
  • Local (non-pg-llm-batch) deployments now get honest, non-zero token counts and honestly-estimated costs in batch results instead of silent zeros/mislabeled estimates.

Test plan

  • python -m pytest tests -q (2833 passed / 1 skipped / 1 pre-existing unrelated failure)
  • interrogate — repo docstring-coverage gate unaffected by this change's scope

Generated by Claude Code

PgLlmBatchBackend.retrieve() and PgLlmBatchEmbeddingBackend.retrieve() used
to convert an explicit backend download failure ({"success": false, ...})
into a bare `return []`, indistinguishable from a batch that legitimately
completed with zero items. For embeddings this was severe:
embeddings_batch_document() cached the resulting fabricated zero-vector
result under status="completed", permanently poisoning that batch_id since
the cache short-circuits all future poll/retrieve calls.

Add BatchDownloadError (job id + backend-reported reason), raised instead of
the silent []. retrieve_batch() lets it propagate (server.py maps it to 502
batch_download_failed); embeddings_batch_document() catches it, returns
status="failed" with no caching, so a failed retrieval stays retryable.

Also: LocalBatchBackend (the default backend for standalone deployments)
was discarding real per-request usage from orchestrator.complete()'s nested
result["trace"][i]["usage"], leaving BatchResultItem's token counts at their
0 default. It now aggregates the real counts, and BatchResultItem carries
the original request messages through so retrieve_batch()'s heuristic
estimate fallback estimates from the real prompt instead of a hardcoded
blank "" placeholder.
@coderabbitai

coderabbitai Bot commented Aug 31, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 50 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: 80a39440-7dd1-4d20-a177-27bcca6f3f32

📥 Commits

Reviewing files that changed from the base of the PR and between 2919b66 and 53930b1.

📒 Files selected for processing (12)
  • CHANGELOG.md
  • contextual_orchestrator/__init__.py
  • contextual_orchestrator/batch_routing.py
  • contextual_orchestrator/cost_ledger.py
  • contextual_orchestrator/cost_router.py
  • contextual_orchestrator/server.py
  • tests/test_batch_routing.py
  • tests/test_batch_routing_boundaries.py
  • tests/test_cost_review_server.py
  • tests/test_cost_router.py
  • tests/test_cost_router_boundaries.py
  • tests/test_metering.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.

Copy link
Copy Markdown
Contributor Author

opencode-review failed on the current head (0a3b31ed) — same signature as ContextualWisdomLab/.github#1485 (zero reviews exist yet, job failed seconds after starting). Not a defect in this PR's diff. A root-cause fix for #1485 is already in progress (a workflow_run second-chance re-entry for opencode-review.yml, mirroring noema-review.yml's existing pattern); once it merges this class of failure should stop recurring across the org. Queued one re-run of the failed job in the meantime.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

@opencode-agent please review this draft PR.


Generated by Claude Code

@seonghobae
seonghobae marked this pull request as ready for review August 31, 2026 16:57
test_batch_routing_jobs_endpoint_submits_multiple_requests hardcoded
"one ledger row per batch item," which the per-trace-step usage
attribution contract already documented in
CostRoutingCoordinator.complete() contradicts: each conduct-mode trace
step is a separate billable provider call. Submitting 2 requests
through /api/v1/batch_routing_jobs with the default single-agent mock
pool triages to the conduct path (4 steps each), producing 8 ledger
rows, not 2 -- this was breaking CI on the latest commit. The
assertion now derives its expectation from the retrieved results' own
usage_record_ids, matching the fix already applied to the sibling
assertions in tests/test_cost_router.py.
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

Copy link
Copy Markdown
Contributor Author

noema-review failed on 70003ef7 — same root cause just observed on #961's noema-review run minutes earlier, not specific to this PR's diff. The orchestrator/free provider-route preflight rejected 10 of 12 candidate routes this time (TimeoutError/HTTP 404/429 across nvidia_nim/nvidia_nim_sub), and the actual review LLM call then also timed out after 120s at the same noema_review_gate.py:656 call_llm() call site:

TimeoutError: timed out

Two independent PRs hitting the identical failure signature within ~15 minutes points to live orchestrator/free pool degradation right now, not a defect in either PR under review — neither #957's diff (batch retrieval accounting) nor #961's (batch_route judge verification) touches the review gateway or provider routing. One recurring detail worth flagging separately from this transient timeout: google/gemma-3-12b-it and google/gemma-3-4b-it (both nvidia_nim and nvidia_nim_sub) returned HTTP 404 — not a timeout — on both runs, which usually means a retired/invalid model id rather than transient load; if that's stable across future runs too, it's worth excluding those ids from the free-pool candidate set so they stop consuming preflight budget and denying diversity margin to routes that would actually succeed.

Re-ran the failed job once (rerun_failed_jobs on run 33419886086).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

noema-review failed identically on the re-run (70003ef7, run 33419886086, attempt 2) — same TimeoutError at noema_review_gate.py:656 call_llm(), same root cause flagged in my previous comment.

That HTTP 404 observation is now fixed in #979 (draft, fully validated): a hand-maintained denylist excludes the two known-retired NVIDIA NIM ids (google/gemma-3-12b-it, google/gemma-3-4b-it) from ever being selected, plus closes a related gap where a non-text-input-only model could exhaust the same blind review pool. #979 hasn't merged yet, so it doesn't help this specific re-run. Already used this failure's one re-run; not re-running again without a state change — watching #979 to landing, which should let this self-heal, and will re-check noema-review once it does.


Generated by Claude Code

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

@seonghobae

Copy link
Copy Markdown
Contributor Author

Cross-PR integration contract: routing identity is provider-neutral model_group only; do not add or preserve a provider-family abstraction. OpenRouter discovery must retain concrete free model IDs, while the aggregate openrouter/free router is not a serving candidate. OpenCode, Noema, and Strix must call contextual-orchestrator. Do not impose fixed wall-clock deadlines on inference, initial ping, readiness/health, provider discovery, or OpenRouter ZDR-list retrieval; use explicit cancellation and evidence-backed transport failure instead. Reconcile this PR with #971 and central .github #1508 before merge.

Copy link
Copy Markdown
Contributor Author

Acknowledged. This PR's diff (batch_routing.py::retrieve, cost_router.py::retrieve_batch/embeddings_batch_document) doesn't touch model routing identity, OpenRouter discovery, or any fixed wall-clock deadline — no changes needed here to satisfy the contract. #979 is where the actual model_group/provider-family and #971/#1508 reconciliation work is happening.


Generated by Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

Contextual-Orchestrator와 관계한 것들을 같이 손보든 어쩌든 해결하세요. Bypass merge 필요하면 가능 (chicken and eggs 상황이라면) + NVIDIA NIM 만 쓰는 건 허용하지 않아요. Contextual-Orchestrator를 쓰세요. Timeout은 적어도 3시간으로 잡으세요. 120초 같은 건 당황스럽군요. Opencode와 Noema 는 Coderabbitai 및 Devin 수준으로 실제로 리뷰를 하게 하시오. Strix도 보안 리뷰를 꼼꼼하게 하도록 하시오. 특히 보안 리뷰는 전체 코드로 수행하는 것입니다. Contextual-Orchestrator는 실시간으로 빠르면서 능력이 좋은 모델에 요청을 보내어 시간을 당기시오.

Copy link
Copy Markdown
Contributor Author

Update on head 922c88d3: both noema-review and opencode-review are failing again — noema-review with the identical TimeoutError at noema_review_gate.py:656 call_llm() already flagged twice above; opencode-review waited the full 90-minute polling window (180×30s) with no APPROVED/CHANGES_REQUESTED verdict from opencode-agent on this head at all, i.e. the dispatch never produced a verdict in time. Neither is caused by this PR's diff (batch_routing.py::retrieve, cost_router.py::retrieve_batch/embeddings_batch_document).

Correcting my own earlier comment: I previously pointed at #979 as the fix for the recurring NVIDIA NIM 404s feeding into this timeout chain. #979 was closed by the repo owner as policy-incompatible (hand-maintained provider denylist conflicts with the required provider-neutral model_group contract) — it will not land. The actual root cause (the central .github repo's scripts/ci/noema_review_gate.py still hard-codes opener.open(request, timeout=120), materialized into every target repo's noema-review job) is now being fixed centrally, with multiple PRs converging on it in ContextualWisdomLab/.github: #1507 (most advanced — 86 commits, actively updated, a verified monotonic-deadline + watchdog design with a real 4-hour Noema budget) and #1508 (a narrower timeout= removal, flagged by review as not actually bounding response.read()). Once whichever lands merges to .github main, this class of failure should stop recurring org-wide, including opencode-review's own dependency on the same orchestrator/free gateway.

No code change needed in this PR. Already spent this failure's one re-run earlier (33419886086); not re-running again since the cause is unchanged upstream infrastructure, not a flake — watching .github#1507/#1508 to landing and will re-check once one merges.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Genuine state change since my last comment: confirmed .github main just merged #1532, which raised the opencode-review verdict-poll loop from 180 to 660 attempts (90min → 5.5h wait budget). The opencode-review failure on this PR was the dispatch never producing a verdict within the old 90-minute window — this fix directly targets that. Re-ran the failed jobs on run 33432751577; noema-review's underlying timeout bug is unaffected by this (separate root cause, still tracked in .github#1507/#1508).


Generated by Claude Code

@seonghobae

Copy link
Copy Markdown
Contributor Author

흠.. 아직 Strix가 해결이 안 됐네요. 여긴.

Copy link
Copy Markdown
Contributor Author

"Strix가 아직 해결이 안 됐다"는 말씀 관련 현황입니다.

Strix Security Scan ContextualWisdomLab/contextual-orchestrator#957@f8ee07da... 런을 직접 조회한 결과, 코드나 워크플로 결함이 아니라 조직 전체 GitHub Actions 러너 용량 부족이 원인입니다. 같은 시각 .github/contextual-orchestrator 양쪽에서 수십 개 PR의 Job이 동시에 queued(대기) 상태였고, 이 PR의 Strix 런도 동시성 그룹 경합으로 cancelled 처리된 것을 확인했습니다. Required OpenCode Review/Required Noema Review도 같은 이유로 아직 러너 슬롯을 못 받아 queued 상태입니다.

이 PR 자체는 이미 mergeable_state: blocked(충돌 없음, 리뷰/체크 대기만 남음) 상태이고, 재실행을 반복하는 것은 혼잡을 더 키울 뿐이라 판단해 별도 재실행 없이 대기 중입니다. 큐가 풀리는 대로 자동으로 진행될 것으로 보이며, 계속 지켜보겠습니다.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

noema-review's TimeoutError: timed out at noema_review_gate.py:656's opener.open(request, timeout=120) is a known root cause, not a defect in this PR — the failing job materialized noema_review_gate.py from .github's trusted source at commit 7b1a028e (which still hardcodes the 120s socket timeout). .github main's current tip already fixes this: NOEMA_LLM_TIMEOUT_SECONDS = 4 * 60 * 60 at scripts/ci/noema_review_gate.py:36/967 (landed via #1507). Re-ran the failed job (rerun_failed_jobs) so it re-resolves the trusted source from the current main tip rather than the stale one. Watching for the result.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Correction to my earlier comment: the re-run did not pick up the fix. noema-review.yml's TRUSTED_SOURCE_REF resolves from github.workflow_sha (the job context's own workflow_sha), which is pinned to whatever .github commit was current when this run was originally createdrerun_failed_jobs re-executes the same run with that same pinned context, it does not re-resolve it against .github's current main. So this specific check will keep failing with the same TimeoutError until a genuinely new triggering event creates a fresh run (a new push to this PR, or the org's own required-workflow re-dispatch), not from another manual re-run — I won't re-run it again for this same cause.

The underlying fix is real and already on .github main (NOEMA_LLM_TIMEOUT_SECONDS = 4*60*60, confirmed at scripts/ci/noema_review_gate.py:36/967 on tip 2436454e) — this is purely a timing artifact of when this particular run was dispatched relative to when that fix landed, not a defect in this PR or an unresolved bug upstream. Keeping this PR watched; will act on the next real signal (a new push, or a fresh required-check run).


Generated by Claude Code

@seonghobae
seonghobae merged commit 57e4b9f into main Sep 1, 2026
22 of 25 checks passed
@seonghobae
seonghobae deleted the fix/batch-retrieval-failure-and-usage-honesty branch September 1, 2026 07:17

@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 5 new potential issues.

Devin Review

Comment on lines +892 to +895
persistence_settled = self.ledger.wait_for_usage_record_ids(
list(record_ids),
timeout=_BATCH_LEDGER_SETTLEMENT_TIMEOUT_SECONDS,
)

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.

🔴 Ledger read failures abort batch results

When wait_for_usage_record_ids encounters a ledger query error, it propagates after successful result download. Batch callers receive a server error instead of completed results.

Prompt for agents
Make batch ledger settlement tolerate lookup failures in CostRoutingCoordinator.retrieve_batch and CostLedger.wait_for_usage_record_ids. A successful backend result must still be returned with usage_persistence_status set to pending when the ledger lookup raises, matching the existing best-effort behavior for append failures. Preserve deterministic usage IDs and avoid hiding the persistence failure from telemetry.
Devin Review

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

Comment on lines +156 to +161
counts = self._provider_usage(usage)
if counts is not None:
race_usage.append({
"agent_id": endpoint_id,
"usage": {"prompt_tokens": counts[0], "completion_tokens": counts[1]},
})

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.

🟡 Unmetered race losers disappear from billing

When a local batch race loser reports missing or malformed usage, _run_local_batch drops it. Cost reports omit a completed billable provider call.

Prompt for agents
Preserve every completed local-batch race loser in _run_local_batch, including entries whose usage cannot be parsed. Carry explicit unavailable-measurement evidence into BatchResultItem.race_usage, then have retrieve_batch create a deterministic measurement_status="unavailable" ledger row with zero token placeholders. Aggregate batch cost status with unavailable taking precedence, as the synchronous race path already does.
Devin Review

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

Comment on lines +214 to +222
# The original request messages, carried through so a caller whose
# backend reports no usage (e.g. LocalBatchBackend against a mock/local
# runner) can estimate from the real prompt instead of a blank placeholder.
messages: List[Dict[str, str]] = field(default_factory=list)
# Local runs keep their per-call identities and usage intact so the cost
# router can meter heterogeneous conduct workflows one provider at a time.
trace: List[Dict[str, Any]] = field(default_factory=list)
cache_status: Optional[str] = None
race_usage: List[Dict[str, Any]] = field(default_factory=list)

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.

🟡 Existing batch result constructors misbind fields

Positional callers that supplied usage_valid now bind that value to messages. Retrieval can crash token estimation or misclassify provider usage.

Prompt for agents
Restore BatchResultItem positional compatibility by keeping usage_valid in its previous field position and appending the new messages, trace, cache_status, and race_usage fields after it. Update internal constructors to use keywords where practical and add a regression test that constructs the public dataclass with the former positional signature.
Devin Review

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

Comment on lines +248 to +287
def _validated_download_responses(
payload: Dict[str, Any],
*,
expected_custom_ids: set[str],
request_count: int,
job_id: str,
) -> List[Dict[str, Any]]:
"""Return a complete one-to-one set of successful downloaded rows."""
responses = payload.get("responses")
if not isinstance(responses, list):
raise BatchDownloadError(job_id, "malformed response list")
if request_count and not expected_custom_ids:
raise BatchDownloadError(job_id, "submitted request metadata is unavailable")

seen: set[str] = set()
for entry in responses:
if not isinstance(entry, dict):
raise BatchDownloadError(job_id, "malformed response row")
custom_id = entry.get("custom_id")
if (
not isinstance(custom_id, str)
or custom_id not in expected_custom_ids
or custom_id in seen
):
raise BatchDownloadError(job_id, "response identifiers do not match the submission")
seen.add(custom_id)
if entry.get("error") is not None:
raise BatchDownloadError(job_id, "one or more batch items failed")
response = entry.get("response")
if not isinstance(response, dict):
raise BatchDownloadError(job_id, "malformed response row")
status_code = response.get("status_code")
if status_code is not None and (
type(status_code) is not int or not 200 <= status_code < 300
):
raise BatchDownloadError(job_id, "one or more batch items failed")

if seen != expected_custom_ids or len(responses) != request_count:
raise BatchDownloadError(job_id, "download returned an incomplete result set")
return responses

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: Body validation remains downstream

_validated_download_responses validates row identity and transport success only. Empty answers or vectors retain the existing endpoint-specific handling.

Devin Review

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

self._embedding_part_counts = registry.mapping("embedding_part_counts")
self._embedding_part_limits = registry.mapping("embedding_part_limits")
self._embedding_documents = registry.mapping("embedding_documents")
self._batch_documents = registry.mapping("batch_documents")

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.

🔍 Batch document registry is unused

_batch_documents is never read or written. Deterministic ledger IDs provide idempotency, so this mapping misleadingly suggests response caching exists.

Devin Review

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

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.

3 participants