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