fix(orchestrator): grade a forced-kill timeout instead of discarding it - #117
Open
joeysbase wants to merge 4 commits into
Open
fix(orchestrator): grade a forced-kill timeout instead of discarding it#117joeysbase wants to merge 4 commits into
joeysbase wants to merge 4 commits into
Conversation
A TaskTimeoutError/TurnTimeoutError used to throw away whatever the agent had already produced, so a task that timed out but had in fact satisfied its success criteria was reported TIMEOUT. Both handlers now run _grade_after_forced_kill against the recorded trajectory and finalize SUCCESS (plain, error_message cleared) when the criteria pass, falling back to TIMEOUT otherwise. The grading pass is deliberately conservative: - It commits the fallback status synchronously before its first await and only ever upgrades to SUCCESS, so a BaseException (Ctrl-C, a batch-level cancel) mid-grade cannot leave the row at the constructor default. - It quiesces the agent first. On a TurnTimeoutError nothing has torn the harness down yet (Antigravity's kill_sync is intent-only and _cleanup runs later, in run()'s finally), so without this the criteria could read a sandbox a backgrounded build was still writing. - It is wall-clock bounded (60s), never raises, and honors the same FIRED-ONLY early-stop gate as a normal run via _gate_passed, back-filling result.early_stop from the watcher that a hard-killed run never reaches. - It re-grades rather than reusing results whose _graded_iteration_count predates the last recorded turn -- the simulation loop rewrites success_criteria_results every turn under check_criteria: every_turn, so a non-empty list alone does not mean the grade covers the trajectory. - It folds its judge slice into the dialog-wide accumulator, so a mid-dialog kill no longer drops every earlier turn's judge cost. Antigravity's background-work poll loop is rebounded. A cycle's cost is bimodal: against a backgrounded job the connection is idle and receive_steps() returns immediately (5s/cycle), while a wedged connection burns the full 30s per-step timeout. _MAX_BACKGROUND_POLLS stays at 120 (120 x 5s = 600s, ~2x the worst 60-300s job that motivated the poll loop) and a new _MAX_BACKGROUND_POLL_WALL_SECONDS bounds the wedged mode. The flat backstop is anchored at poll-loop ENTRY, not turn start: anchoring it at turn start meant a turn_timeout: null turn that had already run longer than the backstop got zero poll cycles. The configured-timeout deadline stays turn-anchored because it must win its race with the watchdog. CE022 is generalized from one hardcoded function to a (file, function, cap) table, since this change adds two # noqa: PLR0915 sites. Its registration contract is self-enforcing: an unregistered carrier is itself a violation. The rule reads source text plumbed through BaseRule.source_lines rather than re-opening its filepath, so a synthetic tree with a real-looking path can no longer scan an unrelated file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
joeysbase
requested review from
akshaylive,
bai-uipath,
tmatup and
uipreliga
as code owners
August 14, 2026 23:08
…grading # Conflicts: # .claude/harness-candidates.md
Round-3 review found two High issues, one of them a regression introduced by round 2's own fix, plus ripple that no earlier round had looked for. - Antigravity: remove the poll-loop cycle cap. Round 2 made it apply alongside the deadline instead of only when there was no deadline, so a task configuring turn_timeout: 1200 (960s of polling) was silently cut at 120 * 5s = 600s. On the timeout=None path the two bounds expired at the same instant anyway, so the cap bounded nothing the wall-clock deadline did not. One clock now covers both cost modes. - Orchestrator: shield and track the forced-kill grading pass. check_all_async offloads each criterion to asyncio.to_thread, which is not cancellable, so the 60s budget left a run_command criterion's subprocess running inside a sandbox that run()'s finally was about to move or rmtree. The budget still bounds how long we WAIT for a verdict; _await_pending_grade bounds when teardown may start. Mirrors SubAgentRunner, which documents this same hazard. - EvaluationResult.forced_kill records the kill durably, alongside final_status like max_turns_exhausted. Once grading can turn a TIMEOUT into SUCCESS the status stops being a usable proxy for "blew its budget": reports_experiment._cost_complete returned True for rows that lost in-flight spend, the error_log_tail allowlist dropped the only evidence of the kill, and telemetry could not count breaches. All three now key off the flag, and run.json carries it. - Bound the two new unbounded awaits (the pre-grading agent quiesce and the poll-budget cancel); both run on a connection already declared unresponsive, outside any watchdog. The quiesce also catches BaseException so a queued task.cancel landing there cannot skip the grading pass it protects. - DRY: _evaluation_loop now calls _gate_passed instead of keeping a second hand-maintained copy of the FIRED-ONLY rule, and the two timeout handlers collapse into _handle_forced_kill. That brings run() back under ruff's ceiling, so its # noqa: PLR0915 and its CE022 _TARGETS entry are both gone. - Docs: REPORT_SCHEMA.md gains the TIMEOUT-is-a-fallback gotcha and the ERROR -> TIMEOUT migration for turn timeouts; CLAUDE.md records forced_kill; smoke_task_timeout.yaml's comments no longer claim criteria are never evaluated on a timeout, which this change made false. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| self.task.task_id, | ||
| ) | ||
| with contextlib.suppress(Exception, asyncio.CancelledError): | ||
| await grade |
…iesce CodeQL flagged the except BaseException added in the last round, and it was right: swallowing CancelledError there meant a batch shutdown or Ctrl-C arriving at the quiesce was ignored, and the run went on to spend up to 60s grading after being told to stop. Exception is the correct width. The earlier reasoning for BaseException -- that a queued task.cancel must not skip the grading pass -- had the trade backwards: fallback_status is committed before any await, so propagating leaves the row correct and skipping a best-effort grade is exactly what cancellation means. The sibling suppression in _await_pending_grade keeps CancelledError, and now says why: it runs inside run()'s teardown, which this file already establishes must be interrupt-proof, and aborting it would both leak the sandbox and abandon the worker thread it exists to wait for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A structural timeout used to discard the agent's work.
TaskTimeoutError/TurnTimeoutErrorset a status and threw the trajectory away, so a task that timed out but had already satisfied its success criteria was reportedTIMEOUT. Both handlers now grade what the agent produced and finalizeSUCCESS(plain,error_messagecleared) when the criteria pass.Three things ride along, each needed to make that safe:
receive_steps()returns immediately (5s/cycle); against a wedged connection every re-drain burns the full 30s per-step timeout. One bound can't cover both, so_MAX_BACKGROUND_POLLS(120 × 5s = 600s) sizes the cheap mode and a new_MAX_BACKGROUND_POLL_WALL_SECONDSsizes the wedged one.TurnTimeoutErroron the "nothing settled" exit, instead of silently finalizing as an ordinary COMPLETED turn — that's what gives the orchestrator's grading path a chance to run.(file, function, cap)table, since this change adds two# noqa: PLR0915sites.Behavior change worth flagging to consumers
FinalStatus.TIMEOUTno longer means "every timed-out run". A timed-out task whose criteria pass now finalizesSUCCESS, so anything readingTIMEOUTas a proxy for "hit the wall" (dashboards, error-rate rollups) will see a shift.CLAUDE.md,docs/agents/HARNESS_PARITY.mdanddocs/agents/ANTIGRAVITY.mdare updated to say so.Safety properties of the grading pass
awaitand only ever upgrades toSUCCESS, so aBaseExceptionmid-grade can't leave the row at the constructor default.TurnTimeoutErrornothing has torn the harness down yet — Antigravity'skill_sync()is intent-only and_cleanup()runs later inrun()'sfinally— so without this the criteria could read a sandbox a backgrounded build was still writing.success_criteria_resultsevery turn undercheck_criteria: every_turn, so a non-empty list alone doesn't mean the grade covers the trajectory; a_graded_iteration_countstamp gates the shortcut.Known limitation (not fixed here)
With a configured
turn_timeout, the poll deadline is0.8 × turn_timeoutmeasured from turn start (it has to be turn-anchored to win its race with the watchdog). At the repo defaultturn_timeout: 300that's 240s — below the 300s worst backgrounded job on record. Raising it is a defaults change rather than a constants change, so it's asserted explicitly intest_background_poll_budget_still_covers_the_worst_observed_backgrounded_job: changing the default deliberately trips the test.Testing
make verify— 4149 passed, coverage 91.68%;make lint— 340 passed.One pre-existing failure remains, unrelated to this branch:
test_effective_model_prefers_config_then_defaultasserts_effective_model()falls through to_DEFAULT_MODEL, which fails on any machine whose.envsetsANTIGRAVITY_MODEL(pydantic-settings reads.env). It passes in CI, which has no.env.New coverage includes the forced-kill grading matrix (pass → SUCCESS, fail → TIMEOUT, already-graded shortcut, stale-snapshot re-grade, 60s budget expiry,
CancelledErrormid-grade, agent quiesce + quiesce failure), both branches of the FIRED-ONLY gate, and the poll-budget bounds.Review
Two rounds of multi-model review (three Opus reviewers each; the
multiMCP server was unavailable, so this used the documented fallback). Round 2 reviewed round 1's fixes and found three High issues in those fixes — a grading/live-agent race, a poll budget that could be zero, and a lostTIMEOUTclassification underBaseException— all fixed here.🤖 Generated with Claude Code