Skip to content

Commit d3f1432

Browse files
joeysbaseclaude
andauthored
fix(antigravity): poll for backgrounded work instead of grading it incomplete (#111)
* fix(antigravity): poll for backgrounded work instead of grading it incomplete AntigravityAgent.communicate() used to drain the SDK's step stream once and finalize the instant it went idle, so a task Gemini backgrounds and pauses on (intending to check back later) got graded before the work finished. Now the turn polls (sleep + re-drain) while an orphaned tool call is still ACTIVE, bounded by _MAX_BACKGROUND_POLLS and the existing turn watchdog. Hardened across three review rounds: the orphan signal allowlists ACTIVE (not "not yet closed") so a tool stuck on WAITING_FOR_USER/CANCELED/UNKNOWN is never polled forever; the fallback synthetic tool-call id (when the SDK's call.id is falsy) is stable across a step's own ACTIVE->DONE re-emissions and unique across trajectories; the poll loop exits promptly once the watchdog has decided to fire; and _drain() retries past the SDK's real two-layer generator re-entrancy window after a cooperative stop instead of crashing the next turn. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: remove redundant asyncio re-import flagged by CodeQL The module already imports asyncio at the top level; the local re-import inside test_communicate_poll_budget_exhausted_finalizes_via_existing_timeout_path was dead weight. The other CodeQL finding on this PR (an unreachable trailing yield in a fake receive_steps() that raises CancelledError first) is a deliberate, necessary idiom -- Python only classifies a function as an async generator if its body contains a yield anywhere, reachable or not, and the same pattern already exists twice, unflagged, on main. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: derive the poll loop's exit bound from the turn's actual timeout The poll loop's graceful-exit path (force-close a never-resolving orphan as unresolved, finalize and grade normally) was bounded by a fixed cycle count (120 x 5s = 600s) that was double experiments/default.yaml's own default turn_timeout (300s). Since the pre-existing ThreadedWatchdog enforces timeout by cancelling the whole turn, it always won that race under default settings, making the graceful path dead code: a tool call spuriously left ACTIVE with no real background job behind it (a real, observed case from the PR's own validation run) went from "finalizes immediately, graded on whatever the agent wrote" pre-fix to "burns the full 300s, then crashes as TurnTimeoutError with zero criteria graded" post-fix -- a strict regression for that input class. Fixes by deriving a poll_deadline from 0.8x the actual timeout passed to communicate(), falling back to the cycle-based cap only when timeout is None. 0.8 is a fraction of the watchdog's own deadline, not an identical value, so it doesn't reintroduce the race an earlier review round removed -- it's a deliberately earlier internal deadline engineered to reliably win. Caught independently by two PR reviewers (bai-uipath, uipreliga) on the same line of arithmetic. Added a regression test proving a never-resolving orphan under a realistic 300s timeout now finalizes gracefully instead of crashing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 02e5151 commit d3f1432

3 files changed

Lines changed: 1000 additions & 23 deletions

File tree

‎.claude/harness-candidates.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,80 @@ with the two `action.yml` items above — one considered change to the action's
314314
advertises otherwise. Either mark it `required: true` (a published-input contract change,
315315
see the `working-directory` item) or fail with a clear message instead of an obscure
316316
discovery error.
317+
318+
## From the coder-eval-code-review of fix/antigravity-wait-for-wakeup (2026-08-12)
319+
320+
- [ ] **A retry/poll loop's continuation state must derive from a stable per-entity
321+
key, never a mutable monotonic counter used as an id fallback.** `_AntigravityTurnState._handle_tool_call`
322+
minted a synthetic tool-call id from `f"{raw_name}_{self._next_seq}"` when the SDK's
323+
`call.id` was falsy; since `_next_seq` advances between a tool call's ACTIVE and DONE
324+
emissions, the DONE step computed a *different* fallback id than the ACTIVE step,
325+
stranding the ACTIVE entry as a permanent orphan and stalling `communicate()`'s new
326+
poll loop for its full `_MAX_BACKGROUND_POLLS` budget on every id-less turn. Fixed by
327+
deriving the fallback from `(step.step_index, call_index)` instead (stable across a
328+
step's own re-emissions, per this class's own docstring) -- then, in the same PR,
329+
further folded in `step.trajectory_id` (falling back to bare `step_index` when it's
330+
empty, mirroring the SDK's own `trajectory_id:step_index` id scheme), since a
331+
sub-agent trajectory can reuse the same low `step_index` values as the main one and
332+
two id-less calls across trajectories would otherwise collide. Not promoted to a CExxx rule:
333+
this is the only id-fallback-driving-control-flow site in the codebase today (a
334+
single call site, not a recurring class per the existing "single call-site fix, no
335+
recurring pattern to guard" convention) — a mechanical AST rule for "no mutable
336+
counter in a dict-key fallback" would need real design work to avoid false-positiving
337+
on ordinary sequence-numbering counters elsewhere in the file. Caught by two
338+
independent reviewers (Opus fallback pair) in this run's final code review.
339+
340+
- [ ] **A `while` loop built around a cooperative-cancellation watchdog should read the
341+
watchdog's own "already decided to fire" flag in its condition, not rely solely on a
342+
later exception handler to notice.** The antigravity poll loop's condition checked
343+
`not state.stopped_early_hit and state.has_orphaned_tool_call() and poll_count < cap`
344+
but not `state.timeout_hit`, so if `ThreadedWatchdog`'s background thread set the flag
345+
before its `task.cancel()` actually landed on this coroutine, the loop kept
346+
sleeping/re-draining for up to the full poll budget before the pre-existing
347+
post-loop `if state.timeout_hit:` check ever got a chance to run. Fixed by adding
348+
`and not state.timeout_hit` to the condition, plus a mid-body early exit right after
349+
the sleep (`if state.timeout_hit: break`) so a flag landing DURING the sleep skips
350+
the following re-drain too, instead of waiting for the loop's next head check. Not
351+
promoted: `ThreadedWatchdog` + a bespoke poll loop reading its own state flag is a
352+
one-off shape unique to this agent; no second instance exists to generalize a rule
353+
from. Caught in the same
354+
final review as above.
355+
356+
- [ ] **A regression test's fake dependency must model every layer the fix under test
357+
actually touches, not just the outermost one.** `_drain()`'s cooperative-stop path
358+
wraps a real SDK call (`Conversation.receive_steps()`) that is itself a delegating
359+
async generator over an inner, connection-layer generator holding the real
360+
re-entrancy guard. The first regression test written for this fix used a
361+
single-layer fake (the guard lived on the SAME generator `_drain()` iterated), which
362+
passed against an incomplete fix (`contextlib.aclosing` on the outer generator only)
363+
that does not work against the real two-layer SDK shape — confirmed live that the
364+
inner generator's cleanup is deferred to a LATER event-loop turn, not synchronous
365+
with the outer's `aclose()`. Caught by a reviewer re-deriving the real dependency's
366+
shape from its installed source, not by the test itself. Not promoted: detecting "a
367+
test double is missing a delegation layer the source has" is a semantic match
368+
against third-party source, not an AST pattern in our own code — no cheap mechanical
369+
check exists. Caught in the round-3 coder-eval-code-review of this same branch.
370+
371+
- [ ] **An agent's internal sleep-and-retry loop must derive its own exit bound from
372+
the turn's actual `timeout`, never a fixed cycle count picked independently.** The
373+
poll loop's own graceful exit path (force-close a never-resolving orphan as
374+
unresolved, finalize and grade normally) was bounded by `_MAX_BACKGROUND_POLLS * _BACKGROUND_POLL_INTERVAL_SECONDS`
375+
(120 × 5s = 600s) — DOUBLE `experiments/default.yaml`'s own default `turn_timeout: 300`.
376+
Since the pre-existing `ThreadedWatchdog` enforces `timeout` by cancelling the whole
377+
turn, it always won that race under default settings, making the graceful path dead
378+
code: a tool call spuriously left ACTIVE with no real background job behind it (a
379+
real, observed case — see the final validation run) went from "finalizes immediately,
380+
graded on whatever the agent wrote" pre-fix to "burns the full 300s, then crashes as
381+
`TurnTimeoutError` with zero criteria graded" post-fix — a strict regression for that
382+
input class. Fixed by deriving a `poll_deadline` from a fraction (0.8x) of the actual
383+
`timeout` passed to `communicate()`, falling back to the cycle cap only when
384+
`timeout is None`. Caught independently by two reviewers (`bai-uipath`, `uipreliga`)
385+
on the PR, both citing the exact same arithmetic mismatch. **Not promoted in this
386+
pass**, but a stronger candidate than most entries here: `uipreliga` proposed a
387+
generic whole-tree rule (their CE035) — for every sleep-loop under
388+
`src/coder_eval/agents/**`, assert its own cycle-count × interval either references a
389+
timeout-derived name or is provably below `experiments/default.yaml`'s baseline — that
390+
would catch this class of bug in ANY agent, not just this one (confirmed zero
391+
violations on `main` before this bug, one on this PR). Worth a real look next time
392+
`agents/` is touched, since a second agent adding its own disconnected sleep-loop
393+
constant would reintroduce the exact same shape.

0 commit comments

Comments
 (0)