Skip to content

Commit a21ed4f

Browse files
committed
Close the review gaps: locked hash re-check, sticky QA cancel, validator parity
Fixes from an independent review of the fix batch. The audit import checked its pinned content hash in its own short session, then wrote findings later without re-checking: an in-place overwrite committing in between still landed old-bytes findings as SUCCESS on the new bytes, and the stale SUCCESS blocked the None->QUEUED CAS that would audit them. sync_pre_trial_to_task_version now takes the expected hash and re-checks it under the version row lock on every write; the unlocked early check remains only to spare the artifact read. Cancelling QA during the new admission-deferral window (task RUNNING, agents settled, audit live) did not stick: cancel only settled VERDICT_PENDING tasks, so the sweep's advance backstop re-entered admission minutes later and started a QA run the user had cancelled, against a brief whose findings the cancel wiped. cancel_task_qa_core now settles a RUNNING task with no active agent trials the same way. The shared validator skipped ActionItem's optional fields (id, links_to, exploit_evidence, exploited, causal), which the importer's parser still type-checks -- a wrong-typed value passed the sandbox and then failed import terminally with no retry. The validator now type-checks them. The with_verdict=False QA brief said "set verdict null" while its output template still showed the verdict object and schema; with the strict verifier that contradiction would burn every attempt. The template now renders null and omits the schema when no verdict is requested. Also hides the drawer Retry button on qa/audit rows, which could only ever render the server's 400. Claude-Session: https://claude.ai/code/session_01ACF6SUXdbLFarpj3qwF1Ki
1 parent 21384a2 commit a21ed4f

6 files changed

Lines changed: 234 additions & 9 deletions

File tree

