Skip to content

Commit 37aebe8

Browse files
committed
feat(summary): auto-enqueue a trajectory summary for every finished trial
A trajectory summary only ever existed where something asked for one: the post-trial classifier's component map, which runs on `run_analysis` tasks, or a dashboard/share page read. Trials on tasks with QA off carried no summary until a human opened them. `_run_post_trial_hooks` now enqueues one per finished trial, behind `ODDISH_AUTO_TRAJECTORY_SUMMARY` (off by default). Eligibility is real agent trials only -- terminal, not cancelled, not superseded, not a probe, not a nop/oracle baseline, and with a fetchable trajectory. Baselines and probes are excluded because their trajectories are near-empty or harness noise and each would still pay for a full LLM call. The enqueue itself is delegated to `get_or_enqueue_summary_job` through a seam, because that function owns the payload and its `schema_version` idempotency key. A second enqueue site with its own key would not find the first one's job, so a page view after a trial finished would pay for the same summary twice. Eligibility deliberately does not skip a trial that already has a mirrored summary: the mirror can be at an older schema, and only the seam knows the current one. Runs in the hook's own transaction, after the stage transition, so a hook that rolls back leaves no paid job behind and the QA job enqueued above it finds the summary cached rather than building its own.
1 parent 005002b commit 37aebe8

7 files changed

Lines changed: 407 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,14 @@ enqueues one trial-scoped
207207
summary schema. Its endpoint returns explicit queued/running/retrying state and
208208
the client polls until the summary is stored. Terminal failures are returned,
209209
not re-enqueued by anonymous refreshes; a schema bump creates the next valid
210-
idempotency key. Public capability jobs, cache entries, summary warmup, and
210+
idempotency key. `ODDISH_AUTO_TRAJECTORY_SUMMARY` (off by default) adds a second
211+
trigger for that same job: every finished non-baseline, non-probe trial enqueues
212+
one from `_run_post_trial_hooks`, so trials on `run_analysis=False` tasks stop
213+
depending on a human opening them. It must go through
214+
`get_or_enqueue_summary_job` -- reached from `oddish/` via the enqueuer seam in
215+
`workers.queue.trajectory_summary_job`, since only `backend/` knows
216+
`SCHEMA_VERSION`. A second enqueue site owning its own idempotency key would not
217+
find this job, and the next page view would pay for the same summary again. Public capability jobs, cache entries, summary warmup, and
211218
cohort queries are keyed by the published experiment as well as task version;
212219
they must never include trials from another experiment on the same version. Any
213220
completed, fetchable trajectory is enough to queue analysis; cohort size is
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Importing the worker's provider module installs the auto-enqueue seam.
2+
3+
The oddish side no-ops silently when nothing is registered, so a missing
4+
registration would not fail anything -- trials would just quietly stop getting
5+
summaries. That is what this pins.
6+
7+
It also pins the delegation: the enqueuer must go through
8+
``get_or_enqueue_summary_job`` rather than build its own ``worker_jobs`` row.
9+
That function owns the ``schema_version`` idempotency key, and a second writer
10+
with a different key would not find the first one's job, so a page view after a
11+
trial finished would pay for the same summary twice.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import pytest
17+
18+
from oddish.workers.queue import trajectory_summary_job as tsj
19+
20+
21+
def test_importing_the_provider_registers_the_enqueuer():
22+
import worker.trajectory_summary_provider as mod
23+
24+
assert tsj._enqueuer is mod.enqueue_trajectory_summary
25+
26+
27+
@pytest.mark.asyncio
28+
async def test_the_enqueuer_delegates_to_get_or_enqueue_summary_job(monkeypatch):
29+
import api.services.summarize_trajectory as svc
30+
import worker.trajectory_summary_provider as mod
31+
32+
seen: list[tuple] = []
33+
sentinel = object()
34+
35+
async def _fake(session, trial):
36+
seen.append((session, trial))
37+
return sentinel
38+
39+
monkeypatch.setattr(svc, "get_or_enqueue_summary_job", _fake)
40+
41+
session, trial = object(), object()
42+
assert await mod.enqueue_trajectory_summary(session, trial) is sentinel
43+
assert seen == [(session, trial)]

