""" Agent 写工具持久认领、重启防重和会话清理边界测试。 """ from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone from hashlib import sha256 from threading import Barrier import pytest from sqlalchemy import create_engine, select from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.orm import sessionmaker from app.application.invocation import InvocationConflictError, InvocationIdentity from app.application.maintenance import CleanupPolicy, DataCleanupService from app.db.adapters.invocation import TransactionalInvocationRepository from app.db.maintenance import DatabaseCleanupRepository from app.db.models.agentchat import AgentChat from app.db.models.agentinvocation import AgentInvocation from app.db.models.agenttask import AgentTask from app.db.oper.agentchat import AgentChatOper DIGEST = sha256(b'{"operation_id":"downloads.add"}').hexdigest() IDENTITY = InvocationIdentity("user-1", "chat-1", "call-1") @pytest.fixture def invocation_store(tmp_path): """ 用真实独立 SQLite 文件覆盖跨线程和重建适配器的持久行为。 """ path = tmp_path / "invocations.db" engine = create_engine(f"sqlite:///{path}", connect_args={"timeout": 20}) for model in (AgentInvocation, AgentChat, AgentTask): model.__table__.create(engine) factory = sessionmaker(bind=engine) yield TransactionalInvocationRepository(factory), factory, path engine.dispose() def _claim(store, identity=IDENTITY): """ 为同一个宿主写工具生成稳定输入指纹。 """ return store.claim(identity, tool_name="moviepilot_api", arguments_digest=DIGEST) def test_concurrent_claim_grants_exactly_one_execution(invocation_store): """ 多个线程同时竞争同一身份时只有一个调用可以执行外部写入。 """ store, _factory, _path = invocation_store barrier = Barrier(8) def compete(): """ 使多个数据库连接同时尝试插入唯一身份。 """ barrier.wait(timeout=10) return _claim(store) with ThreadPoolExecutor(max_workers=8) as pool: claims = list(pool.map(lambda _index: compete(), range(8))) assert sum(claim.acquired for claim in claims) == 1 assert len({claim.record.claim_token for claim in claims}) == 1 assert all(claim.record.status == "running" for claim in claims) def test_identity_and_argument_conflicts_are_isolated(invocation_store): """ 同一调用不能换参数或工具,不同用户和会话可使用相同调用 ID。 """ store, _factory, _path = invocation_store assert _claim(store).acquired assert not _claim(store).acquired with pytest.raises(InvocationConflictError): store.claim(IDENTITY, tool_name="moviepilot_api", arguments_digest="a" * 64) with pytest.raises(InvocationConflictError): store.claim(IDENTITY, tool_name="another_tool", arguments_digest=DIGEST) assert _claim(store, InvocationIdentity("user-2", "chat-1", "call-1")).acquired assert _claim(store, InvocationIdentity("user-1", "chat-2", "call-1")).acquired assert store.get(InvocationIdentity("unknown", "chat-1", "call-1")) is None def test_restart_preserves_unknown_and_fences_previous_owner(invocation_store): """ 重启不因时间过期重放写入,旧 owner 也不能收口新核验状态。 """ store, factory, _path = invocation_store original = _claim(store) restarted = TransactionalInvocationRepository(factory) assert not _claim(restarted).acquired assert restarted.recover_running() == 1 unknown = restarted.get(IDENTITY) assert unknown.status == "unknown" assert unknown.claim_token != original.record.claim_token assert restarted.recover_running() == 0 assert not _claim(restarted).acquired assert not store.finish( IDENTITY, claim_token=original.record.claim_token, status="succeeded", ) assert restarted.finish(IDENTITY, claim_token=unknown.claim_token, status="succeeded") assert restarted.get(IDENTITY).status == "succeeded" assert not _claim(restarted).acquired assert not restarted.finish(IDENTITY, claim_token=unknown.claim_token, status="failed") def test_unknown_can_be_verified_but_never_reclaimed(invocation_store): """ 执行超时只产生未知回执,核验成功前后均不能再次认领。 """ store, _factory, _path = invocation_store claim = _claim(store) assert store.finish(IDENTITY, claim_token=claim.record.claim_token, status="unknown") assert not _claim(store).acquired assert store.finish(IDENTITY, claim_token=claim.record.claim_token, status="failed") assert not _claim(store).acquired def test_pending_submission_survives_restart_without_blocking_new_intent(invocation_store): """ 已确认提交是历史回执,重启不改未知,同身份仍防重但新意图不受阻。 """ store, factory, _path = invocation_store original = _claim(store) assert store.finish(IDENTITY, claim_token=original.record.claim_token, status="pending") restarted = TransactionalInvocationRepository(factory) assert restarted.recover_running() == 0 record = restarted.get(IDENTITY) assert record.status == "pending" assert record.claim_token == original.record.claim_token assert record.summary == "写操作已确认提交,后续任务完成情况尚未观测" assert not _claim(restarted).acquired assert restarted.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) is None assert _claim(restarted, InvocationIdentity("user-1", "chat-1", "new-intent")).acquired def test_find_unresolved_respects_scope_and_ignores_terminal_receipts(invocation_store): """ 新一轮请求能查到最近旧副作用,但已收口和其他 owner 不能误阻塞。 """ store, _factory, _path = invocation_store first = _claim(store) later_identity = InvocationIdentity("user-1", "chat-1", "later-call") later = _claim(store, later_identity) store.finish(later_identity, claim_token=later.record.claim_token, status="unknown") other_identity = InvocationIdentity("user-2", "chat-1", "other-call") _claim(store, other_identity) found = store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) assert found.identity == later_identity assert found.status == "unknown" assert store.find_unresolved("user-1", "chat-2", tool_name="moviepilot_api", arguments_digest=DIGEST) is None assert store.find_unresolved("user-1", "chat-1", tool_name="another_tool", arguments_digest=DIGEST) is None assert store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest="b" * 64) is None store.finish(later_identity, claim_token=later.record.claim_token, status="succeeded") found = store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) assert found.identity == IDENTITY store.finish(IDENTITY, claim_token=first.record.claim_token, status="failed") assert store.find_unresolved("user-1", "chat-1", tool_name="moviepilot_api", arguments_digest=DIGEST) is None def test_claim_rollback_does_not_grant_execution(invocation_store, monkeypatch): """ 认领事务未提交时不能返回 acquired,失败事务不会残留回执。 """ store, _factory, _path = invocation_store def fail_commit(_self): """ 模拟落盘失败,使适配器退出时回滚 Session。 """ raise RuntimeError("commit failed") monkeypatch.setattr("app.db.adapters.invocation.SqlAlchemyUnitOfWork.commit", fail_commit) with pytest.raises(RuntimeError, match="commit failed"): _claim(store) assert store.get(IDENTITY) is None @pytest.mark.parametrize("identity", [ InvocationIdentity("", "chat-1", "call-1"), InvocationIdentity("user-1", "", "call-1"), InvocationIdentity("user-1", "chat-1", "x" * 256), ]) def test_missing_or_unbounded_identity_is_rejected(invocation_store, identity): """ 不允许匿名共享身份和无界工具调用 ID 落盘。 """ store, _factory, _path = invocation_store with pytest.raises(ValueError): _claim(store, identity) def test_only_digest_and_fixed_host_summary_are_persisted(invocation_store): """ 凭据原文无法通过参数指纹或自由结果摘要进入调用回执。 """ store, factory, _path = invocation_store with pytest.raises(ValueError, match="SHA-256"): store.claim(IDENTITY, tool_name="moviepilot_api", arguments_digest="password=super-secret") claim = _claim(store) store.finish(IDENTITY, claim_token=claim.record.claim_token, status="unknown") with factory() as session: record = session.execute(select(AgentInvocation)).scalar_one() assert record.arguments_digest == DIGEST assert len(record.summary) <= 128 assert record.summary == "写操作结果未知,必须核验状态后再决定下一步" assert "super-secret" not in str(record.to_dict()) def _save_chat(factory, session_id="chat-1"): """ 创建可由生命周期清理的旧会话。 """ with factory() as session: chat = AgentChat( user_id="user-1", session_id=session_id, title="test", created_at="2020-01-01 00:00:00", updated_at="2020-01-01 00:00:00", ) session.add(chat) session.commit() return chat.id @pytest.mark.parametrize("receipt_status", ["succeeded", "failed", "pending"]) def test_explicit_chat_delete_removes_only_confirmed_receipts(invocation_store, receipt_status): """ 用户删除会话可清理终态和已提交回执,未知状态仍保留恢复依据。 """ store, factory, _path = invocation_store chat_id = _save_chat(factory) terminal = _claim(store) store.finish(IDENTITY, claim_token=terminal.record.claim_token, status=receipt_status) unresolved_identity = InvocationIdentity("user-1", "chat-1", "call-unknown") unresolved = _claim(store, unresolved_identity) store.finish(unresolved_identity, claim_token=unresolved.record.claim_token, status="unknown") with factory.begin() as session: AgentChatOper(session).delete_by_id(chat_id) assert store.get(IDENTITY) is None assert store.get(unresolved_identity).status == "unknown" @pytest.mark.asyncio async def test_async_chat_deletion_shares_receipt_transaction(invocation_store): """ 异步请求删除与回执回收共用事务,回滚时两者均还原。 """ store, factory, path = invocation_store _save_chat(factory) claim = _claim(store) store.finish(IDENTITY, claim_token=claim.record.claim_token, status="failed") engine = create_async_engine(f"sqlite+aiosqlite:///{path}") async_factory = async_sessionmaker(engine) try: async with async_factory() as session: assert await AgentChatOper(session).async_stage_delete("chat-1", "user-1") await session.rollback() assert store.get(IDENTITY) is not None async with async_factory.begin() as session: assert await AgentChatOper(session).async_delete("chat-1", "user-1") assert store.get(IDENTITY) is None finally: await engine.dispose() def test_retention_cleanup_preserves_recovery_chats_and_receipts(invocation_store): """ 共享保留期回收已确认回执会话,未知和运行中记录不随年龄回收。 """ store, factory, _path = invocation_store for name, status in (("done", "succeeded"), ("submitted", "pending"), ("unknown", "unknown"), ("running", "running")): _save_chat(factory, name) identity = InvocationIdentity("user-1", name, "call-1") claim = _claim(store, identity) if status != "running": store.finish(identity, claim_token=claim.record.claim_token, status=status) with factory.begin() as session: assert DatabaseCleanupRepository.delete_agent_chats(session, "2021-01-01", 10) == 2 with factory() as session: assert set(session.execute(select(AgentChat.session_id)).scalars()) == {"unknown", "running"} assert set(session.execute(select(AgentInvocation.status)).scalars()) == {"unknown", "running"} @pytest.mark.parametrize("enabled,retention_days,expected_deleted", [ (True, 30, 3), (True, 0, 0), (False, 30, 0), ]) def test_orphan_confirmed_receipts_follow_shared_retention( invocation_store, enabled, retention_days, expected_deleted, ): """ 后台已确认提交和终态按会话保留期清理,恢复态、近期记录和禁用开关受保护。 """ store, factory, _path = invocation_store old = "2026-06-01T12:00:00+00:00" recent = "2026-08-20T12:00:00+00:00" for name, status, timestamp in ( ("old-success", "succeeded", old), ("old-failure", "failed", old), ("old-pending", "pending", old), ("old-unknown", "unknown", old), ("old-running", "running", old), ("recent-success", "succeeded", recent), ("recent-pending", "pending", recent), ("associated-success", "succeeded", old), ): identity = InvocationIdentity("user-1", name, "call-1") claim = _claim(store, identity) if status != "running": store.finish(identity, claim_token=claim.record.claim_token, status=status) with factory.begin() as session: record = session.execute(select(AgentInvocation).where( AgentInvocation.session_id == name, )).scalar_one() record.created_at = record.updated_at = timestamp with factory.begin() as session: session.add(AgentChat( user_id="user-1", session_id="associated-success", title="test", created_at="2026-06-01 12:00:00", updated_at="2026-08-20 12:00:00", )) policy = CleanupPolicy( enabled=enabled, agent_chat_days=retention_days, message_days=0, download_history_days=0, site_userdata_days=0, transfer_history_days=0, download_failure_days=0, subscribe_history_days=0, agent_task_run_days=0, outbox_completed_days=0, outbox_dead_days=0, ) report = DataCleanupService( repository=DatabaseCleanupRepository(session_factory=factory), policy_reader=lambda: policy, clock=lambda: datetime(2026, 8, 26, 12, 0, tzinfo=timezone.utc), ).execute(batch_size=1) assert report["total_deleted"] == expected_deleted if enabled: assert report["tables"]["agentinvocation"]["deleted"] == expected_deleted assert report["tables"]["agentinvocation"]["batches"] == expected_deleted if retention_days == 0: assert report["tables"]["agentinvocation"]["skipped"] is True with factory() as session: remaining = set(session.execute(select(AgentInvocation.session_id)).scalars()) assert {"old-unknown", "old-running", "recent-success", "recent-pending", "associated-success"} <= remaining assert len(remaining) == 8 - expected_deleted