Skip to content

fix(orchestrator): grade a forced-kill timeout instead of discarding it - #117

Open
joeysbase wants to merge 4 commits into
mainfrom
fix/timeout-discards-grading
Open

fix(orchestrator): grade a forced-kill timeout instead of discarding it#117
joeysbase wants to merge 4 commits into
mainfrom
fix/timeout-discards-grading

Conversation

@joeysbase

Copy link
Copy Markdown
Contributor

Summary

A structural timeout used to discard the agent's work. TaskTimeoutError / TurnTimeoutError set a status and threw the trajectory away, so a task that timed out but had already satisfied its success criteria was reported TIMEOUT. Both handlers now grade what the agent produced and finalize SUCCESS (plain, error_message cleared) when the criteria pass.

Three things ride along, each needed to make that safe:

  • Antigravity's poll loop is rebounded. A poll cycle's cost is bimodal — against a backgrounded job the connection is idle and 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_SECONDS sizes the wedged one.
  • A real TurnTimeoutError on 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.
  • CE022 generalized to a (file, function, cap) table, since this change adds two # noqa: PLR0915 sites.

Behavior change worth flagging to consumers

FinalStatus.TIMEOUT no longer means "every timed-out run". A timed-out task whose criteria pass now finalizes SUCCESS, so anything reading TIMEOUT as a proxy for "hit the wall" (dashboards, error-rate rollups) will see a shift. CLAUDE.md, docs/agents/HARNESS_PARITY.md and docs/agents/ANTIGRAVITY.md are updated to say so.

Safety properties of the grading pass

  • Commits the fallback status before its first await and only ever upgrades to SUCCESS, so a BaseException mid-grade can't leave the row at the constructor default.
  • 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.
  • Wall-clock bounded (60s), never raises, and honors the same FIRED-ONLY early-stop gate as a normal run.
  • Re-grades rather than reusing a stale snapshot. The simulation loop rewrites success_criteria_results every turn under check_criteria: every_turn, so a non-empty list alone doesn't mean the grade covers the trajectory; a _graded_iteration_count stamp gates the shortcut.

Known limitation (not fixed here)

With a configured turn_timeout, the poll deadline is 0.8 × turn_timeout measured from turn start (it has to be turn-anchored to win its race with the watchdog). At the repo default turn_timeout: 300 that'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 in test_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_default asserts _effective_model() falls through to _DEFAULT_MODEL, which fails on any machine whose .env sets ANTIGRAVITY_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, CancelledError mid-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 multi MCP 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 lost TIMEOUT classification under BaseException — all fixed here.

🤖 Generated with Claude Code

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>
…grading

# Conflicts:
#	.claude/harness-candidates.md
Comment thread tests/test_antigravity_agent.py Dismissed
Comment thread tests/test_antigravity_agent.py Dismissed
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>
Comment thread src/coder_eval/orchestrator.py Fixed
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>
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.

2 participants