AgentEvalTool/tests/unit/test_llm_task_lifecycle.py
sinohqb 7eae6de52d refactor(evaluation/storage): 结算统一与 repository 拆分(Phase 2 + 3)
合并两个不可分割的深化:

Phase 2 — 智能作业结算统一(ADR-0012)
- intelligence_jobs.execute(job_kind, campaign_id, ...) 作为结算的
  唯一实现:建行 → 认领 → 校验 → generating → 落账,一处编排、
  一处截断(500 字符)。两个 executor 退化为 ensure_queued /
  validate / work_fn 三个小 adapter。
- analysis.validate_analysis_request() 共享校验入口(活动终态 →
  模型),路由捕获映射 400、executor 捕获落 failed 行,与
  validate_comparison_request 先例同构。
- campaign_runner._auto_start_analysis 的跳过守卫收敛至
  auto_intelligence_eligible 单一判断点。
- comparison.py 删除零调用的 build_comparison_payload;
  load_comparison_view 投影归位至 campaign_read_model。
- 新增 characterization 测试(认领竞争、重复触发、截断、恢复上限)。

Phase 3 — storage/repository.py 拆分
- AsyncJobRepository 及两个子类迁至
  storage/async_job_repository.py(Phase 2 的 intelligence_jobs
  与 comparison 必须 import 自该路径,故与 Phase 2 同 commit)。
- ExplorationSession / ExplorationMessage 迁至
  storage/exploration_repository.py;repository.py 由 1180 行降至
  约 814 行,grep 确认无残留符号。
- exploration 子模块与路由 import 全部更新;测试 import 跟随。

刻意不做:CAS 共享原语、app.py 五 registry 关停顺序归一
(ADR-0006 精神,等真实需求出现再议)。
2026-08-24 05:50:27 +08:00

