Skip to content

feat(criteria): make async the primary criterion-checking surface - #60

Open
akshaylive wants to merge 6 commits into
mainfrom
akshaya/async_criteria_v2
Open

feat(criteria): make async the primary criterion-checking surface#60
akshaylive wants to merge 6 commits into
mainfrom
akshaya/async_criteria_v2

Conversation

@akshaylive

Copy link
Copy Markdown
Collaborator

Summary

Redesign of the fix for #55, replacing PR #58. That PR made judge criteria (llm_judge/agent_judge) concurrent by bolting an async path onto a sync-first BaseCriterion — each judge checker ended up with two near-duplicate implementations (a sync client + an async client) plus a supports_native_async marker flag to tell SuccessChecker which path to dispatch through. This PR inverts that relationship instead of layering on top of it:

  • BaseCriterion._check_impl_async is now the primary implementation surface. A checker overrides exactly one of _check_impl (sync) or _check_impl_async (async) — whichever is its natural form — and the base class derives the other automatically:
    • CPU/file-bound checkers (file_exists, command_executed, ...) keep overriding _check_impl only; the default _check_impl_async offloads it via asyncio.to_thread.
    • llm_judge/agent_judge now override only _check_impl_async, using AsyncAnthropic, httpx.AsyncClient, and a new SubAgentRunner.run_async — no sync client duplicated. The default _check_impl derives a sync call via asyncio.run(...) for any caller that still wants one (tests, etc.).
  • register_criterion enforces that a checker overrides at least one of the two (overriding neither would recurse forever between the defaults).
  • SuccessChecker.check_all_async no longer needs an explicit "is this checker async-native" flag — it detects it by introspecting whether _check_impl_async is overridden, then gathers those checkers concurrently with each other while the remaining sync checkers still share one asyncio.to_thread slot (unchanged behavior for the 12 non-judge criteria).
  • SubAgentRunner.run() (sync) is removed — nothing calls it anymore now that agent_judge is async-only; run_async() is the sole entrypoint.

Net effect on behavior is identical to #58 (judge criteria run concurrently instead of serializing, and don't pin a thread-pool thread for the network wait) — this PR just gets there with one implementation per criterion instead of two.

Test plan

  • make check / make typecheck / make lint clean
  • make verify — 3582 passed, 2 skipped, 90.99% coverage
  • tests/test_check_all_async.py — judge criteria overlap instead of serializing, sync/async batches overlap, ordering preserved, error handling parity, _is_native_async detection, register_criterion guard, and direct sync↔async derivation on BaseCriterion
  • tests/test_judge_anthropic.py / tests/test_judge_bedrock.py rewritten against the async invokers
  • tests/test_sub_agent_runner.py converted to run_async

Closes #55 (supersedes #58 — closing that PR in favor of this one)

🤖 Generated with Claude Code

uipreliga

This comment was marked as outdated.

akshaylive added a commit that referenced this pull request Jul 28, 2026
…rride-contract gap

Addresses the review on PR #60:

- BaseCriterion.__init_subclass__ now enforces the "_check_impl or
  _check_impl_async" override contract at class-definition time, for every
  entry point (register_criterion decorator, or CriterionRegistry.register
  called directly) — previously only register_criterion checked, and the
  fallback failure mode was a silent mutual-recursion RuntimeError instead of
  a clear TypeError.
- handle_criterion_errors / handle_criterion_errors_async no longer erase
  their wrapped method's signature to `(...) -> CriterionResult`; typed with
  PEP 695 ParamSpec + Concatenate so a caller passing the wrong criterion
  model or extra args is still caught statically.
- check_all_async now runs the sync-criteria to_thread batch to completion
  BEFORE starting the native-async (judge) batch, instead of overlapping
  them — several first-party sync criteria mutate the sandbox (run_command,
  uipath_eval) while judge criteria read it, so the previous overlap could
  make a judge's score depend on how far a concurrent sandbox-mutating
  command happened to get. Judge criteria still run concurrently with EACH
  OTHER, which is the actual GH #55 fix.
