Skip to content

fix: four harness bugs found by the tb2.1 fusion-vs-opus differential - #789

Merged
ericleepi314 merged 5 commits into
mainfrom
fix/harness-bugs-tb21-fusion
Aug 3, 2026
Merged

fix: four harness bugs found by the tb2.1 fusion-vs-opus differential#789
ericleepi314 merged 5 commits into
mainfrom
fix/harness-bugs-tb21-fusion

Conversation

@ericleepi314

Copy link
Copy Markdown
Collaborator

Diagnosing tb21-fusion-flash-luna against tb21-clawcodex-3 surfaced four
defects in clawcodex itself. On the 41 shared scored tasks the fusion run
scores 0.585 against opus-5's 0.829; roughly 40% of that gap is
harness
, not model. Selection bias is ruled out — opus scores 0.829 on the
shared 41 and 0.826 on the 46 the fusion run never reached.

Each fix is general. None of them trades another model's behaviour for this
run's score, and two carry an explicit regression test for that.

The four

1. No elapsed-time bound on the OpenAI-compatible wires (4dbc78b6)

model-extraction-relu-logits sat through 880 seconds of total silence
after one tool call, then died at harbor's 900 s ceiling with a 2 KB log. The
Anthropic wire has had an idle watchdog since WI-5.2; these consumers checked
guard.aborted and nothing else.

The httpx read timeout does not cover it — that bounds the gap between
BYTES, and a stalled provider is usually still sending keepalives, so it
re-arms indefinitely. Adds ContentProgressDeadline, which measures semantic
deltas
instead. Threshold is the 300 s first-event grace, flat, so prompt
processing and hidden reasoning are never cut short; a test pins that a slow
but productive stream is untouched.

The subtle part: the check runs on every loop iteration, not just when the
chunk queue is empty. Gating it on Empty looks natural and is wrong for exactly
the case it exists for — a stalled-but-chatty stream keeps the queue non-empty.
Both new tests hang against that variant.

2. -no-reboot refused as a dangerous command (f8442768)

\breboot\b matches inside the standard QEMU flag, because - is a non-word
character. 7 blocked Bash calls across qemu-startup and qemu-alpine-ssh,
both of which then scored 0 — opus solved qemu-alpine-ssh. Now
(?<![-\w])X(?![-\w]), which still matches bare reboot, /sbin/reboot and
reboot -f.

Separately the blocklist ignored --dangerously-skip-permissions entirely.
Real Claude Code has no un-bypassable command blocklist; same parity direction
as #673. The bypass predicate requires is_bypass_permissions_mode_available
as well as the mode — load-bearing, because ToolContext defaults to
mode="bypassPermissions", so keying on mode alone would disarm the blocklist
for every default-constructed context. The C8 sandbox hard-gate is deliberately
still not bypassable: it comes from managed settings.

3. Killed trials reported no tokens and no cost (a421fb2e)

Every trial missing metrics was a killed one — 21 of 46 in the fusion job, 17
of 89 in the opus job — i.e. the longest and most expensive. Job totals are
summed per-trial, so both headline costs are floors biased low by exactly the
trials that cost the most, and the jobs' timeout rates differ 2.4x.

Adds a cumulative usage stream-json event per assistant message. Verified
live: the first one lands before the first tool_use, which is precisely
where model-extraction-relu-logits died. Additive (new event type,
ResultEvent untouched), and the adapter takes it as a last resort behind
session totals and the result event.

4. Fusion vision empty response cached as permanent failure (f5322272)

gcode-to-text: the vision leg returned no text for image 2 of 5, that
cached as a _Failure, and the task scored 0 against a baseline that solved
it. The empty-200 case now gets one retry; transport failures keep the old
single-attempt + negative-cache behaviour, which is what keeps a real outage
from costing 2x the calls every turn forever. The retry is charged to the
request's call budget.

Verification

  • Full suite: 9627 passed, 9 skipped, 0 failures.
  • Every fix mutation-tested — 9 mutants, all caught. The two stream mutants
    (deadline removed; deadline gated on Empty) make the tests hang, which is
    the production bug reproduced.
  • Harbor adapter tests run under uv run --python 3.13 --with harbor
    (7 passed); they skip in the repo's venv, which lacks harbor.
  • Live terminal-bench smoke on the four tasks that exercise each fix, plus
    openssl-selfsigned-cert as an untouched control.

🤖 Generated with Claude Code

ericleepi314 and others added 5 commits August 2, 2026 17:58
Two independent defects in the pre-spawn safety guard, both found by a
terminal-bench 2.1 differential (2026-08-02).

