Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [2026-08-07]

### Changed

- The verdict now says `accept` or `reject` instead of `is_good: true/false`. Stored payloads keep `is_good` too, so old rows, the dashboard queries, and the Slack alert still work. The badge shows "Accepted" or "Rejected".
- The verdict judge used to bury its hard rules inside exceptions, and it accepted a task whose own audit had found a `must_fix` leak — on tests the untouched base model already passed (0.96 against a 0.25 threshold). The prompt (`verdict_prompt.txt`) is rewritten as two steps: first look for evidence that rejects the task by itself (a leak, weak tests, a failed baseline), and only then weigh the trials' opinions, which need agreement.
- The task overview panel used to list only the current experiment's trials, but the verdict is computed over every trial of the task — so the panel could show a verdict whose deciding trial it refused to list. It now shows every trial of the version. Trials from other experiments carry a dashed "elsewhere" chip and open in a new tab. Long subtypes also stopped pushing the "View trial" button out of its row.
- The verdict badge used to hide its rerun button once a verdict existed, and the button that did exist re-classified every trial from scratch. Tasks with a verdict now show "Rerun verdict" (`qa/backfill` with `force: false`), which keeps the stored trial analyses and redoes only the verdict. The full re-classify stays on `qa/retry`.
- Submitting new trials used to delete the task's verdict immediately, and the task had no verdict until QA finished the new trials. The old verdict now stays until the new QA run replaces it.

### Removed

