Add the pure scheduling seam (campaign_scheduler.decide_schedule) that, given a static plan and window-clock offset, decides which plan entries are due and whether the window ended — mirroring judgement.combine_case_outcome, with time_scale confined to the clock mapping so it never touches judgement/report. The campaign_runner shell maps injected elapsed time to a window offset, spawns due child Runs through the existing EvalEngine.run(existing_run=...) path with campaign_id + RunTrigger.CAMPAIGN, and persists spawned-entry indices per entry for idempotent, restart-recoverable progress. No auto loop yet (ticket 03).
114 lines
4.1 KiB
Python
114 lines
4.1 KiB
Python
"""Campaign runner — the side-effect shell around the pure scheduler.
|
|
|
|
Given a campaign and an injected clock position (real elapsed seconds), it asks
|
|
``campaign_scheduler.decide_schedule`` what is due, then spawns those child Runs
|
|
by reusing the existing single-run execution path (``EvalEngine.run`` with an
|
|
``existing_run`` that carries ``campaign_id``). Spawned plan-entry indices are
|
|
persisted on the campaign so re-advancing the same clock never double-spawns.
|
|
|
|
This module deliberately has no timer/loop of its own — ticket 02 is driven by
|
|
tests or a manual clock advance. The durable async loop and restart recovery
|
|
arrive in a later ticket.
|
|
"""
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Optional
|
|
|
|
from sqlmodel import Session
|
|
|
|
from agenteval.evaluation.campaign_scheduler import clock_offset, decide_schedule
|
|
from agenteval.evaluation.engine import EvalEngine
|
|
from agenteval.models import Campaign, EvalRun, RunStatus, RunTrigger
|
|
from agenteval.storage.repository import (
|
|
CampaignRepository,
|
|
RunRepository,
|
|
ScenarioRepository,
|
|
TargetRepository,
|
|
)
|
|
|
|
_SPAWNED_KEY = "spawned_indices"
|
|
|
|
|
|
@dataclass
|
|
class AdvanceResult:
|
|
"""Outcome of advancing a campaign's clock once."""
|
|
|
|
spawned_run_ids: list[str] = field(default_factory=list)
|
|
finished: bool = False
|
|
|
|
|
|
def _spawned_indices(campaign: Campaign) -> set[int]:
|
|
scheduler = (campaign.summary or {}).get("scheduler", {})
|
|
return set(scheduler.get(_SPAWNED_KEY, []))
|
|
|
|
|
|
async def _spawn_child_run(campaign: Campaign, scenario_id: str, session: Session) -> str:
|
|
"""Create a Run owned by the campaign and drive it through the engine.
|
|
|
|
The Run row is created on the caller's ``session``, but the engine runs on
|
|
its own session (obtained internally, closed when the run ends) so one
|
|
child Run's lifecycle never closes the campaign's session — the same
|
|
isolation the single-run background task uses.
|
|
"""
|
|
target = TargetRepository(session).get(campaign.target_id)
|
|
scenario = ScenarioRepository(session).get(scenario_id)
|
|
if not target or not scenario:
|
|
raise ValueError(f"campaign target or scenario missing: {campaign.target_id}/{scenario_id}")
|
|
|
|
run = RunRepository(session).create(
|
|
EvalRun(
|
|
target_id=campaign.target_id,
|
|
scenario_id=scenario_id,
|
|
scenario_version=scenario.version or 1,
|
|
campaign_id=campaign.id,
|
|
triggered_by=RunTrigger.CAMPAIGN,
|
|
status=RunStatus.PENDING,
|
|
)
|
|
)
|
|
engine = EvalEngine(target=target, scenario=scenario, triggered_by=RunTrigger.CAMPAIGN)
|
|
await engine.run(existing_run=run)
|
|
return run.id or ""
|
|
|
|
|
|
async def advance_campaign(
|
|
*,
|
|
campaign_id: str,
|
|
elapsed_seconds: float,
|
|
session: Session,
|
|
) -> Optional[AdvanceResult]:
|
|
"""Advance the campaign clock to ``elapsed_seconds`` and spawn due Runs.
|
|
|
|
``elapsed_seconds`` is real wall-clock time since the window started; it is
|
|
mapped to a window offset via ``time_scale``. Returns ``None`` if the
|
|
campaign does not exist.
|
|
"""
|
|
repo = CampaignRepository(session)
|
|
campaign = repo.get(campaign_id)
|
|
if not campaign:
|
|
return None
|
|
|
|
offset = clock_offset(elapsed_seconds=elapsed_seconds, time_scale=campaign.time_scale)
|
|
spawned = _spawned_indices(campaign)
|
|
decision = decide_schedule(
|
|
plan=campaign.plan,
|
|
window_seconds=campaign.window_seconds,
|
|
clock_offset_seconds=offset,
|
|
spawned_indices=spawned,
|
|
)
|
|
|
|
result = AdvanceResult(finished=decision.finished)
|
|
for due in decision.due:
|
|
for _ in range(due.entry.count):
|
|
run_id = await _spawn_child_run(campaign, due.entry.scenario_id, session)
|
|
result.spawned_run_ids.append(run_id)
|
|
spawned.add(due.index)
|
|
# Persist progress per entry: a failure partway through a multi-entry
|
|
# advance must never lose which entries already spawned, since restart
|
|
# recovery (later ticket) reads this back from the DB.
|
|
summary = dict(campaign.summary or {})
|
|
summary["scheduler"] = {_SPAWNED_KEY: sorted(spawned)}
|
|
campaign.summary = summary
|
|
repo.update(campaign)
|
|
|
|
return result
|