`\breboot\b` matches inside `-no-reboot` — a standard QEMU flag — because
`-` is a non-word character. That refused `qemu-system-x86_64 … -no-reboot`
as "potentially dangerous": 7 blocked Bash calls across qemu-startup and
qemu-alpine-ssh, both of which then scored 0 (the opus baseline solved
qemu-alpine-ssh). Replaced the `\b` boundaries on the command-NAME patterns
with `(?<![-\w])X(?![-\w])`, which still matches a bare `reboot`,
`/sbin/reboot` and `reboot -f` while never matching a hyphenated neighbour.
Strictly more precise; nothing new gets through.

Separately the blocklist ran unconditionally, so it refused `sudo` and a
real `reboot` even under --dangerously-skip-permissions. Real Claude Code
has no un-bypassable command blocklist; this list is a port addition. Same
parity direction as #673. The guard now skips the list in a deliberate
bypass posture and stays fully armed in default/acceptEdits/plan.

The bypass predicate requires `is_bypass_permissions_mode_available` as
well as the mode, and that half is load-bearing: `ToolContext` DEFAULTS to
`ToolPermissionContext(mode="bypassPermissions")` (the inversion
mcp_serve.py already documents), so keying on mode alone would disarm the
blocklist for every default-constructed context. Only a session that
actually resolved the posture sets both. Fails closed otherwise.

The C8 sandbox hard-gate is deliberately NOT bypassed — that one comes
from managed settings, and a CLI flag must not override an admin control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Anthropic wire has had an idle watchdog since WI-5.2. The
OpenAI-compatible consumers had no elapsed-time bound of any kind: their
poll loops checked `guard.aborted` and nothing else, so a connection that
was accepted and then produced nothing hung until something above killed
the process. On terminal-bench 2.1 (2026-08-02)
model-extraction-relu-logits sat through 880 SECONDS of total silence
after a single tool call, then died at harbor's 900 s ceiling having
written 2 KB of log.

The httpx `read` timeout does not cover this. `read` bounds the gap
between BYTES, and a stalled provider is usually still sending —
keepalive comments, empty delta frames — so it re-arms indefinitely. That
880 s stall exceeding 120 s x (1 + 1 retry) is the proof it never fired.
Byte liveness cannot answer "is this stream still producing anything?".

Adds `ContentProgressDeadline`, the semantic sibling of `StreamWatchdog`:
progress is recorded for content, reasoning and tool-call deltas and the
terminal finish_reason — deliberately NOT for bare frame arrival, since a
provider emitting empty frames forever is the failure being caught. Wired
into both consumers that had the gap (Chat Completions and the ChatGPT
subscription Responses loop), driven from their existing poll ticks, so
no timer thread is introduced.

Checked on every loop iteration rather than only when the chunk queue is
empty. Gating it on Empty looks natural and is wrong for exactly the case
this exists for: a stalled-but-chatty stream keeps the queue non-empty,
so the check never runs. Both new tests hang against that variant.

Threshold is the FIRST-EVENT grace (300 s), not the 90 s inter-event
idle, and stays flat instead of tightening after the first delta. Prompt
processing on a large context legitimately runs minutes before the first
token, and a model doing hidden internal reasoning can legitimately go
quiet mid-response; no healthy provider goes five minutes between deltas.
Erring long costs one stalled request, erring short would truncate
healthy generations on slow providers. A test pins that a slow but
productive stream is never interrupted.

Recovery is one re-issue through the existing retry wrapper, reusing its
`emitted` guard so a stream that already reached the caller is never
replayed. StreamIdleTimeout stays a plain Exception and stays out of
`is_transport_error`, so the two retry budgets still refuse to compose —
see the note in services/api/errors.py.

Reuses CLAUDE_STREAM_FIRST_EVENT_TIMEOUT_MS / CLAUDE_STREAM_IDLE_TIMEOUT_MS;
no new env surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`_describe` raised on an empty 200 and `_substitute` cached that as a
permanent `_Failure`, so one transient empty completion lost the image for
the rest of the session. Observed on terminal-bench gcode-to-text
(2026-08-02): `openai:gpt-5.6-luna` returned no text for image 2 of 5 and
the task scored 0 against a baseline that solved it.

The empty case now gets exactly one retry. Nothing else does, and that
asymmetry is the point. A transport failure means the provider is
unreachable, and caching it permanently is deliberate — the docstring's
arithmetic (8 images x 60 s x 2 attempts on every turn, forever,
uninterruptible) is why it exists. An empty completion is the opposite
situation: the provider is up, answered fast, and just produced nothing.
Scoping the retry to it keeps the outage cost at one call per image.

