One task file, run on three harnesses, must be the same task. run_limits.max_turns
was the field that broke that promise hardest: Claude Code enforced it, and Codex and
Antigravity accepted it and never read it, so max_turns: 6 ran capped on one
backend and unbounded on the other two.
This page is the contract for what each run limit means per harness.
| Limit | claude-code | codex | antigravity |
|---|---|---|---|
run_limits.max_turns |
native SDK cap (agent-loop turns) | visible-turn cap (resolved tool calls) | visible-turn cap (resolved tool calls) |
run_limits.turn_timeout |
watchdog, SIGKILL on the CLI subprocess | watchdog + cooperative interrupt | watchdog, plus an earlier internal poll deadline at 80% of it (see below) |
run_limits.task_timeout |
orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic | orchestrator-level, agent-agnostic |
run_limits.stop_early |
cooperative should_stop |
cooperative should_stop |
cooperative should_stop |
A "visible turn" is one entry in the run's timeline: one resolved tool call. It is
the unit reports_stats.visible_turn_count reports and the unit that lands in
TurnRecord.commands. Both backends count it live off the shared
EventCollector.visible_turn_count, so one max_turns value means one thing on
both.
They need their own counter because a native one would be meaningless: Codex and
Antigravity each deliver exactly one SDK turn per communicate() call, so an
SDK-level cap would clamp at 1 no matter what the task asked for.
The cap is enforced on the same loop boundary as the cooperative early stop: the
step or notification that reaches the cap is processed whole, and the next one is
never pulled. The in-flight turn is then cancelled server-side (best effort) so
the cap actually stops spend. A run cut this way finalizes cleanly as
max_turns_exhausted — it is not a crash, and it is not retried.
claude-code keeps its native SDK cap. That is a real, honored cap, so it is left alone rather than reimplemented in a different unit. Its unit is the SDK's own agent-loop turn, which absorbs an arbitrary number of parallel tool calls, so the same number bounds very different amounts of work: under a prompt that encourages batching, a cap of N here permits many more than N tool calls, where it buys exactly N on the other two.
So holding max_turns constant across harnesses does not hold the budget
constant. If you are A/B-ing across backends and the cap is close to binding, that
is the number to distrust.
The signals a capped run leaves behind, on every backend:
- Criteria are still checked against whatever the agent produced, because the cap is
an ordinary end-of-run rather than an error. So a capped run that nonetheless
satisfies its criteria finishes as
SUCCESS; one that does not finishes asMAX_TURNS_EXHAUSTED(reporting categoryfailed, iconM). NeverERROR, and never retried. max_turns_exhausted: trueon the task record.- On Codex and Antigravity, the count of resolved tool calls the model itself
issued equals the cap. Two things can add a further recorded command, and
neither means the cap leaked:
- A tool call already in flight when the cap fires is force-closed and recorded
with
result_status: unknownrather than dropped, so the trajectory shows what was interrupted. - On Codex, a sub-agent's inner tool calls are recovered from its rollout after the pump stops, so the child's work and its tokens still reach the record. The cap bounds what the model was allowed to do, not what the record may explain.
- A tool call already in flight when the cap fires is force-closed and recorded
with
On Claude Code and Codex a turn_timeout breach is a failure: the watchdog fires
at the deadline, the partial turn is preserved on pending_turn, and the turn is
marked crashed.
Antigravity stops earlier and more gently, for the reason in the next section.
The Antigravity localharness has a 10-second maximum synchronous wait for shell commands. Past it, the harness moves the command to a background task and hands the model a task id instead of a result. That is harness behavior, not something coder_eval configures.
What coder_eval does about it: the turn polls for the backgrounded result rather than finalizing the moment the step stream goes idle, so slow work does finish and its real exit code reaches the model. Without that poll, a command over the 10s boundary left the tool call unresolved and the turn was graded on work that had not happened yet.
The wait is bounded by 80% of turn_timeout (or 120 five-second cycles when the
task sets no timeout), not by turn_timeout itself. A job that outlives that bound
is force-closed as unresolved and the turn is graded on everything else, where
Claude Code and Codex instead raise a turn timeout and mark the turn crashed.
So the residual divergence is the terminal signal, not whether slow work completes:
a long npm install or build runs to completion here the way it does on the other
two, but a command that never finishes reads as an ordinary low score rather than a
timeout.
A timeout is a failure (partial turn captured, error status); the turn cap is a clean stop. Conflating them is the mistake this page exists to prevent: a task whose cap fires should not look like a task whose harness hung.
tasks/run_limits/ holds one fixture per limit: max_turns_cap.yaml asks for more
sequential work than its cap allows, and turn_timeout.yaml runs a command that
outlives its watchdog. Run either with --type claude-code / --type codex /
--type antigravity to check a backend against the contract above.
- Claude Code · Codex · Antigravity
- Task Definition Guide — the full
run_limitsschema