Skip to content

Commit 1e1bffa

Browse files
charlesyhuangclaude
andcommitted
fix(qa): stop a retried trial inheriting the failed attempt's classification
A trial that fails an attempt, gets classified HARNESS_ERROR, then passes on a later attempt kept the HARNESS_ERROR forever -- describing artifacts the row no longer held. Three defects compound to produce that: - `_prepare_trial_run` cleared every result field before an attempt (reward, result, tokens, trial_s3_key, ...) but left `analysis`/`analysis_status`. QA is terminal-sticky, so the stale verdict could never be revisited. - The task itself was never reopened. `maybe_start_qa_stage` only fires from PENDING/RUNNING, so a task that reached COMPLETED while an attempt was failing stayed closed through the attempt that passed, and no QA pass was ever enqueued again. - The classifier picked the first `task-*` job dir from an unsorted `iterdir()`. Every attempt re-uploads into the same S3 prefix without clearing it, so a retried trial's tree holds one job dir per attempt and the classifier analysed an arbitrary one. `_prepare_trial_run` now clears the analysis fields alongside the results they describe, and reopens a COMPLETED/FAILED task the way `append_trials` already does. Reopening is limited to the terminal statuses because reaching them is what proves no QA job is in flight, so it cannot race one. The classifier now resolves the current attempt from the job-level `result.json`, whose `reward_stats`/`exception_stats` name that attempt's directory, and falls back to sorted order so the choice is deterministic rather than readdir-dependent. Verified against four FVSmith runs wedged at QA_PROBE showing 3/3 HARNESS_ERROR on sweeps whose pass@1 was 1.0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 5482eab commit 1e1bffa

4 files changed

Lines changed: 391 additions & 7 deletions

File tree

oddish/src/oddish/analyze/classifier.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,62 @@ def classify_trial(
228228
)
229229

230230

231+
def _attempt_dirs_named_by(root_data: dict) -> set[str]:
232+
"""Job directories the job-level ``result.json`` attributes to this attempt.
233+
234+
Harbor mints a fresh ``task-<slug>__<id>`` directory per attempt inside a
235+
per-trial wrapper, and every attempt re-uploads that wrapper to the same S3
236+
prefix without clearing it -- so a retried trial's downloaded tree holds one
237+
such directory per attempt, side by side. Only the *latest* attempt
238+
overwrites the job-level ``result.json``, and its ``reward_stats`` /
239+
``exception_stats`` name that attempt's directory. That is the only signal
240+
in the tree that tells the current attempt from the stale ones.
241+
"""
242+
names: set[str] = set()
243+
stats = root_data.get("stats")
244+
evals = stats.get("evals") if isinstance(stats, dict) else None
245+
if not isinstance(evals, dict):
246+
return names
247+
for eval_data in evals.values():
248+
if not isinstance(eval_data, dict):
249+
continue
250+
for key in ("reward_stats", "exception_stats"):
251+
grouped = eval_data.get(key)
252+
if not isinstance(grouped, dict):
253+
continue
254+
for entry in grouped.values():
255+
# reward_stats nests one level deeper than exception_stats:
256+
# {metric: {value: [names]}} vs {exception: [names]}.
257+
buckets = entry.values() if isinstance(entry, dict) else [entry]
258+
for bucket in buckets:
259+
if isinstance(bucket, list):
260+
names.update(n for n in bucket if isinstance(n, str))
261+
return names
262+
263+
264+
def _current_attempt_result(trial_dir: Path, root_data: dict) -> Path | None:
265+
"""Pick the nested per-attempt ``result.json`` this trial's row describes.
266+
267+
Prefer the directory the job-level result names; fall back to the first in
268+
sorted order so the choice is at least deterministic when the job result
269+
names nothing we can see (older layouts, partial uploads).
270+
"""
271+
candidates = sorted(
272+
subdir
273+
for subdir in trial_dir.iterdir()
274+
if subdir.is_dir()
275+
and subdir.name.startswith("task-")
276+
and (subdir / "result.json").exists()
277+
)
278+
if not candidates:
279+
return None
280+
named = _attempt_dirs_named_by(root_data)
281+
for subdir in candidates:
282+
if subdir.name in named:
283+
return subdir / "result.json"
284+
return candidates[0] / "result.json"
285+
286+
231287
class TrialClassifier:
232288
"""Classifies trial outcomes using Claude Code to identify task quality issues."""
233289

