Skip to content

Commit 620eaf4

Browse files
authored
Merge branch 'staging' into kate/task-version-model-metrics
2 parents 68508de + f6cdc7e commit 620eaf4

112 files changed

Lines changed: 10148 additions & 987 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/scripts/preview/prune_stale_supabase_branches.sh

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,14 @@ cutoff=$(($(date +%s) - MAX_AGE_DAYS * 86400))
2727
# be read as "nothing is stale".
2828
stale=$(supabase branches list --project-ref "$SUPABASE_PROJECT_REF" -o json \
2929
| jq -r --argjson cutoff "$cutoff" '
30+
def parse_supabase_time:
31+
sub("\\+00:00$"; "Z")
32+
| sub("\\.[0-9]+Z$"; "Z")
33+
| fromdateiso8601;
34+
3035
.[] | select(.persistent != true)
3136
| select(.name | test("^pr-[0-9]+$"))
32-
| select((.created_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601) < $cutoff)
37+
| select((.created_at | parse_supabase_time) < $cutoff)
3338
| [.id, .name, .created_at] | @tsv')
3439

3540
if [ -z "$stale" ]; then

AGENTS.md

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,22 @@ by model (`series_qa_by_model`) and by analyzer job kind
323323

324324
### Task Identity
325325

326+
`GET /tasks/{task_id}/open` is the bounded first-paint contract for the task
327+
page. It resolves one org-scoped task plus the requested/default version before
328+
running aggregate work. Top-level task status always uses the default version
329+
from `tasks.current_version_id`; selected-version counters, direct version tags,
330+
experiments, and exact agent/model summaries use the requested version. Its
331+
experiment list is derived from that version's live, non-probe, non-superseded,
332+
non-combine trial population, matching `/detail`. Pre-trial audit metadata stays
333+
on `/detail` and is not serialized with the bounded version summary. The
334+
response also carries compact QA verdict
335+
presentation/control fields and caps the selected-version trial preview at 20
336+
lightweight refs. The handler uses at most three SQL statements, stays below the
337+
50 KB response budget, and must not select trial `result`, `analysis`,
338+
`error_message`, jobs, or ORM relationships. `GET /tasks/{task_id}/detail`
339+
remains the compatibility bundle for CLI and drawer consumers during the soak;
340+
do not point the task route back at it.
341+
326342
`tasks.name` is the human-readable lookup key within an org. Live task names
327343
must stay unique and indexed (`idx_tasks_unique_org_name`) so an upload of the
328344
same task name resolves to the existing task and creates a new `task_versions`
@@ -520,7 +536,7 @@ extensions) — see `backend/README.md`.
520536
| Task upload | `POST /tasks/upload/init` (returns presigned PUT URL), `POST /tasks/upload/complete` |
521537
| Trial import | `POST /trials/import/init`, `POST /trials/import/complete` |
522538
| Sweeps | `POST /tasks/sweep`, `POST /tasks/sweep/batch` |
523-
| Tasks | `GET /tasks`, `GET /tasks/browse`, `GET /tasks/browse/experiment-options` (typeahead for the experiment filter; `facets.experiments` is deprecated/always empty; the other facet lists are served from the `trial_facets` vocabulary — write-through on trial creation plus a periodic rebuild sweep, see `oddish/src/oddish/core/trial_facets.py`), `GET /tasks/{task_id}`, `GET /tasks/{task_id}/detail`, `GET /tasks/{task_id}/versions[/{version}]`, `PUT /tasks/{task_id}/versions/{version}/default`, `POST /tasks/cancel` (optional `experiment_id` scopes the cancel to that experiment's trials so shared tasks keep running elsewhere) |
539+
| Tasks | `GET /tasks`, `GET /tasks/browse`, `GET /tasks/browse/experiment-options` (typeahead for the experiment filter; `facets.experiments` is deprecated/always empty; the other facet lists are served from the `trial_facets` vocabulary — write-through on trial creation plus a periodic rebuild sweep, see `oddish/src/oddish/core/trial_facets.py`), `GET /tasks/{task_id}`, `GET /tasks/{task_id}/open`, `GET /tasks/{task_id}/detail`, `GET /tasks/{task_id}/versions[/{version}]`, `PUT /tasks/{task_id}/versions/{version}/default`, `POST /tasks/cancel` (optional `experiment_id` scopes the cancel to that experiment's trials so shared tasks keep running elsewhere) |
524540
| Task QA | `POST /tasks/{task_id}/qa/retry`, `POST /tasks/{task_id}/qa/cancel`, `POST /tasks/{task_id}/qa/backfill` |
525541
| Experiments | `POST /experiments/combine`, `PATCH /experiments/{experiment_id}` |
526542
| Trials | `GET /tasks/{task_id}/trials/{index}`, `POST /trials/{trial_id}/retry` (optional `registry_auth` body), `GET /trials/{trial_id}/live` ((attempt, seq)-cursor live transcript), `GET /trials/{trial_id}/logs[/structured]`, `GET /trials/{trial_id}/trajectory`, `GET /trials/{trial_id}/result` |
@@ -577,9 +593,13 @@ Keep these routing rules in sync with `oddish/src/oddish/config.py` and
577593
deployment, task/trial, worker-job, worker-attempt, sandbox-run, unguessable
578594
launch-token, and Harbor-session tags. A durable `sandbox_runs` row is created
579595
before launch; Harbor's `environment-provisioned` event binds the structured
580-
handle before SSH/bootstrap. Normal teardown, cancellation, stale-heartbeat
581-
cleanup, and reconciliation terminate only after the full ledger/tag tuple
582-
agrees.
596+
handle before SSH/bootstrap. The locked Harbor exposes that event natively;
597+
ephemeral pins that predate it are bridged by wrapping
598+
`EC2Environment._launch_instance` and emitting the same identity immediately
599+
after launch. A pin whose EC2 environment does not expose the required launch
600+
seam fails before `Job.run()` rather than launching untracked provider state.
601+
Normal teardown, cancellation, stale-heartbeat cleanup, and reconciliation
602+
terminate only after the full ledger/tag tuple agrees.
583603
- EC2 orphan reconciliation snapshots deployment-tagged instances before the
584604
shared cleanup transaction, evaluates worker liveness using the database clock,
585605
and terminates only after the transaction commits. It preserves live linked
@@ -591,6 +611,11 @@ Keep these routing rules in sync with `oddish/src/oddish/config.py` and
591611
with heartbeat-renewed `sandbox_capacity_leases`, independent of model/variant
592612
queue slots. The dispatcher budgets against live EC2 leases before spawning,
593613
while each worker still acquires the lease atomically before claiming a job.
614+
A successful inventory snapshot also closes `PROVISIONING` / `TERMINATING`
615+
ledger rows that have no provider identity, no running owner, no matching
616+
inventory tags, and are older than the 30-minute launch-race grace. Capacity
617+
cleanup reruns after that transaction commits so those rows cannot reserve
618+
slots forever; an inventory failure never authorizes this finalization.
594619
Inventory and termination failures stay visible in logs/metrics while the rest
595620
of queue cleanup continues.
596621
- Claude trials run through AWS Bedrock by default. `CLAUDE_CODE_USE_BEDROCK=1` is

backend/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,7 @@ def create_app() -> FastAPI:
258258
reports,
259259
skills,
260260
public,
261+
public_analysis,
261262
slack,
262263
tags,
263264
tasks,
@@ -283,6 +284,7 @@ def create_app() -> FastAPI:
283284
api.include_router(skills.router)
284285
api.include_router(documents.router)
285286
api.include_router(public.router)
287+
api.include_router(public_analysis.router)
286288
api.include_router(slack.router)
287289
api.include_router(admin.router)
288290
api.include_router(cost_excluded_keys.router)

backend/api/routers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
dashboard,
88
github_webhooks,
99
orgs,
10+
public_analysis,
1011
slack,
1112
tasks,
1213
trials,
@@ -20,6 +21,7 @@
2021
"github_webhooks",
2122
"orgs",
2223
"public",
24+
"public_analysis",
2325
"slack",
2426
"tasks",
2527
"trials",
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
"""Public (unauthenticated) analysis reads for shared experiments.
2+
3+
These live here rather than beside their siblings in
4+
``oddish/core/sharing/public.py`` because they read the hosted analysis
5+
services (``api.services.*``), and the ``oddish`` package may not import the
6+
backend. The share-token resolvers go the other way, which is allowed.
7+
8+
Both routes are cache reads. Their authenticated counterparts generate on a
9+
miss -- a Claude call per trajectory summary, a claude-code run per comparison
10+
-- and neither may be reachable without a login: the spend is unbounded by
11+
anything the caller has to hold, and the comparison additionally parks one of
12+
an API container's three connections for minutes. A miss here is a 404.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import logging
18+
19+
from fastapi import APIRouter, HTTPException, Query
20+
21+
from api.services.blocks.analyzer.cohort.cohort_prompts import short_model_name
22+
from oddish.core.model_display_names import (
23+
display_model_name,
24+
load_model_display_names,
25+
)
26+
from oddish.core.sharing.helpers import (
27+
get_public_task_for_experiment,
28+
get_public_trial_for_experiment,
29+
)
30+
from oddish.db import get_session
31+
32+
logger = logging.getLogger(__name__)
33+
34+
router = APIRouter(tags=["Public"])
35+
36+
37+
@router.get("/public/experiments/{public_token}/trials/{trial_id}/trajectory/summary")
38+
async def get_public_trial_trajectory_summary(
39+
public_token: str, trial_id: str
40+
) -> dict:
41+
"""The stored trajectory summary for a public trial."""
42+
from api.services.summarize_trajectory import load_stored_summary
43+
44+
async with get_session() as session:
45+
trial = await get_public_trial_for_experiment(session, public_token, trial_id)
46+
if trial is None:
47+
raise HTTPException(status_code=404, detail="Trial not found")
48+
summary = await load_stored_summary(session, trial)
49+
if summary is None:
50+
raise HTTPException(
51+
status_code=404, detail="No trajectory summary for this trial"
52+
)
53+
return summary
54+
55+
56+
def _short_name_aliases(names: dict[str, str]) -> dict[str, str]:
57+
"""Index the alias table by the spelling the comparison actually stores.
58+
59+
``load_model_display_names`` keys on the full id (``anthropic/claude-opus-4-8``)
60+
because that is what ``trials.model`` holds. The comparison does not: both
61+
``models[].model`` and ``trial_models`` are written through
62+
``short_model_name``, which strips the provider and region
63+
(``global.anthropic.claude-opus-4-8`` -> ``claude-opus-4-8``). Masking on the
64+
full id alone therefore matches nothing and publishes the real names.
65+
66+
A short name can collide -- ``global.anthropic.…`` and ``us.anthropic.…``
67+
reduce to one string. When two aliases disagree on a collision the key is
68+
dropped rather than guessed: naming the wrong model misattributes the
69+
behaviour the analysis describes, which is worse than leaving the short
70+
name showing.
71+
"""
72+
short: dict[str, str] = {}
73+
dropped: set[str] = set()
74+
for key in sorted(names):
75+
alias = names[key]
76+
name = short_model_name(key)
77+
if not name or name == key:
78+
continue
79+
if short.setdefault(name, alias) != alias:
80+
dropped.add(name)
81+
for name in dropped:
82+
short.pop(name, None)
83+
logger.warning(
84+
"model display names disagree for short name %r; leaving it unmasked",
85+
name,
86+
)
87+
return {**short, **names}
88+
89+
90+
def _mask_models(comparison: dict, names: dict[str, str]) -> dict:
91+
"""Rewrite the comparison's model ids through the operator alias table.
92+
93+
Mutating a copy, not the argument: the dict is an ``AnalyzerBlock`` row's
94+
``output``, and the session it came from is still open.
95+
"""
96+
if not names:
97+
return comparison
98+
names = _short_name_aliases(names)
99+
masked = dict(comparison)
100+
models = masked.get("models")
101+
if isinstance(models, dict):
102+
masked["models"] = {
103+
side: [
104+
{**entry, "model": display_model_name(entry.get("model"), names)}
105+
if isinstance(entry, dict)
106+
else entry
107+
for entry in entries
108+
]
109+
if isinstance(entries, list)
110+
else entries
111+
for side, entries in models.items()
112+
}
113+
trial_models = masked.get("trial_models")
114+
if isinstance(trial_models, dict):
115+
masked["trial_models"] = {
116+
trial_id: display_model_name(model, names)
117+
for trial_id, model in trial_models.items()
118+
}
119+
return masked
120+
121+
122+
async def _version_is_in_experiment(
123+
session, experiment_id: str, task_version_id: str
124+
) -> bool:
125+
"""Whether the shared experiment has a trial on this task version.
126+
127+
Membership goes through ``trial_in_experiment`` -- a collection gathers
128+
trials that keep the ``experiment_id`` of wherever they ran, so an FK-only
129+
filter misses them.
130+
"""
131+
from sqlalchemy import select
132+
133+
from oddish.core.experiment_membership import trial_in_experiment
134+
from oddish.db.models import TrialModel
135+
136+
return (
137+
await session.execute(
138+
select(TrialModel.id)
139+
.where(
140+
TrialModel.task_version_id == task_version_id,
141+
TrialModel.is_probe.is_(False),
142+
trial_in_experiment(experiment_id),
143+
)
144+
.limit(1)
145+
)
146+
).scalar_one_or_none() is not None
147+
148+
149+
@router.get("/public/experiments/{public_token}/tasks/{task_id}/agent-capabilities")
150+
async def get_public_task_agent_capabilities(
151+
public_token: str,
152+
task_id: str,
153+
version: int | None = Query(
154+
None,
155+
description=(
156+
"Compare this task version instead of the current one. A share "
157+
"page pins the version its trials ran on, so without this an "
158+
"older version would show the current version's comparison."
159+
),
160+
),
161+
) -> dict:
162+
"""The stored successful-vs-failing comparison for a public task version."""
163+
from api.services.agent_capabilities import load_stored_analysis
164+
165+
async with get_session() as session:
166+
resolved = await get_public_task_for_experiment(session, public_token, task_id)
167+
if resolved is None:
168+
raise HTTPException(status_code=404, detail="Task not found")
169+
experiment, task, _ = resolved
170+
if not task.current_version_id:
171+
raise HTTPException(status_code=404, detail="Task not found")
172+
version_id = task.current_version_id
173+
if version is not None:
174+
from oddish.db.models import TaskVersionModel
175+
from sqlalchemy import select
176+
177+
version_id = (
178+
await session.execute(
179+
select(TaskVersionModel.id).where(
180+
TaskVersionModel.task_id == task.id,
181+
TaskVersionModel.version == version,
182+
)
183+
)
184+
).scalar_one_or_none()
185+
if version_id is None:
186+
raise HTTPException(status_code=404, detail="Task version not found")
187+
# The token publishes an experiment, not a task's whole history. Without
188+
# this, `?version=` walks every version the task ever had -- including
189+
# ones this experiment never ran, whose trial ids, models and trajectory
190+
# quotes the share was never meant to carry. Bound it to the versions
191+
# the share actually displays.
192+
if not await _version_is_in_experiment(session, experiment.id, version_id):
193+
raise HTTPException(status_code=404, detail="Task version not found")
194+
comparison = await load_stored_analysis(
195+
session, version_id, task_id=task.id
196+
)
197+
if comparison is None:
198+
raise HTTPException(
199+
status_code=404, detail="No comparison stored for this version"
200+
)
201+
names = await load_model_display_names(session)
202+
# Stamped at serve time, matching the authenticated route: the id is what
203+
# the UI addresses a version by, while this route takes the number.
204+
return {**_mask_models(comparison, names), "task_version_id": version_id}

backend/api/routers/tasks.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
delete_task_core,
4343
get_experiment_cost_totals,
4444
get_task_detail_core,
45+
get_task_open_core,
4546
get_task_for_org_core,
4647
get_task_status_core,
4748
get_task_version_core,
@@ -97,7 +98,7 @@
9798
stamp_experiment_owner,
9899
)
99100
from dashboard_attribution import resolve_search_authors
100-
from api.services.cohort_comparison import get_or_generate_comparison
101+
from api.services.agent_capabilities import get_or_generate_analysis
101102
from oddish.core.tasks import (
102103
complete_task_upload,
103104
initialize_task_upload,
@@ -134,6 +135,7 @@
134135
TaskBrowseResponse,
135136
TaskBatchCancelRequest,
136137
TaskDetailResponse,
138+
TaskOpenResponse,
137139
TaskUploadCompleteRequest,
138140
TaskUploadInitRequest,
139141
TaskUploadInitResponse,
@@ -1571,6 +1573,26 @@ async def get_task_status(
15711573
)
15721574

15731575

1576+
@router.get("/tasks/{task_id}/open", response_model=TaskOpenResponse)
1577+
async def get_task_open(
1578+
request: Request,
1579+
task_id: str,
1580+
auth: Annotated[AuthContext, Depends(require_auth)],
1581+
version_id: str | None = None,
1582+
) -> TaskOpenResponse:
1583+
"""Bounded task-page header, aggregates, and trial preview."""
1584+
auth.require_scope(APIKeyScope.READ)
1585+
1586+
async with get_session() as session:
1587+
return await get_task_open_core(
1588+
session,
1589+
task_id=task_id,
1590+
version_id=version_id,
1591+
org_id=auth.org_id,
1592+
record_timing=_make_timing_recorder(request),
1593+
)
1594+
1595+
15741596
@router.get("/tasks/{task_id}/detail", response_model=TaskDetailResponse)
15751597
async def get_task_detail(
15761598
task_id: str,
@@ -1583,14 +1605,18 @@ async def get_task_detail(
15831605
return await get_task_detail_core(session, task_id=task_id, org_id=auth.org_id)
15841606

15851607

1586-
@router.get("/tasks/{task_id}/cohort-comparison")
1587-
async def get_task_cohort_comparison(
1608+
@router.get("/tasks/{task_id}/agent-capabilities")
1609+
# Pre-rename path. Kept so a frontend deploy that lags this one -- or a
1610+
# rollback to it -- keeps working; undocumented so only the new path is
1611+
# published. Remove once no released frontend calls it.
1612+
@router.get("/tasks/{task_id}/cohort-comparison", include_in_schema=False)
1613+
async def get_task_agent_capabilities(
15881614
task_id: str,
15891615
auth: Annotated[AuthContext, Depends(require_auth)],
15901616
refresh: bool = Query(
15911617
False,
15921618
description=(
1593-
"Discard the stored comparison and generate a new one. Costs an "
1619+
"Discard the stored analysis and generate a new one. Costs an "
15941620
"LLM call, so it needs the same scope as an analysis rerun."
15951621
),
15961622
),
@@ -1639,7 +1665,7 @@ async def get_task_cohort_comparison(
16391665
).scalar_one_or_none()
16401666
if version_id is None:
16411667
raise HTTPException(status_code=404, detail="Task version not found")
1642-
result = await get_or_generate_comparison(
1668+
result = await get_or_generate_analysis(
16431669
session,
16441670
version_id,
16451671
task_id=task.id,

0 commit comments

Comments
 (0)