Skip to content

Commit 9203989

Browse files
Benjamin Feuerclaude
andcommitted
[FIX] Forbid emitting silently-unscored single-step trials
A finished agentic eval had 63/300 trials finalize in a masked state: labelled AgentTimeoutError, verifier/ dir empty, verifier_result == None -- indistinguishable from the 111 trials that timed out but DID verify to a reward, and silently dropped from the scored denominator at aggregation (JobStats.increment only counts trials with verifier_result.rewards). Root cause (confirmed on Jupiter artifacts): after the agent timed out, exception_info was set to AgentTimeoutError. _run_verification() was then invoked (its TimingInfo was set) but raised AddTestsDirError uploading the tests dir into the timed-out sandbox. That exception propagated to the outer `except Exception` in Trial.run(), where first-write-wins kept the AgentTimeoutError label and swallowed the verifier-setup failure, leaving verifier_result == None with no distinct signal. Fix: enforce a lifecycle invariant in _cleanup_and_finalize (runs on every path incl. cancellation): for single-step, verification-enabled trials with no verifier_result, record an explicit, distinctly-typed VerificationNotCompletedError (chaining any prior exception_info into the message). Trials that produced a verifier_result -- including the legitimately-scored-after-timeout case -- and verifier-disabled / multi-step trials are left byte-for-byte unchanged. New tests (red pre-fix, green post-fix): - test_timeout_then_verifier_failure_is_not_masked - test_emitted_result_json_is_not_silently_unscored Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 0f5a6e9 commit 9203989

2 files changed

Lines changed: 302 additions & 0 deletions

File tree

src/harbor/trial/trial.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,25 @@ class VerifierTimeoutError(asyncio.TimeoutError):
6767
pass
6868

6969

70+
class VerificationNotCompletedError(Exception):
71+
"""Raised/recorded when a trial finalizes without a ``verifier_result``.
72+
73+
Single-step trials are expected to produce a ``verifier_result`` whenever
74+
verification is enabled. If the verifier is never invoked, is cut off
75+
(cancellation), or raises before producing a result, the trial would
76+
otherwise finalize *silently unscored* — and, because the outer handlers
77+
record ``exception_info`` first-write-wins, the missing-score state can be
78+
masked under an unrelated label (e.g. an earlier ``AgentTimeoutError``).
79+
80+
Recording this explicit, distinctly-typed failure makes that masked state
81+
impossible to confuse with either a clean scored run or a benign
82+
agent timeout: downstream aggregation surfaces it as its own error class
83+
rather than dropping the trial from the scored denominator unnoticed.
84+
"""
85+
86+
pass
87+
88+
7089
class EnvironmentStartTimeoutError(asyncio.TimeoutError):
7190
pass
7291

@@ -529,12 +548,64 @@ async def _cleanup_and_finalize(self) -> None:
529548
if self.result.exception_info is None:
530549
self.result.exception_info = ExceptionInfo.from_exception(e)
531550

551+
self._enforce_verifier_result_invariant()
552+
532553
self.result.finished_at = datetime.now(timezone.utc)
533554

534555
self._trial_paths.result_path.write_text(self.result.model_dump_json(indent=4))
535556

536557
await self._invoke_hooks(TrialEvent.END)
537558

