feat(campaigns): durable scheduler loop with restart recovery and cancel
Add a thin async loop (run_campaign_loop) that ticks on real wall-clock time,
maps elapsed×time_scale to a window offset via the pure decide_schedule, spawns
due child Runs, and marks the campaign COMPLETED at window end. All authority
lives in the DB (started_at, spawned_indices, status), so the app lifespan can
resume every RUNNING campaign on startup without double-spawning and stop all
loops gracefully on shutdown. A failing plan entry is skipped and recorded
rather than wedging the campaign.
Creating a campaign now starts its loop; POST /api/campaigns/{id}/cancel stops
further spawning (completed child Runs are kept); GET /api/campaigns/{id}
reports live progress (window offset, spawned/completed Run counts).
This commit is contained in:
parent
c6b102a9b5
commit
8910fd17e0
@ -1,24 +1,30 @@
|
|||||||
"""Campaign runner — the side-effect shell around the pure scheduler.
|
"""Campaign runner — the side-effect shell around the pure scheduler.
|
||||||
|
|
||||||
Given a campaign and an injected clock position (real elapsed seconds), it asks
|
Given a campaign and a clock position (real elapsed seconds), it asks
|
||||||
``campaign_scheduler.decide_schedule`` what is due, then spawns those child Runs
|
``campaign_scheduler.decide_schedule`` what is due, then spawns those child Runs
|
||||||
by reusing the existing single-run execution path (``EvalEngine.run`` with an
|
by reusing the existing single-run execution path (``EvalEngine.run`` with an
|
||||||
``existing_run`` that carries ``campaign_id``). Spawned plan-entry indices are
|
``existing_run`` that carries ``campaign_id``). Spawned plan-entry indices are
|
||||||
persisted on the campaign so re-advancing the same clock never double-spawns.
|
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
|
This module also hosts the durable scheduler loop: a thin async shell that,
|
||||||
tests or a manual clock advance. The durable async loop and restart recovery
|
tick by tick, maps real wall-clock elapsed time (since the campaign's persisted
|
||||||
arrive in a later ticket.
|
``started_at``) to a window offset and calls ``advance_campaign``. All authority
|
||||||
|
lives in the DB (window start, spawned progress, status), so the loop can be
|
||||||
|
torn down and rebuilt on restart without losing or duplicating work.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.evaluation.campaign_scheduler import clock_offset, decide_schedule
|
from agenteval.evaluation.campaign_scheduler import clock_offset, decide_schedule
|
||||||
from agenteval.evaluation.engine import EvalEngine
|
from agenteval.evaluation.engine import EvalEngine
|
||||||
from agenteval.models import Campaign, EvalRun, RunStatus, RunTrigger
|
from agenteval.models import Campaign, CampaignStatus, EvalRun, RunStatus, RunTrigger
|
||||||
|
from agenteval.storage.db import get_session, utc_now
|
||||||
from agenteval.storage.repository import (
|
from agenteval.storage.repository import (
|
||||||
CampaignRepository,
|
CampaignRepository,
|
||||||
RunRepository,
|
RunRepository,
|
||||||
@ -27,6 +33,18 @@ from agenteval.storage.repository import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_SPAWNED_KEY = "spawned_indices"
|
_SPAWNED_KEY = "spawned_indices"
|
||||||
|
_ERRORS_KEY = "errors"
|
||||||
|
|
||||||
|
# Real wall-clock seconds between scheduler ticks. time_scale compresses the
|
||||||
|
# *window*, not the tick cadence — production 24h campaigns still tick slowly.
|
||||||
|
DEFAULT_TICK_SECONDS = 1.0
|
||||||
|
|
||||||
|
_logger = logging.getLogger("agenteval")
|
||||||
|
|
||||||
|
# Live loop tasks + cooperative cancel events, keyed by campaign id. Authority
|
||||||
|
# is the DB; these are only the in-process handles for the running loop.
|
||||||
|
_tasks: dict[str, asyncio.Task] = {}
|
||||||
|
_cancel_events: dict[str, asyncio.Event] = {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@ -37,11 +55,46 @@ class AdvanceResult:
|
|||||||
finished: bool = False
|
finished: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def _elapsed_seconds(started_at) -> float:
|
||||||
|
"""Real seconds since ``started_at`` (tolerating naive UTC from SQLite)."""
|
||||||
|
if started_at.tzinfo is None:
|
||||||
|
started_at = started_at.replace(tzinfo=timezone.utc)
|
||||||
|
return (utc_now() - started_at).total_seconds()
|
||||||
|
|
||||||
|
|
||||||
def _spawned_indices(campaign: Campaign) -> set[int]:
|
def _spawned_indices(campaign: Campaign) -> set[int]:
|
||||||
scheduler = (campaign.summary or {}).get("scheduler", {})
|
scheduler = (campaign.summary or {}).get("scheduler", {})
|
||||||
return set(scheduler.get(_SPAWNED_KEY, []))
|
return set(scheduler.get(_SPAWNED_KEY, []))
|
||||||
|
|
||||||
|
|
||||||
|
def current_window_offset(campaign: Campaign) -> float:
|
||||||
|
"""The campaign's live window position (seconds), clamped to the window.
|
||||||
|
|
||||||
|
RUNNING campaigns derive it from the persisted ``started_at`` and
|
||||||
|
``time_scale``; not-yet-started campaigns report 0.
|
||||||
|
"""
|
||||||
|
if campaign.started_at is None:
|
||||||
|
return 0.0
|
||||||
|
offset = clock_offset(
|
||||||
|
elapsed_seconds=_elapsed_seconds(campaign.started_at),
|
||||||
|
time_scale=campaign.time_scale,
|
||||||
|
)
|
||||||
|
return min(offset, float(campaign.window_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
def campaign_progress(campaign: Campaign, runs: list[EvalRun]) -> dict:
|
||||||
|
"""Live progress of a campaign, derived from its child Runs.
|
||||||
|
|
||||||
|
``completed_runs`` counts only COMPLETED child Runs; failures stay out of
|
||||||
|
this field (pass_rate semantics are ADR-0002's concern, not this counter).
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"current_offset_seconds": current_window_offset(campaign),
|
||||||
|
"spawned_runs": len(runs),
|
||||||
|
"completed_runs": sum(1 for r in runs if r.status == RunStatus.COMPLETED),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _spawn_child_run(campaign: Campaign, scenario_id: str, session: Session) -> str:
|
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.
|
"""Create a Run owned by the campaign and drive it through the engine.
|
||||||
|
|
||||||
@ -75,11 +128,14 @@ async def advance_campaign(
|
|||||||
campaign_id: str,
|
campaign_id: str,
|
||||||
elapsed_seconds: float,
|
elapsed_seconds: float,
|
||||||
session: Session,
|
session: Session,
|
||||||
|
cancel_event: Optional[asyncio.Event] = None,
|
||||||
) -> Optional[AdvanceResult]:
|
) -> Optional[AdvanceResult]:
|
||||||
"""Advance the campaign clock to ``elapsed_seconds`` and spawn due Runs.
|
"""Advance the campaign clock to ``elapsed_seconds`` and spawn due Runs.
|
||||||
|
|
||||||
``elapsed_seconds`` is real wall-clock time since the window started; it is
|
``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
|
mapped to a window offset via ``time_scale``. A due entry whose spawning
|
||||||
|
raises is skipped and recorded (so one bad entry never wedges the loop),
|
||||||
|
but still marked spawned to avoid infinite retries. Returns ``None`` if the
|
||||||
campaign does not exist.
|
campaign does not exist.
|
||||||
"""
|
"""
|
||||||
repo = CampaignRepository(session)
|
repo = CampaignRepository(session)
|
||||||
@ -97,17 +153,138 @@ async def advance_campaign(
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = AdvanceResult(finished=decision.finished)
|
result = AdvanceResult(finished=decision.finished)
|
||||||
|
errors: list[dict] = list((campaign.summary or {}).get("scheduler", {}).get(_ERRORS_KEY, []))
|
||||||
for due in decision.due:
|
for due in decision.due:
|
||||||
|
# Cancellation stops further spawning; runs already in flight finish.
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
break
|
||||||
|
try:
|
||||||
for _ in range(due.entry.count):
|
for _ in range(due.entry.count):
|
||||||
run_id = await _spawn_child_run(campaign, due.entry.scenario_id, session)
|
run_id = await _spawn_child_run(campaign, due.entry.scenario_id, session)
|
||||||
result.spawned_run_ids.append(run_id)
|
result.spawned_run_ids.append(run_id)
|
||||||
|
except Exception as exc: # one entry failing must not wedge the campaign
|
||||||
|
errors.append({"entry_index": due.index, "error": str(exc)})
|
||||||
|
_logger.warning("活动 %s 计划条目 %d 派生失败(跳过): %s", campaign_id, due.index, exc)
|
||||||
spawned.add(due.index)
|
spawned.add(due.index)
|
||||||
# Persist progress per entry: a failure partway through a multi-entry
|
# Persist progress per entry: a failure partway through a multi-entry
|
||||||
# advance must never lose which entries already spawned, since restart
|
# advance must never lose which entries already spawned, since restart
|
||||||
# recovery (later ticket) reads this back from the DB.
|
# recovery reads this back from the DB.
|
||||||
|
scheduler_state: dict = {_SPAWNED_KEY: sorted(spawned)}
|
||||||
|
if errors:
|
||||||
|
scheduler_state[_ERRORS_KEY] = errors
|
||||||
summary = dict(campaign.summary or {})
|
summary = dict(campaign.summary or {})
|
||||||
summary["scheduler"] = {_SPAWNED_KEY: sorted(spawned)}
|
summary["scheduler"] = scheduler_state
|
||||||
campaign.summary = summary
|
campaign.summary = summary
|
||||||
repo.update(campaign)
|
repo.update(campaign)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── durable scheduler loop ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def run_campaign_loop(campaign_id: str, *, tick_seconds: float = DEFAULT_TICK_SECONDS) -> None:
|
||||||
|
"""Drive one campaign to completion, ticking on real wall-clock time.
|
||||||
|
|
||||||
|
The loop holds no authority: every tick it reloads the campaign from the DB,
|
||||||
|
recomputes elapsed time from the persisted ``started_at``, and advances.
|
||||||
|
It exits when the window finishes, the campaign leaves RUNNING (e.g. it was
|
||||||
|
cancelled), or the cooperative cancel event fires.
|
||||||
|
"""
|
||||||
|
cancel = _cancel_events.setdefault(campaign_id, asyncio.Event())
|
||||||
|
session = get_session()
|
||||||
|
try:
|
||||||
|
while not cancel.is_set():
|
||||||
|
repo = CampaignRepository(session)
|
||||||
|
campaign = repo.get(campaign_id)
|
||||||
|
if not campaign or campaign.status != CampaignStatus.RUNNING or campaign.started_at is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed = _elapsed_seconds(campaign.started_at)
|
||||||
|
result = await advance_campaign(
|
||||||
|
campaign_id=campaign_id,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
session=session,
|
||||||
|
cancel_event=cancel,
|
||||||
|
)
|
||||||
|
if result and result.finished:
|
||||||
|
current = repo.get(campaign_id)
|
||||||
|
# Only complete if still running (not cancelled meanwhile).
|
||||||
|
if current and current.status == CampaignStatus.RUNNING:
|
||||||
|
current.status = CampaignStatus.COMPLETED
|
||||||
|
current.completed_at = utc_now()
|
||||||
|
repo.update(current)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(cancel.wait(), timeout=tick_seconds)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
pass
|
||||||
|
except Exception as exc: # a crashing loop must not take down the app
|
||||||
|
_logger.warning("活动调度循环 %s 异常退出: %s", campaign_id, exc)
|
||||||
|
finally:
|
||||||
|
session.close()
|
||||||
|
_cancel_events.pop(campaign_id, None)
|
||||||
|
_tasks.pop(campaign_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def start_campaign(campaign_id: str, session: Session, *, tick_seconds: float = DEFAULT_TICK_SECONDS) -> Optional[asyncio.Task]:
|
||||||
|
"""Move a campaign into RUNNING (stamping ``started_at`` on first start) and
|
||||||
|
launch its loop. Reused for both create-then-start and restart recovery:
|
||||||
|
a PLANNED campaign gets a fresh ``started_at``; an already-RUNNING one keeps
|
||||||
|
its original window start so recovery resumes at the correct offset.
|
||||||
|
"""
|
||||||
|
repo = CampaignRepository(session)
|
||||||
|
campaign = repo.get(campaign_id)
|
||||||
|
if not campaign or campaign.status in (CampaignStatus.COMPLETED, CampaignStatus.CANCELLED, CampaignStatus.FAILED):
|
||||||
|
return None
|
||||||
|
if campaign_id in _tasks:
|
||||||
|
return _tasks[campaign_id]
|
||||||
|
|
||||||
|
if campaign.status == CampaignStatus.PLANNED:
|
||||||
|
campaign.status = CampaignStatus.RUNNING
|
||||||
|
campaign.started_at = utc_now()
|
||||||
|
repo.update(campaign)
|
||||||
|
|
||||||
|
_cancel_events.setdefault(campaign_id, asyncio.Event())
|
||||||
|
task = asyncio.create_task(
|
||||||
|
run_campaign_loop(campaign_id, tick_seconds=tick_seconds),
|
||||||
|
name=f"campaign-{campaign_id}",
|
||||||
|
)
|
||||||
|
_tasks[campaign_id] = task
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
def request_cancel(campaign_id: str) -> None:
|
||||||
|
"""Signal the loop (if live) to stop spawning and exit promptly."""
|
||||||
|
event = _cancel_events.get(campaign_id)
|
||||||
|
if event is not None:
|
||||||
|
event.set()
|
||||||
|
|
||||||
|
|
||||||
|
def resume_running_campaigns(session: Session, *, tick_seconds: float = DEFAULT_TICK_SECONDS) -> int:
|
||||||
|
"""On startup, relaunch a loop for every campaign left in RUNNING."""
|
||||||
|
resumed = 0
|
||||||
|
for campaign in CampaignRepository(session).list_all():
|
||||||
|
if campaign.status == CampaignStatus.RUNNING and campaign.id:
|
||||||
|
start_campaign(campaign.id, session, tick_seconds=tick_seconds)
|
||||||
|
resumed += 1
|
||||||
|
return resumed
|
||||||
|
|
||||||
|
|
||||||
|
async def shutdown_all() -> None:
|
||||||
|
"""Gracefully stop all live loops (Web app shutdown)."""
|
||||||
|
for event in list(_cancel_events.values()):
|
||||||
|
event.set()
|
||||||
|
tasks = list(_tasks.values())
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
for task in tasks:
|
||||||
|
# CancelledError is expected here (we just cancelled the task) and does
|
||||||
|
# not derive from Exception in 3.8+, so it must be listed explicitly.
|
||||||
|
try:
|
||||||
|
await task
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
_tasks.clear()
|
||||||
|
_cancel_events.clear()
|
||||||
|
|||||||
@ -303,6 +303,14 @@ class RunRepository:
|
|||||||
statement = select(EvalRunDB).order_by(EvalRunDB.started_at.desc())
|
statement = select(EvalRunDB).order_by(EvalRunDB.started_at.desc())
|
||||||
return [_run_from_db(r) for r in self.session.exec(statement).all()]
|
return [_run_from_db(r) for r in self.session.exec(statement).all()]
|
||||||
|
|
||||||
|
def list_by_campaign(self, campaign_id: str) -> list[EvalRun]:
|
||||||
|
statement = (
|
||||||
|
select(EvalRunDB)
|
||||||
|
.where(EvalRunDB.campaign_id == campaign_id)
|
||||||
|
.order_by(EvalRunDB.started_at)
|
||||||
|
)
|
||||||
|
return [_run_from_db(r) for r in self.session.exec(statement).all()]
|
||||||
|
|
||||||
def get(self, run_id: str) -> Optional[EvalRun]:
|
def get(self, run_id: str) -> Optional[EvalRun]:
|
||||||
db = self.session.get(EvalRunDB, run_id)
|
db = self.session.get(EvalRunDB, run_id)
|
||||||
return _run_from_db(db) if db else None
|
return _run_from_db(db) if db else None
|
||||||
|
|||||||
@ -27,11 +27,24 @@ async def lifespan(_: FastAPI):
|
|||||||
count = RunRepository(session).mark_orphans_failed()
|
count = RunRepository(session).mark_orphans_failed()
|
||||||
if count:
|
if count:
|
||||||
logging.getLogger("agenteval").warning("启动清理:%d 个中断的运行已标记为 failed", count)
|
logging.getLogger("agenteval").warning("启动清理:%d 个中断的运行已标记为 failed", count)
|
||||||
|
# 据库恢复所有未完成的评估活动,重建其调度循环(不重复派生已到点条目)
|
||||||
|
from agenteval.evaluation.campaign_runner import resume_running_campaigns
|
||||||
|
|
||||||
|
resumed = resume_running_campaigns(session)
|
||||||
|
if resumed:
|
||||||
|
logging.getLogger("agenteval").warning("启动恢复:%d 个进行中的评估活动已续跑", resumed)
|
||||||
finally:
|
finally:
|
||||||
session.close()
|
session.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.getLogger("agenteval").warning("启动清理失败(忽略): %s", exc)
|
logging.getLogger("agenteval").warning("启动清理失败(忽略): %s", exc)
|
||||||
yield
|
yield
|
||||||
|
# 优雅停止所有活动调度循环
|
||||||
|
try:
|
||||||
|
from agenteval.evaluation.campaign_runner import shutdown_all
|
||||||
|
|
||||||
|
await shutdown_all()
|
||||||
|
except Exception as exc:
|
||||||
|
logging.getLogger("agenteval").warning("活动调度停止失败(忽略): %s", exc)
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@ -1,15 +1,25 @@
|
|||||||
"""API routes for evaluation campaigns (评估活动).
|
"""API routes for evaluation campaigns (评估活动).
|
||||||
|
|
||||||
This ticket covers persistence and create/query only — no scheduling or child
|
Creating a campaign starts its durable scheduler loop (``campaign_runner``),
|
||||||
Run spawning. Those arrive in later tickets.
|
which spawns child Runs across the (optionally compressed) service-cycle window
|
||||||
|
until it finishes. Progress is authoritative in the DB, so detail queries report
|
||||||
|
the live window position and spawned/completed Run counts, and a campaign can be
|
||||||
|
cancelled mid-flight.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from agenteval.models import Campaign, CampaignPlanEntry
|
from agenteval.evaluation.campaign_runner import campaign_progress, request_cancel, start_campaign
|
||||||
from agenteval.storage.repository import CampaignRepository, ScenarioRepository, TargetRepository
|
from agenteval.models import Campaign, CampaignPlanEntry, CampaignStatus
|
||||||
|
from agenteval.storage.db import utc_now
|
||||||
|
from agenteval.storage.repository import (
|
||||||
|
CampaignRepository,
|
||||||
|
RunRepository,
|
||||||
|
ScenarioRepository,
|
||||||
|
TargetRepository,
|
||||||
|
)
|
||||||
from agenteval.web.deps import get_db
|
from agenteval.web.deps import get_db
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@ -51,7 +61,26 @@ async def create_campaign(
|
|||||||
time_scale=request.time_scale,
|
time_scale=request.time_scale,
|
||||||
plan=request.plan,
|
plan=request.plan,
|
||||||
)
|
)
|
||||||
campaign = CampaignRepository(session).create(campaign)
|
repo = CampaignRepository(session)
|
||||||
|
campaign = repo.create(campaign)
|
||||||
|
# Kick off the durable loop; it moves the campaign into RUNNING.
|
||||||
|
start_campaign(campaign.id, session)
|
||||||
|
return (repo.get(campaign.id) or campaign).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{campaign_id}/cancel")
|
||||||
|
async def cancel_campaign(campaign_id: str, session: Session = Depends(get_db)) -> dict:
|
||||||
|
repo = CampaignRepository(session)
|
||||||
|
campaign = repo.get(campaign_id)
|
||||||
|
if not campaign:
|
||||||
|
raise HTTPException(status_code=404, detail="campaign not found")
|
||||||
|
if campaign.status not in (CampaignStatus.PLANNED, CampaignStatus.RUNNING):
|
||||||
|
raise HTTPException(status_code=400, detail="campaign is not in a cancellable state")
|
||||||
|
|
||||||
|
campaign.status = CampaignStatus.CANCELLED
|
||||||
|
campaign.completed_at = utc_now()
|
||||||
|
repo.update(campaign)
|
||||||
|
request_cancel(campaign_id)
|
||||||
return campaign.model_dump()
|
return campaign.model_dump()
|
||||||
|
|
||||||
|
|
||||||
@ -60,4 +89,8 @@ async def get_campaign(campaign_id: str, session: Session = Depends(get_db)) ->
|
|||||||
campaign = CampaignRepository(session).get(campaign_id)
|
campaign = CampaignRepository(session).get(campaign_id)
|
||||||
if not campaign:
|
if not campaign:
|
||||||
raise HTTPException(status_code=404, detail="campaign not found")
|
raise HTTPException(status_code=404, detail="campaign not found")
|
||||||
return campaign.model_dump()
|
|
||||||
|
runs = RunRepository(session).list_by_campaign(campaign_id)
|
||||||
|
data = campaign.model_dump()
|
||||||
|
data["progress"] = campaign_progress(campaign, runs)
|
||||||
|
return data
|
||||||
|
|||||||
154
tests/integration/test_campaign_scheduler_loop.py
Normal file
154
tests/integration/test_campaign_scheduler_loop.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""Integration tests for the durable campaign scheduler loop (ticket 03).
|
||||||
|
|
||||||
|
Drives ``campaign_runner.start_campaign`` with a tiny tick and a compressed
|
||||||
|
time scale so a whole window elapses in a few real milliseconds. Covers:
|
||||||
|
auto-run-to-completion, restart recovery (no double-spawn, no lost progress),
|
||||||
|
and cancellation (no further spawning).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from agenteval.evaluation import campaign_runner
|
||||||
|
from agenteval.evaluation.campaign_runner import request_cancel, start_campaign
|
||||||
|
from agenteval.models import (
|
||||||
|
Campaign, CampaignPlanEntry, CampaignStatus, Case, CaseType, ChannelType,
|
||||||
|
EvalTarget, PlatformType, RunStatus, Scenario, TargetStatus,
|
||||||
|
)
|
||||||
|
from agenteval.storage.db import utc_now
|
||||||
|
from agenteval.storage.repository import CampaignRepository, RunRepository, ScenarioRepository, TargetRepository
|
||||||
|
from tests.unit.mock_channel import MockChannel
|
||||||
|
|
||||||
|
TICK = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def seeded_db(db_session, monkeypatch):
|
||||||
|
from agenteval.channels import factory as factory_module
|
||||||
|
from agenteval.evaluation import engine as engine_module
|
||||||
|
from agenteval.storage import db as db_module
|
||||||
|
from agenteval.storage import repository as repo_module
|
||||||
|
|
||||||
|
def _test_get_session():
|
||||||
|
return db_session
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module, "get_session", _test_get_session)
|
||||||
|
monkeypatch.setattr(repo_module, "get_session", _test_get_session)
|
||||||
|
monkeypatch.setattr(engine_module, "get_session", _test_get_session)
|
||||||
|
monkeypatch.setattr(campaign_runner, "get_session", _test_get_session)
|
||||||
|
|
||||||
|
channel = MockChannel(reply_delay=0.0)
|
||||||
|
monkeypatch.setattr(factory_module.ChannelFactory, "create", lambda target: channel)
|
||||||
|
|
||||||
|
TargetRepository(db_session).create(EvalTarget(
|
||||||
|
id="t-1", name="mock-target",
|
||||||
|
platform=PlatformType.AI_DIGITAL_EMPLOYEE, channel_type=ChannelType.TUTU_API,
|
||||||
|
channel_config={"base_url": "http://mock", "token": "x"}, status=TargetStatus.ACTIVE,
|
||||||
|
))
|
||||||
|
ScenarioRepository(db_session).create(Scenario(
|
||||||
|
id="s-1", name="mock-scenario",
|
||||||
|
cases=[Case(id="c1", type=CaseType.SINGLE, messages=["hi"])],
|
||||||
|
))
|
||||||
|
return db_session
|
||||||
|
|
||||||
|
|
||||||
|
def _make_campaign(session, **overrides) -> Campaign:
|
||||||
|
payload = dict(
|
||||||
|
name="loop",
|
||||||
|
target_id="t-1",
|
||||||
|
window_seconds=1,
|
||||||
|
time_scale=1000.0, # elapsed×1000 → window ends in ~1ms of real time
|
||||||
|
plan=[
|
||||||
|
CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1),
|
||||||
|
CampaignPlanEntry(scenario_id="s-1", offset_seconds=1, count=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
payload.update(overrides)
|
||||||
|
return CampaignRepository(session).create(Campaign(**payload))
|
||||||
|
|
||||||
|
|
||||||
|
async def _await_task(campaign_id, timeout=3.0):
|
||||||
|
import asyncio
|
||||||
|
task = campaign_runner._tasks.get(campaign_id)
|
||||||
|
if task is not None:
|
||||||
|
await asyncio.wait_for(task, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_loop_runs_to_completion(seeded_db):
|
||||||
|
campaign = _make_campaign(seeded_db)
|
||||||
|
start_campaign(campaign.id, seeded_db, tick_seconds=TICK)
|
||||||
|
await _await_task(campaign.id)
|
||||||
|
|
||||||
|
final = CampaignRepository(seeded_db).get(campaign.id)
|
||||||
|
assert final.status == CampaignStatus.COMPLETED
|
||||||
|
assert final.completed_at is not None
|
||||||
|
|
||||||
|
runs = RunRepository(seeded_db).list_by_campaign(campaign.id)
|
||||||
|
assert len(runs) == 2 # both plan entries spawned exactly once
|
||||||
|
assert all(r.status == RunStatus.COMPLETED for r in runs)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restart_recovery_does_not_respawn(seeded_db):
|
||||||
|
# Simulate a campaign that was already RUNNING before a restart, with its
|
||||||
|
# window start well in the past and entry 0 already recorded as spawned.
|
||||||
|
campaign = _make_campaign(seeded_db)
|
||||||
|
campaign.status = CampaignStatus.RUNNING
|
||||||
|
campaign.started_at = utc_now() - timedelta(seconds=10)
|
||||||
|
campaign.summary = {"scheduler": {"spawned_indices": [0]}}
|
||||||
|
CampaignRepository(seeded_db).update(campaign)
|
||||||
|
|
||||||
|
# Recovery relaunches the loop for the already-RUNNING campaign.
|
||||||
|
start_campaign(campaign.id, seeded_db, tick_seconds=TICK)
|
||||||
|
await _await_task(campaign.id)
|
||||||
|
|
||||||
|
final = CampaignRepository(seeded_db).get(campaign.id)
|
||||||
|
assert final.status == CampaignStatus.COMPLETED
|
||||||
|
|
||||||
|
runs = RunRepository(seeded_db).list_by_campaign(campaign.id)
|
||||||
|
# Entry 0 was already spawned pre-restart (not re-spawned); only entry 1 runs.
|
||||||
|
assert len(runs) == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_restart_preserves_original_window_start(seeded_db):
|
||||||
|
original = utc_now() - timedelta(seconds=5)
|
||||||
|
campaign = _make_campaign(seeded_db, window_seconds=100000, time_scale=1.0)
|
||||||
|
campaign.status = CampaignStatus.RUNNING
|
||||||
|
campaign.started_at = original
|
||||||
|
CampaignRepository(seeded_db).update(campaign)
|
||||||
|
|
||||||
|
# Resume must NOT reset started_at (that would rewind the window clock).
|
||||||
|
start_campaign(campaign.id, seeded_db, tick_seconds=TICK)
|
||||||
|
request_cancel(campaign.id) # stop the long-window loop promptly
|
||||||
|
await _await_task(campaign.id)
|
||||||
|
|
||||||
|
reloaded = CampaignRepository(seeded_db).get(campaign.id)
|
||||||
|
assert abs((reloaded.started_at.replace(tzinfo=None) - original.replace(tzinfo=None)).total_seconds()) < 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_stops_further_spawning(seeded_db):
|
||||||
|
import asyncio
|
||||||
|
# Long real window (scale 1.0) so entry 1 (offset 50s) never comes due fast.
|
||||||
|
campaign = _make_campaign(
|
||||||
|
seeded_db, window_seconds=100, time_scale=1.0,
|
||||||
|
plan=[
|
||||||
|
CampaignPlanEntry(scenario_id="s-1", offset_seconds=0, count=1),
|
||||||
|
CampaignPlanEntry(scenario_id="s-1", offset_seconds=50, count=1),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
start_campaign(campaign.id, seeded_db, tick_seconds=TICK)
|
||||||
|
await asyncio.sleep(0.05) # let the first tick spawn entry 0
|
||||||
|
|
||||||
|
# Cancel: mark DB authoritative + signal the live loop.
|
||||||
|
repo = CampaignRepository(seeded_db)
|
||||||
|
current = repo.get(campaign.id)
|
||||||
|
current.status = CampaignStatus.CANCELLED
|
||||||
|
current.completed_at = utc_now()
|
||||||
|
repo.update(current)
|
||||||
|
request_cancel(campaign.id)
|
||||||
|
await _await_task(campaign.id)
|
||||||
|
|
||||||
|
final = repo.get(campaign.id)
|
||||||
|
assert final.status == CampaignStatus.CANCELLED
|
||||||
|
runs = RunRepository(seeded_db).list_by_campaign(campaign.id)
|
||||||
|
assert len(runs) == 1 # entry 1 (offset 50s) never spawned
|
||||||
@ -23,8 +23,12 @@ def seeded_db(db_session, monkeypatch):
|
|||||||
from agenteval.storage import db as db_module
|
from agenteval.storage import db as db_module
|
||||||
from agenteval.storage import repository as repo_module
|
from agenteval.storage import repository as repo_module
|
||||||
from agenteval.web import app as app_module
|
from agenteval.web import app as app_module
|
||||||
|
from agenteval.web.routers import campaigns as campaigns_module
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, "init_db", lambda: None)
|
monkeypatch.setattr(app_module, "init_db", lambda: None)
|
||||||
|
# These tests cover persistence/validation/CRUD only — stub out the durable
|
||||||
|
# scheduler so creation stays PLANNED and no background loop is launched.
|
||||||
|
monkeypatch.setattr(campaigns_module, "start_campaign", lambda *a, **k: None)
|
||||||
|
|
||||||
def _test_get_session():
|
def _test_get_session():
|
||||||
return db_session
|
return db_session
|
||||||
@ -177,3 +181,38 @@ async def test_run_can_belong_to_campaign(seeded_db):
|
|||||||
assert run.campaign_id == "camp-1"
|
assert run.campaign_id == "camp-1"
|
||||||
fetched = repo.get(run.id)
|
fetched = repo.get(run.id)
|
||||||
assert fetched.campaign_id == "camp-1"
|
assert fetched.campaign_id == "camp-1"
|
||||||
|
|
||||||
|
|
||||||
|
# ── cancel endpoint + progress in detail (ticket 03; scheduler stubbed) ─────
|
||||||
|
|
||||||
|
async def test_cancel_campaign_then_state(client, seeded_db):
|
||||||
|
campaign_id = (await client.post("/api/campaigns", json=_valid_payload())).json()["id"]
|
||||||
|
|
||||||
|
resp = await client.post(f"/api/campaigns/{campaign_id}/cancel")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["status"] == "cancelled"
|
||||||
|
|
||||||
|
got = (await client.get(f"/api/campaigns/{campaign_id}")).json()
|
||||||
|
assert got["status"] == "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_already_cancelled_rejected(client, seeded_db):
|
||||||
|
campaign_id = (await client.post("/api/campaigns", json=_valid_payload())).json()["id"]
|
||||||
|
await client.post(f"/api/campaigns/{campaign_id}/cancel")
|
||||||
|
again = await client.post(f"/api/campaigns/{campaign_id}/cancel")
|
||||||
|
assert again.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
async def test_cancel_missing_campaign_404(client, seeded_db):
|
||||||
|
resp = await client.post("/api/campaigns/nope/cancel")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
async def test_detail_includes_progress_fields(client, seeded_db):
|
||||||
|
campaign_id = (await client.post("/api/campaigns", json=_valid_payload())).json()["id"]
|
||||||
|
got = (await client.get(f"/api/campaigns/{campaign_id}")).json()
|
||||||
|
assert "progress" in got
|
||||||
|
progress = got["progress"]
|
||||||
|
assert progress["spawned_runs"] == 0
|
||||||
|
assert progress["completed_runs"] == 0
|
||||||
|
assert progress["current_offset_seconds"] == 0.0
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user