- The native-async gather now uses return_exceptions=True and re-raises
  only after every sibling settles, so a JudgeInfrastructureError no longer
  orphans sibling judge work (e.g. an agent_judge sub-agent left running
  against a sandbox already being torn down).
- Added/repaired tests: native-async dispatch is now pinned against the
  *real* registered llm_judge/agent_judge checkers (not just fakes);
  check_all_async's reference_code/reference_dir persistence has a
  regression test; a two-criterion test covers the no-orphaned-siblings
  gather semantics; test_registry's context-kwarg conformance check now
  inspects whichever of _check_impl/_check_impl_async a checker actually
  overrides instead of vacuously passing for llm_judge/agent_judge.
- Fixed stale docstrings/docs naming the old sync invoke_*_judge symbols or
  describing check_all() as the orchestrator's entry point; briefly
  documented the dual-surface contract in docs/EXTENDING.md and CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
akshaylive added a commit that referenced this pull request Jul 28, 2026
…ck event loop, harden dispatch

Second pass over the PR #60 review, covering the remaining nits and
"what's missing" items not yet handled by the previous commit:

- llm_judge / agent_judge now offload JudgeContextBuilder.build() (sandbox +
  reference file reads) to a worker thread via asyncio.to_thread instead of
  doing that synchronous file I/O directly on the event loop — the whole
  point of these checkers being "native async" is that they never block the
  loop, and this closed the one place they still did.
- SubAgentRunner's finally-block cleanup no longer awaits shutil.rmtree via
  asyncio.to_thread — run_async is awaited directly on the orchestrator's
  loop (not under its own asyncio.run on a worker thread), so that await was
  reachable by cancellation (e.g. the task_timeout watchdog) and could skip
  cleanup, leaking the mkdtemp sandbox copy. It's now a plain synchronous
  rmtree (best-effort, bounded) that cancellation can't interrupt mid-copy,
  with a regression test that cancels run_async mid-communicate and asserts
  judge_dir is gone.
- _is_native_async now classifies via the registered CLASS
  (CriterionRegistry.get_checker), not a constructed instance — it runs
  before any per-criterion error boundary in check_all_async, so a checker
  constructor failure no longer escapes and aborts the whole task instead of
  scoring that one criterion 0.
- Collapsed the sync/async twin duplication in SuccessChecker: the
  reference/turn-record persist preamble (previously copy-pasted into
  check/check_all/check_all_async) is now one _resolve_refs() helper: the
  result-shaping + logging tail of _check_single/_check_single_async
  (previously a ~30-line verbatim clone apart from one line) is now shared
  via _finalize_result/_missing_checker_result/_error_result, so the two
  methods differ only in checker.check(...) vs await checker.check_async(...).
- check_all_async's dead early-return-on-empty guard removed (the two
  dispatch batches already no-op on empty input) and the O(n^2)
  index-membership scan now uses a set; the [None]*len prefill is resolved
  with an assert + cast instead of a blanket # type: ignore.
- Replaced the two wall-clock-timing concurrency assertions in
  test_check_all_async.py with a deterministic rendezvous barrier
  (_ConcurrencyProbe) / ordered event markers, and added an end-to-end
  regression test using the REAL registered run_command + llm_judge checkers
  (not fakes) proving the sequencing fix: a judge now reliably sees a file a
  concurrent run_command wrote, where it flakily wouldn't before.
- Added check_all_async's missing Args/Returns docstring block (parity with
  its sync twin).