frontend/src/components/trial-detail-panel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ import { QaAssessmentReport } from "@/components/qa-report/qa-assessment-report"
6666
import { TimingBreakdownBar } from "@/components/timing-breakdown-bar";
6767
import { CodeBlock } from "@/components/code-block";
6868
import type { Trial, Task } from "@/lib/types";
69+
import { isAgentTrial } from "@/lib/types";
6970
import {
7071
costEstimateMarks,
7172
formatCostUsd,
@@ -1068,8 +1069,12 @@ export function TrialDetailPanel({
10681069
artifactsLines,
10691070
]);
10701071

1072+
// Agent rows only: the generic retry endpoint refuses qa/audit kinds, so
1073+
// offering the button on their drawers would only ever render its 400.
10711074
const showRetry =
1072-
allowRetry && (trial?.status === "failed" || trial?.status === "success");
1075+
allowRetry &&
1076+
Boolean(trial && isAgentTrial(trial)) &&
1077+
(trial?.status === "failed" || trial?.status === "success");
10731078
const canRetry = actionsReady && showRetry;
10741079
const showDelete = allowDelete && Boolean(onDelete) && Boolean(trial);
10751080
const canDelete = actionsReady && showDelete;

oddish/src/oddish/core/endpoints/qa.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,21 @@ async def cancel_task_qa_core(
186186
else TaskStatus.FAILED
187187
)
188188
task.finished_at = now_value
189+
elif task.status == TaskStatus.RUNNING and not await _count_active_trials(
190+
session, task_id=task.id, task_version_id=task.current_version_id
191+
):
192+
# QA admission holds a task in RUNNING with every agent trial
193+
# settled while its audit runs. Cancelling that audit must
194+
# settle the task too: left RUNNING, the sweep's advance
195+
# backstop re-enters admission minutes later and starts a QA
196+
# run the user just cancelled -- against a brief whose audit
197+
# findings this cancel wiped.
198+
task.status = (
199+
TaskStatus.COMPLETED
200+
if task.verdict_status == VerdictStatus.SUCCESS
201+
else TaskStatus.FAILED
202+
)
203+
task.finished_at = now_value
189204
# A pre-trial status left QUEUED/RUNNING with nothing behind it would
190205
# keep the card in a running state forever, so cancel always clears it.
191206
# An audit trial pins the version it audits, and that version can be

oddish/src/oddish/core/verdict_sync.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -150,22 +150,41 @@ async def sync_pre_trial_to_task_version(
150150
*,
151151
payload: dict | None,
152152
error: BaseException | str | None,
153+
expected_content_hash: str | None = None,
153154
) -> str | None:
154155
"""Write the pre-trial columns on the audited task version. Unlike
155156
:func:`sync_verdict_to_task`, this never completes the task and never
156157
touches a verdict column -- pre-trial is a per-version source audit that
157158
runs independently of trial classification.
158159
160+
``expected_content_hash`` pins the source bytes the audit actually read:
161+
the check runs here, under the version row lock, because an in-place
162+
overwrite can replace the bytes between any earlier unlocked check and
163+
this write. On a mismatch nothing is written -- the overwrite already
164+
reset the pre-trial state, so a fresh audit of the new bytes can still
165+
be enqueued.
166+
159167
Returns the terminal ``VerdictStatus`` value written, or ``None`` when
160-
the write was skipped (version gone) so the caller can release its claim
161-
on the version.
168+
the write was skipped (version gone, or overwritten bytes) so the caller
169+
can release its claim on the version.
162170
"""
163171
async with get_session() as session:
164172
version = await session.get(
165173
TaskVersionModel, task_version_id, with_for_update=True
166174
)
167175
if version is None:
168176
return None
177+
if (
178+
expected_content_hash is not None
179+
and version.content_hash is not None
180+
and version.content_hash != expected_content_hash
181+
):
182+
logger.warning(
183+
"pre-trial write for version %s skipped: content hash changed "
184+
"since the audit started (in-place overwrite)",
185+
task_version_id,
186+
)
187+
return None
169188

170189
if error is None:
171190
version.pre_trial = payload

oddish/src/oddish/worker/analysis_result_check.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ def _check_action_item(item: object, expected: dict, where: str) -> list[str]:
7171
for key in ("title", "detail", "recommendation"):
7272
if _missing(item.get(key)):
7373
errors.append(f"{where}.{key} must be a non-empty string")
74+
# Optional fields still fail the importer's parser when wrong-typed, so
75+
# they must fail here first, where failing buys a retry.
76+
for key in ("id", "links_to", "exploit_evidence"):
77+
if key in item and item[key] is not None and not isinstance(item[key], str):
78+
errors.append(f"{where}.{key} must be a string or null")
79+
for key in ("exploited", "causal"):
80+
if key in item and not isinstance(item[key], bool):
81+
errors.append(f"{where}.{key} must be a boolean")
7482
return errors
7583

7684

oddish/src/oddish/workers/analysis_trials.py

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,16 @@ def build_qa_brief(
351351
if with_verdict
352352
else '== TASK VERDICT ==\nDo NOT produce a verdict for this task: there are too few trials to judge it. Set "verdict": null in the output.\n'
353353
)
354+
# The output template must agree with the section above: showing the
355+
# verdict object shape while the prose says null would make the model
356+
# fail the (strict) verifier on every attempt.
357+
verdict_value = "<object matching this JSON schema>" if with_verdict else "null"
358+
verdict_schema = (
359+
"Verdict JSON schema:\n"
360+
f"{json.dumps(TaskVerdictModel.model_json_schema(), indent=1)}\n\n"
361+
if with_verdict
362+
else ""
363+
)
354364
return f"""You are the QA auditor for the task `{task_name}`. You are in a clean analysis sandbox, not the task's own environment. The task source, each trial's logs, and each trial's trajectory come from the oddish-query CLI. Do not solve the task.
355365
356366
Audit these trials:
@@ -389,13 +399,10 @@ def build_qa_brief(
389399
"trajectory_summary": <object with the exact shape given in the trajectory summary section>
390400
}}
391401
],
392-
"verdict": <object matching this JSON schema>
402+
"verdict": {verdict_value}
393403
}}
394404
395-
Verdict JSON schema:
396-
{json.dumps(TaskVerdictModel.model_json_schema(), indent=1)}
397-
398-
Every trial listed above must appear in "trials". The file must be valid JSON. Do not write anything else to /logs."""
405+
{verdict_schema}Every trial listed above must appear in "trials". The file must be valid JSON. Do not write anything else to /logs."""
399406

400407

401408
def build_audit_brief(*, task_name: str) -> str:
@@ -877,6 +884,7 @@ async def _import_audit_result(trial: TrialModel) -> None:
877884
version_id,
878885
payload=None,
879886
error=RuntimeError(error),
887+
expected_content_hash=pinned_hash,
880888
)
881889
return
882890
items: list[ActionItem] = []
@@ -892,15 +900,22 @@ async def _import_audit_result(trial: TrialModel) -> None:
892900
)
893901
logger.warning("audit import for version %s failed: %s", version_id, error)
894902
await sync_pre_trial_to_task_version(
895-
version_id, payload=None, error=RuntimeError(error)
903+
version_id,
904+
payload=None,
905+
error=RuntimeError(error),
906+
expected_content_hash=pinned_hash,
896907
)
897908
return
909+
# The early check above spared the artifact read, but only this locked
910+
# re-check (inside sync) closes the race with an in-place overwrite
911+
# committing between that check and this write.
898912
await sync_pre_trial_to_task_version(
899913
version_id,
900914
payload=build_pre_trial_payload(
901915
items, cost_usd=trial.cost_usd, block_id=trial.id
902916
),
903917
error=None,
918+
expected_content_hash=pinned_hash,
904919
)
905920
logger.info(
906921
"audit trial %s: stored %d findings for version %s",

oddish/tests/test_analysis_trials.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ def test_the_audit_brief_names_its_output_file():
6767
assert "Do not solve the task" in brief
6868

6969

70+
def test_the_no_verdict_brief_does_not_contradict_itself():
71+
"""with_verdict=False must not show the verdict-object template or its
72+
schema: the strict verifier requires null there, and a template
73+
contradicting the prose would burn every retry attempt."""
74+
brief = build_qa_brief(
75+
task_name="demo",
76+
trial_ids=["t-1"],
77+
pre_trial_items=None,
78+
with_verdict=False,
79+
)
80+
assert '"verdict": null' in brief
81+
assert "Verdict JSON schema" not in brief
82+
assert "<object matching this JSON schema>" not in brief
83+
84+
7085
def _qa_check_payload(trial_ids: list[str], *, with_verdict: bool = False) -> dict:
7186
from oddish.workers.analysis_trials import analysis_check_payload
7287

@@ -716,6 +731,131 @@ async def read_clean(trial, filename):
716731
assert version.pre_trial is not None
717732

718733

734+
@pytest.mark.asyncio
735+
async def test_pre_trial_write_rechecks_the_hash_under_the_lock():
736+
"""Needs a database. The audit import's early hash check runs unlocked,
737+
so an in-place overwrite can commit between it and the write. The write
738+
itself must re-check the pin under the version row lock and skip -- or
739+
old-bytes findings land as SUCCESS on the new bytes and block the
740+
None->QUEUED CAS that would audit them."""
741+
if not URL:
742+
pytest.skip("ODDISH_DATABASE_URL not set")
743+
from oddish.core.verdict_sync import sync_pre_trial_to_task_version
744+
from oddish.db import VerdictStatus, get_session, init_db
745+
from oddish.db.models import TaskModel, TaskVersionModel
746+
747+
await init_db()
748+
run = uuid.uuid4().hex[:8]
749+
task_id = f"qa-hash-lock-{run}"
750+
version_id = f"{task_id}-v1"
751+
async with get_session() as session:
752+
session.add(
753+
TaskModel(
754+
id=task_id, name=task_id, user="u", task_path="p", run_analysis=True
755+
)
756+
)
757+
await session.flush()
758+
session.add(
759+
TaskVersionModel(
760+
id=version_id,
761+
task_id=task_id,
762+
version=1,
763+
task_path="p",
764+
content_hash="new-bytes",
765+
)
766+
)
767+
await session.commit()
768+
769+
stale = await sync_pre_trial_to_task_version(
770+
version_id,
771+
payload={"items": []},
772+
error=None,
773+
expected_content_hash="old-bytes",
774+
)
775+
assert stale is None
776+
async with get_session() as session:
777+
version = await session.get(TaskVersionModel, version_id)
778+
assert version.pre_trial is None
779+
assert version.pre_trial_status is None
780+
781+
current = await sync_pre_trial_to_task_version(
782+
version_id,
783+
payload={"items": []},
784+
error=None,
785+
expected_content_hash="new-bytes",
786+
)
787+
assert current == VerdictStatus.SUCCESS.value
788+
async with get_session() as session:
789+
version = await session.get(TaskVersionModel, version_id)
790+
assert version.pre_trial_status == VerdictStatus.SUCCESS
791+
792+
793+
@pytest.mark.asyncio
794+
async def test_cancelling_qa_in_the_deferral_window_settles_the_task():
795+
"""Needs a database. With every agent trial settled and the audit still
796+
live, admission holds the task in RUNNING. Cancelling QA there must
797+
settle the task too: left RUNNING, the sweep's advance backstop would
798+
re-enter admission minutes later and start a QA run the user just
799+
cancelled, against a brief whose audit findings the cancel wiped."""
800+
if not URL:
801+
pytest.skip("ODDISH_DATABASE_URL not set")
802+
from oddish.core.endpoints.qa import cancel_task_qa_core
803+
from oddish.db import TaskStatus, TrialStatus, get_session, init_db
804+
from oddish.db.models import ExperimentModel, TaskModel
805+
806+
await init_db()
807+
run = uuid.uuid4().hex[:8]
808+
task_id = f"qa-cancel-window-{run}"
809+
agent_id = f"{task_id}-1"
810+
audit_id = f"{task_id}-2"
811+
async with get_session() as session:
812+
experiment = ExperimentModel(name=f"exp-{run}")
813+
session.add(experiment)
814+
session.add(
815+
TaskModel(
816+
id=task_id,
817+
name=task_id,
818+
user="u",
819+
task_path="p",
820+
status=TaskStatus.RUNNING,
821+
run_analysis=True,
822+
)
823+
)
824+
await session.flush()
825+
for trial_id, kind, status in (
826+
(agent_id, "agent", TrialStatus.SUCCESS),
827+
(audit_id, "audit", TrialStatus.RUNNING),
828+
):
829+
session.add(
830+
TrialModel(
831+
id=trial_id,
832+
name=trial_id,
833+
task_id=task_id,
834+
experiment_id=experiment.id,
835+
agent="claude-code",
836+
provider="local",
837+
queue_key="q",
838+
kind=kind,
839+
status=status,
840+
attempts=1,
841+
max_attempts=3,
842+
)
843+
)
844+
await session.commit()
845+
846+
async with get_session() as session:
847+
await cancel_task_qa_core(session, task_id=task_id)
848+
await session.commit()
849+
850+
async with get_session() as session:
851+
audit = await session.get(TrialModel, audit_id)
852+
assert audit.status == TrialStatus.FAILED
853+
assert audit.harbor_stage == "cancelled"
854+
task = await session.get(TaskModel, task_id)
855+
assert task.status == TaskStatus.FAILED
856+
assert task.finished_at is not None
857+
858+
719859
@pytest.mark.asyncio
720860
async def test_cleanup_reimports_a_settled_audit(monkeypatch):
721861
"""Needs a database. A settled audit whose importer died mid-write
@@ -1032,6 +1172,29 @@ def test_the_validator_holds_audit_items_to_the_prompt_schema():
10321172
assert check_analysis_result({"items": [broken]}, expected), key
10331173
assert check_analysis_result({"items": {}}, expected)
10341174

1175+
# Optional fields the importer's parser still type-checks: a wrong type
1176+
# must fail here, in the sandbox where failing buys a retry, not at
1177+
# import where refusal is terminal.
1178+
for key, bad in (
1179+
("id", 1),
1180+
("links_to", 7),
1181+
("exploited", "yes"),
1182+
("causal", "no"),
1183+
("exploit_evidence", 3),
1184+
):
1185+
typed = dict(item)
1186+
typed[key] = bad
1187+
assert check_analysis_result({"items": [typed]}, expected), key
1188+
optional_ok = dict(
1189+
item,
1190+
id=None,
1191+
links_to="a1",
1192+
exploited=True,
1193+
causal=False,
1194+
exploit_evidence=None,
1195+
)
1196+
assert check_analysis_result({"items": [optional_ok]}, expected) == []
1197+
10351198

10361199
@pytest.mark.asyncio
10371200
async def test_the_qa_import_is_all_or_nothing(monkeypatch):

0 commit comments

Comments
 (0)