Typed as `_EmptyVisionResponse` rather than matched on the message, and
the retry is charged to the request's call budget so a provider stuck
returning empty 200s cannot double the fan-out that cap bounds. A retry
is also skipped outright once the budget's call/time limit is reached.

Mutants covered by the new tests: no retry, retrying every failure, and a
retry that does not charge the budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`ResultEvent` carries the authoritative usage but only exists if the run
REACHES the end. A run that is killed — an eval harness hitting its
per-task ceiling, a SIGKILL, a dropped connection — emitted nothing, so
everything it had spent became unmeasurable.

On terminal-bench 2.1 (2026-08-02) every trial missing token metrics was a
killed one: 21 of 46 in the fusion job, 17 of 89 in the opus job. Those
are the LONGEST and most expensive trials, since length is what makes them
time out. Harbor sums job totals from per-trial values, so the headline
cost was a floor biased low by exactly the trials that cost the most, and
the two jobs' timeout rates differ 2.4x — which makes any cost comparison
between them meaningless.

Adds a `usage` stream-json event carrying cumulative totals, emitted per
assistant message (one model round trip). That granularity is the point:
a turn killed before it completes never reaches the per-turn accounting,
but it has already produced assistant messages. Verified live — the first
usage event lands before the first tool_use, which is exactly where
model-extraction-relu-logits died.

Accumulated in its own `live_usage` dict rather than the existing
`usage_total`: that one folds `result.usage` once per completed turn, and
feeding both from either source would double-count. The ResultEvent is
untouched.

Additive by design — a new event `type`, so consumers that switch on the
types they know are unaffected, and `ResultEvent.usage` keeps its exact
meaning. The harbor adapter gains it as a third and last-resort lane
behind session totals and the result event, never as a replacement.

The adapter's arithmetic is now shared through `_usage_columns` so the two
lanes cannot drift on counting cached tokens — the bug #786 fixed.

Adapter tests skip in the repo's 3.11 venv (the adapter needs 3.12+
`typing.override` and harbor); verified under `uv run --python 3.13
--with harbor`, 7 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

Test Results

    1 files      1 suites   7m 39s ⏱️
9 636 tests 9 624 ✅ 12 💤 0 ❌
9 936 runs  9 924 ✅ 12 💤 0 ❌

Results for commit 61b1eae.

@ericleepi314
ericleepi314 merged commit 35b558c into main Aug 3, 2026
3 checks passed
@ericleepi314
ericleepi314 deleted the fix/harness-bugs-tb21-fusion branch August 3, 2026 01:56
ericleepi314 added a commit that referenced this pull request Aug 3, 2026
A critic pass on #789 returned REVISE with four defects and three coverage
holes; the re-review of the fixes returned APPROVE with two residual gaps,
closed here as well. Every finding verified by mutation or direct probe.

Two defects where #789 asserted something untrue:

* UsageEvent.num_turns was structurally always 0 — the headless turn counter
  only advances after the agent loop returns, and the harbor adapter promoted
  that zero into killed trials' metrics, so the trial that did the most work
  reported num_turns: 0. The same floor-biased-low-on-the-longest-trials
  defect #789 set out to remove, relocated from tokens to turns. Replaced
  with assistant_messages, deliberately NOT mapped back onto num_turns.
* "Cached permanently for the rest of the session" was false —
  _FAILURE_TTL_SECONDS = 90.0 and entries expire. The retry is still right;
  the reason given for it was not.

ContentProgressDeadline's docstring documented the REJECTED design
(`except Empty: check()`) and asserted a false safety property — mutating to
it hangs. Rewritten as a warning rather than a description.

The new bound now has a real escape hatch:
CLAWCODEX_CONTENT_PROGRESS_TIMEOUT_MS (0 disables, malformed fails closed),
independent of the Anthropic knob it previously shared, named in the error
message and documented in the README. The 300s default is measured, not
asserted: 727 completed trials across 44 harbor jobs, median 14.2 s/turn,
p99 100.3, max 183.8, zero above 300 — on a metric that overstates model
latency. What the corpus cannot rule out is stated too.

Five previously-surviving mutants now fail, including deleting the usage
emission entirely, the live_usage/usage_total double-count, and the
tool-call progress signal (a model streaming a large Write emits argument
deltas and nothing else, so the biggest tool calls would be the ones killed
and replayed). Nothing called run_headless before.

Tests that regressed by HANGING now fail in 16s instead. New
Harbor adapter (3.13) CI job runs the guards the 3.11 job cannot import —
the num_turns regression could previously return with CI green.

Suite: 9641 passed, 9 skipped, 0 failures.
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.

1 participant