fix: improve cross-device evidence and exploration lifecycle
Align Responses cache affinity across execution and summary calls, preserve required checks and evidence uncertainty, and allow safe deletion of inactive explorations while retaining independent reruns. Validation: backend suite passed except intentional prompt golden drift, then all 17 transcript tests passed after snapshot refresh; 268 frontend tests and production build passed; Ruff, quality ratchet and core type checks passed.
This commit is contained in:
1 parent
f1bb5d02aa
commit
2328a4df0c
32 files changed
+748
-79
No files matched your search
@@ -0,0 +1,166 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.routers import sessions
|
||||
from apps.admin_console.services import exploration_deletion as deletion
|
||||
from apps.admin_console.services import report_export
|
||||
from apps.admin_console.services.automation_store import AutomationStore
|
||||
from artemis.config.exploration import ExplorationConfig
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from artemis.exploration import store as store_module
|
||||
from artemis.exploration.models import CaseDraft
|
||||
from artemis.exploration.store import ExplorationConflict, ExplorationStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def setup(tmp_path, monkeypatch):
|
||||
db = tmp_path / "data.db"
|
||||
traces = tmp_path / "traces"
|
||||
storage = StorageManager(db, traces)
|
||||
repository = ExplorationStore(AutomationStore(db))
|
||||
monkeypatch.setattr(store_module, "explorations", repository)
|
||||
monkeypatch.setattr(report_export, "TRACES_PATH", traces)
|
||||
monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports")
|
||||
monkeypatch.setattr(deletion, "state", SimpleNamespace(queue_items=[], active_runs={}))
|
||||
monkeypatch.setattr(deletion, "read_state", lambda _: {})
|
||||
app = FastAPI()
|
||||
app.include_router(sessions.router)
|
||||
session = repository.create("探索页面", ExplorationConfig(), {}, "create")
|
||||
return repository, session["id"], storage, TestClient(app)
|
||||
|
||||
|
||||
def make_run(repository, sid, request, *, kind="exploration", finished=True):
|
||||
run = repository.reserve_run(
|
||||
sid,
|
||||
request,
|
||||
kind=kind,
|
||||
goal="观察",
|
||||
task_config={},
|
||||
case_version=1 if kind == "validation" else None,
|
||||
)
|
||||
if finished:
|
||||
repository.finish_run(run["id"], {"status": "cancelled"})
|
||||
return run["id"]
|
||||
|
||||
|
||||
def test_delete_conversation_removes_all_exploration_runs_but_keeps_reruns(setup):
|
||||
repository, sid, storage, client = setup
|
||||
first = make_run(repository, sid, "first")
|
||||
second = make_run(repository, sid, "continue")
|
||||
repository.save_case(sid, CaseDraft(name="可复用用例", goal="检查页面"), 0, "case")
|
||||
validation = make_run(repository, sid, "validate", kind="validation")
|
||||
unrelated = repository.create("另一探索", ExplorationConfig(), {}, "other")["id"]
|
||||
directories = []
|
||||
for run_id in (first, second, validation):
|
||||
repository.database.save_job({"session_id": run_id, "status": "cancelled", "position": 1})
|
||||
with repository.database.connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO sessions (session_id, initial_goal, start_time, status, device_info) VALUES (?, ?, 1, ?, '{}')",
|
||||
(run_id, "观察", "cancelled"),
|
||||
)
|
||||
connection.commit()
|
||||
for root in (storage.base_trace_dir, report_export.EXPORT_ROOT / "sessions"):
|
||||
folder = root / run_id
|
||||
folder.mkdir(parents=True)
|
||||
(folder / "evidence.txt").write_text("saved", encoding="utf-8")
|
||||
if run_id != validation:
|
||||
directories.append(folder)
|
||||
with repository._transaction() as connection:
|
||||
session = repository._require(connection, sid)
|
||||
session["status"] = "paused" # Resumable conversation, no running process.
|
||||
repository._save_session(connection, session)
|
||||
response = client.post(f"/api/sessions/{first}/delete")
|
||||
assert response.status_code == 200, response.text
|
||||
assert set(response.json()["deleted_session_ids"]) == {first, second}
|
||||
assert not any(folder.exists() for folder in directories)
|
||||
assert (storage.base_trace_dir / validation / "evidence.txt").exists()
|
||||
assert repository.database.job(validation) is not None
|
||||
assert repository.database.job(first) is None
|
||||
assert storage.get_session(UUID(validation)) is not None
|
||||
assert storage.get_session(UUID(first)) is None
|
||||
assert repository.get(unrelated)["goal"] == "另一探索"
|
||||
assert repository.run(validation) is None # Detach it from the deleted conversation.
|
||||
deleted_requests = [
|
||||
record
|
||||
for record in repository.database.records("exploration_request")
|
||||
if record.get("deleted")
|
||||
]
|
||||
assert len(deleted_requests) >= 5
|
||||
assert all("result" not in record for record in deleted_requests)
|
||||
with repository.database.connection() as connection:
|
||||
assert not connection.execute(
|
||||
"SELECT 1 FROM automation_records WHERE kind != 'exploration_request' "
|
||||
"AND json_extract(payload,'$.exploration_id')=?",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
with pytest.raises(KeyError):
|
||||
repository.get(sid)
|
||||
with pytest.raises(ExplorationConflict, match="已删除"):
|
||||
repository.create("探索页面", ExplorationConfig(), {}, "create")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["running", "paused", "waiting_user", "demonstrating"])
|
||||
def test_live_execution_cannot_be_deleted_even_through_an_old_run(setup, state):
|
||||
repository, sid, _, client = setup
|
||||
first = make_run(repository, sid, "first")
|
||||
active = make_run(repository, sid, "continue", finished=False)
|
||||
repository.set_run_state(active, state)
|
||||
response = client.post(f"/api/sessions/{first}/delete")
|
||||
assert response.status_code == 409
|
||||
assert repository.get(sid)["active_run_id"] == active
|
||||
assert repository.run(first) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["exploration_generation", "exploration_discussion"])
|
||||
def test_editorial_work_prevents_deletion(setup, kind):
|
||||
repository, sid, _, client = setup
|
||||
run_id = make_run(repository, sid, "first")
|
||||
repository.database.put(kind, "job", {"exploration_id": sid, "status": "running"})
|
||||
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
|
||||
assert repository.get(sid)["status"] == "cancelled"
|
||||
|
||||
|
||||
def test_worker_still_exiting_prevents_deletion(setup, monkeypatch):
|
||||
repository, sid, _, client = setup
|
||||
run_id = make_run(repository, sid, "first")
|
||||
monkeypatch.setattr(
|
||||
deletion, "state", SimpleNamespace(queue_items=[], active_runs={run_id: {}})
|
||||
)
|
||||
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 409
|
||||
assert repository.run(run_id) is not None
|
||||
|
||||
|
||||
def test_cleanup_failure_is_retryable_and_blocks_new_work(setup, monkeypatch):
|
||||
repository, sid, storage, client = setup
|
||||
run_id = make_run(repository, sid, "first")
|
||||
original = StorageManager.delete_session
|
||||
|
||||
def fail(*args):
|
||||
raise OSError("file busy")
|
||||
|
||||
monkeypatch.setattr(StorageManager, "delete_session", fail)
|
||||
response = client.post(f"/api/sessions/{run_id}/delete")
|
||||
assert response.status_code == 500
|
||||
with pytest.raises(ExplorationConflict, match="正在删除"):
|
||||
make_run(repository, sid, "restart")
|
||||
monkeypatch.setattr(StorageManager, "delete_session", original)
|
||||
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
|
||||
assert not repository.list()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delayed_discussion_cannot_recreate_deleted_metadata(setup):
|
||||
from artemis.exploration.editorial import discuss
|
||||
|
||||
repository, sid, _, client = setup
|
||||
run_id = make_run(repository, sid, "first")
|
||||
message = repository.add_message(sid, "不再继续", "message")
|
||||
assert client.post(f"/api/sessions/{run_id}/delete").status_code == 200
|
||||
with pytest.raises(KeyError):
|
||||
await discuss(repository, sid, message)
|
||||
assert not repository.database.records("exploration_discussion")
|
||||
Reference in new issue
Block a user