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