- The cc_chat dashboard chat feature is gone end to end: the `/chat-sessions`
Expand Down
7 changes: 4 additions & 3 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ or globally via the env default. Under `enforce`, an over-cap submission gets
HTTP **402** (`"Your organization is over its monthly budget …"`); under
`shadow` it emits `metric=quota.would_block reason=org_over_budget`. Admins see
month-to-date org usage on `GET /quotas`; any member can read the org budget
snapshot + adaptive daily goal on `GET /quotas/org`. Advisory-lock order is
org → payer → row locks (ENFORCE-only on admission; the org lock is always
taken first, even when no org cap is configured).
snapshot + adaptive daily goal on `GET /quotas/org`. Admission takes no
locks; concurrent submissions can briefly overshoot a cap and the
enforcement sweep cancels the overage. Only the sweep takes the quota
advisory locks (org → payer, non-blocking).
4 changes: 1 addition & 3 deletions backend/api/routers/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,7 @@ async def create_task_sweep(
request_hash=request_hash,
)
except TimeoutError as exc:
# Quota advisory-lock waits (and other DB wait timeouts) surface as
# bare TimeoutError from asyncpg. Map to 503 so the CLI retries with
# a legible message instead of an opaque "Internal Server Error".
# asyncpg raises bare TimeoutError on DB wait timeouts.
logger.error(
"create_task_sweep timed out for task_id=%s org_id=%s",
submission.task_id,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/(app)/dashboard/dashboard-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ const STATUS_FILTER_OPTIONS = [
{ value: "active", label: "Active trials" },
{ value: "retrying", label: "Retrying trials" },
{ value: "completed", label: "Completed" },
{ value: "needs-review", label: "Needs review" },
{ value: "needs-review", label: "Rejected tasks" },
{ value: "pending-verdict", label: "QA pending" },
{ value: "failed", label: "Failures" },
] as const;
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/app/(app)/tasks/[task_id]/task-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1102,11 +1102,12 @@ export function TaskDetailClient({
if (!task?.id || isRunningJudge) return;
setIsRunningJudge(true);
setJudgeError(null);
// One task-level QA job: classify every trial, then synthesize the
// task verdict.
// force:false keeps stored trial analyses; only the verdict is redone.
try {
const res = await fetch(`/api/tasks/${task.id}/qa/retry`, {
const res = await fetch(`/api/tasks/${task.id}/qa/backfill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ force: false, enable_analysis: true }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/app/api/tasks/[task_id]/qa/backfill/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextResponse } from "next/server";
import { auth } from "@clerk/nextjs/server";
import {
getAuthHeaders,
getBackendUrl,
getClerkToken,
} from "@/lib/backend-config";
import { backendErrorPayload, readBackendJson } from "@/lib/backend-response";

export async function POST(
request: Request,
{ params }: { params: Promise<{ task_id: string }> },
) {
try {
const { getToken } = await auth();
const token = await getClerkToken(getToken);

if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const { task_id } = await params;
const body = await request.json().catch(() => ({}));

const url = getBackendUrl("tasks", `/${task_id}/qa/backfill`);
const res = await fetch(url, {
method: "POST",
headers: { ...getAuthHeaders(token), "Content-Type": "application/json" },
body: JSON.stringify(body),
});

const parsed = await readBackendJson(res, "Failed to queue task QA");

if (parsed.parseError) {
return NextResponse.json(parsed.parseError, { status: parsed.status });
}

if (!res.ok) {
return NextResponse.json(
backendErrorPayload(parsed.data, "Failed to queue task QA"),
{
status: res.status,
},
);
}

return NextResponse.json(parsed.data);
} catch (error) {
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Unknown error" },
{ status: 503 },
);
}
}
150 changes: 112 additions & 38 deletions frontend/src/components/task-overview-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,9 @@ export function TaskOverviewPanel({
* aggregates every trial, and undefined means still resolving — the
* trial aggregation waits instead of briefly spanning all versions. */
version?: number | null;
/** The host's authoritative trial set for this context — the experiment
* drawer passes its own trials so the aggregation never widens to other
* experiments; the task page passes the version's trials. The fetch
* then only enriches these rows with what the compact payload omits
* (action items, exploitation). Null/undefined = no host scope: the
* fetched, version-scoped set is used as-is. */
/** The trials that belong to the host's context (an experiment drawer
* passes its own; the task page passes the version's). Trials outside
* this set still render, marked as from elsewhere. Null = no context. */
scopeTrials?: Trial[] | null;
/** The host is still streaming its trial rows — an empty scope renders
* as loading, not as "no trials". */
Expand Down Expand Up @@ -178,9 +175,7 @@ export function TaskOverviewPanel({
},
});

// The host's rows define the set — the experiment drawer must never
// surface another experiment's trials just because they share the task.
// They can include probes, so apply the same filters here.
// Host rows can include probes and superseded trials; filter them here too.
const scoped = useMemo(() => {
if (scopeTrials == null) return null;
return scopeTrials.filter(
Expand All @@ -191,15 +186,35 @@ export function TaskOverviewPanel({
() => new Map((trials ?? []).map((trial) => [trial.id, trial])),
[trials],
);
// Show every trial of the version. The verdict is computed over all of
// them, so a shorter list can hide the evidence behind it.
const displayTrials = useMemo(() => {
if (scoped != null) {
// The fetch only enriches the host's rows with the fields the
// compact payload omits; a row the fetch doesn't know keeps its
// compact self.
return scoped.map((trial) => fetchedById.get(trial.id) ?? trial);
}
return trials ?? null;
}, [scoped, fetchedById, trials]);
if (scoped == null) return trials ?? null;
const inScope = new Set(scoped.map((trial) => trial.id));
// Until the host's rows have loaded, rows from elsewhere would render
// without their mark -- hold them back.
const elsewhere = scopeLoading
? []
: (trials ?? []).filter(
(trial) => !inScope.has(trial.id) && !trial.superseded_by_trial_id,
);
return [
...scoped.map((trial) => fetchedById.get(trial.id) ?? trial),
...elsewhere,
];
}, [scoped, scopeLoading, fetchedById, trials]);
// Null until the host's rows have loaded, so nothing is marked too early.
const foreignIds = useMemo(() => {
if (scoped == null || scopeLoading) return null;
const inScope = new Set(scoped.map((trial) => trial.id));
return new Set(
(trials ?? [])
.filter(
(trial) => !inScope.has(trial.id) && !trial.superseded_by_trial_id,
)
.map((trial) => trial.id),
);
}, [scoped, scopeLoading, trials]);
const versionTrials = useMemo(() => {
if (version === undefined) return [];
const all = displayTrials ?? [];
Expand Down Expand Up @@ -271,6 +286,8 @@ export function TaskOverviewPanel({
withQa.sort(
(a, b) =>
classificationRank(a) - classificationRank(b) ||
Number(foreignIds?.has(a.id) ?? false) -
Number(foreignIds?.has(b.id) ?? false) ||
a.created_at.localeCompare(b.created_at),
);
return {
Expand All @@ -280,7 +297,7 @@ export function TaskOverviewPanel({
mergedFindings: Array.from(byKey.values()),
qaTrials: withQa,
};
}, [versionTrials, checksFindings]);
}, [versionTrials, checksFindings, foreignIds]);

// The rows handed to SeverityGroups carry only copy-safe fields — its
// per-item copy button serializes the row as-is, so the trial objects
Expand All @@ -298,14 +315,32 @@ export function TaskOverviewPanel({
() => new Map(mergedFindings.map((f) => [f.id ?? "", f])),
[mergedFindings],
);
const foreignShownCount = useMemo(
() =>
foreignIds
? versionTrials.filter((trial) => foreignIds.has(trial.id)).length
: 0,
[foreignIds, versionTrials],
);

const openTrial = (trial: Trial) => {
if (onOpenTrial?.(trial)) return;
if (!taskId) return;
const taskTrialHref = (trial: Trial): string | null => {
if (!taskId) return null;
const params = new URLSearchParams();
if (trial.task_version_id) params.set("version", trial.task_version_id);
params.set("trial", trial.id);
router.push(`/tasks/${taskId}?${params.toString()}`);
return `/tasks/${taskId}?${params.toString()}`;
};

const openTrial = (trial: Trial) => {
// Trials from elsewhere open in a new tab; the drawer keeps its context.
if (foreignIds?.has(trial.id)) {
const href = taskTrialHref(trial);
if (href) window.open(href, "_blank", "noopener,noreferrer");
return;
}
if (onOpenTrial?.(trial)) return;
const href = taskTrialHref(trial);
if (href) router.push(href);
};

const renderFindingSources = (item: PreTrialFinding) => {
Expand All @@ -326,18 +361,28 @@ export function TaskOverviewPanel({
Source audit
</span>
) : null}
{(sourced.trials ?? []).map((trial) => (
<button
key={trial.id}
type="button"
onClick={() => openTrial(trial)}
className="border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 inline-flex max-w-full items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors"
title={`Open trial ${trial.name}`}
>
<span className="truncate">{trialLabel(trial)}</span>
<ArrowUpRight className="h-3 w-3 shrink-0" aria-hidden="true" />
</button>
))}
{(sourced.trials ?? []).map((trial) => {
const foreign = foreignIds?.has(trial.id) ?? false;
return (
<button
key={trial.id}
type="button"
onClick={() => openTrial(trial)}
className={cn(
"border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
foreign && "border-dashed",
)}
title={
foreign
? `Open trial ${trial.name} in a new tab — ran outside this experiment`
: `Open trial ${trial.name}`
}
>
<span className="min-w-0 truncate">{trialLabel(trial)}</span>
<ArrowUpRight className="h-3 w-3 shrink-0" aria-hidden="true" />
</button>
);
})}
</div>
);
};
Expand Down Expand Up @@ -526,6 +571,7 @@ export function TaskOverviewPanel({
<TrialQaRow
key={trial.id}
trial={trial}
foreign={foreignIds?.has(trial.id) ?? false}
onOpen={() => openTrial(trial)}
/>
))}
Expand Down Expand Up @@ -601,7 +647,11 @@ export function TaskOverviewPanel({
? "Loading…"
: `${analyzedCount}/${versionTrials.length} trial${
versionTrials.length === 1 ? "" : "s"
} analyzed${version != null ? ` · v${version}` : ""}`}
} analyzed${version != null ? ` · v${version}` : ""}${
foreignShownCount > 0
? ` · ${foreignShownCount} from outside this experiment`
: ""
}`}
</span>
</div>
{trialQaBody()}
Expand All @@ -610,7 +660,16 @@ export function TaskOverviewPanel({
);
}

function TrialQaRow({ trial, onOpen }: { trial: Trial; onOpen: () => void }) {
function TrialQaRow({
trial,
foreign,
onOpen,
}: {
trial: Trial;
/** From outside the host's context. */
foreign?: boolean;
onOpen: () => void;
}) {
const analysis = trial.analysis;
const running = isActivePipelineStatus(trial.analysis_status);
const failed = !analysis && trial.analysis_status === "failed";
Expand Down Expand Up @@ -653,13 +712,24 @@ function TrialQaRow({ trial, onOpen }: { trial: Trial; onOpen: () => void }) {
: "PENDING"}
</span>
{analysis?.subtype ? (
<span className="text-muted-foreground shrink-0 font-mono text-[10px]">
<span
className="text-muted-foreground min-w-0 truncate font-mono text-[10px]"
title={analysis.subtype}
>
{analysis.subtype}
</span>
) : null}
<span className="text-muted-foreground min-w-0 flex-1 truncate text-[11px]">
{trialLabel(trial)}
</span>
{foreign ? (
<span
className="border-border text-muted-foreground shrink-0 rounded border border-dashed px-1.5 py-0.5 font-mono text-[9.5px]"
title="This trial ran outside this experiment"
>
elsewhere
</span>
) : null}
<button
type="button"
onClick={(event) => {
Expand All @@ -668,7 +738,11 @@ function TrialQaRow({ trial, onOpen }: { trial: Trial; onOpen: () => void }) {
onOpen();
}}
className="border-border text-muted-foreground hover:text-foreground hover:border-foreground/40 inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors"
title={`Open trial ${trial.name}`}
title={
foreign
? `Open trial ${trial.name} in a new tab`
: `Open trial ${trial.name}`
}
>
View trial
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
Expand Down
Loading
Loading