"""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