backend/worker/trajectory_summary_provider.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,11 @@
2121
from oddish.workers.queue.analysis_handler import (
2222
register_trajectory_summary_provider as register_analysis_provider,
2323
)
24+
from oddish.workers.queue.trajectory_summary_job import (
25+
register_trajectory_summary_enqueuer,
26+
)
2427

28+
from api.services import summarize_trajectory
2529
from api.services.summarize_trajectory import get_or_generate_summary
2630

2731

@@ -44,6 +48,17 @@ async def provide_trajectory_summary(
4448
)
4549

4650

47-
# Importing this module (from backend.worker.functions) installs the hook.
51+
async def enqueue_trajectory_summary(session, trial):
52+
"""TrajectorySummaryEnqueuer impl for the post-trial auto-enqueue.
53+
54+
A pass-through on purpose: ``get_or_enqueue_summary_job`` stays the only
55+
writer of the job's payload and its ``schema_version`` idempotency key, so a
56+
later page view finds this job instead of paying for the summary again.
57+
"""
58+
return await summarize_trajectory.get_or_enqueue_summary_job(session, trial)
59+
60+
61+
# Importing this module (from backend.worker.functions) installs the hooks.
4862
register_analysis_provider(provide_trajectory_summary)
4963
register_job_provider(provide_trajectory_summary)
64+
register_trajectory_summary_enqueuer(enqueue_trajectory_summary)

oddish/src/oddish/config.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1341,6 +1341,13 @@ def analyzer_snapshot(self) -> str:
13411341
# ODDISH_GATE_LLM_ON_BASELINES; default off leaves every path unchanged.
13421342
gate_llm_on_baselines: bool = False
13431343

1344+
# When enabled, every finished agent trial enqueues its own trajectory-summary
1345+
# job from the post-trial hook, instead of a summary existing only where
1346+
# post-trial classification built one (run_analysis tasks) or somebody read a
1347+
# trajectory. Env-driven via ODDISH_AUTO_TRAJECTORY_SUMMARY. Off by default:
1348+
# it is one LLM call per trial, and standalone oddish registers no enqueuer.
1349+
auto_trajectory_summary: bool = False
1350+
13441351
# DEPRECATED (default OFF; see workers.queue.concurrency_controller). The
13451352
# self-tuning advisory controller predates database-backed admin overrides,
13461353
# which are now the supported way to change a per-model limit at runtime:
@@ -1576,6 +1583,15 @@ def normalize_model_overrides(self) -> "Settings":
15761583
"on",
15771584
}
15781585

