deer-flow/backend/tests/test_extension_agent_runs.py
Hyeonsang Cho 8850f8eb7e
fix(runtime): keep assembly evidence from reviving a run a peer reclaimed (#8046)
* fix(runtime): keep assembly evidence from reviving a run a peer reclaimed

In heartbeat mode every run-row write is conditional on this worker's
live lease except assembly evidence, which went through RunStore.put().
That upsert rewrote status, owner, lease, error and stop_reason from the
local record. When a peer had already reclaimed the run, it put the row
back to running and erased the takeover error. If the owner's local lease
was still live, its renewal then succeeded, the peer never reclaimed it
again, and the run finished as success after the peer had published its
terminal recovery event. If the write landed late, the row came back with
an expired lease and was reclaimed a second time.

Add RunStore.merge_owned_metadata(), which merges metadata keys only for
the owner holding a live lease on a pending/running row and writes no
other field. SQL follows the existing clock -> row lock -> conditional
UPDATE order; MemoryRunStore mirrors it. With heartbeat enabled,
record_assembly_evidence() uses it, refuses a locally fenced run before
any write, fences the run when the store rejects the merge, and fails
closed on stores without the method instead of falling back to put().
Single-worker mode keeps the full snapshot.

* docs(changelog): reference #8046 for the assembly evidence takeover fix

* test(runtime): scope the simulated lease expiry to the run's thread

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-11 07:56:53 +08:00

670 lines
34 KiB
Python

"""Host-bound run control: authorization, lifecycle and ordinary admission."""
import os
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from deerflow_extension_api.agent_runs import AgentRunError, require_agent_runs, resolve_agent_runs
from fastapi import FastAPI, Request
from app.gateway.auth.models import User
from app.gateway.authz import AuthContext
from app.gateway.extension_agent_runs import GatewayAgentRunsHost
@pytest.fixture
def host(monkeypatch):
user = User(email="alice@example.com")
app = FastAPI()
load_user = AsyncMock(return_value=user)
control = GatewayAgentRunsHost(app, load_user=load_user)
request = Request({"type": "http", "app": app, "headers": [], "state": {}})
request.state.user = user
request.state.auth_source = "session"
request.state.auth = AuthContext(user, ["threads:write", "threads:read", "runs:create", "runs:read", "runs:cancel"])
monkeypatch.setattr("app.gateway.extension_agent_runs.resolve_route_permissions", AsyncMock(return_value=list(request.state.auth.permissions)))
return control, request, load_user
def test_unsupported_host_and_credentials_fail_closed(host):
control, request, _ = host
assert resolve_agent_runs(request) is None
with pytest.raises(NotImplementedError):
require_agent_runs(request)
request.state.auth_source = "pat"
assert control.bind(request) is None
request.state.auth_source = "internal"
assert control.bind(request) is None
def test_unstamped_requests_receive_no_delegated_capability(host):
control, request, _ = host
assert control.bind(SimpleNamespace(state=SimpleNamespace())) is None
assert control.bind(SimpleNamespace(state=SimpleNamespace(user=request.state.user, auth_source="internal"))) is None
request.state.auth = SimpleNamespace(is_authenticated=False)
assert control.bind(request) is None
def test_binding_rejects_inconsistent_authenticated_identity(host):
control, request, _ = host
request.state.user = User(email="other@example.com")
with pytest.raises(PermissionError):
control.bind(request)
@pytest.mark.asyncio
async def test_handle_rechecks_user_and_shutdown_before_dispatch(host, monkeypatch):
control, request, load_user = host
runs = control.bind(request)
handler = AsyncMock(return_value=SimpleNamespace(thread_id="thread-a"))
monkeypatch.setattr("app.gateway.routers.threads.create_thread", handler)
assert await runs.create_thread() == "thread-a"
load_user.return_value = request.state.user.model_copy(update={"token_version": 1})
with pytest.raises(AgentRunError, match="revoked"):
await runs.create_thread()
load_user.return_value = request.state.user
control.close()
with pytest.raises(AgentRunError, match="unavailable"):
await runs.create_thread()
assert handler.await_count == 1
@pytest.mark.asyncio
async def test_permissions_are_intersected_with_original_grant_and_current_policy(host, monkeypatch):
control, request, _ = host
request.state.auth.permissions = ["runs:read"]
runs = control.bind(request)
request.state.auth.permissions.append("threads:write")
monkeypatch.setattr("app.gateway.extension_agent_runs.resolve_route_permissions", AsyncMock(return_value=["threads:write", "runs:read"]))
with pytest.raises(AgentRunError) as denied:
await runs.create_thread()
assert denied.value.status_code == 403
monkeypatch.setattr("app.gateway.extension_agent_runs.resolve_route_permissions", AsyncMock(return_value=[]))
with pytest.raises(AgentRunError) as revoked:
await runs.get(thread_id="thread-a", run_id="run-a")
assert revoked.value.status_code == 403
def test_runtime_handle_is_host_owned_redacted_and_released():
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from deerflow.runtime.runs.worker import _build_runtime_context, _install_runtime_context, _release_run_scoped_references
from deerflow.runtime.secret_context import redact_config_secrets
key = AGENT_RUNS_CONTEXT_KEY
assert key not in _build_runtime_context("thread", "run", {key: "forged"})
handle = object()
runtime = _build_runtime_context("thread", "run", {key: "forged"}, agent_runs=handle)
config = {"context": {key: "forged"}, "configurable": {key: "forged"}}
_install_runtime_context(config, runtime)
assert config["context"][key] is handle
assert key not in config["configurable"]
assert key not in redact_config_secrets(config)["context"]
_release_run_scoped_references([config], runtime, None)
assert key not in runtime
assert key not in config["context"]
@pytest_asyncio.fixture
async def runtime(host, monkeypatch, tmp_path):
import asyncio
from deerflow_extension_api.agent_runs import AGENT_RUNS_CONTEXT_KEY
from langchain_core.messages import AIMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import MessagesState, StateGraph
from langgraph.runtime import Runtime
from langgraph.store.memory import InMemoryStore
from langgraph.types import interrupt
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
from deerflow.runtime import RunManager
from deerflow.runtime.events.store.memory import MemoryRunEventStore
from deerflow.runtime.runs.store.memory import MemoryRunStore
from deerflow.runtime.stream_bridge.memory import MemoryStreamBridge
control, request, _ = host
state = request.app.state
state.agent_runs_host = control
state.checkpointer = InMemorySaver()
state.store = InMemoryStore()
state.thread_store = MemoryThreadMetaStore(state.store)
state.run_store = MemoryRunStore()
state.run_manager = RunManager(store=state.run_store)
state.run_event_store = MemoryRunEventStore()
state.stream_bridge = MemoryStreamBridge()
monkeypatch.setenv("DEER_FLOW_HOME", str(tmp_path))
set_app_config(AppConfig.model_validate({"sandbox": {"use": "deerflow.sandbox.local:LocalSandboxProvider"}}))
entered = asyncio.Event()
handles = []
async def answer(state: MessagesState, runtime: Runtime):
handles.append(runtime.context.get(AGENT_RUNS_CONTEXT_KEY))
prompt = state["messages"][-1].content
if prompt == "wait":
entered.set()
await asyncio.Event().wait()
if prompt == "approve":
prompt = interrupt({"question": "Continue?"})
return {"messages": [AIMessage(content=f"answer:{prompt}")]}
def factory(*, config):
builder = StateGraph(MessagesState)
builder.add_node("answer", answer)
builder.set_entry_point("answer")
builder.set_finish_point("answer")
from deerflow_extension_api import AgentAssemblyDescriptor
from deerflow.agents.lead_agent.agent import LeadAgentAssembly
descriptor = AgentAssemblyDescriptor(
namespace="fixture",
agent_name="lead-agent",
requested_model=None,
effective_model="fixture-model",
model_parameters={"temperature": 0},
thinking_enabled=False,
reasoning_effort=None,
base_prompt_hash="fixture-prompt",
tools=(),
middlewares=(),
deferred_tool_names=(),
enabled_skills=(),
effective_policies={"skill_catalog_hash": "fixture-skills"},
)
return LeadAgentAssembly(graph=builder.compile(), descriptor=descriptor, effective_model="fixture-model")
monkeypatch.setattr("app.gateway.services.resolve_agent_factory", lambda _: factory)
try:
yield control.bind(request), state, request, entered, handles
finally:
control.close()
await state.run_manager.shutdown()
reset_app_config()
@pytest.mark.asyncio
async def test_real_run_lifecycle_and_automatic_followup(runtime):
runs, state, request, entered, handles = runtime
thread_a = await runs.create_thread(thread_id="extension-a")
first = await runs.start(thread_id=thread_a, input={"messages": [{"role": "user", "content": "first"}]}, idempotency_key="team:first")
assert (await runs.wait(thread_id=thread_a, run_id=first.run_id, timeout=5)).status == "success"
retry = await runs.start(thread_id=thread_a, input={"messages": [{"role": "user", "content": "first"}]}, idempotency_key="team:first")
assert retry.run_id == first.run_id
output_a = await runs.get_state(thread_id=thread_a)
assert output_a["values"]["messages"][-1]["content"] == "answer:first"
assert handles and all(handle is not None for handle in handles)
# A service holding only the original capability can hand A's result to B.
thread_b = await runs.create_thread(thread_id="extension-b")
second = await runs.start(thread_id=thread_b, input={"messages": [{"role": "user", "content": output_a["values"]["messages"][-1]["content"]}]})
assert (await runs.wait(thread_id=thread_b, run_id=second.run_id, timeout=5)).status == "success"
continued = await runs.start(thread_id=thread_a, input={"messages": [{"role": "user", "content": "next"}]})
assert (await runs.wait(thread_id=thread_a, run_id=continued.run_id, timeout=5)).status == "success"
snapshot = await runs.get_state(thread_id=thread_a)
assert [message["content"] for message in snapshot["values"]["messages"]] == ["first", "answer:first", "next", "answer:next"]
stored = await state.thread_store.get(thread_a, user_id=str(request.state.user.id))
assert stored["user_id"] == str(request.state.user.id)
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "posix", reason="Descriptor-confined artifact imports require POSIX")
async def test_team_artifact_import_is_authorized_idempotent_and_confined(runtime):
import asyncio
import hashlib
from deerflow.config.paths import get_paths
runs, state, request, entered, _ = runtime
runs = runs.for_plugin("artifact-fixture")
thread = await runs.create_thread(thread_id="artifact-recipient")
content = b"%PDF-1.7\n\x00\xff immutable source"
imported = await runs.import_artifact(thread_id=thread, filename="report.pdf", content=content)
assert imported["sha256"] == hashlib.sha256(content).hexdigest()
assert imported["size"] == len(content)
target = get_paths().sandbox_uploads_dir(thread, user_id=str(request.state.user.id)) / imported["filename"]
assert await asyncio.to_thread(target.read_bytes) == content
assert await runs.import_artifact(thread_id=thread, filename="report.pdf", content=content) == imported
assert len(await asyncio.to_thread(lambda: list(target.parent.iterdir()))) == 1
await asyncio.to_thread(target.write_bytes, b"recipient edited its copy")
with pytest.raises(AgentRunError) as changed:
await runs.import_artifact(thread_id=thread, filename="report.pdf", content=content)
assert changed.value.status_code == 422
assert await asyncio.to_thread(target.read_bytes) == b"recipient edited its copy"
run = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "wait"}]})
await asyncio.wait_for(entered.wait(), 5)
with pytest.raises(AgentRunError) as busy:
await runs.import_artifact(thread_id=thread, filename="other.pdf", content=content)
assert busy.value.status_code == 409
with pytest.raises(AgentRunError) as capture_busy:
await runs.read_artifact(thread_id=thread, path="/mnt/user-data/outputs/report.pdf")
assert capture_busy.value.status_code == 409
await runs.cancel(thread_id=thread, run_id=run.run_id)
await runs.wait(thread_id=thread, run_id=run.run_id, timeout=5)
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "posix", reason="Descriptor-confined artifact imports require POSIX")
async def test_team_artifact_import_rechecks_permission_owner_and_payload(runtime, monkeypatch):
runs, state, request, _, _ = runtime
thread = await runs.create_thread(thread_id="artifact-recipient")
runs = runs.for_plugin("artifact-fixture")
for filename, content in (("../escape.txt", b"x"), ("/etc/passwd", b"x"), ("bad\nname.txt", b"x"), ("report.pdf", b"x" * (2 * 1024 * 1024 + 1))):
with pytest.raises(AgentRunError) as invalid:
await runs.import_artifact(thread_id=thread, filename=filename, content=content)
assert invalid.value.status_code == 422
with pytest.raises(AgentRunError) as missing:
await runs.import_artifact(thread_id="unknown-thread", filename="report.txt", content=b"x")
assert missing.value.status_code == 404
monkeypatch.setattr("app.gateway.extension_agent_runs.resolve_route_permissions", AsyncMock(return_value=["threads:read"]))
with pytest.raises(AgentRunError) as denied:
await runs.import_artifact(thread_id=thread, filename="report.txt", content=b"x")
assert denied.value.status_code == 403
@pytest.mark.asyncio
@pytest.mark.parametrize("launcher", ["scheduled", "mcp"])
async def test_internal_launchers_work_with_installed_host_without_delegation(runtime, launcher, monkeypatch):
from app.gateway.services import launch_mcp_task_notification_run, launch_scheduled_thread_run
runs, state, request, _, handles = runtime
monkeypatch.setattr("app.gateway.services.get_local_provider", lambda: SimpleNamespace(get_user=AsyncMock(return_value=request.state.user)))
thread = await runs.create_thread(thread_id="internal-launch")
kwargs = dict(app=request.app, thread_id=thread, assistant_id="lead_agent", owner_user_id=str(request.state.user.id))
if launcher == "scheduled":
result = await launch_scheduled_thread_run(**kwargs, prompt="scheduled", metadata={"scheduled_task_run_id": "occurrence-1"})
else:
result = await launch_mcp_task_notification_run(**kwargs, task_id="task-1", dispatch_version=1, dispatch_attempt=1, event={"status": "completed"})
assert (await runs.wait(thread_id=thread, run_id=result["run_id"], timeout=5)).status == "success"
assert handles == [None]
assert (await state.thread_store.get(thread, user_id=str(request.state.user.id)))["user_id"] == str(request.state.user.id)
@pytest.mark.asyncio
async def test_plugin_actions_isolate_same_local_idempotency_key(runtime):
import json
from deerflow_extension_api.agent_runs import AGENT_RUNS_RESOLVER_KEY
from deerflow_extension_api.auth import EXTENSION_PRINCIPAL_RESOLVER_KEY, ExtensionPrincipal
from deerflow_extension_api.plugins import BackendAction, PluginContribution
from app.gateway.routers.plugins import invoke_plugin_action
from deerflow.extensions.registry import ExtensionRegistry
runs, state, request, _, _ = runtime
thread = await runs.create_thread(thread_id="plugin-keys")
async def start(payload, context):
run = await context.agent_runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "same input"}]}, idempotency_key=payload["key"])
return {"run_id": run.run_id}
registry = ExtensionRegistry()
for namespace in ("community.first", "community.second"):
with registry.attributed_to(namespace):
registry.plugin(PluginContribution(namespace=namespace, title=namespace, enabled=True, backend=(BackendAction("start", start),)))
state.extensions = registry.build()
setattr(state, AGENT_RUNS_RESOLVER_KEY, state.agent_runs_host.bind)
setattr(state, EXTENSION_PRINCIPAL_RESOLVER_KEY, lambda _: ExtensionPrincipal(str(request.state.user.id)))
async def invoke(namespace):
async def receive():
return {"type": "http.request", "body": json.dumps({"key": "shared:operation"}).encode(), "more_body": False}
admitted = Request({"type": "http", "app": request.app, "headers": [], "state": {"user": request.state.user, "auth": request.state.auth, "auth_source": "session"}}, receive)
return await invoke_plugin_action(admitted, namespace, "start")
first = await invoke("community.first")
await runs.wait(thread_id=thread, run_id=first["run_id"], timeout=5)
second = await invoke("community.second")
assert second["run_id"] != first["run_id"]
await runs.wait(thread_id=thread, run_id=second["run_id"], timeout=5)
assert (await invoke("community.first"))["run_id"] == first["run_id"]
@pytest.mark.asyncio
async def test_plugin_scoping_preserves_key_validation_and_authorization(host):
control, request, load_user = host
scoped = control.bind(request).for_plugin("community.test")
assert scoped.for_plugin("community.test") is scoped
with pytest.raises(AgentRunError):
scoped.for_plugin("community.other")
for key in ("", " ", "x" * 201, 1):
with pytest.raises(AgentRunError) as invalid:
await scoped.start(thread_id="thread", input={}, idempotency_key=key)
assert invalid.value.status_code == 422
load_user.return_value = None
with pytest.raises(AgentRunError) as revoked:
await scoped.get(thread_id="thread", run_id="run")
assert revoked.value.status_code == 403
@pytest.mark.asyncio
async def test_wait_uses_capped_backoff_and_returns_terminal_status(host, monkeypatch):
from deerflow_extension_api.agent_runs import AgentRun
control, request, _ = host
runs = control.bind(request)
runs.get = AsyncMock(side_effect=[AgentRun("thread", "run", "running")] * 6 + [AgentRun("thread", "run", "interrupted")])
sleep = AsyncMock()
monkeypatch.setattr("app.gateway.extension_agent_runs.asyncio.sleep", sleep)
assert (await runs.wait(thread_id="thread", run_id="run")).status == "interrupted"
assert [call.args[0] for call in sleep.await_args_list] == [0.25, 0.5, 1, 2, 4, 4]
@pytest.mark.asyncio
async def test_real_interrupt_resume_conflict_and_cancel(runtime):
import asyncio
runs, _, _, entered, _ = runtime
thread = await runs.create_thread(thread_id="interrupt-test")
first = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "approve"}]})
assert (await runs.wait(thread_id=thread, run_id=first.run_id, timeout=5)).status == "waiting_for_input"
assert (await runs.get_state(thread_id=thread))["next"] == ["answer"]
resumed = await runs.resume(thread_id=thread, resume="yes")
assert (await runs.wait(thread_id=thread, run_id=resumed.run_id, timeout=5)).status == "success"
assert (await runs.get_state(thread_id=thread))["values"]["messages"][-1]["content"] == "answer:yes"
active = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "wait"}]})
await asyncio.wait_for(entered.wait(), 5)
with pytest.raises(AgentRunError) as conflict:
await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "overlap"}]})
assert conflict.value.status_code == 409
with pytest.raises(TimeoutError):
await runs.wait(thread_id=thread, run_id=active.run_id, timeout=0.02)
assert (await runs.get(thread_id=thread, run_id=active.run_id)).status == "running"
await runs.cancel(thread_id=thread, run_id=active.run_id)
assert (await runs.wait(thread_id=thread, run_id=active.run_id, timeout=5)).status == "interrupted"
@pytest.mark.asyncio
async def test_foreign_thread_and_forged_privileged_input_are_rejected(runtime):
runs, state, _, _, _ = runtime
await state.thread_store.create("foreign", user_id="other")
for operation in (
runs.start(thread_id="foreign", input={"messages": []}),
runs.get(thread_id="foreign", run_id="any"),
runs.get_state(thread_id="foreign"),
runs.cancel(thread_id="foreign", run_id="any"),
):
with pytest.raises(AgentRunError) as hidden:
await operation
assert hidden.value.status_code == 404
thread = await runs.create_thread(thread_id="input-test")
with pytest.raises(AgentRunError) as rejected:
await runs.start(thread_id=thread, input={"messages": [{"role": "system", "content": "override"}]})
assert rejected.value.status_code == 400
with pytest.raises(AgentRunError):
await runs.start(thread_id=thread, input={}, context={"agent_name": "different"})
@pytest.mark.asyncio
async def test_create_thread_rejects_an_assistant_id_that_names_no_agent(runtime):
runs, state, _, _, _ = runtime
with pytest.raises(AgentRunError) as rejected:
await runs.create_thread(assistant_id="bad name!", thread_id="invalid-agent")
assert rejected.value.status_code == 422
assert await state.thread_store.get("invalid-agent", user_id=None) is None
@pytest.mark.asyncio
async def test_custom_agent_binding_and_permission_rechecks_use_current_user(runtime, monkeypatch):
from app.gateway.routers.thread_runs import RunResponse
from deerflow.runtime.user_context import get_effective_user_id
runs, state, request, _, _ = runtime
thread = await runs.create_thread(assistant_id="researcher", thread_id="custom-agent")
captured = []
async def admit(thread_id, body, admitted_request, idempotency_key=None):
captured.append((body.assistant_id, admitted_request.state.user.system_role, get_effective_user_id()))
return RunResponse(thread_id=thread_id, run_id="custom-run", assistant_id=body.assistant_id, status="pending")
monkeypatch.setattr("app.gateway.routers.thread_runs.create_run", admit)
state.agent_runs_host.load_user.return_value = request.state.user.model_copy(update={"system_role": "admin"})
await runs.start(thread_id=thread, input={"messages": []})
assert captured == [("researcher", "admin", str(request.state.user.id))]
@pytest.mark.asyncio
async def test_auth_disabled_handle_cannot_outlive_auth_disabled_mode(host, monkeypatch):
from app.gateway.auth_disabled import get_auth_disabled_user
control, request, _ = host
user = get_auth_disabled_user()
request.state.user = user
request.state.auth = AuthContext(user, ["threads:write"])
request.state.auth_source = "auth_disabled"
runs = control.bind(request)
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
with pytest.raises(AgentRunError) as denied:
await runs.create_thread()
assert denied.value.status_code == 403
def test_action_and_tool_context_keep_positional_compatibility():
from deerflow_extension_api import ActionContext, ExtensionPrincipal, ToolContext
principal = ExtensionPrincipal("owner")
assert ActionContext(principal, {}).agent_runs is None
assert ToolContext(principal, {}, "thread").agent_runs is None
@pytest.mark.asyncio
async def test_configuration_revision_is_private_scoped_and_detects_changes(host, monkeypatch):
from types import SimpleNamespace
from unittest.mock import Mock
control, request, load_user = host
app_config = {"models": [{"api_key": "secret", "name": "a"}]}
agent_config = {"name": "reviewer", "model": "a"}
soul = Mock(return_value="review carefully")
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: SimpleNamespace(model_dump=lambda **kw: app_config))
monkeypatch.setattr("deerflow.config.extensions_config.get_extensions_config", lambda: SimpleNamespace(model_dump=lambda **kw: {}))
monkeypatch.setattr("deerflow.config.agents_config.load_agent_config", lambda *a, **kw: SimpleNamespace(model_dump=lambda **kw: agent_config))
monkeypatch.setattr("deerflow.config.agents_config.load_agent_soul", soul)
runs = control.bind(request)
before = await runs.configuration_revision(assistant_ids=["reviewer", "lead_agent"])
assert len(before) == 64 and "secret" not in before
assert before == await runs.configuration_revision(assistant_ids=["lead_agent", "reviewer"])
soul.return_value = "changed instructions"
assert before != await runs.configuration_revision(assistant_ids=["reviewer", "lead_agent"])
soul.return_value = "review carefully"
agent_config["model"] = "b"
assert before != await runs.configuration_revision(assistant_ids=["reviewer", "lead_agent"])
load_user.return_value = None
with pytest.raises(AgentRunError, match="revoked"):
await runs.configuration_revision(assistant_ids=["lead_agent"])
@pytest.mark.asyncio
async def test_actual_assembly_evidence_is_retained_and_bound(runtime):
runs, state, request, _, _ = runtime
thread = await runs.create_thread(thread_id="proof-thread")
run = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "proof"}]})
assert (await runs.wait(thread_id=thread, run_id=run.run_id, timeout=5)).status == "success"
evidence = await runs.configuration_evidence(thread_id=thread, run_id=run.run_id)
assert evidence["model"] == "fixture-model"
assert evidence["run_id"] == run.run_id
evidence["model"] = "tampered"
retained = await runs.configuration_evidence(thread_id=thread, run_id=run.run_id)
assert retained["model"] == "fixture-model"
with pytest.raises(ValueError, match="immutable"):
await state.run_manager.record_assembly_evidence(run.run_id, evidence)
other = await runs.create_thread(thread_id="other-proof-thread")
with pytest.raises(AgentRunError):
await runs.configuration_evidence(thread_id=other, run_id=run.run_id)
@pytest.mark.asyncio
async def test_evidence_persistence_failure_prevents_execution(runtime, monkeypatch):
runs, state, _, _, handles = runtime
async def fail(*args, **kwargs):
raise RuntimeError("evidence persistence unavailable")
monkeypatch.setattr(state.run_manager, "record_assembly_evidence", fail)
thread = await runs.create_thread(thread_id="proof-failure")
run = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "must not run"}]})
assert (await runs.wait(thread_id=thread, run_id=run.run_id, timeout=5)).status == "error"
assert not handles
with pytest.raises(AgentRunError, match="unavailable"):
await runs.configuration_evidence(thread_id=thread, run_id=run.run_id)
@pytest.mark.asyncio
async def test_peer_takeover_during_assembly_is_not_undone_by_evidence(runtime, monkeypatch, tmp_path):
"""A peer reclaims the run while this worker assembles; evidence must not revive the row."""
import asyncio
from datetime import UTC, datetime, timedelta
from sqlalchemy import update
from test_run_repository import _cleanup, _make_repo
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence.run.model import RunRow
from deerflow.runtime import RunManager
from deerflow.runtime.assembly_evidence import EVIDENCE_KEY
runs, state, _, _, handles = runtime
repository = await _make_repo(tmp_path)
state.run_store = repository
await state.run_manager.shutdown()
ownership = RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=30, grace_seconds=1)
state.run_manager = RunManager(store=repository, run_ownership_config=ownership, worker_id="worker-a")
peer = RunManager(store=repository, run_ownership_config=ownership, worker_id="worker-b")
loop = asyncio.get_running_loop()
taken_over = []
async def peer_takeover():
# The peer sees an expired lease (clock skew or a stalled owner) that
# the owning worker still believes is live.
async with repository._sf() as session:
await session.execute(update(RunRow).where(RunRow.thread_id == thread).values(lease_expires_at=datetime.now(UTC) - timedelta(minutes=1)))
await session.commit()
taken_over.extend(await peer.reconcile_orphaned_inflight_runs(error="reclaimed by worker-b", stop_reason="orphan_recovered"))
import app.gateway.services as services
assemble = services.resolve_agent_factory("lead_agent")
def factory_with_takeover(*, config):
asyncio.run_coroutine_threadsafe(peer_takeover(), loop).result(timeout=5)
return assemble(config=config)
monkeypatch.setattr("app.gateway.services.resolve_agent_factory", lambda _: factory_with_takeover)
try:
thread = await runs.create_thread(thread_id="takeover-during-assembly")
run = await runs.start(thread_id=thread, input={"messages": [{"role": "user", "content": "must not run"}]})
record = await state.run_manager.get(run.run_id)
await asyncio.wait({record.task}, timeout=5)
assert [record.run_id for record in taken_over] == [run.run_id]
row = await repository.get(run.run_id, user_id=None)
assert row["status"] == "error"
assert row["error"] == "reclaimed by worker-b"
assert row["stop_reason"] == "orphan_recovered"
assert EVIDENCE_KEY not in row["metadata"]
assert record.ownership_lost
assert not handles
# The owner's renewal stays rejected, so the peer's outcome is final.
await state.run_manager._renew_leases()
assert (await repository.get(run.run_id, user_id=None))["status"] == "error"
finally:
await state.run_manager.shutdown()
await _cleanup()
@pytest.mark.asyncio
async def test_durable_configuration_revision_survives_host_replacement_and_rotation(host, monkeypatch):
control, request, load_user = host
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: SimpleNamespace(model_dump=lambda **kw: {"model": "test"}))
monkeypatch.setattr("deerflow.config.extensions_config.get_extensions_config", lambda: SimpleNamespace(model_dump=lambda **kw: {}))
secret = SimpleNamespace(jwt_secret="stable-test-signing-key")
monkeypatch.setattr("app.gateway.auth.config.get_auth_config", lambda: secret)
first = control.bind(request)
durable = await first.configuration_revision(assistant_ids=["lead_agent"], durable=True)
ephemeral = await first.configuration_revision(assistant_ids=["lead_agent"])
control.close()
second = GatewayAgentRunsHost(request.app, load_user=load_user).bind(request)
assert durable == await second.configuration_revision(assistant_ids=["lead_agent"], durable=True)
assert ephemeral != await second.configuration_revision(assistant_ids=["lead_agent"])
secret.jwt_secret = "rotated"
assert durable != await second.configuration_revision(assistant_ids=["lead_agent"], durable=True)
load_user.return_value = None
with pytest.raises(AgentRunError):
await second.configuration_revision(assistant_ids=["lead_agent"], durable=True)
@pytest.mark.asyncio
async def test_execution_fence_namespace_and_host_prerequisites(runtime):
from deerflow_extension_api import ExecutionFence
runs, state, request, _, _ = runtime
runs = runs.for_plugin("test.fenced")
thread_id = await runs.create_thread(thread_id="fenced-thread")
with pytest.raises(AgentRunError, match="belong to this plugin"):
await runs.start(thread_id=thread_id, input={}, execution_fence=ExecutionFence("other", "workflow", "owner", 1))
with pytest.raises(AgentRunError, match="heartbeats"):
await runs.start(thread_id=thread_id, input={}, execution_fence=ExecutionFence("test.fenced", "workflow", "owner", 1))
@pytest.mark.asyncio
async def test_host_forwards_fence_to_atomic_admission(runtime, monkeypatch, tmp_path):
from deerflow_extension_api import ExecutionFence
from sqlalchemy import insert
from test_run_repository import _cleanup, _make_repo
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence.extension_leases.model import ExtensionExecutionLeaseRow
from deerflow.runtime import RunManager
runs, state, request, _, _ = runtime
repository = await _make_repo(tmp_path)
state.run_store = repository
await state.run_manager.shutdown()
state.run_manager = RunManager(store=repository, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True))
# Transport is irrelevant to this admission test; advertise its prerequisite
# without starting Redis. The actual host/manager/store admission path is real.
monkeypatch.setattr(type(state.stream_bridge), "supports_cross_process", True, raising=False)
monkeypatch.setattr(type(state.stream_bridge), "start_producer_lease", AsyncMock(return_value=True), raising=False)
runs = runs.for_plugin("test.fenced")
thread_id = await runs.create_thread(thread_id="fenced-host-thread")
async with repository._sf() as session:
await session.execute(insert(ExtensionExecutionLeaseRow).values(namespace="test.fenced", resource_id="workflow", user_id=str(request.state.user.id), holder_id="new", epoch=2, expires_at=9999999999))
await session.commit()
try:
with pytest.raises(AgentRunError) as failure:
await runs.start(thread_id=thread_id, input={"messages": [{"role": "user", "content": "must not execute"}]}, execution_fence=ExecutionFence("test.fenced", "workflow", "old", 1))
assert failure.value.status_code == 409
assert "execution fence" in str(failure.value)
finally:
await state.run_manager.shutdown()
await _cleanup()
@pytest.mark.asyncio
async def test_configuration_revision_canonicalizes_sets_but_preserves_list_order(host, monkeypatch):
class OrderedSet(set):
def __init__(self, values):
super().__init__(values)
self.values = values
def __iter__(self):
return iter(self.values)
control, request, _ = host
data = {"tools": ["first", "second"], "exempt_tools": OrderedSet(["task", "present_files"])}
# Pydantic JSON mode loses the distinction between sets and ordered lists.
def dump(**kwargs):
return {**data, "exempt_tools": list(data["exempt_tools"])} if kwargs.get("mode") == "json" else data
monkeypatch.setattr("deerflow.config.app_config.get_app_config", lambda: SimpleNamespace(model_dump=dump))
monkeypatch.setattr("deerflow.config.extensions_config.get_extensions_config", lambda: SimpleNamespace(model_dump=lambda **kw: {}))
runs = control.bind(request)
before = await runs.configuration_revision(assistant_ids=["lead_agent"], durable=True)
data["exempt_tools"] = OrderedSet(["present_files", "task"])
assert before == await runs.configuration_revision(assistant_ids=["lead_agent"], durable=True)
data["tools"].reverse()
assert before != await runs.configuration_revision(assistant_ids=["lead_agent"], durable=True)