deer-flow/backend/tests/test_agents_router_write_drain.py
yetuge 7eb41cfc93
refactor(gateway): hoist the persistent-write drain into one shared helper (#6151)
* refactor(gateway): hoist the persistent-write drain into one shared helper

Each persistent-write surface forked its own per-router drain wrapper:
_drained_write in routers/agents.py (action label, expected_errors
pass-through, lost-failure type-only logging), _run_store_mutation in
routers/subagents.py (bare drain), and the inline await_drained call in
routers/managed_models.py. Hoist the superset into
app.gateway.persistent_writes.run_drained_write and point all three
routers at it, per the review suggestion on #6087 (its family
predecessors #6078/#6092/#6093 have since landed).

The subagents and managed-models call sites now also emit the type-only
lost-failure log line on a drained-then-failed write (previously
silent), matching the tradeoff acknowledged in the #6087 review.
artifacts.py and personal_mcp.py keep their direct await_drained forms:
the former drains a coroutine with its own rollback flow, the latter
needs no label or expected-error handling.

Router tests move their monkeypatch targets to the shared name; the
lost-failure assertion pins the helper's logger. AGENTS.md points the
next persistent-write surface at the canonical implementation.

Signed-off-by: yetuge <2219677952@qq.com>

* fix(gateway): quiet expected domain errors and scope the AGENTS.md drain claim

Review follow-ups on #6151:

- The subagents create/update call sites pass (ManagedSubagentExistsError,)
  and (FileNotFoundError,) so the shared helper stays silent on routine
  4xx outcomes (duplicate-name 409, stale-store 404 race), matching
  create_agent's (AgentExistsError,).
- The managed-models save passes (HTTPException,), so the helper stops
  double-logging failures that _save_with_failure_logging already
  reports and stays silent on its routine 4xx outcomes.
- The AGENTS.md sentence now names the three converted routers instead
  of implying every persistent-write surface uses the helper; the
  shortened form also keeps the file under the guidance byte budget
  that agent-guidance enforces.

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
2026-10-01 20:13:57 +08:00

190 lines
6.7 KiB
Python

"""Agent-router persistent writes drain across handler cancellation.
The gateway convention (managed subagents, managed models, custom skills) is
that a client disconnect must not cancel a queued persistence worker or
silently drop its failure. The agent store writes (create / update / delete)
and the USER.md write route through ``await_drained`` the same way; reads stay
bare because abandoning them loses nothing.
"""
from __future__ import annotations
import asyncio
import concurrent.futures
import logging
import threading
from pathlib import Path
import pytest
from app.gateway.routers.agents import (
AgentCreateRequest,
AgentUpdateRequest,
UserProfileUpdateRequest,
create_agent_endpoint,
get_agent,
update_agent,
update_user_profile,
)
from deerflow.config.agents_api_config import load_agents_api_config_from_dict
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.model_config import ModelConfig
from deerflow.config.sandbox_config import SandboxConfig
pytestmark = pytest.mark.asyncio
@pytest.fixture
def _agent_env(tmp_path: Path, monkeypatch):
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
monkeypatch.setattr("deerflow.config.paths._paths", None)
load_agents_api_config_from_dict({"enabled": True})
set_app_config(
AppConfig(
models=[ModelConfig(name="agent-model", display_name="Agent Model", description=None, use="langchain_openai:ChatOpenAI", model="agent-model")],
sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"),
)
)
try:
yield
finally:
load_agents_api_config_from_dict({})
reset_app_config()
async def test_agent_persistent_writes_route_through_write_drain(_agent_env, monkeypatch):
from app.gateway.routers import agents as router
calls: list[str] = []
async def drained(action, func, expected_errors=(), /, *args, **kwargs):
calls.append(action)
assert isinstance(expected_errors, tuple)
return func(*args, **kwargs)
monkeypatch.setattr(router, "run_drained_write", drained)
await create_agent_endpoint(AgentCreateRequest(name="planner", model="agent-model"))
await update_agent("planner", AgentUpdateRequest(description="later"))
await get_agent("planner")
await update_user_profile(UserProfileUpdateRequest(content="prefs"))
await router.delete_agent("planner")
assert calls == ["Create agent", "Update agent", "Update user profile", "Delete agent"]
async def test_agent_update_drains_started_store_write_across_repeated_cancellation(_agent_env, monkeypatch):
from app.gateway.routers import agents as router
await create_agent_endpoint(AgentCreateRequest(name="planner", model="agent-model"))
started = threading.Event()
release = threading.Event()
class BlockingStore:
def update(self, *_args, **_kwargs):
started.set()
assert release.wait(timeout=5)
monkeypatch.setattr(router, "get_agent_store", lambda: BlockingStore())
task = asyncio.create_task(update_agent("planner", AgentUpdateRequest(description="later")))
try:
assert await asyncio.to_thread(started.wait, 5)
task.cancel()
await asyncio.sleep(0.05)
task.cancel()
await asyncio.sleep(0.05)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
async def test_agent_update_logs_lost_worker_failure_after_cancellation(_agent_env, monkeypatch, caplog):
from app.gateway.routers import agents as router
await create_agent_endpoint(AgentCreateRequest(name="planner", model="agent-model"))
started = threading.Event()
release = threading.Event()
class FailingStore:
def update(self, *_args, **_kwargs):
started.set()
assert release.wait(timeout=5)
raise OSError("worker-secret must not appear in logs")
monkeypatch.setattr(router, "get_agent_store", lambda: FailingStore())
with caplog.at_level(logging.ERROR, logger=router.__name__):
task = asyncio.create_task(update_agent("planner", AgentUpdateRequest(description="later")))
try:
assert await asyncio.to_thread(started.wait, 5)
task.cancel()
await asyncio.sleep(0)
assert not task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await task
finally:
release.set()
if not task.done():
task.cancel()
await asyncio.gather(task, return_exceptions=True)
failures = [record for record in caplog.records if record.name == "app.gateway.persistent_writes" and "Update agent failed" in record.message]
assert len(failures) == 1
assert "OSError" in failures[0].message
assert "worker-secret" not in caplog.text
async def test_queued_user_profile_write_runs_despite_cancellation(_agent_env, tmp_path):
"""A write queued behind a busy executor must still run when the caller goes away.
Without the drain, cancelling the handler cancels the still-queued worker
and the profile silently never lands; the run of this test on unfixed code
is exactly that data loss.
"""
blocker_started = threading.Event()
release_blocker = threading.Event()
def _block():
blocker_started.set()
assert release_blocker.wait(timeout=5)
loop = asyncio.get_running_loop()
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
loop.set_default_executor(executor)
try:
jam = asyncio.create_task(asyncio.to_thread(_block))
# Poll without touching the executor: it has a single worker, and the
# blocker occupies it — an executor-based wait would deadlock here.
for _ in range(100):
if blocker_started.is_set():
break
await asyncio.sleep(0.05)
assert blocker_started.is_set()
task = asyncio.create_task(update_user_profile(UserProfileUpdateRequest(content="saved-prefs")))
await asyncio.sleep(0.1) # the write is now queued behind the blocker
task.cancel()
await asyncio.sleep(0.1)
release_blocker.set()
with pytest.raises(asyncio.CancelledError):
await task
await asyncio.gather(jam, return_exceptions=True)
finally:
release_blocker.set()
executor.shutdown(wait=False)
assert list(tmp_path.rglob("USER.md")), "queued USER.md write was lost to cancellation"