import os import time import subprocess import sys from unittest.mock import AsyncMock, Mock import pytest from apps.admin_console.services.automation_store import AutomationStore from apps.admin_console.services.exploration_recovery import reconcile_explorations from artemis.config.exploration import ExplorationConfig from artemis.exploration.editorial import DiscussionReply, discuss from artemis.exploration.store import ExplorationStore from artemis.runtime import task_control def test_worker_can_import_exploration_without_importing_web_server(): result = subprocess.run( [ sys.executable, "-c", "import sys; from artemis.exploration.runtime import ExplorationControl; assert 'apps.admin_console.server' not in sys.modules", ], capture_output=True, text=True, encoding="utf-8", timeout=30, ) assert result.returncode == 0, result.stderr def test_web_and_exploration_share_the_same_queue_state(): code = "from apps.admin_console import server; from apps.admin_console.services import task_queue_service, exploration_recovery; from apps.admin_console.routers import tasks; assert server.state is task_queue_service.state is exploration_recovery.state is tasks.state" result = subprocess.run( [sys.executable, "-c", code], capture_output=True, text=True, encoding="utf-8", timeout=30 ) assert result.returncode == 0, result.stderr @pytest.fixture def records(tmp_path, monkeypatch): monkeypatch.setattr(task_control, "TRACES_PATH", tmp_path / "traces") repo = ExplorationStore(AutomationStore(tmp_path / "history.db")) session = repo.create("探索设置", ExplorationConfig(), {}, "create") return repo, session def test_recovery_preserves_live_paused_owner_and_real_verdict(records, monkeypatch): repo, session = records run = repo.reserve_run(session["id"], "start", kind="exploration", goal="设置", task_config={}) repo.set_run_state(run["id"], "paused", reason="execution_budget") monkeypatch.setattr( "apps.admin_console.services.exploration_recovery.read_state", lambda _: {"pid": os.getpid()}, ) reconcile_explorations(repo) assert repo.get(session["id"])["status"] == "paused" monkeypatch.setattr( "apps.admin_console.services.exploration_recovery.read_worker_outcome", lambda _: { "task_status": "completed", "test_result": {"status": "passed", "reason": "有证据"}, }, ) reconcile_explorations(repo) assert repo.get(session["id"])["active_run_id"] is None assert repo.run(run["id"])["outcome"]["test_result"]["status"] == "passed" def test_orphan_editorial_jobs_become_retryable_without_starting_tasks(records): repo, session = records for kind in ("exploration_generation", "exploration_discussion"): repo.database.put( kind, "dead", { "id": "dead", "status": "running", "owner_pid": 0, "lease_until": time.time() + 999, "exploration_id": session["id"], }, ) reconcile_explorations(repo) assert repo.runs(session["id"]) == [] assert repo.database.records("exploration_generation")[0]["status"] == "failed" assert repo.database.records("exploration_discussion")[0]["status"] == "failed" def test_validation_status_follows_actual_worker_without_creating_outcome(records, monkeypatch): from artemis.exploration.models import CaseDraft repo, session = records repo.save_case(session["id"], CaseDraft(name="设置", goal="检查设置"), 0, "save") run = repo.reserve_run( session["id"], "validate", kind="validation", goal="复跑", task_config={}, case_version=1 ) monkeypatch.setattr( "apps.admin_console.services.exploration_recovery.read_state", lambda _: {"pid": os.getpid(), "status": "running"}, ) reconcile_explorations(repo) assert repo.run(run["id"])["status"] == "running" assert repo.run(run["id"])["outcome"] is None @pytest.mark.asyncio async def test_completed_discussion_is_idempotent_and_never_admits_device_work( records, monkeypatch ): repo, session = records message = repo.add_message(session["id"], "为什么选这个入口?", "m") monkeypatch.setattr("artemis.exploration.editorial.editorial_context", Mock()) model = Mock() model.with_structured_output.return_value.ainvoke = AsyncMock( return_value=DiscussionReply(reply="已观察到的入口在右上角;可编辑提示词。") ) monkeypatch.setattr("artemis.exploration.editorial.get_llm", Mock(return_value=model)) await discuss(repo, session["id"], message) await discuss(repo, session["id"], message) assert len([m for m in repo.messages(session["id"]) if m["role"] == "assistant"]) == 1 assert repo.runs(session["id"]) == [] model.with_structured_output.return_value.ainvoke.assert_awaited_once()