Deliberately NOT done (flagged in review as "Harness & Lint Improvements",
scoped as new repo-wide tooling/policy investments rather than defects in
this PR's diff — better suited to a follow-up issue than bundled in):
new CE0xx lint rules (CE026/032-038), dropping tests/ from the pyright
exclude, reportImplicitOverride / reportUnnecessaryTypeIgnoreComment pyright
config, ruff PGH lint selection, a diff-cover patch-coverage gate, a
duplicate-code detector step, and a global judge-concurrency semaphore for
nightly batch runs (task-level --max-parallel already caps total fan-out;
the concurrency added here is only across one task's own 1-3 judge
criteria, not an unbounded multiplier).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@akshaylive

Copy link
Copy Markdown
Collaborator Author

Addressed the review across two commits (7dcb8b6d4e6f87):

Blockers

  • Override contract enforcement moved to BaseCriterion.__init_subclass__ (fires at class-definition time on every subclass, not just via register_criterion) — closes the CriterionRegistry.register bypass, turns the mutual-recursion failure mode into an immediate, clear TypeError.
  • handle_criterion_errors(_async) re-typed with PEP 695 ParamSpec + Concatenate instead of Callable[..., ...] — no longer erases the checked method's signature.
  • check_all_async's sync batch now runs to completion before the native-async (judge) batch starts, instead of overlapping — closes the sandbox-mutation-vs-judge-read race (run_command/uipath_eval writes vs. llm_judge/agent_judge reads). Judges still run concurrently with each other, which is the actual GH Judge criteria (llm_judge/agent_judge) serialize and pin threads during check_all #55 fix.
  • The native-async gather now uses return_exceptions=True and re-raises only after every sibling settles — a JudgeInfrastructureError no longer orphans sibling judge work (e.g. an agent_judge sub-agent left running against a sandbox already being torn down).
  • Closed the three lost test guards: native-async dispatch is now pinned against the real registered llm_judge/agent_judge; test_registry's context-kwarg check now inspects whichever of _check_impl/_check_impl_async is actually overridden; check_all_async's reference_code/reference_dir persistence has a regression test.

Non-blocking / nits

  • llm_judge/agent_judge now offload JudgeContextBuilder.build() (sandbox file reads) via asyncio.to_thread instead of blocking the event loop directly.
  • SubAgentRunner's finally-block cleanup is a plain synchronous shutil.rmtree instead of an awaited to_thread call, so cancellation (task_timeout watchdog) can't skip it; added a cancellation regression test.
  • _is_native_async now classifies via the registered class (CriterionRegistry.get_checker) instead of a constructed instance, so a checker-constructor failure can't escape the per-criterion error boundary.
  • Collapsed the _check_single/_check_single_async duplication and the triplicated reference-persist preamble into shared helpers.
  • Replaced the two wall-clock-timing concurrency assertions with a deterministic rendezvous barrier / ordered event markers, and added an end-to-end regression test with the real run_command + llm_judge checkers proving the sequencing fix.
  • Fixed stale docstrings (old invoke_*_judge names, check_all vs check_all_async) and lightly documented the dual-surface contract in docs/EXTENDING.md / CLAUDE.md.

Deliberately not done — flagged as new repo-wide tooling/policy investments rather than defects in this diff, better suited to a follow-up issue: the ~13 new CE0xx lint rules, dropping tests/ from the pyright exclude, reportImplicitOverride/reportUnnecessaryTypeIgnoreComment pyright config, ruff PGH selection, a diff-cover patch-coverage gate, a duplicate-code detector step, and a global judge-concurrency semaphore for nightly runs (task-level --max-parallel already caps total fan-out; this PR's added concurrency is only across one task's own 1-3 judge criteria).

make verify (3587 passed, 91.04% coverage) and make lint are green on both commits.

Comment thread src/coder_eval/criteria/base.py Fixed
Comment thread src/coder_eval/criteria/base.py Fixed
Comment thread tests/test_check_all_async.py Fixed
Comment thread tests/test_sub_agent_runner.py Fixed
@UiPath UiPath deleted a comment from github-actions Bot Jul 28, 2026
uipreliga

This comment was marked as outdated.

uipreliga

This comment was marked as outdated.

akshaylive added a commit that referenced this pull request Jul 28, 2026
…and silent misuse scoring

Addresses the second review pass on PR #60 — three real scoring/correctness
blockers plus the contract/API-surface follow-ups:

- check_all_async now splits criteria into maximal CONTIGUOUS runs of the
  same kind (sync vs native-async) and executes the runs strictly in
  declaration order, instead of always running the whole sync batch before
  any async criterion. The previous scheme inverted declaration order for
  e.g. [llm_judge, run_command] — the judge, though declared first, always
  ran after run_command, silently grading post-mutation sandbox state
  instead of the pre-mutation state check_all's serial order would give it.
  Adjacent judges in the same run still gather concurrently (the actual
  GH #55 fix); results are built from a dict proven total over all runs,
  replacing the previous assert-guarded cast with a real KeyError on any
  future gap. Every captured sibling exception in a run is now logged (not
  silently dropped) before the first is re-raised.

- SubAgentRunner.run_async no longer leaks a full sandbox copy when
  cancelled mid-copytree: judge_dir is bound with a plain synchronous
  tempfile.mkdtemp (one syscall, not cancellable, not worth to_thread's
  cancellation-window cost), and the copytree/rmtree to_thread calls are
  wrapped in asyncio.shield + tracked in a pending list that `finally` awaits
  BEFORE its own rmtree — so an orphaned worker thread can no longer
  recreate files after cleanup already ran. Applied the same
  await-in-finally fix to simulation/user_simulator.py's scratch-dir
  teardown (same class of leak, previously only fixed in sub_agent.py).

- BaseCriterion._check_impl's derived asyncio.run bridge now detects a
  running event loop and raises a new CheckerMisuseError (which
  handle_criterion_errors(_async) escalate, like JudgeInfrastructureError)
  instead of letting asyncio.run's RuntimeError get silently swallowed into
  a scored-0.0 CriterionResult — a library/embedder calling the public sync
  check()/check_all() on an async-only checker from async host code now
  gets a loud, named error instead of a wrong score.

- __init_subclass__ now enforces "exactly one", not just "at least one": a
  checker overriding BOTH _check_impl and _check_impl_async is also
  rejected (two live implementations that could drift into different scores
  depending on which entry point ran). Added an `abstract=True` class-kwarg
  escape hatch for intentional abstract intermediate bases, and a __new__
  guard so BaseCriterion itself can't be instantiated directly (lost when
  @AbstractMethod was dropped).

- Promoted the native-async capability check to a public, typed
  BaseCriterion.is_native_async() classmethod; SuccessChecker._is_native_async
  and test_registry.py now call it instead of comparing `_check_impl_async`
  identity across package boundaries. Decorated check()/check_async() with
  @typing.final so the "these are FINAL" docstring contract is
  pyright-enforced. Extracted the duplicated 18-line error-capture tail out
  of handle_criterion_errors/_async into one shared _failed_result() helper.

- Tests: reversed-declaration-order regression test (real llm_judge +
  run_command checkers) proving the contiguous-run fix; a cancel-during-copy
  test on SubAgentRunner reproducing and closing the leak; a
  loud-CheckerMisuseError-from-a-running-loop test; both-overridden /
  abstract=True / direct-instantiation tests for the tightened
  __init_subclass__ contract; a thread-affinity assertion (not just score)
  for the derived async bridge; replaced two inert TestNativeAsyncDetection
  tests (injected into _checker_instances, which classification never reads)
  with registry-based ones that actually exercise the class-based dispatch
  rule; a checker-__init__-failure test closing the last uncovered branch in
  _check_single_async; one check_all_async happy-path test each for
  llm_judge/agent_judge (production's actual entry point — every other test
  in both files still drives the derived sync bridge).

- Fixed stale ``check_all`` (vs check_all_async) mentions in CLAUDE.md,
  early_stop.py, reports.py, and orchestrator.py; documented the
  must-not-block-the-loop obligation and the CheckerMisuseError caveat in
  docs/EXTENDING.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@akshaylive

Copy link
Copy Markdown
Collaborator Author

Addressed the second review pass (commit a91cf5c23929f4).

Blockers fixed

  • Declaration-order regression: check_all_async now splits criteria into maximal contiguous runs of the same kind (sync vs native-async) and executes the runs strictly in declaration order, instead of always running the whole sync batch before any async criterion. [llm_judge, run_command] used to invert order (judge always ran after run_command regardless of being declared first, silently grading post-mutation state) — a real scoring bug. Adjacent judges within one run still gather concurrently, which is the actual GH Judge criteria (llm_judge/agent_judge) serialize and pin threads during check_all #55 fix. Added a real end-to-end regression test (real llm_judge + run_command checkers) covering the reversed order, plus the already-existing forward-order test.
  • Judge-sandbox cancellation leak: SubAgentRunner.run_async's mkdtemp/copytree are no longer plain asyncio.to_thread awaits (not cancellable — a cancellation mid-copy could leave an orphan thread that recreates files after finally's rmtree already ran). judge_dir is now bound with a synchronous tempfile.mkdtemp (one syscall), and the copy/rmtree calls are asyncio.shielded + tracked so finally awaits them to completion before cleaning up. Reproduced the leak, confirmed the fix closes it, and added a cancel-mid-copytree regression test. Applied the same fix to simulation/user_simulator.py's scratch-dir teardown (same class of bug, previously only fixed in sub_agent.py).
  • Silent 0.0 from a running loop: the derived sync _check_impl (asyncio.run bridge) now detects a running event loop and raises a new CheckerMisuseError (escalates like JudgeInfrastructureError) instead of letting asyncio.run's RuntimeError get swallowed into a scored-0.0 result — a library/embedder calling the public sync check()/check_all() on an async-only checker from async host code now gets a loud, named error instead of a wrong score.