@@ -280,12 +336,9 @@ async def classify_trial(
280336
try:
281337
root_data = json.loads(result_path.read_text())
282338
if "n_total_trials" in root_data or "stats" in root_data:
283-
for subdir in trial_dir.iterdir():
284-
if subdir.is_dir() and subdir.name.startswith("task-"):
285-
nested_result = subdir / "result.json"
286-
if nested_result.exists():
287-
result_path = nested_result
288-
break
339+
nested = _current_attempt_result(trial_dir, root_data)
340+
if nested is not None:
341+
result_path = nested
289342
except Exception:
290343
pass
291344

oddish/src/oddish/workers/queue/trial_handler.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,18 @@ async def _prepare_trial_run(
503503
# reservation mid-run. Settlement overwrites it with the actual key.
504504
trial.phase_timing = None
505505
trial.has_trajectory = False
506+
# A re-attempt invalidates the previous attempt's QA verdict: the reward,
507+
# result and artifacts it was derived from were just cleared above. QA is
508+
# terminal-sticky (``_trial_needs_classification`` skips SUCCESS/FAILED),
509+
# so leaving it would pin the old attempt's classification -- e.g. a
510+
# HARNESS_ERROR from an infra-killed attempt riding on a trial that goes
511+
# on to pass -- and no later QA pass would ever revisit it.
512+
trial.analysis = None
513+
trial.analysis_status = None
514+
trial.analysis_error = None
515+
trial.analysis_started_at = None
516+
trial.analysis_finished_at = None
517+
trial.analysis_log = None
506518
trial.attempts += 1
507519

508520
if not trial.idempotency_key:
@@ -512,6 +524,27 @@ async def _prepare_trial_run(
512524
if task and task.status == TaskStatus.PENDING:
513525
task.status = TaskStatus.RUNNING
514526
task.started_at = utcnow()
527+
elif task and task.status in (TaskStatus.COMPLETED, TaskStatus.FAILED):
528+
# The task already closed out -- QA ran and synthesized a verdict --
529+
# yet this trial still had attempts left and is running again. Its
530+
# old result (and the verdict built on it) describe a run that no
531+
# longer exists, and ``maybe_start_qa_stage`` only fires from
532+
# PENDING/RUNNING, so leaving the task closed would strand the new
533+
# attempt permanently unclassified. Reopen it the way
534+
# ``append_trials`` reopens a finished task when live trials appear.
535+
#
536+
# Only from the terminal statuses: reaching them is precisely what
537+
# proves no QA job is in flight, so this can never race one. A task
538+
# still in VERDICT_PENDING/ANALYZING has a live QA job that will
539+
# pick the trial up on its own now that its analysis is cleared.
540+
task.status = TaskStatus.RUNNING
541+
task.finished_at = None
542+
if task.run_analysis:
543+
task.verdict = None
544+
task.verdict_status = None
545+
task.verdict_error = None
546+
task.verdict_started_at = None
547+
task.verdict_finished_at = None
515548

516549
task_id = task.id if task else trial.task_id
517550
task_name = task.name if task else trial.task_id
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
"""A retried trial's downloaded tree holds one job dir per attempt.
2+
3+
Harbor mints a fresh ``task-<slug>__<id>`` directory per attempt, and every
4+
attempt re-uploads into the same S3 prefix without clearing it, so the
5+
classifier sees every attempt side by side. Only the last attempt overwrites
6+
the job-level ``result.json``; picking any other dir classifies a stale
7+
attempt and can label a passing trial HARNESS_ERROR.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import json
13+
import sys
14+
from pathlib import Path
15+
16+
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src"))
17+
18+
from oddish.analyze.classifier import ( # noqa: E402
19+
_attempt_dirs_named_by,
20+
_current_attempt_result,
21+
)
22+
23+
24+
def _job_dir(trial_dir: Path, name: str, payload: dict) -> Path:
25+
job = trial_dir / name
26+
job.mkdir(parents=True)
27+
(job / "result.json").write_text(json.dumps(payload))
28+
return job
29+
30+
31+
def _root_reward(dir_name: str, reward: str = "1.0") -> dict:
32+
return {
33+
"n_total_trials": 1,
34+
"stats": {
35+
"evals": {
36+
"grok-build__m__adhoc": {
37+
"reward_stats": {"reward": {reward: [dir_name]}},
38+
"exception_stats": {},
39+
}
40+
}
41+
},
42+
}
43+
44+
45+
def _root_exception(dir_name: str, exc: str = "EnvironmentStartTimeoutError") -> dict:
46+
return {
47+
"n_total_trials": 1,
48+
"stats": {
49+
"evals": {
50+
"grok-build__m__adhoc": {
51+
"reward_stats": {},
52+
"exception_stats": {exc: [dir_name]},
53+
}
54+
}
55+
},
56+
}
57+
58+
59+
def test_picks_the_dir_the_job_result_names_not_the_first(monkeypatch, tmp_path):
60+
"""The regression, pinned against an adversarial directory order.
61+
62+
``iterdir`` yields in filesystem order, so the old "first ``task-*`` dir
63+
wins" scan picked an arbitrary attempt. Force the stale attempt to come
64+
first: only a scan that consults the job-level result gets this right.
65+
"""
66+
_job_dir(tmp_path, "task-x__AAAA", {"verifier_result": None})
67+
_job_dir(
68+
tmp_path, "task-x__ZZZZ", {"verifier_result": {"rewards": {"reward": 1.0}}}
69+
)
70+
71+
real_iterdir = Path.iterdir
72+
monkeypatch.setattr(Path, "iterdir", lambda self: iter(sorted(real_iterdir(self))))
73+
74+
picked = _current_attempt_result(tmp_path, _root_reward("task-x__ZZZZ"))
75+
76+
assert picked == tmp_path / "task-x__ZZZZ" / "result.json"
77+
78+
79+
def test_named_dir_wins_for_an_errored_final_attempt(tmp_path):
80+
_job_dir(
81+
tmp_path, "task-x__AAAA", {"verifier_result": {"rewards": {"reward": 1.0}}}
82+
)
83+
_job_dir(tmp_path, "task-x__BBBB", {"exception_info": "boom"})
84+
85+
picked = _current_attempt_result(tmp_path, _root_exception("task-x__BBBB"))
86+
87+
assert picked == tmp_path / "task-x__BBBB" / "result.json"
88+
89+
90+
def test_single_attempt_is_unchanged(tmp_path):
91+
_job_dir(tmp_path, "task-x__ONLY", {"verifier_result": None})
92+
93+
picked = _current_attempt_result(tmp_path, _root_reward("task-x__ONLY"))
94+
95+
assert picked == tmp_path / "task-x__ONLY" / "result.json"
96+
97+
98+
def test_falls_back_deterministically_when_the_job_result_names_nothing(tmp_path):
99+
"""Older layouts name no dirs; the pick must still be stable, not readdir order."""
100+
_job_dir(tmp_path, "task-x__BBBB", {})
101+
_job_dir(tmp_path, "task-x__AAAA", {})
102+
103+
picked = _current_attempt_result(tmp_path, {"n_total_trials": 1, "stats": {}})
104+
105+
assert picked == tmp_path / "task-x__AAAA" / "result.json"
106+
107+
108+
def test_ignores_dirs_without_a_result(tmp_path):
109+
(tmp_path / "task-x__AAAA").mkdir()
110+
_job_dir(tmp_path, "task-x__BBBB", {})
111+
112+
picked = _current_attempt_result(tmp_path, {"n_total_trials": 1, "stats": {}})
113+
114+
assert picked == tmp_path / "task-x__BBBB" / "result.json"
115+
116+
117+
def test_no_job_dirs_yields_none(tmp_path):
118+
assert _current_attempt_result(tmp_path, _root_reward("task-x__AAAA")) is None
119+
120+
121+
def test_named_dirs_survive_both_stat_shapes():
122+
payload = {
123+
"stats": {
124+
"evals": {
125+
"e1": {
126+
"reward_stats": {"reward": {"1.0": ["a"], "0.0": ["b"]}},
127+
"exception_stats": {"Boom": ["c"]},
128+
},
129+
"e2": {"reward_stats": {}, "exception_stats": {}},
130+
}
131+
}
132+
}
133+
134+
assert _attempt_dirs_named_by(payload) == {"a", "b", "c"}
135+
136+
137+
def test_malformed_job_result_names_nothing():
138+
for payload in ({}, {"stats": None}, {"stats": {"evals": []}}):
139+
assert _attempt_dirs_named_by(payload) == set()

0 commit comments

Comments
 (0)