fix(harness): recover trials lost to empty turns, transport drops, and a headless-only tool#747
Merged
Merged
Conversation
…d a headless-only tool
Four defects found by diffing two full terminal-bench 2.1 runs at
identical model/effort/dataset (claude-opus-5, xhigh): clawcodex 0.742
vs the official Claude Code harness 0.843. Of the 13 tasks Claude Code
solved and clawcodex did not, 3 were crashes and 3 were trials that did
nothing at all — none of it model capability.
All four fixes are general robustness. Nothing here is conditional on an
eval: no env checks, no branches, no wording aimed at a grader.
## An empty assistant turn ended the run as "completed"
Three trials — break-filter-js-from-html, crack-7z-hash,
vulnerable-secret — produced ONE output token in ~3 seconds, returned an
empty result, reported `subtype: success`, and scored 0. Claude Code
solved all three.
No text and no tool calls is a degenerate response, not an answer. The
continuation nudge could not catch it because it gates on the text being
truthy, so an empty turn fell straight through to
`Terminal(reason="completed")`. Empty turns are now re-prompted, bounded
by the same MAX_CONTINUATION_NUDGES cap as every other nudge.
Carve-out: a model that speaks through SendUserMessage (documented as the
primary visible output channel) legitimately ends with empty assistant
text, and agent_loop_compat already treats the outbox as the response
text. The existing parity test caught that case.
## A transport blip killed a 24-minute trial
regex-chess died on `peer closed connection without sending complete
message body (incomplete chunked read)` seconds after a passing
1500-position fuzz run. Transport drops now take the same bounded retry
the idle watchdog already takes, and the ORIGINAL exception is re-raised
on exhaustion so the log names the real failure instead of an idle
timeout.
Gated two ways. Status-bearing errors (401/529/400) and aborts fail fast
— retrying a revoked token just burns the budget on guaranteed 401s. And
the retry is skipped once any text has been handed to the caller: a
re-attempt replays the response from scratch, so retrying after partial
output would emit those chunks twice, which is exactly what query.py:1635
forbids ("never after partial output"). That check sits above this layer
and cannot see a retry taken here, so the gate has to live here too.
## AskUserQuestion was reachable in --print
fix-git called it twice, then ended with "let me explain what I found"
instead of finishing; the verifier found the files untouched. It is not
in the initial toolset — the model reached it through ToolSearch.
Nothing on the headless surface can answer it (StreamJsonReader parses
only `user` messages; there is no can_use_tool protocol), so it is now
unregistered rather than left registered with a stubbed responder. This
is parity, not special-casing: the TS reference's AskUserQuestionTool
already reports itself disabled when nobody is at the keyboard. Removal
happens before the allow/deny filters so --allowed-tools cannot resurrect
it, and it propagates to subagents, which share the registry instance.
Test doubles gained `remove_tool` rather than the production call gaining
a getattr guard — a silent no-op there is the bug class that left
--allowed-tools dead for months (#739).
## The idle-timeout message was written for a grader
It appended "Connection timed out" with a comment saying the phrasing was
deliberate so eval harnesses would classify the failure as retryable.
That optimizes for the scoreboard rather than the agent, and it was also
untrue — an idle stream is not a connection timeout, so it pointed
debuggers at the wrong subsystem. Removed; the test now asserts the
honest wording and that the old clause stays gone.
## Diagnosed, deliberately NOT fixed
Two trials died on `401 OAuth access token has been revoked` — not
expiry (one died 4.7 minutes in against a 2h runway) but refresh
rotation: containers hold a refresh-token-free copy, so anything rotating
the host credential revokes the in-flight token. An in-adapter retry
would hand clawcodex attempts the official harness would not give another
agent, so this is documented in the adapter with a recommendation to use
an API key for scored runs.
Tests: 8841 passed. Every fix is revert-sensitive — each line was deleted
and the intended test confirmed failing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Jul 26, 2026
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.
Found by diffing two full terminal-bench 2.1 runs at identical model, effort, dataset and concurrency (claude-opus-5, xhigh):
62 tasks both solved, 13 CC-only, 4 clawcodex-only. Of the 13-task deficit, 3 were crashes and 3 were trials that did nothing at all — none of it model capability. Per-step latency is basically identical (19.3s vs 21.5s median), so the gap is dominated by trials that died or no-oped, not by the model being slower.
All four fixes are general robustness. Nothing here is conditional on an eval — no env checks, no branches, no wording aimed at a grader.
1. An empty assistant turn ended the run as "completed"
break-filter-js-from-html,crack-7z-hash,vulnerable-secret: one output token, ~3 seconds, empty result,subtype: success, scored 0. Claude Code solved all three.No text and no tool calls is a degenerate response, not an answer. The continuation nudge couldn't catch it — it gates on the text being truthy — so an empty turn fell straight through to
Terminal(reason="completed"). Empty turns are now re-prompted, bounded by the existingMAX_CONTINUATION_NUDGEScap.Carve-out for
SendUserMessage: a model that speaks through the outbox legitimately ends with empty assistant text, andagent_loop_compatalready treats the outbox as the response text. The existing parity test caught that case when I first missed it.2. A transport blip killed a 24-minute trial
regex-chessdied onpeer closed connection without sending complete message body (incomplete chunked read)seconds after a passing 1500-position fuzz run.Transport drops now take the same bounded retry the idle watchdog already takes, with the original exception re-raised on exhaustion so the log names the real failure. Gated two ways:
query.py:1635forbids. That check sits above this layer and can't see a retry taken here, so the gate has to live here too. Free for the eval: harbor runs without--include-partial-messages, so the callback isNonein a scored trial.3.
AskUserQuestionwas reachable in--printfix-gitcalled it twice, then ended with "let me explain what I found" instead of finishing; the verifier found the files untouched. It isn't in the initial toolset — the model reached it viaToolSearch.Nothing headless can answer it (
StreamJsonReaderparses onlyusermessages; there's nocan_use_toolprotocol), so it's unregistered rather than left registered with a stubbed responder. Parity, not special-casing — the TS reference'sAskUserQuestionToolalready reports itself disabled when nobody's at the keyboard. Removal happens before the allow/deny filters so--allowed-toolscan't resurrect it, and it propagates to subagents (shared registry instance).Test doubles gained
remove_toolrather than the production call gaining agetattrguard — a silent no-op there is the bug class that left--allowed-toolsdead for months (#739).4. The idle-timeout message was written for a grader
It appended
"Connection timed out", with a comment stating the phrasing was deliberate so eval harnesses would classify the failure as retryable. That optimizes for the scoreboard rather than the agent — and it was untrue, since an idle stream is not a connection timeout, so it pointed debuggers at the wrong subsystem. Removed; the test now pins the honest wording and asserts the old clause stays gone.Diagnosed, deliberately NOT fixed
Two trials died on
401 OAuth access token has been revoked— not expiry (one died 4.7 minutes in against a 2h runway) but refresh rotation: containers hold a refresh-token-free copy, so anything rotating the host credential revokes the in-flight token. An in-adapter retry would hand clawcodex attempts the official harness wouldn't give another agent, so it's documented in the adapter with a recommendation to use an API key for scored runs.Also unfixed and model-shaped rather than harness-shaped: on
torch-tensor-parallelismneither agent could find a Python interpreter, but Claude Code stopped searching and wrote the deliverable (1.0) while clawcodex kept globbing until the budget expired. That's prompt-shaping and wants an A/B, not a guess.Verification
test_connection_error_re_raises, a pre-existing hang confirmed on cleanmain).Estimated recovery if these hold: 3 (empty turns) + 1 (
regex-chess) + 1 (fix-git) ≈ 5 tasks. That's an estimate until a rerun confirms it — and note both runs recordedn_retries: 0, so it rests on the fixes rather than on any harness retry behavior.🤖 Generated with Claude Code