Skip to content
Merged
16 changes: 10 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,18 +363,22 @@ token fields are the billed-user subset used by the frontend's New spend tile.

### Task Browser Summary

`task_version_browse_summaries` keeps one bounded aggregate row per task
version: exact card counters, status buckets, model-grouped cost inputs, and
`last_run_at`. It exists so the default `GET /tasks/browse` page can select
and order tasks without scanning organization trial history; until the read
path cuts over to it, the rows are maintained but unread.
The default `GET /tasks/browse` path selects and paginates tasks before card
enrichment. Ordering and exact card counters come from the selected
`tasks.current_version_id` row in `task_version_browse_summaries`; there is no
fallback scan over organization trial history when a summary row is missing.
The visible cards then fetch at most 24 current-version trials per task through
a lateral query. `latest_trials_truncated` tells the frontend that the preview
is shorter than the exact `total_trials`.

Summary scope matches normal task cards: exclude probes, superseded attempts,
soft-deleted trials, and `combine:` copies. Any mutation that changes that
population or its metrics must call
`refresh_task_browse_summaries` inside the same transaction. This includes
trial create/import, start/reset, completion, cancellation, retry/supersede,
scoped deletion, and default-version selection.
scoped deletion, and default-version selection. Advanced aggregate filters,
comparisons, and non-default aggregate sorts intentionally retain their
on-demand trial aggregation path.

Refreshes serialize per version with sorted transaction-scoped PostgreSQL
advisory locks; do not replace those locks with `FOR UPDATE` on
Expand Down
8 changes: 5 additions & 3 deletions backend/tests/test_browse_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
browse_tasks_core,
)
from oddish.core.trial_facets import rebuild_trial_facets_core
from oddish.core.task_browse_summary import refresh_task_browse_summaries
from oddish.db.models import Base

URL = os.environ.get("ODDISH_DATABASE_URL")
Expand Down Expand Up @@ -81,6 +82,9 @@ async def _setup(engine):
for stmt in stmts.split(";"):
if stmt.strip():
await c.execute(text(stmt))
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
await refresh_task_browse_summaries(session, ["v-a", "v-a-old", "v-b", "v-c"])
await session.commit()


async def _names(session, **filters):
Expand Down Expand Up @@ -216,9 +220,7 @@ async def test_experiment_options():

# ids= hydration returns named rows and wins over query.
resp = await opts(session, org_id=ORG, ids=["exp-real"], query="probe")
assert [(o.id, o.name) for o in resp.items] == [
("exp-real", "Real Exp")
]
assert [(o.id, o.name) for o in resp.items] == [("exp-real", "Real Exp")]

# Hydration is a keyed lookup, not a paged search: every id
# resolves even past the default search page size (a restored
Expand Down
114 changes: 114 additions & 0 deletions backend/tests/test_browse_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import models # noqa: F401 registers cloud tables on the shared Base
from oddish.core.endpoints import browse_tasks_core
from oddish.core.endpoints.deletion import delete_experiment_core
from oddish.core.endpoints.task_detail import set_task_default_version_core
from oddish.core.task_browse_summary import refresh_task_browse_summaries
from oddish.db.models import Base

Expand Down Expand Up @@ -62,6 +63,9 @@ async def _setup(engine):
for stmt in stmts.split(";"):
if stmt.strip():
await c.execute(text(stmt))
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
await refresh_task_browse_summaries(session, ["v-old", "v-new"])
await session.commit()


async def test_probe_runs_do_not_pollute_browse():
Expand Down Expand Up @@ -121,6 +125,9 @@ async def _setup_combine(engine):
for stmt in stmts.split(";"):
if stmt.strip():
await c.execute(text(stmt))
async with async_sessionmaker(engine, expire_on_commit=False)() as session:
await refresh_task_browse_summaries(session, ["v-c"])
await session.commit()


async def test_combine_copies_excluded_from_browse():
Expand All @@ -142,6 +149,65 @@ async def test_combine_copies_excluded_from_browse():
await engine.dispose()


