"""Managed-worker failure, affinity, admission, and 70-client regression tests.""" import asyncio from types import SimpleNamespace import httpx import pytest from fastapi import HTTPException from app.gateway.worker_pool.admission import Admission, bounded_worker_run from app.gateway.worker_pool.controller import Controller, PoolUnavailable from app.gateway.worker_pool.docker import DockerError, worker_spec from app.gateway.worker_pool.settings import PoolSettings, SettingsStore def source(): return { "Image": "sha256:fixed", "Config": {"Env": ["AUTH_JWT_SECRET=fixed", "WEB_CONCURRENCY=8"], "WorkingDir": "/app"}, "NetworkSettings": {"Networks": {"deerflow": {}}}, "Mounts": [{"Type": "volume", "Name": "runtime", "Destination": "/data", "RW": True}, {"Type": "bind", "Source": "/var/run/docker.sock", "Destination": "/var/run/docker.sock", "RW": True}], } def test_secrets_encrypted_and_preserved(tmp_path): store = SettingsStore(tmp_path) store.save(PoolSettings(password="secret-sentinel")) assert "secret-sentinel" not in store.path.read_text() assert "password" not in store.public() assert store.public()["password_set"] store.save(PoolSettings(desired=2)) assert SettingsStore(tmp_path).load().password == "secret-sentinel" assert store.load().desired == 2 def test_worker_template_has_limits_no_scheduler_no_socket(): spec = worker_spec(source(), PoolSettings(network="deerflow"), "instance", "token", "/private") assert spec["Image"] == "sha256:fixed" assert spec["HostConfig"]["Memory"] == 2048 * 1024**2 assert len(spec["HostConfig"]["Mounts"]) == 1 assert "SCHEDULED_TASKS_ENABLED=false" in spec["Env"] assert not any(e.startswith("WEB_CONCURRENCY=") for e in spec["Env"]) assert "PortBindings" not in spec["HostConfig"] assert spec["Cmd"][-2:] == ["--workers", "1"] def test_worker_template_uses_snapshot_of_main_runtime(): spec = worker_spec( source(), PoolSettings(network="deerflow"), "instance", "token", "/private", runtime_image="sha256:runtime-snapshot", ) assert spec["Image"] == "sha256:runtime-snapshot" def test_postgres_worker_requires_shared_persistent_chat_archive_directory(): template = source() template["Config"]["Env"].append("DEER_FLOW_HOME=/data") spec = worker_spec( template, PoolSettings(network="deerflow"), "instance", "token", "/private", require_shared_archive=True, ) assert any(mount["Target"] == "/data" and mount["ReadOnly"] is False for mount in spec["HostConfig"]["Mounts"]) template["Mounts"] = [ {"Type": "volume", "Name": "other", "Destination": "/other", "RW": True}, ] with pytest.raises(DockerError, match="共享的可写持久卷"): worker_spec( template, PoolSettings(network="deerflow"), "instance", "token", "/private", require_shared_archive=True, ) @pytest.mark.asyncio async def test_live_storage_backend_change_reprobes_and_snapshots_main_runtime(tmp_path, monkeypatch): from app.gateway.worker_pool import controller as controller_module store = SettingsStore(tmp_path) store.save(PoolSettings( enabled=True, source_container="main", network="deerflow", desired=1, )) pool = Controller(store) storage_backends = iter(("sqlite", "postgres")) discovered = [] snapshots = [] template = source() template.update({ "Id": "main-container-id", "Image": "sha256:base-image", "Config": {"Env": ["AUTH_JWT_SECRET=fixed", "DEER_FLOW_HOME=/data"], "Labels": {}, "User": ""}, "NetworkSettings": {"Networks": {"deerflow": {}}}, }) class Engine: def request(self, method, path, body=None): assert method == "GET" and path == "/containers/main/json?size=true" return template def configured_storage_backend(self, source_detail, preferred, working_dir): return next(storage_backends) def discover_worker_python(self, source_detail, preferred, working_dir): discovered.append(working_dir) return "/opt/venv/bin/python" def snapshot_worker_runtime(self, source_detail, instance): image = f"sha256:runtime-{len(snapshots) + 1}" snapshots.append((source_detail["Id"], instance)) return image engine = Engine() monkeypatch.setattr(controller_module, "DockerEngine", lambda settings: engine) try: _, sqlite_spec = await pool.inspect_template(store.load()) _, postgres_spec = await pool.inspect_template(store.load()) assert sqlite_spec["Image"] == "sha256:runtime-1" assert postgres_spec["Image"] == "sha256:runtime-2" assert len(discovered) == 2 assert len(snapshots) == 2 finally: await pool.close() def test_worker_keeps_service_networks_without_main_aliases_and_uses_private_health(): template = source() template["NetworkSettings"]["Networks"]["knowledge"] = {"Aliases": ["main-only"], "IPAddress": "172.20.0.3"} spec = worker_spec(template, PoolSettings(network="deerflow"), "instance", "token", "/private") assert spec["NetworkingConfig"]["EndpointsConfig"] == {"deerflow": {}, "knowledge": {}} health = spec["Healthcheck"]["Test"] assert health[:3] == ["CMD", "/opt/venv/bin/python", "-c"] assert "/internal/worker-status" in health[3] assert "os.environ['DEER_FLOW_WORKER_TOKEN']" in health[3] def test_dedicated_private_volume_is_excluded(): template = source() template["Mounts"].append({"Type": "volume", "Name": "secrets", "Destination": "/private", "RW": True}) spec = worker_spec(template, PoolSettings(network="deerflow"), "instance", "token", "/private") assert all(mount["Target"] != "/private" for mount in spec["HostConfig"]["Mounts"]) @pytest.mark.asyncio async def test_orphaned_run_becomes_terminal_without_retry(): from app.gateway.worker_pool.manager import WorkerRunManager from deerflow.runtime.runs.store.memory import MemoryRunStore store = MemoryRunStore() await store.put("old", thread_id="thread", status="running") manager = WorkerRunManager(store=store) assert await manager.get_persisted("old", authorized_thread_id="different-thread") is None assert (await store.get("old"))["status"] == "running" record = await manager.get_persisted("old", authorized_thread_id="thread") assert record.status == "error" assert (await store.get("old"))["status"] == "error" assert record.task is None def test_reject_shared_controller_secrets(): with pytest.raises(DockerError, match="密钥目录"): worker_spec(source(), PoolSettings(network="deerflow"), "i", "t", "/data/control") @pytest.mark.asyncio async def test_70_simultaneous_runs_are_bounded_and_complete(): gate = Admission(4, 128, 10) maximum = 0 completed = set() async def run(i): nonlocal maximum await gate.acquire() try: maximum = max(maximum, gate.active) await asyncio.sleep(0.003) assert i not in completed completed.add(i) finally: gate.release() await asyncio.gather(*(run(i) for i in range(70))) assert len(completed) == 70 assert maximum == 4 assert gate.active == gate.waiting == 0 @pytest.mark.asyncio async def test_cancelled_waiter_and_timeout_do_not_leak_capacity(): gate = Admission(1, 1, 0.03) await gate.acquire() waiter = asyncio.create_task(gate.acquire()) await asyncio.sleep(0) with pytest.raises(HTTPException) as error: await gate.acquire() assert error.value.status_code == 429 waiter.cancel() await asyncio.gather(waiter, return_exceptions=True) with pytest.raises(HTTPException): await gate.acquire() gate.release() await gate.acquire() gate.release() assert gate.active == gate.waiting == 0 @pytest.mark.asyncio async def test_background_run_holds_slot_after_http_returns(): gate = Admission(1) event = asyncio.Event() request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(worker_admission=gate))) @bounded_worker_run async def start(body, thread, request): return SimpleNamespace(task=asyncio.create_task(event.wait())) record = await start(None, "thread", request) assert gate.active == 1 event.set() await record.task await asyncio.sleep(0) assert gate.active == 0 @pytest.mark.asyncio async def test_affinity_stable_on_scaleup_fail_closed_and_persisted(tmp_path): store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True)) pool = Controller(store) pool.nodes["a"] = {"name": "a", "ready": True, "active": 0, "draining": False} try: first = await pool.reserve("thread", create=True) pool.release("a") pool.nodes["b"] = {"name": "b", "ready": True, "active": 0, "draining": False} assert (await pool.reserve("thread", create=True))["name"] == first["name"] pool.release("a") pool.nodes["a"]["ready"] = False with pytest.raises(PoolUnavailable): await pool.reserve("thread", create=True) assert pool._ensure_db().execute("SELECT node FROM affinity WHERE thread='thread'").fetchone()[0] == "a" finally: await pool.close() restarted = Controller(store) try: with pytest.raises(PoolUnavailable): await restarted.reserve("thread", create=True) finally: await restarted.close() @pytest.mark.asyncio async def test_scale_down_waits_for_active_runs_and_requests(tmp_path, monkeypatch): store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, source_container="main", network="deerflow", desired=0)) pool = Controller(store) name = f"dfw-{store.raw()['instance']}-0" calls = [] class Engine: def request(self, method, path, body=None): calls.append((method, path)) if path.startswith("/containers/json"): return [{"Names": ["/" + name], "Id": "id", "State": "running"}] return {} async def template(settings): return Engine(), {} monkeypatch.setattr(pool, "inspect_template", template) active = 1 async def status(request): return httpx.Response(200, json={"ready": True, "active": active, "capacity": 4}) await pool.http.aclose() pool.http = httpx.AsyncClient(transport=httpx.MockTransport(status)) try: await pool.reconcile() assert not any(method == "POST" for method, _ in calls) active = 0 pool.inflight[name] = 1 await pool.reconcile() assert not any(method == "POST" for method, _ in calls) pool.release(name) await pool.reconcile() assert ("POST", "/containers/id/stop?t=30") in calls assert ("DELETE", "/containers/id") in calls assert not pool.nodes finally: await pool.close() @pytest.mark.asyncio async def test_changed_runtime_image_drains_and_recreates_old_workers(tmp_path, monkeypatch): store = SettingsStore(tmp_path) store.save(PoolSettings( enabled=True, source_container="main", network="deerflow", desired=1, )) pool = Controller(store) name = f"dfw-{store.raw()['instance']}-0" calls = [] class Engine: def request(self, method, path, body=None): calls.append((method, path, body)) if path.startswith("/containers/json"): return [{ "Names": ["/" + name], "Id": "old-worker", "State": "running", "ImageID": "sha256:old-runtime", }] if path.startswith("/containers/create?"): assert body["Image"] == "sha256:new-runtime" return {"Id": "new-worker"} return {} async def template(_settings): return Engine(), {"Image": "sha256:new-runtime", "Entrypoint": ["python"], "WorkingDir": "/app"} async def status(_request): return httpx.Response(200, json={"ready": True, "active": 0, "capacity": 4}) monkeypatch.setattr(pool, "inspect_template", template) await pool.http.aclose() pool.http = httpx.AsyncClient(transport=httpx.MockTransport(status)) try: await pool.reconcile() paths = [(method, path) for method, path, _ in calls] assert ("POST", "/containers/old-worker/stop?t=30") in paths assert ("DELETE", "/containers/old-worker") in paths create_index = next(i for i, (_, path) in enumerate(paths) if path.startswith("/containers/create?")) assert paths.index(("DELETE", "/containers/old-worker")) < create_index assert ("POST", "/containers/new-worker/start") in paths assert pool.nodes[name]["id"] == "new-worker" finally: await pool.close() @pytest.mark.asyncio async def test_70_http_clients_distribute_to_four_workers_with_sse(tmp_path, monkeypatch): from fastapi import FastAPI from app.gateway import deps from app.gateway.worker_pool.proxy import WorkerProxy store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, concurrency=32)) pool = Controller(store) for i in range(4): name = f"worker-{i}" pool.nodes[name] = {"name": name, "ready": True, "active": 0, "capacity": 32, "draining": False, "url": f"http://{name}:8001"} async def user(request): return SimpleNamespace(id="user") async def access(*args, **kwargs): return True async def metadata(*args, **kwargs): return {"metadata": {}} monkeypatch.setattr(deps, "get_current_user_from_request", user) monkeypatch.setattr(deps, "get_thread_store", lambda request: SimpleNamespace(check_access=access, get=metadata)) gates = {name: Admission(32) for name in pool.nodes} counts = {name: 0 for name in pool.nodes} class EventStream(httpx.AsyncByteStream): async def __aiter__(self): yield b"event: end\ndata: {}\n\n" async def worker(request): assert request.headers["cookie"] == "session=preserved" assert request.headers["host"] == "gateway" assert request.headers["x-deerflow-worker-token"] == store.raw()["token"] name = request.url.host await gates[name].acquire() try: await asyncio.sleep(0.005) counts[name] += 1 return httpx.Response(200, stream=EventStream(), headers={"content-type": "text/event-stream", "location": "/threads/t/runs/r/stream"}) finally: gates[name].release() await pool.http.aclose() pool.http = httpx.AsyncClient(transport=httpx.MockTransport(worker)) app = FastAPI() app.state.worker_pool = pool app.add_middleware(WorkerProxy) try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://gateway") as client: responses = await asyncio.gather(*(client.post(f"/api/threads/t-{i}/runs/stream", headers={"cookie": "session=preserved"}, json={"input": {}}) for i in range(70))) assert all(response.status_code == 200 and "event: end" in response.text for response in responses) assert all(response.headers["location"] == "/threads/t/runs/r/stream" for response in responses) assert sorted(counts.values()) == [17, 17, 18, 18] assert all(value == 0 for value in pool.inflight.values()) finally: await pool.close() @pytest.mark.asyncio async def test_70_concurrent_runs_fall_back_to_main_without_worker_queue(tmp_path, monkeypatch): from fastapi import FastAPI, Request from starlette.responses import StreamingResponse from app.gateway import deps from app.gateway.worker_pool.proxy import WorkerProxy store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, concurrency=4)) pool = Controller(store) pool.nodes["worker-0"] = { "name": "worker-0", "ready": True, "active": 0, "capacity": 4, "draining": False, "url": "http://worker-0:8001", } async def user(request): return SimpleNamespace(id="user") async def access(*args, **kwargs): return True async def metadata(*args, **kwargs): return {"metadata": {}} monkeypatch.setattr(deps, "get_current_user_from_request", user) monkeypatch.setattr(deps, "get_thread_store", lambda request: SimpleNamespace(check_access=access, get=metadata)) worker_count = 0 main_count = 0 class EventStream(httpx.AsyncByteStream): async def __aiter__(self): await asyncio.sleep(0) yield b"event: end\ndata: {}\n\n" async def worker(request): nonlocal worker_count assert request.headers["x-deerflow-worker-token"] == store.raw()["token"] worker_count += 1 await asyncio.sleep(0.01) return httpx.Response(200, stream=EventStream(), headers={"content-type": "text/event-stream"}) await pool.http.aclose() pool.http = httpx.AsyncClient(transport=httpx.MockTransport(worker)) app = FastAPI() app.state.worker_pool = pool app.state.checkpointer = object() app.state.store = object() app.add_middleware(WorkerProxy) @app.post("/api/threads/{thread_id}/runs/stream") async def main_run(thread_id: str, request: Request): nonlocal main_count assert request.state.worker_pool_fallback is True main_count += 1 return StreamingResponse(iter([b"event: end\ndata: {}\n\n"]), media_type="text/event-stream") try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://gateway") as client: responses = await asyncio.gather(*( client.post(f"/api/threads/t-{i}/runs/stream", json={"input": {}}) for i in range(70) )) assert all(response.status_code == 200 and "event: end" in response.text for response in responses) assert worker_count == 4 assert main_count == 66 assert pool._ensure_db().execute("SELECT count(*) FROM affinity").fetchone()[0] == 4 assert all(value == 0 for value in pool.inflight.values()) finally: await pool.close() @pytest.mark.asyncio @pytest.mark.parametrize("worker_thread_active,status_code", [(False, 200), (True, 409)]) async def test_affined_thread_uses_main_fallback_only_when_worker_thread_is_idle(tmp_path, monkeypatch, worker_thread_active, status_code): from fastapi import FastAPI, Request from app.gateway import deps from app.gateway.worker_pool.proxy import WorkerProxy store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, concurrency=1)) pool = Controller(store) pool.nodes["worker-0"] = { "name": "worker-0", "ready": True, "active": 1, "capacity": 1, "draining": False, "url": "http://worker-0:8001", } pool._ensure_db().execute("INSERT INTO affinity VALUES (?, ?)", ("thread-1", "worker-0")) pool._ensure_db().commit() async def user(request): return SimpleNamespace(id="user") async def access(*args, **kwargs): return True async def metadata(*args, **kwargs): return {"metadata": {}} monkeypatch.setattr(deps, "get_current_user_from_request", user) monkeypatch.setattr(deps, "get_thread_store", lambda request: SimpleNamespace(check_access=access, get=metadata)) checked = [] async def worker(request): checked.append(request.url.path) return httpx.Response(200, json={"active": worker_thread_active}) await pool.http.aclose() pool.http = httpx.AsyncClient(transport=httpx.MockTransport(worker)) app = FastAPI() app.state.worker_pool = pool app.state.checkpointer = object() app.state.store = object() app.add_middleware(WorkerProxy) @app.post("/api/threads/thread-1/runs/stream") async def main_run(request: Request): assert request.state.worker_pool_fallback is True return {"handled_by": "main"} try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://gateway") as client: result = await client.post("/api/threads/thread-1/runs/stream", json={"input": {}}) assert result.status_code == status_code assert checked == ["/internal/worker-thread-active/thread-1"] if status_code == 200: assert result.json() == {"handled_by": "main"} finally: await pool.close() def test_unified_pg_and_legacy_override(monkeypatch): from app.gateway.worker_pool.preflight import shared_postgres_configured cfg = SimpleNamespace(database=SimpleNamespace(backend="postgres"), checkpointer=None) assert shared_postgres_configured(cfg) cfg.checkpointer = SimpleNamespace(type="sqlite", use_postgres=True) assert not shared_postgres_configured(cfg) cfg.checkpointer.type = "postgres" assert shared_postgres_configured(cfg) monkeypatch.setenv("DEER_FLOW_USE_PG_CHECKPOINTER", "false") assert not shared_postgres_configured(cfg) def test_mysql_business_supports_both_chat_backends(monkeypatch, tmp_path): from app.gateway.worker_pool.preflight import shared_storage_configured, validate_sqlite_mount cfg = SimpleNamespace(database=SimpleNamespace(backend="mysql"), checkpointer=SimpleNamespace(type="postgres", use_postgres=True)) monkeypatch.delenv("DEER_FLOW_USE_PG_CHECKPOINTER", raising=False) assert shared_storage_configured(cfg) cfg.checkpointer = SimpleNamespace(type="sqlite", connection_string=str(tmp_path / "chat.db")) assert shared_storage_configured(cfg) mountinfo = f"31 1 8:1 / {tmp_path.as_posix()} rw - ext4 /dev/sda rw" validate_sqlite_mount(cfg, mountinfo) with pytest.raises(RuntimeError, match="network filesystem"): validate_sqlite_mount(cfg, mountinfo.replace("ext4", "nfs4")) with pytest.raises(RuntimeError, match="persistently mounted"): validate_sqlite_mount(cfg, "31 1 8:1 / / rw - ext4 /dev/sda rw") cfg.checkpointer.connection_string = ":memory:" assert not shared_storage_configured(cfg) @pytest.mark.asyncio async def test_delayed_health_probe_does_not_hide_existing_run(tmp_path): from app.gateway.worker_pool.controller import Controller, PoolUnavailable from app.gateway.worker_pool.settings import SettingsStore pool = Controller(SettingsStore(tmp_path)) pool.nodes['worker'] = dict(name='worker', ready=False, ever_ready=True, draining=False, url='http://worker:8001') pool._ensure_db().execute("INSERT INTO affinity VALUES (?, ?)", ('thread', 'worker')) pool._ensure_db().commit() try: assert (await pool.reserve('thread', create=False))['name'] == 'worker' pool.release('worker') with pytest.raises(PoolUnavailable): await pool.reserve('thread', create=True) pool.nodes['worker']['ever_ready'] = False with pytest.raises(PoolUnavailable): await pool.reserve('thread', create=False) finally: await pool.close() @pytest.mark.asyncio async def test_reconcile_retains_previous_readiness_during_probe_timeout(tmp_path, monkeypatch): import httpx store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, desired=1, source_container='main', network='network')) pool = Controller(store) name = f"dfw-{store.raw()['instance']}-0" pool.nodes[name] = dict(name=name, ready=True, ever_ready=True, active=1, draining=False) pool._ensure_db().execute('INSERT INTO affinity VALUES (?, ?)', ('thread', name)) pool._ensure_db().commit() class Engine: def request(self, method, path, body=None): assert path.startswith('/containers/json') return [{'Names': ['/' + name], 'Id': 'id', 'State': 'running'}] async def inspect(_settings): return Engine(), {} async def timeout(*args, **kwargs): raise httpx.ReadTimeout('busy') monkeypatch.setattr(pool, 'inspect_template', inspect) monkeypatch.setattr(pool.http, 'get', timeout) try: await pool.reconcile() await pool.reconcile() assert (await pool.reserve('thread', create=False))['name'] == name with pytest.raises(PoolUnavailable): await pool.reserve('thread', create=True) finally: await pool.close() @pytest.mark.asyncio async def test_read_transport_retries_but_never_replays_model_submission(): import httpx from app.gateway.worker_pool.proxy import send_upstream attempts = [] async def transport(request): attempts.append(request.method) if request.method == 'POST' or len(attempts) == 1: raise httpx.RemoteProtocolError('server closed keep-alive') return httpx.Response(200, json={'status': 'success'}) async with httpx.AsyncClient(transport=httpx.MockTransport(transport)) as client: response = await send_upstream(client, 'GET', 'http://worker/run', [], None) assert response.status_code == 200 await response.aclose() with pytest.raises(httpx.RemoteProtocolError): await send_upstream(client, 'POST', 'http://worker/run', [], b'{}') assert attempts == ['GET', 'GET', 'POST'] @pytest.mark.asyncio async def test_second_gateway_cannot_start_schedulers(tmp_path, monkeypatch): from app.gateway.worker_pool.lifecycle import managed_controller from app.gateway.worker_pool.settings import PoolSettings, SettingsStore monkeypatch.setenv("DEER_FLOW_WORKER_CONTROL_HOME", str(tmp_path)) async with managed_controller(SimpleNamespace(state=SimpleNamespace())): async with managed_controller(SimpleNamespace(state=SimpleNamespace())): pass assert not (tmp_path / "affinity.sqlite3").exists() assert not (tmp_path / "diagnostics.sqlite3").exists() assert not (tmp_path / "controller.lock").exists() assert not (tmp_path / "settings.json").exists() assert not (tmp_path / "secret.key").exists() store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, source_container="main")) async with managed_controller(SimpleNamespace(state=SimpleNamespace())) as first: assert first is not None first.start() with pytest.raises(RuntimeError, match="只能由一个主服务进程"): async with managed_controller(SimpleNamespace(state=SimpleNamespace())) as second: second.start() @pytest.mark.asyncio async def test_disabled_pool_ignores_saved_docker_settings(tmp_path, monkeypatch): store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=False, transport="socket", source_container="main", network="deerflow")) pool = Controller(store) def unexpected_request(*args, **kwargs): pytest.fail("disabled Worker pool must not contact Docker") monkeypatch.setattr("app.gateway.worker_pool.controller.DockerEngine.request", unexpected_request) pool.start() await asyncio.sleep(0) assert pool.task is None assert pool.db is None assert not (tmp_path / "controller.lock").exists() assert not (tmp_path / "affinity.sqlite3").exists() await pool.close() @pytest.mark.asyncio async def test_non_admin_cannot_read_host_credentials(monkeypatch): from app.gateway.worker_pool import router async def user(request): return SimpleNamespace(system_role="user") monkeypatch.setattr(router, "get_current_user_from_request", user) with pytest.raises(HTTPException) as error: await router.controller(SimpleNamespace()) assert error.value.status_code == 403 @pytest.mark.asyncio async def test_disabled_proxy_and_reserved_search_are_unchanged(tmp_path): from fastapi import FastAPI from app.gateway.worker_pool.proxy import WorkerProxy pool = Controller(SettingsStore(tmp_path)) app = FastAPI() app.state.worker_pool = pool app.add_middleware(WorkerProxy) @app.post("/api/threads/search") @app.post("/api/threads/t/runs/stream") async def endpoint(): return {"local": True} try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://test") as client: assert (await client.post("/api/threads/search")).json() == {"local": True} assert (await client.post("/api/threads/t/runs/stream")).json() == {"local": True} finally: await pool.close() @pytest.mark.asyncio @pytest.mark.parametrize("allowed,system,status_code", [(False, False, 404), (True, True, 200)]) async def test_private_and_scheduler_threads_are_not_assigned(tmp_path, monkeypatch, allowed, system, status_code): from fastapi import FastAPI from app.gateway import deps from app.gateway.worker_pool.proxy import WorkerProxy store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True)) pool = Controller(store) async def user(request): return SimpleNamespace(id="user") async def access(*args, **kwargs): return allowed async def metadata(*args, **kwargs): return {"metadata": {"thread_type": "scheduler"} if system else {}} monkeypatch.setattr(deps, "get_current_user_from_request", user) monkeypatch.setattr(deps, "get_thread_store", lambda request: SimpleNamespace(check_access=access, get=metadata)) app = FastAPI() app.state.worker_pool = pool app.add_middleware(WorkerProxy) @app.post("/api/threads/t/runs/stream") async def endpoint(): return {"scheduler_stays_local": True} try: async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://gateway") as client: result = await client.post("/api/threads/t/runs/stream") assert result.status_code == status_code assert pool._ensure_db().execute("SELECT count(*) FROM affinity").fetchone()[0] == 0 finally: await pool.close() @pytest.mark.asyncio async def test_worker_boot_disables_background_and_refuses_memory_fallback(monkeypatch): from contextlib import asynccontextmanager from langgraph.checkpoint.memory import InMemorySaver from app.gateway.worker_pool import worker from deerflow.config.app_config import AppConfig from deerflow.config.database_config import DatabaseConfig cfg = AppConfig.model_construct( database=DatabaseConfig.model_construct(backend="postgres"), checkpointer=None, sandbox=SimpleNamespace(use="LocalSandboxProvider"), ) monkeypatch.setattr(worker, "get_app_config", lambda: cfg) async def configure(config): pass monkeypatch.setattr(worker, "configure_runtime_executor", configure) @asynccontextmanager async def runtime(app, *, background): assert background is False app.state.checkpointer = InMemorySaver() app.state.store = object() yield monkeypatch.setattr(worker, "langgraph_runtime", runtime) app = SimpleNamespace(state=SimpleNamespace()) with pytest.raises(RuntimeError, match="fallback"): async with worker.worker_lifespan(app): pytest.fail("memory worker became ready") @pytest.mark.asyncio async def test_skill_disable_invalidates_other_worker_cache_immediately(monkeypatch): from app.gateway.worker_pool.refresh import SharedSkillRefresh from deerflow.agents.lead_agent import prompt from deerflow.config import extensions_config refresh = SharedSkillRefresh() stamp = 1 calls = [] monkeypatch.setattr(refresh, "extension_stamp", lambda: stamp) monkeypatch.setattr(refresh, "skill_stamp", lambda: ()) monkeypatch.setattr(extensions_config, "reload_extensions_config", lambda: calls.append("reload")) async def invalidate(): calls.append("refresh") monkeypatch.setattr(prompt, "refresh_skills_system_prompt_cache_async", invalidate) await refresh.check() await refresh.check() assert calls == ["reload", "refresh"] stamp = 2 await refresh.check() assert calls == ["reload", "refresh", "reload", "refresh"] @pytest.mark.asyncio async def test_scaleup_partial_failure_remains_owned(tmp_path, monkeypatch): store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True, source_container="main", network="deerflow", desired=1)) pool = Controller(store) class Engine: def request(self, method, path, body=None): if path.startswith("/containers/json"): return [] if path.startswith("/containers/create?"): return {"Id": "created-before-failure"} raise DockerError("start failed") async def template(settings): return Engine(), {} monkeypatch.setattr(pool, "inspect_template", template) try: with pytest.raises(DockerError): await pool.reconcile() assert len(pool.nodes) == 1 assert next(iter(pool.nodes.values()))["id"] == "created-before-failure" assert not next(iter(pool.nodes.values()))["ready"] finally: await pool.close() @pytest.mark.asyncio async def test_local_preparation_cannot_race_remote_assignment(tmp_path): store = SettingsStore(tmp_path) store.save(PoolSettings(enabled=True)) pool = Controller(store) request = SimpleNamespace(app=SimpleNamespace(state=SimpleNamespace(worker_pool=pool))) entered, finish = asyncio.Event(), asyncio.Event() @bounded_worker_run async def start(body, thread_id, request): entered.set() await finish.wait() return SimpleNamespace(task=None) task = asyncio.create_task(start(None, "thread", request)) try: await entered.wait() with pytest.raises(PoolUnavailable, match="主服务执行"): await pool.reserve("thread", create=True) finish.set() await task assert not pool.local_threads finally: task.cancel() await asyncio.gather(task, return_exceptions=True) await pool.close()