|
| 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