AgentEvalTool/backend/agenteval/evaluation/campaign_runner.py
sinohqb c6b102a9b5 feat(campaigns): add scheduling decision and child-Run spawning
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).
2026-07-30 12:06:31 +08:00

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