async def test_default_browse_preview_is_bounded_but_totals_are_exact():
engine = create_async_engine(URL)
maker = async_sessionmaker(engine, expire_on_commit=False)
try:
await _setup_combine(engine)
async with maker() as session:
await session.execute(
text(
"""
INSERT INTO trials (
id, name, task_id, task_version_id, experiment_id, org_id,
agent, model, provider, queue_key, timeout_minutes,
environment, harbor_config, status, origin, is_probe,
reward, cost_usd, billed_user_id, finished_at, attempts,
max_attempts, heartbeat_failure_count, has_trajectory,
created_at, updated_at
)
SELECT 'tr-extra-' || n, 'tr-extra-' || n, 't-c', 'v-c',
'exp-a', 'org1', 'claude', 'sonnet', 'anthropic', 'q',
30, 'modal', '{}'::jsonb, 'SUCCESS', 'oddish', false,
1.0, 1.0,
CASE WHEN n % 2 = 0 THEN 'user-billed' END,
NOW() + n * interval '1 second', 1, 6, 0,
false, NOW() + n * interval '1 second', NOW()
FROM generate_series(1, 30) AS n
"""
)
)
await refresh_task_browse_summaries(session, ["v-c"])
await session.commit()
response = await browse_tasks_core(session, org_id=ORG, limit=10, offset=0)
standalone = await browse_tasks_core(
session, org_id=None, limit=10, offset=0
)

item = response.items[0]
assert item.total_trials == 31
assert item.completed_trials == 31
assert item.pass_count == 31
assert item.cost_usd == 30.0
assert item.cost_trial_count == 30
# Billed trials exercise the summary cost path's "billed" rollup; the
# first landing of #1152 crashed here (KeyError: 'billed_usd') because
# no fixture seeded a billed_user_id.
assert item.billed_cost_usd == 15.0
assert item.billed_trial_count == 15
assert item.billed_has_native is True
assert len(item.latest_trials) == 24
assert item.latest_trials_truncated is True
standalone_item = standalone.items[0]
assert standalone_item.total_trials == item.total_trials
assert standalone_item.cost_usd == item.cost_usd
assert [trial.id for trial in standalone_item.latest_trials] == [
trial.id for trial in item.latest_trials
]
finally:
await engine.dispose()


async def test_experiment_delete_refreshes_surviving_task_summaries():
engine = create_async_engine(URL)
maker = async_sessionmaker(engine, expire_on_commit=False)
Expand Down Expand Up @@ -253,6 +319,54 @@ async def test_summary_refresh_serializes_concurrent_settlements():
await engine.dispose()


async def test_selected_default_version_changes_card_immediately():
engine = create_async_engine(URL)
maker = async_sessionmaker(engine, expire_on_commit=False)
try:
await _setup_combine(engine)
async with maker() as session:
await session.execute(
text(
"""
INSERT INTO task_versions (
id, task_id, version, task_path, created_at, updated_at
) VALUES ('v-c-2', 't-c', 2, 'p2', NOW(), NOW())
"""
)
)
await session.execute(
text(
"""
INSERT INTO trials (
id, name, task_id, task_version_id, experiment_id, org_id,
agent, model, provider, queue_key, timeout_minutes,
environment, harbor_config, status, origin, is_probe,
reward, finished_at, attempts, max_attempts,
heartbeat_failure_count, has_trajectory, created_at, updated_at
) VALUES (
'tr-v2', 'tr-v2', 't-c', 'v-c-2', 'exp-a', 'org1',
'claude', 'sonnet', 'anthropic', 'q', 30, 'modal',
'{}'::jsonb, 'SUCCESS', 'oddish', false, 0.0, NOW(),
1, 6, 0, false, NOW(), NOW()
)
"""
)
)
await set_task_default_version_core(
session, task_id="t-c", version=2, org_id=ORG
)
response = await browse_tasks_core(session, org_id=ORG, limit=10, offset=0)

item = response.items[0]
assert item.current_version == 2
assert item.current_version_id == "v-c-2"
assert item.version_count == 2
assert item.total_trials == 1
assert [trial.id for trial in item.latest_trials] == ["tr-v2"]
finally:
await engine.dispose()


async def test_search_wildcards_are_literals():
engine = create_async_engine(URL)
maker = async_sessionmaker(engine, expire_on_commit=False)
Expand Down
Loading
Loading