feat(criteria): run judge criteria concurrently instead of serializing them - #58
feat(criteria): run judge criteria concurrently instead of serializing them#58akshaylive wants to merge 2 commits into
Conversation
|
Claude finished @akshaylive's task in 1m 29s —— View job Code Review in Progress
|
…g them llm_judge and agent_judge each make a blocking network/subprocess call inside SuccessChecker.check_all's single asyncio.to_thread offload, so a task with multiple judge-type criteria ran their LLM calls serially and pinned a thread-pool thread for the full wait. BaseCriterion gains an async check_async path (supports_native_async) that judge checkers override with AsyncAnthropic / httpx.AsyncClient / an async SubAgentRunner.run_async, and SuccessChecker.check_all_async gathers all native-async criteria concurrently while the remaining sync criteria still share one to_thread slot. Closes #55 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
692bf0b to
1af54fb
Compare
uipreliga
left a comment
There was a problem hiding this comment.
Review: coder_eval — pr:58
Scope: pr:58 · branch akshaya/async_criteria · c34b030 · 2026-07-28T08:13Z · workflow variant
Change class: complex — introduces a parallel async criterion-checking path (new check_async/_check_impl_async seam, asyncio.gather over native-async judges, new async Anthropic/Bedrock/sub-agent I/O clients) and changes the orchestrator's three evaluation call sites; correctness requires concurrency and error-propagation reasoning.
The codebase is healthy — clean typing, near-perfect test health, no security findings, and a well-factored plugin/merge architecture — but the new judge-concurrency seam is where that quality breaks down: one asyncio.gather at src/coder_eval/evaluation/checker.py:225 overlaps sandbox-mutating sync criteria with judges reading/snapshotting the same directory and abandons in-flight judge subprocesses on infrastructure errors, so identical agent output can now score differently, while every newly-live async judge path (including the Bedrock nightly route) ships at 0% coverage behind ~300 lines of hand-copied sync/async twins; fix that one seam and cover the async half and this is a 9.5+ change.
Summary
| Axis | Score | 🔴 | 🟠 | 🟡 | 🔵 | Top Issue |
|---|---|---|---|---|---|---|
| 1. Code Quality & Style | 8.4 / 10 | 0 | 1 | 1 | 1 | Judge stack forked into hand-maintained sync/async twins (~280 near-verbatim lines across 6 sites), with the sync halves now unreachable from production |
| 2. Type Safety | 9.8 / 10 | 0 | 0 | 0 | 2 | New _build_result helper takes 8 same-typed positional parameters, so an argument transposition on the judge scoring path type-checks silently |
| 3. Test Health | 9.9 / 10 | 0 | 0 | 0 | 1 | Concurrency tests gate on wall-clock with a 100 ms absolute margin under -n auto |
| 4. Security | 10 / 10 | 0 | 0 | 0 | 0 | — |
| 5. Architecture & Design | 9.4 / 10 | 0 | 0 | 1 | 1 | Native-async judge paths call the blocking sync _prepare (file reads + reference-dir rglob) directly on the event loop |
| 6. Error Handling & Resilience | 8.9 / 10 | 0 | 1 | 0 | 1 | asyncio.gather without return_exceptions/cancellation at checker.py:225 abandons in-flight sibling criteria on JudgeInfrastructureError, orphaning an evaluator-credentialed judge subprocess and sandbox copy into orchestrator teardown |
| 7. API Surface & Maintainability | 9.5 / 10 | 0 | 0 | 1 | 0 | New async criterion-authoring seam (_check_impl_async / supports_native_async) and the new same-instance concurrent-reentry contract are absent from docs/EXTENDING.md § 2 and CLAUDE.md, whose Evaluation Flow still names the now-unused check_all() |
| 8. Evaluation Harness Quality | 8 / 10 | 0 | 2 | 0 | 0 | check_all_async overlaps the sandbox-mutating sync criterion batch with judge criteria reading/copying the same sandbox dir, so what a judge grades depends on interleaving |
Overall Score: 9.2 / 10 · Weakest Axis: Evaluation Harness Quality at 8 / 10
Totals: 🔴 0 · 🟠 4 · 🟡 3 · 🔵 6 across 8 axes.
Blockers
- [Axis 1] Judge stack forked into hand-maintained sync/async twins (~280 near-verbatim lines across 6 sites), with the sync halves now unreachable from production (
src/coder_eval/evaluation/checker.py:336) —_check_single_async(checker.py:336-405) is a line-for-line copy of_check_single(checker.py:251-335);diffof the two spans shows the only logic difference isresult = checker.check(→result = await checker.check_async((checker.py:276 vs :352) — the ~50 lines of threshold assignment,logger.info("Criterion '%s' %s (score=%.2f, threshold=%.2f): %s", ...), theexcept KeyErrorblock,except JudgeInfrastructureError: raise, and theexcept Exceptionblock are identical (the copy also strips every explanatory comment, e.g.# V3: Catch ALL exceptions, including checker __init__ failures). The same copy-paste is repeated at four more sites in this PR:criteria/base.py:102-152(handle_criterion_errors_asyncvs:49-99, differing only inawait func(and the.check()→.check_async()log string);evaluation/sub_agent.py:207-271(run_asyncvsrunat :117-205 — and the copy silently drops the load-bearing security rationale comments, including "Deliberately NOdirs_exist_ok=Truehere" and the symlink/_referencedefense-in-depth notes, so a future editor ofrun_asynchas no record of why those flags are set);evaluation/judge_bedrock.py:118-181(whole request body + retry loop + response validation duplicated from :67-115, differing only inawait client.postandawait asyncio.sleep);evaluation/judge_anthropic.py:58-90vs :21-55. Compounding it: after this PR nothing insrc/calls the sync entry points any more —grep -rn 'success_checker\.\|checker\.check' src/coder_eval/returns only the threecheck_all_asynccall sites inorchestrator.py:1349/1407/1499, soSuccessChecker.check_all,LLMJudgeChecker._check_impl,AgentJudgeChecker._check_impl,SubAgentRunner.run,invoke_bedrock_judgeandinvoke_anthropic_judgeare production-dead, whilegrep -rn 'invoke_bedrock_judge_async\|invoke_anthropic_judge_async\|run_async' tests/returns nothing — i.e. the duplicated half that still has all the tests is the half that no longer runs. Fix: keep one implementation per concern — implement the sync entry points as thin bridges over the async ones (def run(...): return asyncio.run(self.run_async(...))in sub_agent.py; likewise for the twoinvoke_*_judgefunctions and the judges'_check_impl), and extract the shared result-finalization/error-mapping into helpers used by both_check_single/_check_single_asyncand both decorators. - [Axis 6] asyncio.gather without return_exceptions/cancellation at checker.py:225 abandons in-flight sibling criteria on JudgeInfrastructureError, orphaning an evaluator-credentialed judge subprocess and sandbox copy into orchestrator teardown (
src/coder_eval/evaluation/checker.py:225) —await asyncio.gather(run_sync_batch(), *(run_async_one(i) for i in native_async_indices))(checker.py:225) has noreturn_exceptions=Trueand is not a structured-concurrency scope._check_single_asyncdeliberately re-raisesJudgeInfrastructureError(checker.py:385-386), andinvoke_bedrock_judge_asyncraises it on any non-retryable HTTP status (judge_bedrock.py:173:raise JudgeInfrastructureError(f"Bedrock invoke failed: {response.status_code} ...")— i.e. an expired/invalid bearer token → 401/403) or on retry exhaustion (judge_bedrock.py:181). Per asyncio semantics the exception propagates immediately to the awaiter while the other children keep running: I confirmed this empirically (gather raised infra downprinted before the sibling'sfinallyran 0.5s later). So on a task withllm_judge+agent_judge, a fast llm_judge 403 abandons the in-flightagent_judge: itsSubAgentRunner.run_asynctask is not a child of the orchestrator task, so even the task-timeout watchdog'sloop.call_soon_threadsafe(self._task_to_cancel.cancel)(agents/watchdog.py:103) does not reach it. It keeps a live Claude Code CLI subprocess and a fullsub_agent_*copy of the sandbox in tmp for up toturn_timeout(default 300s, models/criteria.py:927-930), burning Sonnet tokens that are never attributed to any task, and — becauserun_batchshares ONE event loop across tasks gated byasyncio.Semaphore(config.max_parallel)(orchestration/batch.py:81) — it occupies no semaphore slot, so a nightly batch runs more concurrent judge subprocesses thanmax_parallel. The sync path had no such window:check_allran criteria serially, so a raise simply meant the later criteria never started. Fix: useasyncio.TaskGroup(cancels and awaits every sibling on first error, sorun_async'sfinally: await asyncio.to_thread(shutil.rmtree, judge_dir, ...)and_run_agent'sawait agent.kill()actually run; unwrap withexcept* JudgeInfrastructureError), orgather(..., return_exceptions=True)and re-raise the firstJudgeInfrastructureErroronly after every result has settled.orchestration/batch.py:171already usesreturn_exceptions=Trueas the in-repo precedent. Also add a test: the newtest_judge_infrastructure_error_propagates(tests/test_check_all_async.py:160-163) uses a single criterion, so it cannot detect an orphaned sibling — assert that a second, slow native-async criterion is cancelled/awaited when the first raises. - [Axis 8] check_all_async overlaps the sandbox-mutating sync criterion batch with judge criteria reading/copying the same sandbox dir, so what a judge grades depends on interleaving (
src/coder_eval/evaluation/checker.py:225) —await asyncio.gather(run_sync_batch(), *(run_async_one(i) for i in native_async_indices))(checker.py:225) runs the sync batch concurrently with the judge batch against ONE shared sandbox directory. The sync batch is not read-only:RunCommandChecker._check_impldoessandbox.run_command(criterion.command, timeout=criterion.timeout)(criteria/run_command.py:68) which issubprocess.run(command, shell=True, cwd=self.sandbox_dir, ...)(sandbox.py:933-943), andUiPathEvalCheckerdoessandbox.run_command(cmd)writinguipath_eval_output_<hex>.jsoninto the sandbox (criteria/uipath_eval.py:67-73). Concurrently,agent_judgesnapshots that same directory —await asyncio.to_thread(shutil.copytree, src_dir, judge_dir, symlinks=True, ignore=..., dirs_exist_ok=True)(sub_agent.py:223-230) — andllm_judgereads files out of it via_collect_sandbox_file→sandbox.file_exists(path)/sandbox.get_file_content(path)(judge_context.py:289-302). Two concrete failures on a task that mixesrun_command/uipath_evalwith a judge (the pattern docs/TASK_DEFINITION_GUIDE.md:930 explicitly recommends: "consider running it alongside cheaper structural checks rather than as the sole gate"): (a)copytreewalks a tree the sibling command is writing — a transient__pycache__/.pytest_cache/uvtemp file that vanishes betweenos.scandirandcopy2makes copytree raiseFileNotFoundError, whichhandle_criterion_errors_async(base.py:130-148) converts toscore=0.0, flipping a gating judge and the task'sfinal_statusto FAILURE for identical agent output; (b) with no crash, the judge's copy either contains or omitsuipath_eval_output_*.json/ build artifacts depending on interleaving, andllm_judgeintermittently records a graded file inctx.missing_files(judge_context.py:291) — so the LLM verdict itself varies run to run. Before this PRcheck_allwas strictly serial in YAML order (_check_all_sync, checker.py:236-237), making the ordering deterministic; sub_agent.py:128-130 even states the invariant this breaks in reverse: "The sub-agent never touches the original sandbox, so later criteria are unaffected by whatever it does." Fix: do not overlap the sync batch with the judge batch —await run_sync_batch()first, thenawait asyncio.gather(*(run_async_one(i) ...))— or gate the overlap on the sync set containing no sandbox-mutating criterion type (run_command,uipath_eval). Judges may still run concurrently with each other, since each takes its ownmkdtempcopy. - [Axis 8] Every newly added async path (llm_judge/agent_judge
_check_impl_async,invoke_anthropic/bedrock_judge_async,SubAgentRunner.run_async, base to_thread seam, check_all_async forwarding) has zero test coverage while the suites drive the now-dead sync twins (src/coder_eval/evaluation/sub_agent.py:207) — All three orchestrator call sites now usecheck_all_async(orchestrator.py:1349, 1407, 1499) and both judges declaresupports_native_async = True(llm_judge.py:62, agent_judge.py:102), soSuccessChecker.check_all,LLMJudgeChecker._check_impl,AgentJudgeChecker._check_impl,SubAgentRunner.run(sub_agent.py:117),invoke_bedrock_judgeandinvoke_anthropic_judgehave no remaining caller insrc/(verified by grep: the onlyrunner.run(/invoke_bedrock_judge(/invoke_anthropic_judge(hits outside their own definitions are intests/). Meanwhile every existing test drives exactly those dead entry points: all 18 tests intests/test_sub_agent_runner.pycallrunner.run("grade", ...)(lines 81, 126, 177, 210, 226, 248, 274, 295, 320, 402, 432, 458, 490, 542, 581), including the security-floor teststest_runner_copytree_drops_nested_symlinks(line 409),test_runner_excludes_nested_claude_and_mcp(line 464) andtest_runner_handles_sandbox_side_reference_collision(line 134); andtests/test_llm_judge_criterion.py:103,413/tests/test_agent_judge_criterion.py:418usechecker.check()/checker.check_all(). The live twinasync def run_async(sub_agent.py:207) is a full hand-copy ofrun()and is entirely uncovered (coverage: sub_agent.py missing 218-270), as areinvoke_bedrock_judge_async(judge_bedrock.py missing 136-181 — the nightly's--backend bedrockroute),invoke_anthropic_judge_async(missing 75-90) and_invoke_tool_channel_async(llm_judge.py missing 285-316). I diffed the twins and found no behavioural drift today, but any future hardening applied to one side (e.g. tightening the_referenceclear at sub_agent.py:232-243) will silently change nightly scores with a green suite. Fix: parametrizetests/test_sub_agent_runner.py,tests/test_llm_judge_criterion.pyandtests/test_agent_judge_criterion.pyover sync/async (@pytest.mark.parametrize("mode", ["sync", "async"])dispatchingrun/run_asyncandcheck/check_async), or delete the now-dead sync twins so there is a single implementation.
Non-blocking, but please consider before merge
- [Axis 1]
supports_native_asyncis a second source of truth for information derivable from the_check_impl_asyncoverride, with nothing guarding the pair (src/coder_eval/criteria/base.py:197) —base.py:197addssupports_native_async: ClassVar[bool] = False, and the only two subclasses that set it (llm_judge.py:62,agent_judge.py:102) are exactly the two that override_check_impl_async(llm_judge.py:88,agent_judge.py:137). The flag is therefore fully derivable:type(checker)._check_impl_async is not BaseCriterion._check_impl_async. Because it is a separate declaration, it can drift, and the drift is silent in the dangerous direction: a criterion (in-tree or acoder_eval.pluginsthird party) that overrides_check_impl_asyncwith a real async client but forgets the flag is routed bychecker.py:209(native_async_indices = [i for i, c in enumerate(criteria) if _is_native_async(c.type)]) intorun_sync_batch→_check_all_sync→_check_single→checker.check(...)(checker.py:276), i.e. its blocking_check_impltwin runs and the async implementation is never executed — no error, just the concurrency win quietly lost. This repo already rejects this shape twice over:criteria/__init__.py::validate_registry's docstring ("the previous fallback had already rotted ... proving a second source of truth cannot be trusted"), and lint ruletests/lint/rules/ce025_live_verdict_consistency.py, which exists solely to keep the identical ClassVar↔override pair (live_stop_polarities/live_verdict) consistent. Fix: drop the ClassVar and derive it in_is_native_async(checker.py:201-207) from the override identity; if it is kept for readability, add a CE0nn rule mirroring CE025 that fails whensupports_native_async = Trueappears without a_check_impl_asyncoverride and vice versa. - [Axis 5] Native-async judge paths call the blocking sync
_prepare(file reads + reference-dir rglob) directly on the event loop (src/coder_eval/criteria/agent_judge.py:146) —setup = _prepare(criterion, sandbox, reference_code, turn_records, context)(agent_judge.py:146, and llm_judge.py:97) is called with noto_thread, yet_preparerunsJudgeContextBuilder(...).build(...)—text = path.read_text(encoding="utf-8")(judge_context.py:191),content = host_path.read_text(encoding="utf-8")(judge_context.py:282) — andcollect_reference_secrets(reference_dir)(agent_judge.py:296), which rglob-reads up to_MAX_REFERENCE_FILES = 200/_MAX_REFERENCE_BYTES = 2 * 1024 * 1024(judge_context.py:124-125). Sincerun_batchruns all tasks on one loop (await asyncio.gather(*coroutines, return_exceptions=True), orchestration/batch.py:171), this stalls sibling tasks' streaming and watchdogs — a regression versus the old all-in-to_threaddesign. Wrap withawait asyncio.to_thread(_prepare, ...). CE002 (tests/lint/rules/no_blocking_io_in_async.py) misses it because the reads are one frame away in a sync helper — consider extending the rule. - [Axis 7] New async criterion-authoring seam (
_check_impl_async/supports_native_async) and the new same-instance concurrent-reentry contract are absent from docs/EXTENDING.md § 2 and CLAUDE.md, whose Evaluation Flow still names the now-unusedcheck_all()(src/coder_eval/criteria/base.py:289) —check_async(base.py:267),_check_impl_async(base.py:289) andsupports_native_async(base.py:197) extendBaseCriterion, which docs/EXTENDING.md § "2. Custom success criteria" advertises as the third-party extension point — but the PR changes no docs (git diff origin/main...pr-58 --name-only -- docs CLAUDE.mdreturns 0 files). Three surfaces are now stale/incomplete: (1) docs/EXTENDING.md's "Notes:" list (line 175) still tells authors only "- Do not overridecheck()— it's final and wraps_check_implwith error handling" (line 177) and documents the early-stop pair at line 186, with nothing about the async seam — an author writing a network-bound criterion has no way to know_check_impl_async+supports_native_asyncexist; (2) CLAUDE.md's "Adding a New Criterion" 4-step list (CLAUDE.md:221-224) likewise omits it; (3) CLAUDE.md's Evaluation Flow now misstates the live path — line 185 reads "2. SuccessChecker.check_all() → List[CriterionResult]" while all three orchestrator call sites (orchestrator.py:1349/1407/1499) callcheck_all_async. Separately, the change silently adds a NEW contract obligation that no doc states:_get_checker_instancereturns one cached instance per type (checker.py:245-249, "Checker instance (reused within this evaluation run)"), andcheck_all_asyncnow firesrun_async_one(i)for every native-async index in oneasyncio.gather(checker.py:225), so two criteria of the same type re-enter the SAME checker instance concurrently — verified by the new test assertingfake.calls == 2on a single instance (tests/test_check_all_async.py:104-113). Previously a checker instance was only ever entered serially; an out-of-tree checker that stashes per-call state onself(no in-tree checker does —grep "self.[a-z_]* *=" src/coder_eval/criteria/*.pyoutside base.py returns nothing) would now corrupt one criterion's result with another's. Add to docs/EXTENDING.md's notes list: the async seam (when to override_check_impl_asyncand setsupports_native_async), and an explicit "checker instances are cached per type and may be entered concurrently — keep them stateless/reentrant" line; fix CLAUDE.md:185 and CLAUDE.md:221-224. Note no lint rule guards docs/EXTENDING.md (CE030 covers only the YAML-facing modelsTaskDefinition/RunLimits/Dataset/SimulationConfigper tests/lint/doc_schema_parity.py:44-45, and CE028 covers only the generated docs-index tables), so this class of drift is currently unguarded.
Nits
- [Axis 1]
check_all_asyncindex bookkeeping: O(n²) partition,None-sentinel result list requiring atype: ignore(src/coder_eval/evaluation/checker.py:210) — checker.py:209-212 builds the partition with a list-membership scan and a nullable result buffer:sync_indices = [i for i in range(len(criteria)) if i not in native_async_indices](quadratic in the criteria count) followed byresults: list[CriterionResult | None] = [None] * len(criteria), which then forcesreturn results # type: ignore[return-value]at :226. A single-pass partition (for i, c in enumerate(criteria): (async_indices if _is_native_async(c.type) else sync_indices).append(i)) removes the membership scan, and gathering the two groups into separate typed lists and merging them removes both theNonesentinel and thetype: ignore. Noting explicitly: radon's C(13) grade on this function is not by itself a defect — the function reads clearly and the "sync criteria share oneto_threadslot" design (which is what forces the index bookkeeping) is a deliberate, correct choice, since concurrentrun_commandcriteria would race in the shared sandbox. - [Axis 2] New
_build_resulthelper takes 8 same-typed positional parameters, so an argument transposition on the judge scoring path type-checks silently (src/coder_eval/criteria/llm_judge.py:175) —_build_result(llm_judge.py:175-184) declares(criterion, judge_ctx, user_msg: str, scrub_key: str | None, verdict, parse_error: str | None, raw_verdict_text: str, judge_usage)— four adjacent slots that are allstr/str | None— and both new call sites pass all eight positionally (llm_judge.py:84-86andllm_judge.py:108-110, identical arg lists). Nothing in the type system distinguishesuser_msgfromraw_verdict_textorscrub_keyfromparse_error. Add a*aftercriterionso the risky arguments are keyword-only, matching this module's own convention (_invoke_tool_channelat llm_judge.py:225-231 and_invoke_tool_channel_asyncat llm_judge.py:276-282 are both*-only) and the sibling judge (agent_judge.py:341-350, which puts*beforescrub_secrets/system_prompt/user_msg/captureand is called with keywords at agent_judge.py:127-135 and 160-168). Candidate CE lint rule: flag a new module-private helper with >4 positional-or-keyword parameters where two or more share an identical annotation. - [Axis 2]
handle_criterion_errors_asyncerases the newcheck_asyncseam's signature toCallable[..., Awaitable[CriterionResult]], so its call site is unchecked and the "FINAL" contract is unenforceable (src/coder_eval/criteria/base.py:102) —base.py:102-104types the new decorator asdef handle_criterion_errors_async(func: Callable[..., Awaitable[CriterionResult]]) -> Callable[..., Awaitable[CriterionResult]]:. Because it is applied atbase.py:266toasync def check_async(base.py:267-274), the genericC-boundcriterionparameter and the whole parameter list collapse to..., so the only production call site —await checker.check_async(criterion, self.sandbox, reference_code, turn_records=turn_records, context=context)atsrc/coder_eval/evaluation/checker.py:346-352— gets no arity or keyword-name checking (a typo'dturn_record=would pass pyright and land in**-less code as a TypeError only at runtime). The docstring at base.py:275 says "This method is FINAL - subclasses must NOT override it", but the erased callable type also meansreportIncompatibleMethodOverride(enabled aswarningin pyproject.toml:254) can never fire on a subclass that overridescheck_asyncwith a wrong signature. Preserve the signature withParamSpec+Concatenate(def handle_criterion_errors_async[**P](func: Callable[P, Awaitable[CriterionResult]]) -> Callable[P, Awaitable[CriterionResult]], with the wrapper taking*args: P.args, **kwargs: P.kwargsand readingcriterionfromargs[0]), and add@typing.finaltocheck_async(and, for consistency, to the pre-existingcheckat base.py:200) so the FINAL contract is machine-checked rather than documented. Note this same erasure exists on the pre-existing synchandle_criterion_errors(base.py:49) — the fix should cover both twins together. - [Axis 3] Concurrency tests gate on wall-clock with a 100 ms absolute margin under
-n auto(tests/test_check_all_async.py:113) —assert elapsed < SLEEP_SECONDS * 1.5, f"judge criteria serialized: took {elapsed:.3f}s"(line 113, and the same at line 129) allows only 0.1 s of slack over the 0.2 sSLEEP_SECONDS, whilepyproject.toml:286setsaddopts = ["-n", "auto", ...]so these run alongside 3600+ other tests on a contended CI box; line 129's variant additionally depends onasyncio.to_threadacquiring a default-executor worker. I could not make them fail locally (7/7 passed on 3 consecutive runs, and 3/3 with 16 busy-spin processes competing), so this is a latent flake rather than an observed one. Prefer an event-based proof of overlap (e.g. each fake awaits a sharedasyncio.Barrier/Eventthat only resolves if both are entered concurrently, with a generous timeout) over a duration comparison. - [Axis 5] Stale comments/docstrings still assert the removed
check_all-in-a-thread / to_thread execution model (src/coder_eval/orchestrator.py:1486) — orchestrator.py:1486 still says "runcheck_alloff the event loop" while the body awaitscheck_all_asyncat :1499; sub_agent.py:185 still justifies its bareasyncio.run(...)with "Safe because callers run check_all via asyncio.to_thread, so this invocation is on a worker thread with no active event loop" (no production caller does that any more); judge_bedrock.py:84 still says "invoke_bedrock_judge is sync (called via asyncio.to_thread), so a plain time.sleep between attempts is correct here". Update all three, or delete them along with the dead twins. - [Axis 6] Dead
except KeyErrorarm duplicated into_check_single_async(and the new test module claims coverage of it) (src/coder_eval/evaluation/checker.py:374) —_check_single_async'sexcept KeyError:→"No checker registered for criterion type ..."(checker.py:374-384) is unreachable._check_single_asyncruns only for indices innative_async_indices, and membership in that list required_is_native_async→_get_checker_instance(criterion_type)to have already succeeded and cached the instance (checker.py:203, 246-249); the cache hit at checker.py:350 therefore cannot raiseKeyError, and anyKeyErrorraised inside the checker is already absorbed byhandle_criterion_errors_async(criteria/base.py:126) before it can reach this handler. The arm is dead defensive code that also mislabels the cause if it ever did fire.tests/test_check_all_async.py:10-11nonetheless states the async path must "preserve the sync path's error-handling contract (KeyError / generic Exception captured into a failed CriterionResult)" whileTestNativeAsyncErrorHandling(lines 150-167) contains no KeyError/unregistered-type case at all. Fix: delete the unreachable arm (unregistered types are already routed to the sync batch by_is_native_asyncreturningFalse) and drop the KeyError claim from the test docstring, or add the test that actually exercises an unregistered type end-to-end throughcheck_all_async.
What's Missing
Parallel paths:
- 🟠 🟠 The sync half of the judge stack was duplicated rather than replaced:
SuccessChecker.check_all/_check_single,handle_criterion_errors,LLMJudgeChecker._check_impl,AgentJudgeChecker._check_impl,_invoke_tool_channel,invoke_bedrock_judge,invoke_anthropic_judgeandSubAgentRunner.runall still exist as hand-maintained twins (~320 near-verbatim lines across 6 sites), and the judge-specific ones now have no production caller — every future hardening must be applied twice, with only one side under test. Triggered by src/coder_eval/evaluation/checker.py:336, criteria/base.py:102, evaluation/sub_agent.py:207, judge_bedrock.py:118, judge_anthropic.py:58, criteria/llm_judge.py:276. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 1: Judge stack forked into hand-maintained sync/async twins) - 🟡 🟡 The second class of blocking-wait criterion was not given the new seam and is actively mislabelled:
check_all_async's docstring (checker.py:176-178) calls the sync batch "file/command/trajectory checks — CPU/file-bound, no I/O wait", butuipath_evalshells out touv run uipath evalwith no timeout (criteria/uipath_eval.py:73) andrun_commandruns arbitrary shell (criteria/run_command.py:68) — both are network/subprocess-bound, still pin the single sharedto_threadslot, and still serialize with each other. Either apply_check_impl_async(asyncio.create_subprocess_shell) there too, or correct the docstring to say the sync batch is deliberately serialized because it mutates the shared sandbox. (trigger: src/coder_eval/evaluation/checker.py) - 🟡 🟡 The registry-conformance guards that exist precisely for this shape were not extended:
criteria/__init__.py::validate_registrystill validates only_check_impl/union membership, and noCEnnnrule was added alongsidetests/lint/rules/ce025_live_verdict_consistency.py(which guards the structurally identicallive_stop_polarities↔live_verdictClassVar/override pair). Triggered by the newsupports_native_asyncClassVar at src/coder_eval/criteria/base.py:197. (trigger: src/coder_eval/criteria/base.py) (restates: Axis 1:supports_native_asyncis a second source of truth) - 🟡 🟡 CE002 (
tests/lint/rules/no_blocking_io_in_async.py) was not extended even though the PR introduces exactly the pattern it cannot see — blockingread_text/rglobreached from anasync defvia a sync helper one frame away (_prepareat agent_judge.py:146 / llm_judge.py:97). Its own docstring invites extending the allowlist "when a new blocking pattern is found in code review"; a whole new async surface landed with the rule untouched. (trigger: src/coder_eval/criteria/agent_judge.py) (restates: Axis 5: Native-async judge paths call the blocking sync_prepareon the event loop) - 🟡 🟡 The comments documenting the old execution model were not updated on the other side of the move: orchestrator.py:1486 ("run
check_alloff the event loop"), sub_agent.py:185 ("Safe because callers run check_all via asyncio.to_thread" — the justification for a bareasyncio.run()that no production caller now satisfies), judge_bedrock.py:84 ("called via asyncio.to_thread, so a plain time.sleep ... is correct"), and reports.py:767 ("check_all appends one CriterionResult per criterion"). (trigger: src/coder_eval/orchestrator.py) (restates: Axis 5: Stale comments/docstrings still assert the removed to_thread execution model) - 🔵 🔵
SuccessChecker.check(the singular-criterion public wrapper, checker.py:103) got nocheck_asynctwin, soBaseCriterion.check_asyncis reachable in production only throughcheck_all_async; any single-criterion consumer (tests, an out-of-tree re-scoring script,coder_eval_uipath) still runs anllm_judgethrough the fully blocking_check_impl. Either add the twin or state thatcheck()is deliberately sync-only. (trigger: src/coder_eval/evaluation/checker.py)
Tests:
- 🟠 🟠 Zero tests exercise the async code production now takes:
SubAgentRunner.run_async(missing 218-270),invoke_bedrock_judge_async(missing 136-181),invoke_anthropic_judge_async(missing 75-90),_invoke_tool_channel_async(llm_judge missing 285-316), both_check_impl_asyncs, and the baseto_threaddefault (base.py:309). No test referencesrun_async,AsyncAnthropicorhttpx.AsyncClient; the existing judge suites all drive the now-unreachable sync twins. (trigger: src/coder_eval/evaluation/sub_agent.py) (restates: Axis 8: Every newly added async path has zero test coverage) - 🟠 🟠 No test covers cancellation of an in-flight judge — a shape the PR newly makes reachable. Previously the judge ran inside a non-cancellable
to_thread, soSubAgentRunner.run's synchronousfinally: shutil.rmtree(...)always ran;run_async's cleanup is itselfawait asyncio.to_thread(shutil.rmtree, judge_dir, ...)(sub_agent.py:270) and_run_agent's teardown also awaits, so a task-timeout/watchdog cancel (or the gather's first-error path) can skip cleanup and leave asub_agent_*sandbox copy plus a live Claude Code CLI subprocess behind. Add a test that cancels acheck_all_asynccontaining anagent_judgeand asserts the temp dir is gone and the agent was killed. (trigger: src/coder_eval/evaluation/sub_agent.py) - 🟠 🟠
test_judge_infrastructure_error_propagates(tests/test_check_all_async.py:160-163) uses a single criterion, so nothing asserts what happens to siblings when one native-async criterion escalates — the exact regression the baregatherat checker.py:225 introduces. Needs a two-criterion test asserting the slow sibling is cancelled and awaited (cleanup ran) before the error surfaces. (trigger: tests/test_check_all_async.py) (restates: Axis 6: asyncio.gather without return_exceptions abandons in-flight sibling criteria) - 🟡 🟡 The only sync/async parity test (
test_all_sync_criteria_still_produce_same_results_as_check_all, tests/test_check_all_async.py:172-177) compares a singlefile_existscriterion — i.e. it never compares the twins that were actually duplicated. Parametrizetests/test_sub_agent_runner.py,test_llm_judge_criterion.py,test_agent_judge_criterion.py,test_judge_bedrock.pyandtest_judge_anthropic.pyoverrun/run_asyncandcheck/check_asyncso drift between the twins fails the suite. (trigger: tests/test_check_all_async.py) (restates: Axis 1: Judge stack forked into hand-maintained sync/async twins) - 🟡 🟡 The one integration test that mixes a judge with a non-judge criterion (
tests/test_llm_judge_criterion.py:413,checker.check_all([fe, judge])) still drives the serial sync route, so the interleaved production path — and the sandbox-overlap hazard it introduces — is untested end-to-end. Same fortests/test_error_handling.py:739(test_check_all_propagates_judge_infrastructure_error), which only proves the sync escalation. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 8: check_all_async overlaps the sandbox-mutating sync batch with judge criteria) - 🔵 🔵 The two concurrency proofs assert
elapsed < SLEEP_SECONDS * 1.5(lines 113, 129) under-n auto; replace the duration comparison with an event/barrier-based overlap proof so the guarantee is asserted structurally rather than by timing. (trigger: tests/test_check_all_async.py) (restates: Axis 3: Concurrency tests gate on wall-clock with a 100 ms absolute margin) - 🔵 🔵 The new module's docstring claims the async path preserves "KeyError / generic Exception captured into a failed CriterionResult" (tests/test_check_all_async.py:10-11) but
TestNativeAsyncErrorHandlinghas no unregistered-type case — either add a test routing an unregistered type throughcheck_all_async(it goes to the sync batch via_is_native_asyncreturningFalse) or drop the claim and the unreachable arm at checker.py:374. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 6: Deadexcept KeyErrorarm duplicated into_check_single_async)
Downstream consumers:
- 🟡 🟡 Judge cost/token attribution is a downstream consumer of the new failure mode and wasn't revisited:
_accumulate_judge_usage(orchestrator.py:1277, called at :1505) folds usage only from the returned results, so a judge abandoned by thegatherraise (or cancelled mid-flight) is billed by the provider — including a full Sonnet sub-agent run — while contributing nothing tojudge_usage_accum, the task'smax_usdbudget gate, or the run's reported cost. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 6: asyncio.gather without return_exceptions abandons in-flight sibling criteria) - 🟠 🟠 Consumers that relied on declared-order execution (not just result order) were not audited:
check_all_asyncguarantees result ordering but no longer sequencing, so a judge listed after arun_command/uipath_evalcriterion no longer reliably sees that command's artifact (uipath_eval_output_*.json, a test/coverage report) — a pattern docs/TASK_DEFINITION_GUIDE.md:929 explicitly recommends. Either restore ordering between the sync and judge batches or document the loss of execution ordering in the task guide. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 8: check_all_async overlaps the sandbox-mutating sync batch with judge criteria)
Display & mapping dicts:
- 🔵 🔵 "Which criterion types are judges" is already enumerated twice by hand —
_JUDGE_CRITERION_TYPES = frozenset({"llm_judge", "agent_judge"})(models/results.py:228, with a convention comment requiring same-PR updates) and the literal tuple at reports_html.py:430 that gates judge cards — and this PR adds a third de-facto enumeration (supports_native_async) without unifying them. A future native-async judge (in-tree or plugin) would get concurrency but fall through toresult_kind="basic"and render no judge card. (trigger: src/coder_eval/criteria/base.py) (restates: Axis 1:supports_native_asyncis a second source of truth)
Daily/nightly:
- 🟠 🟠 Unstated blast radius: judge fan-out is now unbounded. The only concurrency limiter in the repo is the task-level
asyncio.Semaphore(config.max_parallel)(orchestration/batch.py:81), so peak concurrent Bedrock judge calls becomemax_parallel × judges-per-task, and concurrentagent_judgeruns each hold their own Claude Code CLI subprocess plus a fullsub_agent_*copy of the sandbox. On the nightly this raises 429-throttle exhaustion (→JudgeInfrastructureError→ rowFinalStatus.ERROR) and, underdriver: docker, pushes againstmax_pids/--pids-limit,max_memory_mband temp-disk headroom. The PR says nothing about a judge-level cap or the nightly's expected concurrency. (trigger: src/coder_eval/evaluation/checker.py) - 🟠 🟠 The nightly's judge route flips to untested code with no statement in the PR:
--backend bedrocknow resolves toinvoke_bedrock_judge_async(judge_bedrock.py:118, 0% covered), and the in-container path (cli/run_task_internal_command.py:190→Orchestrator→check_all_async) inherits it as well. State the nightly impact explicitly, and note that no persisted-schema/wire change is involved so no lockstep image rebuild is required (that reassurance is currently absent). (trigger: src/coder_eval/evaluation/judge_bedrock.py) (restates: Axis 8: Every newly added async path has zero test coverage) - 🟡 🟡 Timing/latency semantics change for the external
coder-eval-uipath/ eval-runner dashboards without a note: judge waits now overlap each other and the sync batch, soEvaluationResult.duration_seconds(models/results.py:462) and every trend built on it step-change at this commit, and per-criterionJudgeCriterionResult.duration_secondswindows now overlap rather than sum to the evaluation phase. Any consumer inferring serialized criterion timing fromtask.jsonneeds to be told. (trigger: src/coder_eval/orchestrator.py) - 🟡 🟡 The sandbox-overlap race is assessed only against in-repo tasks (none of which mix a judge with
run_command/uipath_eval); the nightly suites and tasks live in the external repo, so the PR cannot claim "no impact" without checking there — a nightly task pairing a judge with a build/test command would start scoring nondeterministically with a green CI here. (trigger: src/coder_eval/evaluation/checker.py) (restates: Axis 8: check_all_async overlaps the sandbox-mutating sync batch with judge criteria)
Harness & Lint Improvements
Abridged to fit GitHub's 65,536-character comment limit — the full proposals (rule bodies, exact wiring, measured hit lists) are in tmp/code-review-260728-0113/00-summary.md.
Static checks (lint / type):
- [ce-lint] CE026 — capability ClassVar <-> override parity (generalize CE025). New rule
tests/lint/rules/ce026_capability_flag_override_parity.py, wired intoALL_RULESintests/lint/runner.py(CE026 is the only free number below the doc-surface block CE027-CE031). … - [ce-lint] CE032 —
asyncio.gathermust be exception-safe. New per-file ruletests/lint/rules/ce032_gather_return_exceptions.pyinALL_RULES. Forbidden pattern: a call toasyncio.gather(...)insidesrc/coder_eval/that passes neitherreturn_exceptions=Truenor**kwargs, unless the awaiting frame is anasyncio.TaskGroupscope. … - [ce-lint] CE033 — no hand-maintained sync/async twins. Whole-tree rule (needs a cross-file caller index, so wire it as a dedicated
@pytest.mark.linttest class alongside CE027-CE031 intests/lint/, not as aBaseRule). … - [ce-lint] CE034 — sandbox-mutating checkers must declare it, and the batcher must read the declaration. New rule in two halves: (a) in
src/coder_eval/criteria/, a checker class whose body reachessandbox.run_command(,sandbox.write_file(, or anysubprocess/shutilwrite must declaremutates_sandbox: ClassVar[bool] = True(current would-be hits:run_command.py:68,uipath_eval.py:66-73); … - [ce-lint] CE035 — decorators must not erase the wrapped signature. New per-file rule in
ALL_RULES: insrc/coder_eval/, a function whose return annotation is aCallable[...]and which takes a parameter annotatedCallable[..., X](the decorator shape) is a violation — use PEP 695[**P]withCallable[P, X](plusConcatenatewhen the first parameter is read positionally). … - [ce-lint] CE036 — no ambiguous positional tail on module-private helpers. New per-file rule in
ALL_RULES: insrc/coder_eval/, a module-private (_-prefixed) function or method with >=5 positional-or-keyword parameters where two or more share an identical annotation must move the ambiguous parameters after*(keyword-only). … - [ce-lint] CE037 — criterion-authoring seam <->
docs/EXTENDING.mdparity. Extend the CE030 machinery (tests/lint/doc_schema_parity.py) from YAML-facing models to the advertised extension seam: add aDOCUMENTED_SEAMS: list[tuple[type, str]] = [(BaseCriterion, "docs/EXTENDING.md")]table asserting that every subclass-facing member ofBaseCriterion—_check_impl,_check_impl_async,supports_native_async, … - [ce-lint] CE038 — no wall-clock duration assertions in
tests/. New per-file rule scoped totests/: flag anassertwhose comparison operand derives from atime.perf_counter()/time.monotonic()/time.time()subtraction compared against a literal or literal-scaled bound. … - [ce-lint] Extend CE002 (
tests/lint/rules/no_blocking_io_in_async.py) one frame. Its docstring already invites extension "when a new blocking pattern is found in code review". Add a same-module call-graph pass: build the set of module-level syncdefs whose bodies (transitively, within the file) hit_BLOCKING_BUILTINS/_BLOCKING_ATTRS/_BLOCKING_METHODSorrglob/glob/iterdir/stat, then flag a direct … - [ruff] Enable the
ASYNCrule family. Inpyproject.toml[tool.ruff.lint], add"ASYNC"toselectandignore = ["ASYNC109"](measured on the PR HEAD: the only pre-existingsrc/hits are 6 x ASYNC109 on the intentionaltimeout:parameter of theAgent.communicatecontract —agent.py:192,agents/*_agent.py— plus a single ASYNC240 atorchestrator.py:1728, fixable ornoqa-able in the same commit). … - [pyright] Flip
reportIncompatibleMethodOverridefrom"warning"to"error"(pyproject.toml:254) and add@typing.finaltoBaseCriterion.checkandcheck_async.make typecheckruns bareuv run pyright(Makefile:37), which exits non-zero on errors only — so atwarninglevel this setting reports and CI still passes, i.e. it currently gates nothing. …
Harness improvements (not statically reachable):
- Parametrize the judge/sub-agent suites over sync <-> async (a
modefixture).tests/test_sub_agent_runner.py(15 of its 18 tests driverunner.run, including the security floor:test_runner_copytree_drops_nested_symlinks:409,test_runner_excludes_nested_claude_and_mcp:464,test_runner_handles_sandbox_side_reference_collision:134),tests/test_llm_judge_criterion.py(:103,:413), … - Add a changed-lines coverage gate to CI. Keep the global 80 % floor and add a diff-coverage step (
diff-cover coverage.xml --compare-branch=origin/main --fail-under=90, or equivalent) to theverifyjob, so a PR that adds uncovered production paths fails on its own diff instead of being carried by the existing 3624-test suite. Why not static: Coverage is a runtime measurement of which lines a test run executed; … - Event-loop responsiveness test for
check_all_async. Addtests/test_check_all_async.py::test_loop_stays_responsive_during_judge_prep: register a fake native-async criterion whose_prepare-equivalent does a realtime.sleep(0.3)(or reads a large temp tree), runcheck_all_asyncalongside a heartbeat coroutine ticking every 10 ms, and assert the heartbeat recorded >= N ticks (event-based, not a … - Scoring-determinism test over the mixed-criteria shape, plus an in-repo task that exercises it. (a) Build a sandbox where a
run_commandcriterion writes files (a report plus auipath_eval_output_*.json-shaped artifact) alongside a stubbed judge criterion that snapshots/reads the sandbox; … - Orphan-resource assertion in teardown, plus a structured-concurrency acceptance test. An autouse session-scoped fixture (or a
run_batchpost-condition in the integration tests) asserting that nosub_agent_*directories survive under the temp root and that no Claude Code CLI child processes outlive the batch. … - Add a seam-change item to the PR template / plan checklist: "Did this change add or alter a
BaseCriterion/Agent/ plugin-SPI seam? If so, thedocs/EXTENDING.mdsection-2 note and CLAUDE.md's extension steps are part of the diff." This is the process backstop for the slice CE037 cannot phrase mechanically — when an author should choose_check_impl_asyncover_check_impl, and why cached checker instances must …
Top 5 Priority Actions
- Stop overlapping the sandbox-mutating sync batch with the judge batch at src/coder_eval/evaluation/checker.py:225 —
await run_sync_batch()first, then gather the judges (each already takes its ownmkdtempcopy or does a pure read) — because today arun_command/uipath_evalcriterion writing the sandbox whileagent_judgerunsshutil.copytree(src/coder_eval/evaluation/sub_agent.py:223) orllm_judgereads files (src/coder_eval/evaluation/judge_context.py:290) can raiseshutil.Error→score=0.0, or silently hand the judge a snapshot missing the very artifact it is meant to grade, flippingfinal_statusfor identical agent output; the newtest_async_batch_overlaps_sync_batchassertion in tests/test_check_all_async.py must be relaxed as part of the fix. - Replace the bare
asyncio.gatherat src/coder_eval/evaluation/checker.py:225 with anasyncio.TaskGroup(orreturn_exceptions=Trueplus a deferred re-raise of the firstJudgeInfrastructureError) so a fast judge failure — e.g. an expired token → 403 at src/coder_eval/evaluation/judge_bedrock.py:173 — cancels and awaits sibling judges instead of orphaning a credentialed Claude Code subprocess and a fullsub_agent_*sandbox copy for up toturn_timeout(300s) outside anymax_parallelsemaphore slot, following the existingreturn_exceptions=Trueprecedent at src/coder_eval/orchestration/batch.py:171. - Cover the async twins production now actually takes —
SubAgentRunner.run_async(src/coder_eval/evaluation/sub_agent.py:207, missing 218-270),invoke_bedrock_judge_async(src/coder_eval/evaluation/judge_bedrock.py:118, missing 136-181),invoke_anthropic_judge_async,_invoke_tool_channel_async, and both judges'_check_impl_async— ideally by parametrizing tests/test_sub_agent_runner.py, tests/test_llm_judge_criterion.py and tests/test_agent_judge_criterion.py over sync/async, since the security-floor tests (symlink stripping, nested.claude/.mcpexclusion,_referencecollision, temp-dir cleanup) all currently drive the now-unreachable syncrun(). - Offload the blocking judge setup with
await asyncio.to_thread(_prepare, ...)at src/coder_eval/criteria/agent_judge.py:146 and src/coder_eval/criteria/llm_judge.py:97, because_preparedoesread_textfile collection plus a ≤200-file/≤2 MB referencerglob(src/coder_eval/evaluation/judge_context.py:124) directly on the sharedrun_batchevent loop, stalling every sibling task's streaming and watchdogs — and extend CE002 so it catches blocking IO one sync frame away. - Collapse the hand-maintained sync/async duplication instead of growing it: make the sync entry points thin
asyncio.runbridges over the async ones and extract shared result-finalization/error-mapping across src/coder_eval/evaluation/checker.py:336 vs :251, src/coder_eval/criteria/base.py:102 vs :49, sub_agent.py:207 vs :117, judge_bedrock.py:118 vs :43, judge_anthropic.py:58 vs :21 and llm_judge.py:276 vs :225 (~300 near-verbatim lines, with load-bearing security rationale comments dropped from the copies); while there, derivesupports_native_asyncfrom the_check_impl_asyncoverride or add a CE0nn rule mirroring CE025 at src/coder_eval/criteria/base.py:197, document the async seam and the new same-instance concurrent-reentry contract in docs/EXTENDING.md:175 and CLAUDE.md:185/221-224, and delete the staleto_threadcomments at src/coder_eval/orchestrator.py:1486, sub_agent.py:185 and judge_bedrock.py:84 plus the unreachableexcept KeyErrorarm at checker.py:374.
Stats: 0 🔴 · 4 🟠 · 3 🟡 · 6 🔵 across 8 axes reviewed.
|
Closing in favor of #60 (keeping async default) |

Summary
llm_judgeandagent_judgeeach made a blocking network/subprocess call insideSuccessChecker.check_all's singleasyncio.to_threadoffload, so a task stacking multiple judge-type criteria ran their LLM calls serially and pinned a thread-pool thread for the full wait (Judge criteria (llm_judge/agent_judge) serialize and pin threads during check_all #55).BaseCriteriongains an asynccheck_async/_check_impl_asyncpath (default: offloads the sync_check_implto a worker thread) plus asupports_native_asyncmarker.llm_judge/agent_judgeoverride it with genuine non-blocking I/O:AsyncAnthropic,httpx.AsyncClient(Bedrock), and a newSubAgentRunner.run_async.SuccessChecker.check_all_asyncgathers all native-async criteria concurrently with each other while the remaining sync criteria still share a singleto_threadslot; the orchestrator's three call sites now use it instead of wrapping the wholecheck_allinasyncio.to_thread.Test plan
make check/make typecheck/make lintclean on all touched filesmake test— 3576 passed, 2 skippedtests/test_check_all_async.pyproves judge criteria overlap instead of serializing, the sync/async batches overlap, result ordering is preserved, and error handling (KeyError / generic exception /JudgeInfrastructureError) matches the sync pathCloses #55
🤖 Generated with Claude Code