Contract/API-surface follow-ups

  • __init_subclass__ now enforces "exactly one", not just "at least one" — overriding both _check_impl/_check_impl_async is also rejected. Added an abstract=True escape hatch for intentional abstract intermediate bases, and a __new__ guard so BaseCriterion itself can't be instantiated directly.
  • Promoted the native-async check to a public, typed BaseCriterion.is_native_async() classmethod instead of comparing _check_impl_async identity across package boundaries; check()/check_async() are now @typing.final; extracted the duplicated error-capture tail in handle_criterion_errors/_async into one shared helper.
  • Fixed the two inert TestNativeAsyncDetection tests (they injected into _checker_instances, which classification never reads) to actually exercise class-based dispatch via the registry; added a checker-__init__-failure test (closes the last uncovered branch); added one check_all_async happy-path test each for llm_judge/agent_judge (production's real entry point); fixed stale check_all mentions in CLAUDE.md/early_stop.py/reports.py/orchestrator.py.

make verify is green: 3600 passed, 91.08% coverage.

Deliberately not implemented — the "Harness & Lint Improvements" follow-up comment proposed ~8 new CE0xx lint rules (CE034–CE041), several scoped pyright/ruff config changes (reportPrivateUsage execution environments, S101, dropping tests/ from the pyright exclude), a diff-coverage gate, and a nightly mutation-testing job. I didn't implement these: they're net-new, repo-wide tooling/CI investments rather than defects in this diff's code — landing 8 new lint rules and CI policy changes inside a bug-fix PR would meaningfully expand its blast radius and review surface beyond what it's actually fixing. I did fix every underlying code defect those rules were designed to catch (contiguous-run ordering, shielded cancellation-safe cleanup, CheckerMisuseError, both-overridden rejection, is_native_async(), @final, shared error-tail helper, dict-based total materialization instead of assert+cast). Happy to file the lint-rule/CI proposals as follow-up tracked issues if useful, but think they're better scoped as their own PRs.

akshaylive and others added 6 commits July 28, 2026 13:22
BaseCriterion previously required every checker to implement the sync
_check_impl, with async only a to-thread-wrapped afterthought — so the
prior fix for judge-criteria serialization (llm_judge/agent_judge) had to
hand-maintain two near-duplicate implementations (a sync client + an async
client) side by side.

Flip the relationship: _check_impl_async is now the primary surface, with
each of _check_impl / _check_impl_async deriving a default for the other
(to_thread for sync-only checkers, asyncio.run for async-only ones).
register_criterion enforces overriding at least one. llm_judge and
agent_judge now implement ONLY _check_impl_async (AsyncAnthropic,
httpx.AsyncClient, SubAgentRunner.run_async) — no sync client duplicated.
SubAgentRunner.run() (sync) is removed since nothing calls it anymore;
run_async() is the sole entrypoint.

SuccessChecker.check_all_async detects "native async" checkers by
introspecting whether _check_impl_async is overridden (no more explicit
supports_native_async flag) and gathers them concurrently with each other
while the remaining sync criteria still share one to_thread slot — fixing
GH #55 (judge criteria serializing / pinning threads inside check_all).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…rride-contract gap

Addresses the review on PR #60:

- BaseCriterion.__init_subclass__ now enforces the "_check_impl or
  _check_impl_async" override contract at class-definition time, for every
  entry point (register_criterion decorator, or CriterionRegistry.register
  called directly) — previously only register_criterion checked, and the
  fallback failure mode was a silent mutual-recursion RuntimeError instead of
  a clear TypeError.
- handle_criterion_errors / handle_criterion_errors_async no longer erase
  their wrapped method's signature to `(...) -> CriterionResult`; typed with
  PEP 695 ParamSpec + Concatenate so a caller passing the wrong criterion
  model or extra args is still caught statically.
- check_all_async now runs the sync-criteria to_thread batch to completion
  BEFORE starting the native-async (judge) batch, instead of overlapping
  them — several first-party sync criteria mutate the sandbox (run_command,
  uipath_eval) while judge criteria read it, so the previous overlap could
  make a judge's score depend on how far a concurrent sandbox-mutating
  command happened to get. Judge criteria still run concurrently with EACH
  OTHER, which is the actual GH #55 fix.
- The native-async gather now uses return_exceptions=True and re-raises
  only after every sibling settles, so a JudgeInfrastructureError no longer
  orphans sibling judge work (e.g. an agent_judge sub-agent left running
  against a sandbox already being torn down).
- Added/repaired tests: native-async dispatch is now pinned against the
  *real* registered llm_judge/agent_judge checkers (not just fakes);
  check_all_async's reference_code/reference_dir persistence has a
  regression test; a two-criterion test covers the no-orphaned-siblings
  gather semantics; test_registry's context-kwarg conformance check now
  inspects whichever of _check_impl/_check_impl_async a checker actually
  overrides instead of vacuously passing for llm_judge/agent_judge.
- Fixed stale docstrings/docs naming the old sync invoke_*_judge symbols or
  describing check_all() as the orchestrator's entry point; briefly
  documented the dual-surface contract in docs/EXTENDING.md and CLAUDE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ck event loop, harden dispatch

Second pass over the PR #60 review, covering the remaining nits and
"what's missing" items not yet handled by the previous commit:

- llm_judge / agent_judge now offload JudgeContextBuilder.build() (sandbox +
  reference file reads) to a worker thread via asyncio.to_thread instead of
  doing that synchronous file I/O directly on the event loop — the whole
  point of these checkers being "native async" is that they never block the
  loop, and this closed the one place they still did.
- SubAgentRunner's finally-block cleanup no longer awaits shutil.rmtree via
  asyncio.to_thread — run_async is awaited directly on the orchestrator's
  loop (not under its own asyncio.run on a worker thread), so that await was
  reachable by cancellation (e.g. the task_timeout watchdog) and could skip
  cleanup, leaking the mkdtemp sandbox copy. It's now a plain synchronous
  rmtree (best-effort, bounded) that cancellation can't interrupt mid-copy,
  with a regression test that cancels run_async mid-communicate and asserts
  judge_dir is gone.
- _is_native_async now classifies via the registered CLASS
  (CriterionRegistry.get_checker), not a constructed instance — it runs
  before any per-criterion error boundary in check_all_async, so a checker
  constructor failure no longer escapes and aborts the whole task instead of
  scoring that one criterion 0.
- Collapsed the sync/async twin duplication in SuccessChecker: the
  reference/turn-record persist preamble (previously copy-pasted into
  check/check_all/check_all_async) is now one _resolve_refs() helper: the
  result-shaping + logging tail of _check_single/_check_single_async
  (previously a ~30-line verbatim clone apart from one line) is now shared
  via _finalize_result/_missing_checker_result/_error_result, so the two
  methods differ only in checker.check(...) vs await checker.check_async(...).
- check_all_async's dead early-return-on-empty guard removed (the two
  dispatch batches already no-op on empty input) and the O(n^2)
  index-membership scan now uses a set; the [None]*len prefill is resolved
  with an assert + cast instead of a blanket # type: ignore.