295 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""LLM 后台任务生命周期直测(架构保养第二轮候选 1
分析 / 对比 / judge 复核三条 LLM 任务链收进各自的 TaskRegistry
强引用防 GC、按 id 幂等、shutdown 统一收敛;启动清扫把滞留的
generating 行标记为 failed。
"""
import asyncio
import pytest
from agenteval.evaluation import intelligence_jobs
from agenteval.exploration import judge
from agenteval.storage.async_job_repository import (
AsyncJobClaimStatus,
CampaignAnalysisRepository,
CampaignPeriodComparisonRepository,
)
from sqlmodel import Session, SQLModel, create_engine
@pytest.fixture()
def db_session(tmp_path):
from agenteval.storage.db import ( # noqa: F401
CampaignAnalysisDB,
CampaignDB,
CampaignPeriodComparisonDB,
)
engine = create_engine(
f"sqlite:///{tmp_path / 'llmtasks.db'}",
connect_args={"check_same_thread": False},
)
SQLModel.metadata.create_all(engine)
session = Session(engine)
try:
yield session
finally:
session.close()
engine.dispose()
async def test_start_analysis_registers_in_registry(db_session, monkeypatch):
monkeypatch.setattr(intelligence_jobs, "execute_campaign_analysis_job", lambda *a, **kw: asyncio.sleep(0))
intelligence_jobs.enqueue_campaign_analysis("c-1", triggered_by="manual", session=db_session)
assert intelligence_jobs.is_intelligence_job_running("analysis", "c-1")
await intelligence_jobs.shutdown_campaign_intelligence_jobs()
assert not intelligence_jobs.is_intelligence_job_running("analysis", "c-1")
async def test_start_comparison_registers_in_registry(db_session, monkeypatch):
monkeypatch.setattr(intelligence_jobs, "execute_campaign_comparison_job", lambda *a, **kw: asyncio.sleep(0))
intelligence_jobs.enqueue_campaign_comparison(
"c-2",
triggered_by="manual",
baseline_campaign_id="c-1",
session=db_session,
)
assert intelligence_jobs.is_intelligence_job_running("comparison", "c-2")
await intelligence_jobs.shutdown_campaign_intelligence_jobs()
async def test_start_judge_registers_in_registry(monkeypatch):
monkeypatch.setattr(judge, "execute_judge_review", lambda *a, **kw: asyncio.sleep(0))
judge.start_judge_review("s-1")
assert judge.judge_registry.is_running("s-1")
await judge.judge_registry.shutdown_all()
async def test_launch_is_idempotent_for_live_id(db_session, monkeypatch):
gate = asyncio.Event()
async def hang(*args, **kwargs):
await gate.wait()
monkeypatch.setattr(intelligence_jobs, "execute_campaign_analysis_job", hang)
intelligence_jobs.enqueue_campaign_analysis("c-dup", triggered_by="manual", session=db_session)
intelligence_jobs.enqueue_campaign_analysis("c-dup", triggered_by="manual", session=db_session)
assert intelligence_jobs.is_intelligence_job_running("analysis", "c-dup")
gate.set()
await intelligence_jobs.shutdown_campaign_intelligence_jobs()
async def test_shutdown_all_cancels_hanging_task(db_session, monkeypatch):
async def hang(*args, **kwargs):
await asyncio.Event().wait()
monkeypatch.setattr(intelligence_jobs, "execute_campaign_comparison_job", hang)
intelligence_jobs.enqueue_campaign_comparison(
"c-hang",
triggered_by="manual",
baseline_campaign_id="c-base",
session=db_session,
)
assert intelligence_jobs.is_intelligence_job_running("comparison", "c-hang")
await intelligence_jobs.shutdown_campaign_intelligence_jobs()
assert not intelligence_jobs.is_intelligence_job_running("comparison", "c-hang")
def test_mark_orphans_failed_flips_generating_analysis(db_session):
repo = CampaignAnalysisRepository(db_session)
repo.upsert("c-gen", status="generating", triggered_by="auto")
repo.upsert("c-done", status="completed", result={"ok": True}, triggered_by="auto")
count = repo.mark_orphans_failed()
assert count == 1
assert repo.get_by_campaign("c-gen").status == "failed"
assert repo.get_by_campaign("c-gen").error
assert repo.get_by_campaign("c-done").status == "completed"
def test_enqueue_analysis_persists_before_launch(db_session, monkeypatch):
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_analysis",
lambda cid, *, triggered_by: launched.append((cid, triggered_by)),
)
intelligence_jobs.enqueue_campaign_analysis("c-queued", triggered_by="auto", session=db_session)
assert launched == [("c-queued", "auto")]
row = CampaignAnalysisRepository(db_session).get_by_campaign("c-queued")
assert row.status == "queued"
assert row.triggered_by == "auto"
def test_only_one_session_can_claim_a_queued_analysis(db_session):
repo = CampaignAnalysisRepository(db_session)
repo.enqueue("c-claim", triggered_by="auto")
competing_session = Session(db_session.get_bind())
try:
assert repo.claim_queued("c-claim").status is AsyncJobClaimStatus.CLAIMED
assert (
CampaignAnalysisRepository(competing_session).claim_queued("c-claim").status
is AsyncJobClaimStatus.ALREADY_CLAIMED
)
finally:
competing_session.close()
row = repo.get_by_campaign("c-claim")
assert row.status == "generating"
assert row.triggered_by == "auto"
def test_recovery_relaunches_persisted_analysis_jobs(db_session, monkeypatch):
repo = CampaignAnalysisRepository(db_session)
repo.enqueue("c-queued", triggered_by="auto")
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_analysis",
lambda cid, *, triggered_by: launched.append((cid, triggered_by)),
)
assert intelligence_jobs.recover_campaign_intelligence_jobs(db_session) == (0, 1)
assert launched == [("c-queued", "auto")]
def test_mark_orphans_failed_flips_generating_comparison(db_session):
repo = CampaignPeriodComparisonRepository(db_session)
repo.upsert("c-gen", status="generating", baseline_campaign_id="b-1", triggered_by="auto")
repo.upsert("c-done", status="completed", baseline_campaign_id="b-2", result={"ok": True}, triggered_by="auto")
count = repo.mark_orphans_failed()
assert count == 1
assert repo.get_by_campaign("c-gen").status == "failed"
assert repo.get_by_campaign("c-gen").error
assert repo.get_by_campaign("c-done").status == "completed"
def test_enqueue_comparison_preserves_baseline_before_claim(db_session):
repo = CampaignPeriodComparisonRepository(db_session)
row = repo.enqueue(
"c-current",
baseline_campaign_id="c-baseline",
triggered_by="auto",
)
assert row.status == "queued"
assert row.baseline_campaign_id == "c-baseline"
assert row.triggered_by == "auto"
assert repo.claim_queued("c-current").status is AsyncJobClaimStatus.CLAIMED
assert repo.claim_queued("c-current").status is AsyncJobClaimStatus.ALREADY_CLAIMED
def test_enqueue_comparison_persists_before_launch(db_session, monkeypatch):
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_comparison",
lambda cid, *, triggered_by, baseline_campaign_id: launched.append(
(cid, triggered_by, baseline_campaign_id)
),
)
intelligence_jobs.enqueue_campaign_comparison(
"c-current",
baseline_campaign_id="c-baseline",
triggered_by="auto",
session=db_session,
)
assert launched == [("c-current", "auto", "c-baseline")]
row = CampaignPeriodComparisonRepository(db_session).get_by_campaign("c-current")
assert row.status == "queued"
assert row.baseline_campaign_id == "c-baseline"
def test_recovery_relaunches_persisted_comparison_jobs(db_session, monkeypatch):
CampaignPeriodComparisonRepository(db_session).enqueue(
"c-current",
baseline_campaign_id="c-baseline",
triggered_by="auto",
)
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_comparison",
lambda cid, *, triggered_by, baseline_campaign_id: launched.append(
(cid, triggered_by, baseline_campaign_id)
),
)
assert intelligence_jobs.recover_campaign_intelligence_jobs(db_session) == (0, 1)
assert launched == [("c-current", "auto", "c-baseline")]
def test_claim_reports_missing_and_terminal_jobs(db_session):
repo = CampaignAnalysisRepository(db_session)
assert repo.claim_queued("missing").status is AsyncJobClaimStatus.NOT_FOUND
repo.upsert("c-done", status="completed", result={"ok": True})
assert repo.claim_queued("c-done").status is AsyncJobClaimStatus.NOT_QUEUED
def test_reenqueue_does_not_move_generating_analysis_back_to_queued(db_session, monkeypatch):
repo = CampaignAnalysisRepository(db_session)
repo.upsert("c-live", status="generating", triggered_by="auto")
launched = []
monkeypatch.setattr(
intelligence_jobs,
"_launch_analysis",
lambda cid, *, triggered_by: launched.append((cid, triggered_by)),
)
intelligence_jobs.enqueue_campaign_analysis("c-live", triggered_by="manual", session=db_session)
row = repo.get_by_campaign("c-live")
assert row.status == "generating"
assert row.triggered_by == "auto"
assert launched == []
def test_reenqueue_preserves_queued_comparison_context(db_session):
repo = CampaignPeriodComparisonRepository(db_session)
repo.enqueue("c-current", baseline_campaign_id="c-original", triggered_by="auto")
row = repo.enqueue("c-current", baseline_campaign_id="c-replacement", triggered_by="manual")
assert row.status == "queued"
assert row.baseline_campaign_id == "c-original"
assert row.triggered_by == "auto"
@pytest.mark.parametrize(
("repository", "enqueue_kwargs"),
[
(CampaignAnalysisRepository, {}),
(CampaignPeriodComparisonRepository, {"baseline_campaign_id": "c-base"}),
],
)
def test_queued_recovery_is_bounded(db_session, repository, enqueue_kwargs):
repo = repository(db_session)
repo.enqueue("c-recover", triggered_by="auto", **enqueue_kwargs)
for expected_attempts in range(1, intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS + 1):
recovered = repo.prepare_queued_recovery(
intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS,
"recovery exhausted",
)
assert [row.campaign_id for row in recovered] == ["c-recover"]
assert repo.get_by_campaign("c-recover").recovery_attempts == expected_attempts
assert repo.prepare_queued_recovery(
intelligence_jobs.MAX_QUEUED_RECOVERY_ATTEMPTS,
"recovery exhausted",
) == []
exhausted = repo.get_by_campaign("c-recover")
assert exhausted.status == "failed"
assert exhausted.error == "recovery exhausted"