deer-flow/backend/tests/test_run_producer_worker.py
spud 21175ba46f
fix(runtime): persistently fence stale run event producers (#7619)
* fix(runtime): fence revoked run event producers

* fix(runtime): reduce off-mode producer guard locking

* docs: keep inherited guidance within size budget

* chore(runtime): remove development phase labels

* fix(persistence): normalize libpq SSL options for asyncpg connections

* docs(runtime): clarify producer fencing behavior in off mode

* test(persistence): align migration TLS probes with channel-binding rejection

* fix(runtime): authorize fenced Gateway history seeding

* fix(scheduler): preserve takeover identity in recovery publication

* fix(scheduler): report unconfirmed recovery publication

* fix(runtime): preserve history messages before the first human turn

* fix(runtime): clarify run creation failure diagnostic

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-02 15:26:42 +08:00

161 lines
8.6 KiB
Python

"""Strict producer permissions survive the actual Worker and publisher paths."""
import asyncio
import pytest
from test_run_producer_cutover import cutover_db as cutover_db
@pytest.mark.asyncio
async def test_strict_worker_keeps_terminal_hook_open_then_closes_after_drain(cutover_db):
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence.run.producer import activate_producer_fencing
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime.events.producer import current_producer
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.worker import RunContext, _SubagentEventBuffer, run_agent
from deerflow.runtime.stream_bridge import MemoryStreamBridge
_, sf, _ = cutover_db
await activate_producer_fencing(sf, cutover_id="worker-test")
events = DbRunEventStore(sf, producer_fencing="strict", producer_cutover_id="worker-test")
runs = RunRepository(sf)
manager = RunManager(store=runs, event_store=events, worker_id="owner", run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=300))
record = await manager.create_or_reject("thread", user_id="alice")
holder = {}
class RootAgent:
async def astream(self, graph_input, config=None, **kwargs):
holder["journal"] = config["context"]["__run_journal"]
yield {"messages": []}
async def hook(completed):
row = await runs.get(completed.run_id, user_id="alice")
assert row["status"] == "success"
assert row["producer_state"] == "open"
assert current_producer().run_incarnation == record.run_incarnation
assert await asyncio.to_thread(lambda: current_producer().producer_epoch) == record.producer_epoch
holder["journal"].record_middleware("completion", name="hook", hook="completion", action="accepted", changes={})
# Same production buffer used by the Worker for delegated task events.
buffer = _SubagentEventBuffer(events, record.thread_id, record.run_id)
await buffer.add({"type": "task_completed", "task_id": "subtask", "result": "done"})
await buffer.flush()
task = asyncio.create_task(
run_agent(
MemoryStreamBridge(), manager, record, ctx=RunContext(checkpointer=None, event_store=events, on_run_completed=hook), agent_factory=lambda **kwargs: RootAgent(), graph_input={}, config={"configurable": {"thread_id": "thread"}}
)
)
record.task = task
await asyncio.wait_for(task, 15)
row = await runs.get(record.run_id, user_id="alice")
written = await events.list_events("thread", record.run_id, user_id="alice")
types = [event["event_type"] for event in written]
assert "middleware:completion" in types
assert "subagent.end" in types
assert types.count("run.delivery") == types.count("run.terminal") == 1
terminal_seq = next(event["seq"] for event in written if event["event_type"] == "run.terminal")
assert all(event["seq"] < terminal_seq for event in written if event["event_type"] in {"middleware:completion", "subagent.end"})
assert row["producer_state"] == "closed"
assert row["evidence_seal_state"] == "sealed"
assert current_producer() is None
@pytest.mark.asyncio
async def test_strict_takeover_publisher_has_separate_retention_bound_authority(cutover_db):
from datetime import UTC, datetime, timedelta
from deerflow.persistence.run.producer import activate_producer_fencing
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.events.terminal import RecoveryTerminalPublisher
_, sf, _ = cutover_db
await activate_producer_fencing(sf, cutover_id="takeover-test")
runs = RunRepository(sf)
events = DbRunEventStore(sf, producer_fencing="strict")
await runs.put("run", thread_id="thread", user_id="alice", status="running", owner_worker_id="old", lease_expires_at=(datetime.now(UTC) - timedelta(seconds=60)).isoformat())
old = await runs.get("run", user_id="alice")
claimed = await runs.claim_for_takeover("run", grace_seconds=0, error="orphan")
publisher = RecoveryTerminalPublisher(events, runs)
target = publisher.authorize_takeover(claimed=claimed, thread_id="thread", run_id="run", user_id="alice", operation_kind="run", run_incarnation=old["run_incarnation"], retention_revision=old["evidence_retention_revision"])
assert (await publisher.publish(target)).confirmed
assert [event["event_type"] for event in await events.list_events("thread", "run", user_id="alice")] == ["run.delivery", "run.terminal"]
@pytest.mark.asyncio
async def test_strict_journal_keeps_host_identity_across_contextless_callback(cutover_db):
import threading
from types import SimpleNamespace
from deerflow.runtime.events.producer import producer_authority, producer_scope
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.journal import RunJournal
_, sf, _ = cutover_db
from deerflow.persistence.run.sql import RunRepository
row = await RunRepository(sf).put("run", thread_id="thread", user_id="alice", owner_worker_id="owner")
events = DbRunEventStore(sf, producer_fencing="strict")
with producer_scope(producer_authority(SimpleNamespace(**row))):
journal = RunJournal("run", "thread", events, user_id="alice", flush_threshold=1)
def callback():
journal.record_middleware("thread", name="raw-thread", hook="tool", action="accepted", changes={})
thread = threading.Thread(target=callback)
thread.start()
thread.join(timeout=1)
assert not thread.is_alive()
try:
await journal.flush()
assert [event["event_type"] for event in await events.list_events("thread", "run", user_id="alice")] == ["middleware:thread"]
finally:
await journal.close(flush=False)
@pytest.mark.asyncio
async def test_detached_idempotent_handle_cannot_publish_owner_receipt(cutover_db):
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime.events.producer import producer_authority
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.runtime.runs.manager import RunManager, RunStartupError
from deerflow.runtime.runs.worker import RunContext, run_agent
from deerflow.runtime.stream_bridge import MemoryStreamBridge
_, sf, _ = cutover_db
runs = RunRepository(sf)
owner = RunManager(store=runs, worker_id="owner")
admitted = await owner.create_or_reject("thread", user_id="alice", idempotency_key="same-request")
peer = RunManager(store=runs, worker_id="peer")
handle = await peer.create_or_reject("thread", user_id="alice", idempotency_key="same-request")
assert handle.store_only and handle.idempotency_reused
assert producer_authority(handle) is None
events = DbRunEventStore(sf, producer_fencing="strict")
with pytest.raises(RunStartupError):
await run_agent(MemoryStreamBridge(), peer, handle, ctx=RunContext(checkpointer=None, event_store=events), agent_factory=lambda **kwargs: pytest.fail("Detached handle executed the agent"), graph_input={}, config={})
assert (await runs.get(admitted.run_id, user_id="alice"))["status"] == "pending"
assert await events.list_events("thread", admitted.run_id, user_id="alice") == []
@pytest.mark.asyncio
async def test_workspace_recorder_preserves_producer_grant(cutover_db, tmp_path, monkeypatch):
from types import SimpleNamespace
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime.events.producer import producer_authority, producer_scope
from deerflow.runtime.events.store.db import DbRunEventStore
from deerflow.workspace_changes import WorkspaceRoot, record_workspace_changes, scan_workspace_roots
_, sf, _ = cutover_db
row = await RunRepository(sf).put("run", thread_id="thread", user_id="alice", status="success", owner_worker_id="owner")
roots = [WorkspaceRoot(name="workspace", host_path=tmp_path, virtual_prefix="/mnt/user-data/workspace")]
monkeypatch.setattr("deerflow.workspace_changes.recorder.build_thread_workspace_roots", lambda *args, **kwargs: roots)
before = scan_workspace_roots(roots)
(tmp_path / "report.txt").write_text("completed", encoding="utf-8")
with producer_scope(producer_authority(SimpleNamespace(**row))):
event = await record_workspace_changes(DbRunEventStore(sf, producer_fencing="strict"), "thread", "run", before, user_id="alice")
assert event["event_type"] == "workspace_changes"
assert event["user_id"] == "alice"