- Replaced the two wall-clock-timing concurrency assertions in
  test_check_all_async.py with a deterministic rendezvous barrier
  (_ConcurrencyProbe) / ordered event markers, and added an end-to-end
  regression test using the REAL registered run_command + llm_judge checkers
  (not fakes) proving the sequencing fix: a judge now reliably sees a file a
  concurrent run_command wrote, where it flakily wouldn't before.
- Added check_all_async's missing Args/Returns docstring block (parity with
  its sync twin).

Deliberately NOT done (flagged in review as "Harness & Lint Improvements",
scoped as new repo-wide tooling/policy investments rather than defects in
this PR's diff — better suited to a follow-up issue than bundled in):
new CE0xx lint rules (CE026/032-038), dropping tests/ from the pyright
exclude, reportImplicitOverride / reportUnnecessaryTypeIgnoreComment pyright
config, ruff PGH lint selection, a diff-cover patch-coverage gate, a
duplicate-code detector step, and a global judge-concurrency semaphore for
nightly batch runs (task-level --max-parallel already caps total fan-out;
the concurrency added here is only across one task's own 1-3 judge
criteria, not an unbounded multiplier).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- handle_criterion_errors / handle_criterion_errors_async: revert from the
  PEP 695 `def f[**P](...)` type-parameter form back to a module-level
  `P = ParamSpec("P")` (with a targeted `# noqa: UP047` for ruff) — CodeQL's
  Python extractor doesn't parse PEP 695 type params referenced via
  `P.args`/`P.kwargs` and was flagging `P` as "potentially uninitialized".
- test_check_all_async.py: build the both-defaults-unimplemented checker via
  `types.new_class(...)` instead of a `class _NeitherChecker(...):` statement
  inside `pytest.raises` — the name was never usable afterward anyway
  (`__init_subclass__` raises before the class object exists), so CodeQL
  flagged it as an unused local. `type(name, bases, ns)` doesn't support MRO
  entry resolution for the generic alias `BaseCriterion[FileExistsCriterion]`,
  hence `types.new_class` rather than a bare `type(...)` call.
- test_sub_agent_runner.py: `_ = await task` instead of a bare `await task`
  statement inside the cancellation regression test — CodeQL flagged the
  bare await as a no-op statement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…and silent misuse scoring

Addresses the second review pass on PR #60 — three real scoring/correctness
blockers plus the contract/API-surface follow-ups:

- check_all_async now splits criteria into maximal CONTIGUOUS runs of the
  same kind (sync vs native-async) and executes the runs strictly in
  declaration order, instead of always running the whole sync batch before
  any async criterion. The previous scheme inverted declaration order for
  e.g. [llm_judge, run_command] — the judge, though declared first, always
  ran after run_command, silently grading post-mutation sandbox state
  instead of the pre-mutation state check_all's serial order would give it.
  Adjacent judges in the same run still gather concurrently (the actual
  GH #55 fix); results are built from a dict proven total over all runs,
  replacing the previous assert-guarded cast with a real KeyError on any
  future gap. Every captured sibling exception in a run is now logged (not
  silently dropped) before the first is re-raised.

- SubAgentRunner.run_async no longer leaks a full sandbox copy when
  cancelled mid-copytree: judge_dir is bound with a plain synchronous
  tempfile.mkdtemp (one syscall, not cancellable, not worth to_thread's
  cancellation-window cost), and the copytree/rmtree to_thread calls are
  wrapped in asyncio.shield + tracked in a pending list that `finally` awaits
  BEFORE its own rmtree — so an orphaned worker thread can no longer
  recreate files after cleanup already ran. Applied the same
  await-in-finally fix to simulation/user_simulator.py's scratch-dir
  teardown (same class of leak, previously only fixed in sub_agent.py).

- BaseCriterion._check_impl's derived asyncio.run bridge now detects a
  running event loop and raises a new CheckerMisuseError (which
  handle_criterion_errors(_async) escalate, like JudgeInfrastructureError)
  instead of letting asyncio.run's RuntimeError get silently swallowed into
  a scored-0.0 CriterionResult — a library/embedder calling the public sync
  check()/check_all() on an async-only checker from async host code now
  gets a loud, named error instead of a wrong score.

- __init_subclass__ now enforces "exactly one", not just "at least one": a
  checker overriding BOTH _check_impl and _check_impl_async is also
  rejected (two live implementations that could drift into different scores
  depending on which entry point ran). Added an `abstract=True` class-kwarg
  escape hatch for intentional abstract intermediate bases, and a __new__
  guard so BaseCriterion itself can't be instantiated directly (lost when
  @AbstractMethod was dropped).

- Promoted the native-async capability check to a public, typed
  BaseCriterion.is_native_async() classmethod; SuccessChecker._is_native_async
  and test_registry.py now call it instead of comparing `_check_impl_async`
  identity across package boundaries. Decorated check()/check_async() with
  @typing.final so the "these are FINAL" docstring contract is
  pyright-enforced. Extracted the duplicated 18-line error-capture tail out
  of handle_criterion_errors/_async into one shared _failed_result() helper.

- Tests: reversed-declaration-order regression test (real llm_judge +
  run_command checkers) proving the contiguous-run fix; a cancel-during-copy
  test on SubAgentRunner reproducing and closing the leak; a
  loud-CheckerMisuseError-from-a-running-loop test; both-overridden /
  abstract=True / direct-instantiation tests for the tightened
  __init_subclass__ contract; a thread-affinity assertion (not just score)
  for the derived async bridge; replaced two inert TestNativeAsyncDetection
  tests (injected into _checker_instances, which classification never reads)
  with registry-based ones that actually exercise the class-based dispatch
  rule; a checker-__init__-failure test closing the last uncovered branch in
  _check_single_async; one check_all_async happy-path test each for
  llm_judge/agent_judge (production's actual entry point — every other test
  in both files still drives the derived sync bridge).

- Fixed stale ``check_all`` (vs check_all_async) mentions in CLAUDE.md,
  early_stop.py, reports.py, and orchestrator.py; documented the
  must-not-block-the-loop obligation and the CheckerMisuseError caveat in
  docs/EXTENDING.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…cy to a follow-up PR

check_all_async now runs every criterion strictly sequentially, in
declaration order — identical order/isolation semantics to the sync
check_all — instead of gathering adjacent native-async (judge) criteria
concurrently. The concurrent-dispatch scheduling change (the original GH #55
motivation) is deferred to a follow-up PR so it can be reviewed on its own.

What's kept from the async-primary refactor: BaseCriterion's async-primary
_check_impl/_check_impl_async derivation, is_native_async(), the
__init_subclass__ override contract (exactly one, abstract=True escape
hatch), CheckerMisuseError for a misused sync bridge, and native-async
criteria (llm_judge/agent_judge) being awaited directly on the loop instead
of pinning a thread-pool thread — none of that requires concurrent dispatch
to be worthwhile on its own.

check_all_async's docstring, judge_anthropic.py/judge_bedrock.py/
sub_agent.py's "concurrently with other judges" claims, and
docs/EXTENDING.md's native-async guidance are updated to describe the
current sequential behavior and note where the follow-up PR picks up.

Tests: the concurrency-proving tests in test_check_all_async.py (rendezvous
barrier, gather-return_exceptions sibling-settling) are replaced with
sequential-execution tests (ordered event markers proving NO overlap, and a
test that a JudgeInfrastructureError stops remaining criteria outright
rather than letting siblings finish, since nothing runs concurrently
anymore).

Also fixes tests/test_route_seam_exhaustiveness.py, which the main-branch
LiteLLM routing PR added against the pre-refactor sync invoke_bedrock_judge/
invoke_anthropic_judge names and a sync _invoke_tool_channel — rebased onto
this branch's renamed *_async functions and async _invoke_tool_channel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@akshaylive
akshaylive force-pushed the akshaya/async_criteria_v2 branch from 23929f4 to d42e19d Compare July 28, 2026 20:34
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.

Judge criteria (llm_judge/agent_judge) serialize and pin threads during check_all

3 participants