559+
def _enforce_verifier_result_invariant(self) -> None:
560+
"""Forbid emitting a finalized trial that was silently never scored.
561+
562+
For single-step trials with verification enabled, a missing
563+
``verifier_result`` means the verifier was never invoked, was cut off
564+
(cancellation), or raised before producing a result. Such a trial must
565+
not be emitted as if it were a benign completion: the outer ``run()``
566+
handlers record ``exception_info`` first-write-wins, so an earlier,
567+
unrelated failure (e.g. ``AgentTimeoutError``) would otherwise mask the
568+
"never scored" outcome and the trial would vanish from the scored
569+
denominator unnoticed.
570+
571+
We record an explicit, distinctly-typed
572+
:class:`VerificationNotCompletedError`, chaining any pre-existing
573+
``exception_info`` into the message so no diagnostic information is
574+
lost. Trials that *did* produce a ``verifier_result`` (including the
575+
legitimately-scored-after-timeout case) are left byte-for-byte
576+
unchanged, as are verifier-disabled and multi-step trials.
577+
"""
578+
if self.config.verifier.disable or self._task.has_steps:
579+
return
580+
if self.result.verifier_result is not None:
581+
return
582+
583+
prior = self.result.exception_info
584+
if prior is not None:
585+
message = (
586+
"Trial finalized without a verifier_result: the verifier was "
587+
"never invoked or did not produce a result. A prior "
588+
f"'{prior.exception_type}' was recorded "
589+
f"({prior.exception_message!r}) but the operative outcome is "
590+
"that this trial was never scored."
591+
)
592+
else:
593+
message = (
594+
"Trial finalized without a verifier_result: the verifier was "
595+
"never invoked or did not produce a result, and this trial was "
596+
"never scored."
597+
)
598+
599+
error = VerificationNotCompletedError(message)
600+
self.result.exception_info = ExceptionInfo.from_exception(error)
601+
try:
602+
self._trial_paths.exception_message_path.write_text(message)
603+
except Exception:
604+
self._logger.warning(
605+
"Failed to write exception message for unscored trial "
606+
f"{self.config.trial_name} (best-effort)"
607+
)
608+
538609
async def _maybe_download_logs(
539610
self, source_dir: str, target_dir: Path | UPath
540611
) -> None:
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
"""Regression tests for the trial verifier-result invariant.
2+
3+
A single-step trial with verification enabled must never finalize and emit a
4+
``result.json`` in a *silently unscored* state: ``verifier_result is None`` with
5+
no explicit, correctly-typed failure recorded. In production, 63/300 trials in
6+
one agentic eval finalized exactly this way — labelled ``AgentTimeoutError`` (the
7+
swallowed first-write-wins exception from an earlier agent timeout) with an empty
8+
``verifier/`` dir and ``verifier_result == None`` — making them indistinguishable
9+
from the trials that genuinely timed out but *did* verify to a reward, and
10+
silently dropping them from the scored denominator during aggregation.
11+
12+
The verifier was actually invoked (its ``TimingInfo`` was set) but raised
13+
``AddTestsDirError`` while uploading the tests dir into the timed-out sandbox;
14+
that failure propagated to the outer ``except Exception`` handler in
15+
``Trial.run()`` and was swallowed because ``exception_info`` was already set to
16+
``AgentTimeoutError``.
17+
18+
These tests drive a real :class:`~harbor.trial.trial.Trial` with a stubbed agent
19+
that times out and a stubbed environment whose ``upload_dir`` fails (so the
20+
verifier is invoked but raises before producing a result). Pre-fix, the emitted
21+
result is masked under ``AgentTimeoutError`` with ``verifier_result is None``;
22+
post-fix, the trial finalizes with an explicit ``VerificationNotCompletedError``.
23+
"""
24+
25+
import asyncio
26+
import json
27+
from pathlib import Path
28+
29+
from harbor.agents.base import BaseAgent
30+
from harbor.environments.base import BaseEnvironment
31+
from harbor.environments.capabilities import EnvironmentCapabilities
32+
from harbor.models.agent.context import AgentContext
33+
from harbor.models.environment_type import EnvironmentType
34+
from harbor.models.trial.config import (
35+
AgentConfig,
36+
EnvironmentConfig,
37+
TaskConfig,
38+
TrialConfig,
39+
VerifierConfig,
40+
)
41+
from harbor.trial.trial import Trial
42+
43+
44+
class TimeoutAgent(BaseAgent):
45+
"""Agent that sleeps past the (very small) agent timeout, then is cancelled.
46+
47+
Mirrors the production failure: ``_execute_agent`` wraps ``run`` in
48+
``asyncio.wait_for`` and raises ``AgentTimeoutError`` on timeout.
49+
"""
50+
51+
@staticmethod
52+
def name() -> str:
53+
return "timeout-agent"
54+
55+
def version(self) -> str:
56+
return "1.0.0"
57+
58+
async def setup(self, environment: BaseEnvironment) -> None:
59+
pass
60+
61+
async def run(
62+
self, instruction: str, environment: BaseEnvironment, context: AgentContext
63+
) -> None:
64+
await asyncio.sleep(3600)
65+
66+
67+
class UploadFailsEnvironment(BaseEnvironment):
68+
"""Mounted env whose ``upload_dir`` fails, so the verifier raises.
69+
70+
``Verifier.verify`` uploads the tests dir before running and wraps any
71+
failure in ``AddTestsDirError``. This reproduces the timed-out-sandbox
72+
state where verification is *invoked* but cannot produce a result.
73+
"""
74+
75+
@staticmethod
76+
def type() -> EnvironmentType:
77+
return EnvironmentType.DOCKER
78+
79+
@property
80+
def capabilities(self) -> EnvironmentCapabilities:
81+
return EnvironmentCapabilities(mounted=True)
82+
83+
def _validate_definition(self):
84+
pass
85+
86+
async def start(self, force_build: bool) -> None:
87+
pass
88+
89+
async def stop(self, delete: bool):
90+
pass
91+
92+
async def prepare_logs_for_host(self) -> None:
93+
pass
94+
95+
async def upload_file(self, source_path, target_path):
96+
pass
97+
98+
async def upload_dir(self, source_dir, target_dir):
99+
raise RuntimeError("simulated sandbox upload failure after agent timeout")
100+
101+
async def download_file(self, source_path, target_path):
102+
pass
103+
104+
async def download_dir(self, source_dir, target_dir):
105+
pass
106+
107+
async def exec(self, command, cwd=None, env=None, timeout_sec=None):
108+
pass
109+
110+
111+
def _create_task_dir(root: Path) -> Path:
112+
"""Create a minimal valid task directory with a real test.sh."""
113+
task_dir = root / "test-task"
114+
task_dir.mkdir()
115+
116+
# Tiny agent timeout so TimeoutAgent.run() is cut off by wait_for.
117+
(task_dir / "task.toml").write_text(
118+
"[agent]\ntimeout_sec = 0.05\n[verifier]\ntimeout_sec = 10.0\n[environment]\n"
119+
)
120+
(task_dir / "instruction.md").write_text("Do nothing.")
121+
122+
env_dir = task_dir / "environment"
123+
env_dir.mkdir()
124+
(env_dir / "Dockerfile").write_text("FROM ubuntu:24.04\n")
125+
126+
tests_dir = task_dir / "tests"
127+
tests_dir.mkdir()
128+
(tests_dir / "test.sh").write_text(
129+
"#!/bin/bash\necho 1 > /logs/verifier/reward.txt\n"
130+
)
131+
132+
return task_dir
133+
134+
135+
async def _make_trial(tmp_path: Path) -> Trial:
136+
"""Trial with TimeoutAgent + UploadFailsEnvironment and verification ON."""
137+
task_dir = _create_task_dir(tmp_path)
138+
trials_dir = tmp_path / "trials"
139+
trials_dir.mkdir()
140+
141+
config = TrialConfig(
142+
task=TaskConfig(path=task_dir),
143+
trials_dir=trials_dir,
144+
agent=AgentConfig(
145+
import_path="tests.unit.test_trial_verifier_invariant:TimeoutAgent"
146+
),
147+
environment=EnvironmentConfig(
148+
import_path=(
149+
"tests.unit.test_trial_verifier_invariant:UploadFailsEnvironment"
150+
),
151+
delete=True,
152+
),
153+
# Verification ENABLED: this is the path that must never silently drop.
154+
verifier=VerifierConfig(disable=False),
155+
)
156+
trial = await Trial.create(config)
157+
assert isinstance(trial._agent, TimeoutAgent)
158+
assert isinstance(trial._environment, UploadFailsEnvironment)
159+
return trial
160+
161+
162+
class TestVerifierResultInvariant:
163+
"""A finalized single-step trial must never be silently unscored."""
164+
165+
async def test_timeout_then_verifier_failure_is_not_masked(self, tmp_path: Path):
166+
"""Agent times out, verifier is invoked but raises before producing a
167+
result. The trial must NOT finalize masked under ``AgentTimeoutError``
168+
with ``verifier_result is None`` — it must record an explicit
169+
``VerificationNotCompletedError``.
170+
"""
171+
trial = await _make_trial(tmp_path)
172+
173+
result = await trial.run()
174+
175+
# The verifier never produced a result (the bug's necessary condition).
176+
if result.verifier_result is not None:
177+
raise AssertionError(
178+
"expected verifier_result to be None for this failure mode"
179+
)
180+
181+
# The masked state must be impossible: the operative outcome (never
182+
# scored) must be recorded explicitly, not hidden under the earlier
183+
# agent-timeout label.
184+
if result.exception_info is None:
185+
raise AssertionError(
186+
"trial finalized unscored with no exception_info recorded "
187+
"(silently-dropped masked state)"
188+
)
189+
if result.exception_info.exception_type == "AgentTimeoutError":
190+
raise AssertionError(
191+
"unscored trial is masked under AgentTimeoutError; downstream "
192+
"cannot distinguish it from a trial that timed out but verified"
193+
)
194+
if result.exception_info.exception_type != "VerificationNotCompletedError":
195+
raise AssertionError(
196+
"expected VerificationNotCompletedError, got "
197+
f"{result.exception_info.exception_type}"
198+
)
199+
200+
async def test_emitted_result_json_is_not_silently_unscored(self, tmp_path: Path):
201+
"""The persisted ``result.json`` (what downstream harvests) must carry
202+
the explicit failure, and the trial must be finalized.
203+
"""
204+
trial = await _make_trial(tmp_path)
205+
206+
await trial.run()
207+
208+
result_path = trial._trial_paths.result_path
209+
if not result_path.exists():
210+
raise AssertionError("result.json was not emitted")
211+
212+
emitted = json.loads(result_path.read_text())
213+
214+
# Finalized (finished_at set) — the trial did complete its lifecycle.
215+
if emitted.get("finished_at") is None:
216+
raise AssertionError("emitted result.json has no finished_at")
217+
218+
if emitted.get("verifier_result") is not None:
219+
raise AssertionError("expected verifier_result null in emitted result")
220+
221+
exc = emitted.get("exception_info")
222+
if exc is None:
223+
raise AssertionError(
224+
"emitted result.json is silently unscored: verifier_result null "
225+
"with no exception_info"
226+
)
227+
if exc.get("exception_type") != "VerificationNotCompletedError":
228+
raise AssertionError(
229+
"emitted result.json masks the unscored state under "
230+
f"{exc.get('exception_type')}"
231+
)

0 commit comments

Comments
 (0)