AgentEvalTool/backend/agenteval/evaluation/campaign_scheduler.py
sinohqb e815298ce5 refactor(campaign): move tick decisions into the pure scheduler seam
Extend the pure scheduler with elapsed_seconds (clock injected), decide_tick
(offset + due + lifecycle action) and resolve_finalize (cancel-race guard),
so the durable loop stops hand-coding elapsed/finished/status checks and only
does I/O. Deletes the runner's private _elapsed_seconds and converges
current_window_offset onto the one pure elapsed computation. The clock-skew
tolerance and cancel-race guard are now unit-testable at the seam.
2026-07-31 02:20:14 +08:00

145 lines
5.2 KiB
Python

"""Campaign scheduling decision — pure functions (no I/O).
This is the key testable seam for the durable scheduler (ADR-0003), mirroring
``judgement.combine_case_outcome``. Given a campaign's static plan and the
current window-clock position, it decides which plan entries are due to spawn
now and whether the window has ended. The async loop and Run-spawning side
effects live in ``campaign_runner``; this module never touches the database,
the clock, or ``time_scale`` semantics beyond the offset mapping below.
Idempotency is the caller's responsibility: it passes the set of already-spawned
plan-entry indices, and this function excludes them from the due list.
"""
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Iterable, Optional
from agenteval.models import CampaignPlanEntry, CampaignStatus
def clock_offset(*, elapsed_seconds: float, time_scale: float) -> float:
"""Map real elapsed wall-clock time to a window offset.
``time_scale`` only changes *when* an entry becomes due — 1.0 is real wall
clock (production), larger compresses the window (development). It never
touches judgement or report semantics.
"""
return elapsed_seconds * time_scale
def elapsed_seconds(*, now: datetime, started_at: datetime) -> float:
"""Real seconds between ``started_at`` and ``now``.
The clock is injected (``now``) so this stays pure and unit-testable.
``started_at`` from SQLite may be naive; treat it as UTC (AGENTS.md #2).
"""
if started_at.tzinfo is None:
started_at = started_at.replace(tzinfo=timezone.utc)
if now.tzinfo is None:
now = now.replace(tzinfo=timezone.utc)
return (now - started_at).total_seconds()
@dataclass(frozen=True)
class DueEntry:
"""A plan entry that is due to spawn, tagged with its plan index."""
index: int
entry: CampaignPlanEntry
@dataclass(frozen=True)
class ScheduleDecision:
"""What the scheduler should do at one clock position."""
due: tuple[DueEntry, ...]
finished: bool
def decide_schedule(
*,
plan: list[CampaignPlanEntry],
window_seconds: int,
clock_offset_seconds: float,
spawned_indices: Iterable[int] = (),
) -> ScheduleDecision:
"""Decide which plan entries are due at ``clock_offset_seconds``.
An entry is due once the clock reaches its offset and it hasn't been
spawned yet. ``finished`` is True once the clock reaches the window end.
"""
already = set(spawned_indices)
due = tuple(
DueEntry(index=i, entry=entry)
for i, entry in enumerate(plan)
if i not in already and entry.offset_seconds <= clock_offset_seconds
)
finished = clock_offset_seconds >= window_seconds
return ScheduleDecision(due=due, finished=finished)
class TickAction(str, Enum):
"""What the durable loop should do after one tick's decision."""
CONTINUE = "continue" # still inside the window — spawn due, then sleep
COMPLETE = "complete" # window ended — mark the campaign completed
STOP = "stop" # not running (cancelled/absent) — exit, touch nothing
@dataclass(frozen=True)
class TickDecision:
"""The full per-tick decision: window position, what's due, and lifecycle."""
offset: float
due: tuple[DueEntry, ...]
finished: bool
action: TickAction
def decide_tick(
*,
now: datetime,
started_at: Optional[datetime],
status: CampaignStatus,
window_seconds: int,
time_scale: float,
plan: list[CampaignPlanEntry],
spawned_indices: Iterable[int] = (),
) -> TickDecision:
"""Decide everything one loop tick needs, purely from the clock + DB state.
The shell reads ``now`` and the campaign row, calls this, then only does
I/O (spawn due Runs, persist progress, write COMPLETED). A campaign that is
not RUNNING or has no ``started_at`` yields ``STOP`` — the loop exits without
touching status. Otherwise the window offset drives ``CONTINUE`` vs
``COMPLETE``. The COMPLETE write is still gated by ``resolve_finalize`` on a
freshly re-read status, so a cancel arriving mid-tick is never overwritten.
"""
if status != CampaignStatus.RUNNING or started_at is None:
return TickDecision(offset=0.0, due=(), finished=False, action=TickAction.STOP)
offset = clock_offset(
elapsed_seconds=elapsed_seconds(now=now, started_at=started_at),
time_scale=time_scale,
)
schedule = decide_schedule(
plan=plan,
window_seconds=window_seconds,
clock_offset_seconds=offset,
spawned_indices=spawned_indices,
)
action = TickAction.COMPLETE if schedule.finished else TickAction.CONTINUE
return TickDecision(offset=offset, due=schedule.due, finished=schedule.finished, action=action)
def resolve_finalize(current_status: CampaignStatus) -> TickAction:
"""Cancel-race guard: complete only if the campaign is *still* RUNNING.
Called by the shell after spawning, on a freshly re-read status, so a
cancellation that landed between finish-detection and the COMPLETED write
wins — a cancelled campaign stays cancelled.
"""
return TickAction.COMPLETE if current_status == CampaignStatus.RUNNING else TickAction.STOP