1586+
auto_summary_raw = os.getenv("ODDISH_AUTO_TRAJECTORY_SUMMARY")
1587+
if auto_summary_raw is not None:
1588+
self.auto_trajectory_summary = auto_summary_raw.strip().lower() in {
1589+
"1",
1590+
"true",
1591+
"yes",
1592+
"on",
1593+
}
1594+
15791595
raw_buckets = os.getenv("ODDISH_PROVIDER_RATE_LIMITS")
15801596
if raw_buckets:
15811597
try:
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
"""Auto-enqueue a trajectory-summary job for every finished agent trial.
2+
3+
The durable job already exists (``ANALYZER`` + ``payload.mode ==
4+
"trajectory_summary"``, handled in ``workers.jobs.handlers``). What was missing
5+
is anything that creates one on its own: the only enqueue site is a public page
6+
read, so a trial on a ``run_analysis=False`` task carried no summary until a
7+
human opened its trajectory.
8+
9+
Only the *policy* lives here -- the flag and which trials are worth summarizing.
10+
The enqueue itself is delegated to the seam below, which the hosted backend fills
11+
with ``get_or_enqueue_summary_job``. That function owns the payload shape and the
12+
``schema_version`` idempotency key, and it must stay the single writer of them: a
13+
second enqueue site with its own key would not find the first one's job, so a
14+
page view after a trial finished would pay for the same summary twice. Standalone
15+
oddish leaves the seam empty and this whole module is a no-op, which is correct --
16+
there is no summary generator to reach.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
from typing import Any, Awaitable, Callable
22+
23+
from oddish.config import is_nop_oracle_agent, settings
24+
from oddish.core.cost_basis import CANCELLED_HARBOR_STAGE
25+
from oddish.core.helpers import _has_fetchable_trajectory
26+
from oddish.db import TrialStatus
27+
28+
# Takes (session, trial) and returns the one durable job for that trial and
29+
# schema, enqueueing it if absent. Runs in the caller's transaction.
30+
TrajectorySummaryEnqueuer = Callable[[Any, Any], Awaitable[Any]]
31+
32+
_enqueuer: TrajectorySummaryEnqueuer | None = None
33+
34+
35+
def register_trajectory_summary_enqueuer(fn: TrajectorySummaryEnqueuer) -> None:
36+
"""Install the hosted enqueue implementation."""
37+
global _enqueuer
38+
_enqueuer = fn
39+
40+
41+
def trial_wants_trajectory_summary(trial) -> bool:
42+
"""Whether *trial* is one a human would ever read a summary of.
43+
44+
Baselines and probes are excluded because their trajectories are near-empty
45+
or sanctioned-harness noise, and each would still pay for a full LLM call.
46+
47+
Deliberately does *not* skip a trial that already has a
48+
``trajectory_summary``: a mirrored summary can be at an older
49+
``schema_version``, and only the enqueuer knows the current one. Short-
50+
circuiting here would silently skip exactly the trials a schema bump means
51+
to re-summarize.
52+
"""
53+
if trial.status not in (TrialStatus.SUCCESS, TrialStatus.FAILED):
54+
return False
55+
if (trial.harbor_stage or "") == CANCELLED_HARBOR_STAGE:
56+
return False
57+
if trial.superseded_by_trial_id is not None:
58+
return False
59+
if trial.is_probe:
60+
return False
61+
if is_nop_oracle_agent(trial.agent):
62+
return False
63+
return _has_fetchable_trajectory(trial)
64+
65+
66+
async def enqueue_trajectory_summary_job(session, trial):
67+
"""Ensure *trial* has a summary job, or return ``None`` if it wants none.
68+
69+
``None`` when the flag is off, no enqueuer is registered, or the trial is
70+
ineligible. Runs in the caller's transaction so the job commits with the
71+
rest of the post-trial hook rather than outliving a rollback.
72+
"""
73+
if not settings.auto_trajectory_summary:
74+
return None
75+
enqueuer = _enqueuer
76+
if enqueuer is None:
77+
return None
78+
if not trial_wants_trajectory_summary(trial):
79+
return None
80+
return await enqueuer(session, trial)
81+
82+
83+
__all__ = [
84+
"TrajectorySummaryEnqueuer",
85+
"enqueue_trajectory_summary_job",
86+
"register_trajectory_summary_enqueuer",
87+
"trial_wants_trajectory_summary",
88+
]

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -958,6 +958,9 @@ async def _store_trial_results(
958958

959959
async def _run_post_trial_hooks(trial_id: str) -> None:
960960
from oddish.queue import maybe_gate_llm_trials, maybe_start_qa_stage
961+
from oddish.workers.queue.trajectory_summary_job import (
962+
enqueue_trajectory_summary_job,
963+
)
961964

962965
async with get_session() as session:
963966
if (
@@ -982,6 +985,10 @@ async def _run_post_trial_hooks(trial_id: str) -> None:
982985
console.print(
983986
f"[blue]Task {trial.task_id} transitioned to next stage[/blue]"
984987
)
988+
# In this same transaction, and after the stage transition: a hook that
989+
# rolls back must not leave a paid job behind, and the QA job enqueued
990+
# above finds the summary already cached rather than building its own.
991+
await enqueue_trajectory_summary_job(session, trial)
985992

986993

987994
async def _finish_trial_settlement(

0 commit comments

Comments
 (0)