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.
145 lines
5.2 KiB
Python
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
|