Files

934 lines
30 KiB
Python

"""Cross-user roundtable step-2 room cap."""
from __future__ import annotations
import asyncio
from datetime import UTC, datetime, timedelta
from pathlib import Path
from types import SimpleNamespace
import pytest
import pytest_asyncio
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
import deerflow.persistence.roundtable_jobs.model # noqa: F401
import deerflow.persistence.roundtable_rooms.model # noqa: F401
from app.gateway import roundtable_room_gate
from app.gateway.routers import system_settings as ss_router
from app.gateway.routers.multi_agent import (
_is_step2_room_limited,
_run_payload,
_step3_thread_metadata,
)
from deerflow.config import system_settings as ss
from deerflow.persistence.base import Base
from deerflow.persistence.roundtable_jobs import RoundtableJobRepository
from deerflow.persistence.roundtable_jobs.model import RoundtableJobRow
from deerflow.persistence.roundtable_jobs.sql import compute_active_dedupe_key
from deerflow.persistence.roundtable_rooms.model import RoundtableRoomLeaseRow
from deerflow.persistence.roundtable_rooms.sql import (
RoundtableRoomRepository,
compute_room_session_key,
)
@pytest.fixture
def tmp_runtime_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
monkeypatch.setattr(ss, "runtime_home", lambda: tmp_path)
return tmp_path
def _patch_admin(monkeypatch: pytest.MonkeyPatch) -> None:
admin = SimpleNamespace(id="admin1", system_role="admin", email="admin@example.com")
async def _optional(_request):
return admin
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
@pytest_asyncio.fixture
async def rooms(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'rooms.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
factory = async_sessionmaker(engine, expire_on_commit=False)
try:
yield RoundtableRoomRepository(factory), factory
finally:
await engine.dispose()
async def _admit(repo: RoundtableRoomRepository, draft_id: str, user_id: str, **kwargs):
return await repo.admit(
draft_id=draft_id,
user_id=user_id,
task_id=kwargs.get("task_id"),
room_session_id=kwargs.get("room_session_id"),
enabled=kwargs.get("enabled", True),
max_rooms=kwargs.get("max_rooms", 3),
extra_keys=kwargs.get("extra_keys"),
)
def _room_key(draft_id: str, user_id: str) -> str:
key = compute_active_dedupe_key(draft_id, user_id, None)
assert key
return key
@pytest.mark.asyncio
async def test_opening_a_page_does_not_occupy_a_room(rooms) -> None:
repo, _factory = rooms
# Merely loading the page/history performs no acquire call.
for index in range(4):
assert await repo.summary() == 0, index
assert await repo.summary() == 0
@pytest.mark.asyncio
async def test_four_simultaneous_acquires_admit_exactly_three(rooms) -> None:
_repo, factory = rooms
# Separate repository objects simulate separate Gateway workers: the
# process-local asyncio locks cannot coordinate these contenders.
workers = [RoundtableRoomRepository(factory) for _ in range(4)]
results = await asyncio.gather(
*(
_admit(worker, f"concurrent-{index}", f"u-{index}")
for index, worker in enumerate(workers)
)
)
assert sum(1 for result in results if result["ok"]) == 3
rejected = [result for result in results if not result["ok"]]
assert len(rejected) == 1
assert rejected[0]["code"] == "ROUNDTABLE_ROOM_FULL"
assert rejected[0]["message"] == "当前会商房间暂时已满,请稍候片刻后再试。"
assert await workers[0].summary() == 3
@pytest.mark.asyncio
async def test_same_draft_in_four_live_pages_still_counts_as_four_rooms(rooms) -> None:
"""A saved draft is data identity, not live-execution identity."""
_repo, factory = rooms
workers = [RoundtableRoomRepository(factory) for _ in range(4)]
results = await asyncio.gather(
*(
_admit(
worker,
"same-draft",
"same-user",
room_session_id=f"browser-page-{index}",
)
for index, worker in enumerate(workers)
)
)
assert sum(1 for result in results if result["ok"]) == 3
assert sum(1 for result in results if not result["ok"]) == 1
assert await workers[0].summary() == 3
@pytest.mark.asyncio
async def test_same_live_room_session_renews_one_room_across_steps(rooms) -> None:
repo, _factory = rooms
first = await _admit(
repo,
"draft-a",
"user-a",
room_session_id="one-live-run",
)
renewed = await _admit(
repo,
"draft-a",
"user-a",
room_session_id="one-live-run",
)
assert first["ok"] is True
assert renewed["ok"] is True
assert renewed["renewed"] is True
assert await repo.summary() == 1
@pytest.mark.asyncio
async def test_heartbeat_only_renews_an_existing_live_lease(rooms) -> None:
repo, _factory = rooms
missing = await repo.renew(
draft_id="draft-a",
user_id="user-a",
task_id=None,
room_session_id="live-session",
enabled=True,
max_rooms=2,
)
assert missing["ok"] is False
assert missing["code"] == "ROOM_LEASE_EXPIRED"
assert await repo.summary() == 0
await _admit(
repo,
"draft-a",
"user-a",
room_session_id="live-session",
max_rooms=2,
)
renewed = await repo.renew(
draft_id="draft-a",
user_id="user-a",
task_id=None,
room_session_id="live-session",
enabled=True,
max_rooms=2,
)
assert renewed["ok"] is True
assert renewed["renewed"] is True
assert await repo.summary() == 1
@pytest.mark.asyncio
async def test_heartbeat_cannot_extend_the_configured_hard_room_deadline(rooms) -> None:
repo, factory = rooms
await _admit(
repo,
"draft-hard-limit",
"user-a",
room_session_id="hard-limit-session",
max_rooms=2,
)
key = compute_room_session_key("hard-limit-session")
assert key is not None
async with factory() as session:
row = await session.get(RoundtableRoomLeaseRow, key)
assert row is not None
row.created_at = datetime.now(UTC) - timedelta(minutes=31)
row.lease_until = datetime.now(UTC) + timedelta(minutes=1)
await session.commit()
decision = await repo.renew(
draft_id="draft-hard-limit",
user_id="user-a",
task_id=None,
room_session_id="hard-limit-session",
enabled=True,
max_rooms=2,
max_hold_minutes=30,
)
assert decision["ok"] is False
assert decision["code"] == "ROOM_MAX_HOLD_EXCEEDED"
assert await repo.summary(max_hold_minutes=30) == 0
@pytest.mark.asyncio
async def test_delayed_heartbeat_cannot_resurrect_a_released_room(rooms) -> None:
repo, _factory = rooms
await _admit(
repo,
"draft-disconnected",
"user-a",
room_session_id="disconnected-session",
max_rooms=1,
)
assert await repo.release(
draft_id="draft-disconnected",
user_id="user-a",
task_id=None,
room_session_id="disconnected-session",
)
delayed = await repo.renew(
draft_id="draft-disconnected",
user_id="user-a",
task_id=None,
room_session_id="disconnected-session",
enabled=True,
max_rooms=1,
)
assert delayed["ok"] is False
assert delayed["code"] == "ROOM_LEASE_EXPIRED"
assert await repo.summary() == 0
next_room = await _admit(
repo,
"draft-next",
"user-b",
room_session_id="next-session",
max_rooms=1,
)
assert next_room["ok"] is True
@pytest.mark.asyncio
async def test_stale_run_for_leased_draft_does_not_consume_a_second_room(rooms) -> None:
"""Run-manager fallback must not double-count the authoritative lease."""
repo, _factory = rooms
admitted = await _admit(
repo,
"same-draft",
"same-user",
room_session_id="current-page",
max_rooms=2,
)
assert admitted["ok"] is True
stale_key = compute_room_session_key("stale-previous-page")
scope_key = _room_key("same-draft", "same-user")
assert stale_key
class _Run:
run_id = "stale-run"
thread_id = "stale-thread"
metadata = {
"roundtable_room_key": stale_key,
"roundtable_room_session_id": "stale-previous-page",
"draft_id": "same-draft",
"user_id": "same-user",
}
class _Runs:
async def list_active(self):
return [_Run()]
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(run_manager=_Runs())),
)
extra = await roundtable_room_gate.backend_occupancy_keys(request)
assert extra == {stale_key: scope_key}
assert await repo.summary(extra_keys=extra) == 1
second = await _admit(
repo,
"same-draft",
"same-user",
room_session_id="second-real-page",
max_rooms=2,
extra_keys=extra,
)
assert second["ok"] is True
assert second["active_count"] == 2
blocked = await _admit(
repo,
"same-draft",
"same-user",
room_session_id="third-real-page",
max_rooms=2,
extra_keys=extra,
)
assert blocked["ok"] is False
assert blocked["active_count"] == 2
@pytest.mark.asyncio
async def test_fourth_backend_process_is_rejected_and_same_room_renews(rooms) -> None:
repo, _factory = rooms
extra = {_room_key(f"d{index}", f"u{index}") for index in range(3)}
assert await repo.summary(extra_keys=extra) == 3
blocked = await _admit(repo, "d4", "u4", extra_keys=extra)
assert blocked["ok"] is False
assert blocked["code"] == "ROUNDTABLE_ROOM_FULL"
assert blocked["active_count"] == 3
renewed = await _admit(repo, "d1", "u1", extra_keys=extra)
assert renewed["ok"] is True
assert renewed["renewed"] is True
assert await repo.summary(extra_keys=extra) == 3
@pytest.mark.asyncio
async def test_disabled_limit_accepts_more_than_three_backend_processes(rooms) -> None:
repo, _factory = rooms
extra = {_room_key(f"d{index}", f"u{index}") for index in range(4)}
decision = await _admit(repo, "d4", "u4", enabled=False, extra_keys=extra)
assert decision["ok"] is True
assert decision["active_count"] == 5
assert await repo.summary(extra_keys=extra) == 5
@pytest.mark.asyncio
async def test_executing_background_job_occupies_one_room(rooms) -> None:
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
await jobs.try_create_or_get_active(
draft_id="draft-live",
user_id="owner",
task_id=None,
id="job-live",
status="running",
)
assert await repo.summary() == 1
renewed = await _admit(repo, "draft-live", "owner")
assert renewed["ok"] is True
assert renewed["renewed"] is True
blocked = await _admit(repo, "draft-other", "someone-else", max_rooms=1)
assert blocked["ok"] is False
@pytest.mark.asyncio
async def test_background_job_releases_room_as_soon_as_step2_reaches_consensus(rooms) -> None:
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
await jobs.try_create_or_get_active(
draft_id="draft-consensus",
user_id="owner",
task_id=None,
id="job-consensus",
status="running",
)
async with factory() as session:
row = await session.get(RoundtableJobRow, "job-consensus")
assert row is not None
row.phase = "consensus"
await session.commit()
assert await repo.summary() == 0
admitted = await _admit(repo, "next-step2", "next-user", max_rooms=1)
assert admitted["ok"] is True
@pytest.mark.parametrize(
("agent_type", "agent_name", "expected"),
[
("leader", "roundtable-coordinator", True),
("special", "business-seat", True),
("special", "roundtable-summary", False),
("special", "roundtable-report", False),
("special", "roundtable-dashboard", False),
("special", "roundtable-structure", False),
("special", "position-action-planner", False),
],
)
def test_only_step2_runs_consume_room_capacity(
agent_type: str,
agent_name: str,
expected: bool,
) -> None:
assert _is_step2_room_limited(agent_type, agent_name) is expected
def test_final_coordinator_closeout_is_not_blocked_by_expired_room_lease() -> None:
assert _is_step2_room_limited(
"leader",
"roundtable-coordinator",
finalize_only=True,
) is False
def test_step3_threads_are_not_marked_as_step2_room_occupants() -> None:
metadata = _step3_thread_metadata()
assert metadata == {"thread_type": "roundtable", "system": True}
def test_realtime_roundtable_runs_cancel_when_the_sse_disconnects() -> None:
payload = _run_payload(
agent_name="roundtable-seat",
model_name="test-model",
thread_id="thread-1",
new_message="test",
excluded_tools=[],
skill_stop_names=[],
cancel_on_disconnect=True,
)
assert payload["on_disconnect"] == "cancel"
step3_payload = _run_payload(
agent_name="roundtable-summary",
model_name="test-model",
thread_id="thread-2",
new_message="test",
excluded_tools=[],
skill_stop_names=[],
)
assert step3_payload["on_disconnect"] == "continue"
@pytest.mark.asyncio
async def test_historical_awaiting_input_jobs_are_not_rooms(rooms) -> None:
"""查看聊天记录 / 停在待澄清的旧作业不能占满房间上限。"""
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
for index in range(5):
await jobs.try_create_or_get_active(
draft_id=None,
user_id=f"old-{index}",
task_id=None,
id=f"job-old-{index}",
status="awaiting_input",
)
stale = datetime.now(UTC) - timedelta(days=120)
async with factory() as session:
rows = (await session.execute(select(RoundtableJobRow))).scalars().all()
for row in rows:
row.status = "awaiting_input"
row.created_at = stale
row.updated_at = stale
row.lease_until = None
row.draft_id = None
await session.commit()
assert await repo.summary() == 0
admitted = await _admit(repo, "fresh", "u-new", max_rooms=3)
assert admitted["ok"] is True
@pytest.mark.asyncio
async def test_recent_awaiting_input_retains_its_room_but_stale_queued_does_not(rooms) -> None:
"""Clarification can resume without admission; stale queue residue is ignored."""
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
await jobs.try_create_or_get_active(
draft_id="draft-paused",
user_id="viewer",
task_id=None,
id="job-paused",
status="awaiting_input",
)
await jobs.try_create_or_get_active(
draft_id="draft-queued-old",
user_id="other",
task_id=None,
id="job-queued-old",
status="queued",
)
stale = datetime.now(UTC) - timedelta(days=30)
async with factory() as session:
queued = await session.get(RoundtableJobRow, "job-queued-old")
assert queued is not None
queued.updated_at = stale
queued.lease_until = None
await session.commit()
assert await repo.summary() == 1
admitted = await _admit(repo, "fresh-live", "u-live", max_rooms=1)
assert admitted["ok"] is False
assert admitted["code"] == "ROUNDTABLE_ROOM_FULL"
@pytest.mark.asyncio
async def test_jobs_from_every_account_share_one_global_pool(rooms) -> None:
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
for index in range(3):
await jobs.try_create_or_get_active(
draft_id=f"draft-{index}",
user_id=f"account-{index}",
task_id=None,
id=f"job-{index}",
status="running",
)
assert await repo.summary() == 3
blocked = await _admit(repo, "draft-new", "account-new")
assert blocked["ok"] is False
assert blocked["active_count"] == 3
@pytest.mark.asyncio
async def test_hard_limit_requests_cancellation_for_a_stuck_background_job(rooms) -> None:
repo, factory = rooms
jobs = RoundtableJobRepository(factory)
await jobs.try_create_or_get_active(
draft_id="stuck-draft",
user_id="stuck-user",
task_id=None,
id="stuck-job",
status="running",
)
async with factory() as session:
row = await session.get(RoundtableJobRow, "stuck-job")
assert row is not None
row.created_at = datetime.now(UTC) - timedelta(minutes=31)
row.lease_until = datetime.now(UTC) + timedelta(minutes=1)
await session.commit()
assert await repo.summary(max_hold_minutes=30) == 0
async with factory() as session:
row = await session.get(RoundtableJobRow, "stuck-job")
assert row is not None
assert row.status == "cancel_requested"
@pytest.mark.asyncio
async def test_background_handoff_preserves_the_original_room_start_time(rooms) -> None:
_repo, factory = rooms
jobs = RoundtableJobRepository(factory)
room_started_at = datetime.now(UTC) - timedelta(minutes=20)
row, created = await jobs.try_create_or_get_active(
draft_id="handoff-draft",
user_id="handoff-user",
task_id=None,
id="handoff-job",
status="queued",
room_started_at=room_started_at,
)
assert created is True
persisted = datetime.fromisoformat(str(row["created_at"]))
assert abs((persisted - room_started_at).total_seconds()) < 1
@pytest.mark.asyncio
async def test_enabled_gate_fails_closed_without_a_store(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=True, max_rooms=3),
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()), state=SimpleNamespace(user=None))
decision = await roundtable_room_gate.admit_roundtable_room(
request,
draft_id="draft",
task_id=None,
room_session_id="session",
)
assert decision["ok"] is False
assert decision["code"] == "ROOM_STORE_UNAVAILABLE"
@pytest.mark.asyncio
async def test_enabled_gate_rejects_missing_live_session_id(
rooms,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=True, max_rooms=3),
)
repo, _factory = rooms
decision = await roundtable_room_gate.admit_roundtable_room(
_enforce_request(repo, "user"),
draft_id="draft",
task_id=None,
)
assert decision["ok"] is False
assert decision["code"] == "ROOM_SESSION_REQUIRED"
@pytest.mark.asyncio
async def test_disabled_gate_still_allows_without_a_store(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=False, max_rooms=3),
)
request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace()), state=SimpleNamespace(user=None))
decision = await roundtable_room_gate.admit_roundtable_room(request, draft_id=None, task_id=None)
assert decision["ok"] is True
def _enforce_request(repo, user_id: str, run_mgr=None, thread_store=None):
return SimpleNamespace(
app=SimpleNamespace(
state=SimpleNamespace(
roundtable_room_store=repo,
run_manager=run_mgr,
thread_store=thread_store,
)
),
state=SimpleNamespace(user=SimpleNamespace(id=user_id)),
)
@pytest.mark.asyncio
async def test_gate_rejects_a_new_room_when_backend_processes_are_full(
rooms,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=True, max_rooms=3),
)
repo, _factory = rooms
for index in range(3):
await roundtable_room_gate.enforce_roundtable_room(
_enforce_request(repo, f"u{index}"),
draft_id=f"d{index}",
task_id=None,
room_session_id=f"session-{index}",
)
with pytest.raises(HTTPException) as exc_info:
await roundtable_room_gate.enforce_roundtable_room(
_enforce_request(repo, "u-new"),
draft_id="d-new",
task_id=None,
room_session_id="session-new",
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "ROUNDTABLE_ROOM_FULL"
assert exc_info.value.detail["active_count"] == 3
@pytest.mark.asyncio
async def test_run_stream_gate_never_admits_an_unleased_history_continuation(
rooms,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=True, max_rooms=3),
)
repo, _factory = rooms
for index in range(3):
await roundtable_room_gate.enforce_roundtable_room(
_enforce_request(repo, f"u{index}"),
draft_id=f"d{index}",
task_id=None,
room_session_id=f"session-{index}",
)
with pytest.raises(HTTPException) as exc_info:
await roundtable_room_gate.enforce_roundtable_run(
_enforce_request(repo, "history-user"),
draft_id="history-draft",
task_id=None,
room_session_id="history-session",
thread_ids=["legacy-history-thread"],
)
assert exc_info.value.status_code == 409
assert exc_info.value.detail["code"] == "ROOM_LEASE_EXPIRED"
@pytest.mark.asyncio
async def test_run_stream_gate_resolves_room_key_from_thread_metadata(
rooms,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
roundtable_room_gate,
"get_roundtable_room_limit_settings",
lambda: SimpleNamespace(enabled=True, max_rooms=1),
)
repo, _factory = rooms
room_key = _room_key("thread-draft", "owner")
admitted = await repo.admit_room_key(
room_key=room_key,
draft_id="thread-draft",
user_id="owner",
task_id=None,
holder_user_id="owner",
enabled=True,
max_rooms=1,
)
assert admitted["ok"] is True
class _Threads:
async def get(self, thread_id: str, *, user_id=None):
assert thread_id == "room-thread"
return {
"user_id": "owner",
"metadata": {
"roundtable_room_key": room_key,
"draft_id": "thread-draft",
},
}
decision = await roundtable_room_gate.enforce_roundtable_run(
_enforce_request(repo, "owner", thread_store=_Threads()),
draft_id=None,
task_id=None,
thread_ids=["room-thread"],
)
assert decision is not None
assert decision["ok"] is True
assert decision["renewed"] is True
assert await repo.summary() == 1
@pytest.mark.asyncio
async def test_admin_can_change_roundtable_room_limit_without_restart(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
assert ss.get_roundtable_room_limit_settings().enabled is True
assert ss.get_roundtable_room_limit_settings().max_rooms == 3
assert ss.get_roundtable_room_limit_settings().max_hold_minutes == 30
assert ss.get_roundtable_room_limit_settings().max_cycles == 8
assert ss.get_roundtable_room_limit_settings().write_file_compatibility_enabled is False
saved = await ss_router.update_roundtable_room_limit_setting(
ss_router.RoundtableRoomLimitUpdateRequest(
enabled=False,
max_rooms=5,
max_hold_minutes=45,
max_cycles=12,
),
SimpleNamespace(),
)
assert saved.enabled is False
assert saved.max_rooms == 5
assert saved.max_hold_minutes == 45
assert saved.max_cycles == 12
assert ss.load_system_settings().roundtable_room_limit.max_rooms == 5
assert ss.load_system_settings().roundtable_room_limit.max_hold_minutes == 45
assert ss.load_system_settings().roundtable_room_limit.max_cycles == 12
assert saved.write_file_compatibility_enabled is False
@pytest.mark.asyncio
async def test_roundtable_file_stream_is_only_attached_to_roundtable_runs(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
_patch_admin(monkeypatch)
base = dict(
agent_name="roundtable-seat",
model_name="test-model",
thread_id="thread-1",
new_message="test",
excluded_tools=[],
skill_stop_names=[],
)
before = _run_payload(**base, roundtable_file_stream=True)
assert "roundtable_write_file_compatibility" not in before["context"]
saved = await ss_router.update_roundtable_room_limit_setting(
ss_router.RoundtableRoomLimitUpdateRequest(write_file_compatibility_enabled=True),
SimpleNamespace(),
)
assert saved.write_file_compatibility_enabled is True
opted_in = _run_payload(**base, roundtable_file_stream=True)
assert opted_in["context"]["roundtable_write_file_compatibility"] is True
ordinary = _run_payload(**base)
assert "roundtable_write_file_compatibility" not in ordinary["context"]
@pytest.mark.asyncio
async def test_non_admin_cannot_change_roundtable_room_limit(
tmp_runtime_home: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
user = SimpleNamespace(id="user1", system_role="user", email="user@example.com")
async def _optional(_request):
return user
monkeypatch.setattr(ss_router, "get_optional_user_from_request", _optional)
with pytest.raises(HTTPException) as exc_info:
await ss_router.update_roundtable_room_limit_setting(
ss_router.RoundtableRoomLimitUpdateRequest(enabled=False),
SimpleNamespace(),
)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_live_langgraph_runs_occupy_a_room_without_a_lease(rooms) -> None:
"""发起后即使租约被释放,仍在跑的第二步流必须占房间。"""
repo, _factory = rooms
live = compute_active_dedupe_key("draft-run-a", "u-a", None)
other = compute_active_dedupe_key("draft-run-b", "u-b", None)
third = compute_active_dedupe_key("draft-run-c", "u-c", None)
assert live and other and third
extra = {live, other, third}
assert await repo.summary(extra_keys=extra) == 3
blocked = await _admit(repo, "draft-run-d", "u-d", extra_keys=extra)
assert blocked["ok"] is False
assert blocked["active_count"] == 3
# Same session renews instead of taking a fourth slot.
renewed = await _admit(repo, "draft-run-a", "u-a", extra_keys=extra)
assert renewed["ok"] is True
assert renewed["renewed"] is True
@pytest.mark.asyncio
async def test_live_runs_from_every_account_count_together(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"deerflow.persistence.engine.get_session_factory",
lambda: None,
)
class _Run:
def __init__(self, thread_id: str) -> None:
self.thread_id = thread_id
class _Mgr:
async def list_active(self):
return [_Run("alice-leader"), _Run("bob-leader"), _Run("carol-leader")]
class _Threads:
async def get(self, thread_id: str, *, user_id=None):
rooms = {
"alice-leader": {
"user_id": "alice",
"metadata": {"roundtable_room_key": "room-alice"},
},
"bob-leader": {
"user_id": "bob",
"metadata": {"roundtable_room_key": "room-bob"},
},
"carol-leader": {
"user_id": "carol",
"metadata": {"roundtable_room_key": "room-carol"},
},
}
return rooms.get(thread_id)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(run_manager=_Mgr(), thread_store=_Threads())),
)
keys = await roundtable_room_gate.extra_live_roundtable_keys(request)
assert set(keys) == {"room-alice", "room-bob", "room-carol"}
@pytest.mark.asyncio
async def test_multiple_seats_in_one_session_count_as_one_room(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
"deerflow.persistence.engine.get_session_factory",
lambda: None,
)
class _Run:
def __init__(self, thread_id: str) -> None:
self.thread_id = thread_id
class _Mgr:
async def list_active(self):
return [_Run("seat-1"), _Run("seat-2"), _Run("leader"), _Run("intent")]
class _Threads:
async def get(self, thread_id: str, *, user_id=None):
rooms = {
"seat-1": {"metadata": {"roundtable_room_key": "room-1"}},
"seat-2": {"metadata": {"roundtable_room_key": "room-1"}},
"leader": {"metadata": {"roundtable_room_key": "room-1"}},
"intent": {"metadata": {"thread_type": "roundtable"}},
}
return rooms.get(thread_id)
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(run_manager=_Mgr(), thread_store=_Threads())),
)
keys = await roundtable_room_gate.extra_live_roundtable_keys(request)
assert set(keys) == {"room-1"}
@pytest.mark.asyncio
async def test_finished_runtime_task_with_stale_status_is_not_a_room(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
"deerflow.persistence.engine.get_session_factory",
lambda: None,
)
class _FinishedTask:
@staticmethod
def done() -> bool:
return True
class _Run:
run_id = "finished-run"
thread_id = "finished-thread"
task = _FinishedTask()
metadata = {"roundtable_room_key": "stale-room"}
class _Runs:
async def list_active(self):
return [_Run()]
request = SimpleNamespace(
app=SimpleNamespace(state=SimpleNamespace(run_manager=_Runs())),
)
assert await roundtable_room_gate.extra_live_roundtable_keys(request) == {}