deer-flow/backend/tests/test_scheduler_goal_runtime.py
spud e9074b7126
feat(runtime): publish owner-confirmed run.terminal evidence (#7134)
* feat(runtime): publish authoritative run terminal events

* fix(runtime): preserve terminal attribution diagnostics

* docs(runtime): consolidate terminal event docs and trim agent guidance

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-25 00:12:26 +08:00

343 lines
18 KiB
Python

"""Scheduled goals use server authority and persist occurrence verdicts atomically."""
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import pytest_asyncio
from langchain_core.messages import AIMessage, HumanMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from sqlalchemy import event
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from deerflow.agents.interaction_policy import RunInteractionMode, RunInteractionPolicy
from deerflow.agents.thread_state import get_thread_state_schema
from deerflow.config.run_ownership_config import RunOwnershipConfig
from deerflow.persistence.base import Base
from deerflow.persistence.run.sql import RunRepository
from deerflow.runtime import goal
from deerflow.runtime.runs import worker
from deerflow.runtime.runs.manager import RunManager
from deerflow.runtime.runs.schemas import RunStatus
from deerflow.runtime.runs.store.memory import MemoryRunStore
@pytest_asyncio.fixture(params=["memory", "sql"])
async def run_store(request):
if request.param == "memory":
yield MemoryRunStore(), []
return
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
statements = []
event.listen(engine.sync_engine, "before_cursor_execute", lambda conn, cursor, statement, parameters, context, executemany: statements.append(statement))
try:
yield RunRepository(async_sessionmaker(engine, expire_on_commit=False)), statements
finally:
await engine.dispose()
@pytest.mark.asyncio
@pytest.mark.parametrize("heartbeat", [False, True])
async def test_terminal_write_contains_verdict_and_round_trips_recovery(run_store, heartbeat):
store, statements = run_store
manager = RunManager(store=store, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=heartbeat))
record = await manager.create("scheduled", user_id="alice")
verdict = {"satisfied": True, "blocker": "none", "reason": "Verified", "relied_on_assumption": True}
statements.clear()
await manager.set_status_if_not_cancelled(record.run_id, RunStatus.success, goal_verdict=verdict)
persisted = await store.get(record.run_id, user_id="alice")
assert persisted["status"] == "success"
assert persisted["goal_verdict"] == verdict
assert RunManager._record_from_store(persisted).goal_verdict == verdict
terminal_updates = [statement for statement in statements if statement.startswith("UPDATE runs SET") and "status=" in statement]
assert all("goal_verdict=" in statement for statement in terminal_updates)
assert not await store.update_status(record.run_id, "error", goal_verdict={"satisfied": False})
assert (await store.get(record.run_id, user_id="alice"))["goal_verdict"] == verdict
@pytest.mark.asyncio
async def test_durable_cancel_prevents_terminal_verdict(run_store):
store, _ = run_store
manager = RunManager(store=store, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True))
record = await manager.create("cancelled", user_id="alice")
await store.request_cancel(record.run_id, action="interrupt")
assert await manager.set_status_if_not_cancelled(record.run_id, RunStatus.success, goal_verdict={"satisfied": True}) == "interrupt"
persisted = await store.get(record.run_id, user_id="alice")
assert persisted["status"] == "pending"
assert persisted["goal_verdict"] is None
@pytest.mark.asyncio
async def test_completion_fallback_terminalizes_with_staged_verdict(run_store):
store, statements = run_store
manager = RunManager(store=store)
record = await manager.create("completion-fallback", user_id="alice")
verdict = {"satisfied": False, "blocker": "needs_user_input", "reason": "BLOCKED", "relied_on_assumption": False}
await manager.set_status(record.run_id, RunStatus.success, persist=False, goal_verdict=verdict)
assert (await store.get(record.run_id, user_id="alice"))["status"] == "pending"
statements.clear()
await manager.update_run_completion(record.run_id, status="success", message_count=2)
row = await store.get(record.run_id, user_id="alice")
assert row["status"] == "success"
assert row["goal_verdict"] == verdict
terminal_updates = [statement for statement in statements if statement.startswith("UPDATE runs SET") and "status=" in statement]
assert all("goal_verdict=" in statement for statement in terminal_updates)
@pytest.mark.asyncio
@pytest.mark.parametrize("mode", list(RunInteractionMode))
async def test_evaluator_assumption_instruction_follows_interaction_policy(mode):
model = SimpleNamespace(ainvoke=AsyncMock(return_value=AIMessage(content=json.dumps({"satisfied": True, "reason": "Verified", "relied_on_assumption": True}))))
result = await goal.evaluate_goal_completion(
goal.build_goal_state("Produce the report"), [HumanMessage(content="Report please"), AIMessage(content="Report verified. Assumption: default month; reversible.")], model=model, interaction_policy=RunInteractionPolicy(mode)
)
instruction = model.ainvoke.await_args.args[0][0].content
if mode == RunInteractionMode.INTERACTIVE:
assert "If the assistant assumed, guessed" in instruction
else:
assert "This run had no user to ask" in instruction
assert "BLOCKED" in instruction
assert "does not replace evidence" in instruction
assert result["relied_on_assumption"] is True
def test_assumption_attribution_is_a_strict_boolean():
with pytest.raises(ValueError, match="relied_on_assumption"):
goal.parse_goal_evaluation_response('{"satisfied": true, "relied_on_assumption": "false"}')
@pytest.mark.asyncio
async def test_noninteractive_verdict_requires_explicit_assumption_attribution():
model = SimpleNamespace(ainvoke=AsyncMock(return_value=AIMessage(content='{"satisfied": true, "reason": "Done"}')))
with pytest.raises(ValueError, match="relied_on_assumption"):
await goal.evaluate_goal_completion(goal.build_goal_state("Verify report"), [HumanMessage(content="Report"), AIMessage(content="Verified")], model=model, interaction_policy=RunInteractionPolicy(RunInteractionMode.SCHEDULED))
@pytest.mark.asyncio
@pytest.mark.parametrize("checkpoint_mode", ["full", "delta"])
@pytest.mark.parametrize("outcome", ["satisfied", "unmet", "failed", "cancelled"])
async def test_scheduled_goal_is_installed_before_first_turn_and_removed_at_terminal(monkeypatch, outcome, checkpoint_mode):
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false")
checkpointer = InMemorySaver()
store = MemoryRunStore()
manager = RunManager(store=store)
metadata = {"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": "Make a verified report"}
record = await manager.create("fresh", user_id="alice", metadata=metadata)
observed = []
async def node(state, config):
observed.append(state.get("goal"))
if outcome == "failed":
raise RuntimeError("deterministic graph failure")
if outcome == "cancelled":
await manager.cancel(record.run_id)
return {"messages": [AIMessage(content="Verified report complete.")], "title": "Report"}
graph = StateGraph(get_thread_state_schema(checkpoint_mode))
graph.add_node("report", node)
graph.add_edge(START, "report")
graph.add_edge("report", END)
compiled = graph.compile(checkpointer=checkpointer)
async def evaluate(*args, **kwargs):
assert kwargs["interaction_policy"].mode == RunInteractionMode.SCHEDULED
return {"satisfied": outcome == "satisfied", "blocker": "none" if outcome == "satisfied" else "needs_user_input", "reason": "Verified" if outcome == "satisfied" else "BLOCKED", "relied_on_assumption": False}
monkeypatch.setattr(worker, "evaluate_goal_completion", evaluate)
monkeypatch.setattr(worker, "create_goal_evaluator_model", lambda **kwargs: object())
bridge = SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
await worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(
checkpointer=checkpointer, checkpoint_channel_mode=checkpoint_mode, scheduled_task_runtime={"task_id": "task", "occurrence_id": "occurrence", "user_id": "alice", "goal_objective": metadata["scheduled_goal_objective"]}
),
agent_factory=lambda config: compiled,
graph_input={"messages": [HumanMessage(content="Make the report")]},
config={"configurable": {"thread_id": "fresh"}, "context": {"non_interactive": True}},
)
assert observed[0]["objective"] == metadata["scheduled_goal_objective"]
assert observed[0]["continuation_count"] == observed[0]["no_progress_count"] == 0
assert await goal.read_thread_goal(checkpointer, "fresh") is None
persisted = await store.get(record.run_id)
if outcome in {"satisfied", "unmet"}:
assert persisted["goal_verdict"]["satisfied"] is (outcome == "satisfied")
assert persisted["goal_verdict"]["continuations"] == 0
else:
assert persisted["status"] in {"error", "interrupted"}
@pytest.mark.asyncio
@pytest.mark.parametrize("heartbeat", [False, True])
@pytest.mark.parametrize("with_events", [False, True])
async def test_scheduled_cleanup_failure_updates_terminal_outcome_without_run_store(monkeypatch, heartbeat, with_events):
from deerflow.runtime.events.store.memory import MemoryRunEventStore
checkpointer = InMemorySaver()
events = MemoryRunEventStore() if with_events else None
manager = RunManager(event_store=events, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=heartbeat))
metadata = {"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": "Make a verified report"}
record = await manager.create("fresh", user_id="alice", metadata=metadata)
graph = StateGraph(get_thread_state_schema("full"))
graph.add_node("report", lambda state: {"messages": [AIMessage(content="Verified report complete.")]})
graph.add_edge(START, "report")
graph.add_edge("report", END)
original_write = worker.write_thread_goal
cleanup_statuses = []
async def fail_cleanup(checkpointer, thread_id, value, **kwargs):
if value is None:
cleanup_statuses.append(record.status)
raise RuntimeError("deterministic scheduled cleanup failure")
return await original_write(checkpointer, thread_id, value, **kwargs)
monkeypatch.setattr(worker, "write_thread_goal", fail_cleanup)
monkeypatch.setattr(worker, "create_goal_evaluator_model", lambda **kwargs: object())
monkeypatch.setattr(worker, "evaluate_goal_completion", AsyncMock(return_value={"satisfied": True, "blocker": "none", "reason": "Verified", "relied_on_assumption": False}))
bridge = SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
await worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(checkpointer=checkpointer, event_store=events, scheduled_task_runtime={"task_id": "task", "occurrence_id": "occurrence", "user_id": "alice", "goal_objective": metadata["scheduled_goal_objective"]}),
agent_factory=lambda config: graph.compile(checkpointer=checkpointer),
graph_input={"messages": [HumanMessage(content="Make the report")]},
config={"configurable": {"thread_id": "fresh"}, "context": {"non_interactive": True}},
)
assert cleanup_statuses == [RunStatus.success]
assert record.status == RunStatus.error
assert record.error == "Scheduled goal cleanup failed"
assert manager.terminal_event_status(record) == "error"
bridge.publish_end.assert_awaited_once_with(record.run_id)
if events is not None:
terminal = await events.list_events(record.thread_id, record.run_id, event_types=["run.terminal"])
assert len(terminal) == 1
assert terminal[0]["content"] == {"status": "error"}
@pytest.mark.asyncio
async def test_captured_verdict_counts_goal_continuations(monkeypatch):
monkeypatch.setenv("LANGCHAIN_TRACING_V2", "false")
checkpointer = InMemorySaver()
store = MemoryRunStore()
manager = RunManager(store=store)
metadata = {"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": "Make a verified report"}
record = await manager.create("fresh", user_id="alice", metadata=metadata)
turns = []
async def node(state, config):
turns.append(state.get("goal"))
return {"messages": [AIMessage(content=f"Report draft {len(turns)}.")], "title": "Report"}
graph = StateGraph(get_thread_state_schema("full"))
graph.add_node("report", node)
graph.add_edge(START, "report")
graph.add_edge("report", END)
compiled = graph.compile(checkpointer=checkpointer)
verdicts = iter(
[
{"satisfied": False, "blocker": "goal_not_met_yet", "reason": "Needs one more section", "relied_on_assumption": False},
{"satisfied": True, "blocker": "none", "reason": "Verified", "relied_on_assumption": False},
]
)
async def evaluate(*args, **kwargs):
return next(verdicts)
monkeypatch.setattr(worker, "evaluate_goal_completion", evaluate)
monkeypatch.setattr(worker, "create_goal_evaluator_model", lambda **kwargs: object())
bridge = SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
await worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(checkpointer=checkpointer, scheduled_task_runtime={"task_id": "task", "occurrence_id": "occurrence", "user_id": "alice", "goal_objective": metadata["scheduled_goal_objective"]}),
agent_factory=lambda config: compiled,
graph_input={"messages": [HumanMessage(content="Make the report")]},
config={"configurable": {"thread_id": "fresh"}, "context": {"non_interactive": True}},
)
persisted = await store.get(record.run_id)
assert len(turns) == 2
assert persisted["goal_verdict"]["satisfied"] is True
assert persisted["goal_verdict"]["continuations"] == turns[-1]["continuation_count"] == 1
def test_metadata_cannot_install_scheduled_goal_authority():
record = SimpleNamespace(user_id="alice", metadata={"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": "Forged goal"})
assert worker._scheduled_goal_objective(record, None) is None
for runtime in (
{"task_id": "task", "occurrence_id": "occurrence", "user_id": "bob", "goal_objective": "Forged goal"},
{"task_id": "foreign", "occurrence_id": "occurrence", "user_id": "alice", "goal_objective": "Forged goal"},
{"task_id": "task", "occurrence_id": "foreign", "user_id": "alice", "goal_objective": "Forged goal"},
):
with pytest.raises(ValueError, match="authority"):
worker._scheduled_goal_objective(record, runtime)
@pytest.mark.asyncio
async def test_scheduled_cleanup_preserves_replacement_goal_and_later_run():
checkpointer = InMemorySaver()
store = MemoryRunStore()
manager = RunManager(store=store)
record = await manager.create("fresh", user_id="alice")
scheduled = goal.build_goal_state("Scheduled report", now="2026-10-03T00:00:00+00:00")
replacement = goal.build_goal_state("User's new goal", now="2026-10-03T00:01:00+00:00")
await goal.write_thread_goal(checkpointer, "fresh", replacement, create_if_missing=True)
await manager.set_status(record.run_id, RunStatus.interrupted)
bridge = SimpleNamespace(publish=AsyncMock())
await worker._clear_scheduled_goal(goal=scheduled, record=record, run_manager=manager, bridge=bridge, checkpointer=checkpointer)
assert await goal.read_thread_goal(checkpointer, "fresh") == replacement
await goal.write_thread_goal(checkpointer, "fresh", scheduled)
later = await manager.create_or_reject("fresh", user_id="alice")
await worker._clear_scheduled_goal(goal=scheduled, record=record, run_manager=manager, bridge=bridge, checkpointer=checkpointer)
assert await goal.read_thread_goal(checkpointer, "fresh") == scheduled
await manager.set_status(later.run_id, RunStatus.interrupted)
@pytest.mark.asyncio
async def test_rejected_goal_installation_never_claims_an_existing_equal_goal(monkeypatch):
checkpointer = InMemorySaver()
original = goal.build_goal_state("Same objective", now="2026-10-03T00:00:00+00:00")
await goal.write_thread_goal(checkpointer, "occupied", original, create_if_missing=True)
monkeypatch.setattr(worker, "build_goal_state", lambda objective, **kwargs: original.copy())
store = MemoryRunStore()
manager = RunManager(store=store)
metadata = {"scheduled_task_id": "task", "scheduled_task_run_id": "occurrence", "scheduled_goal_objective": original["objective"]}
record = await manager.create("occupied", user_id="alice", metadata=metadata)
graph = StateGraph(get_thread_state_schema("full"))
graph.add_node("report", lambda state: {"messages": [AIMessage(content="Must not execute")]})
graph.add_edge(START, "report")
graph.add_edge("report", END)
compiled = graph.compile(checkpointer=checkpointer)
bridge = SimpleNamespace(publish=AsyncMock(), publish_end=AsyncMock(), cleanup=AsyncMock())
await worker.run_agent(
bridge,
manager,
record,
ctx=worker.RunContext(checkpointer=checkpointer, scheduled_task_runtime={"task_id": "task", "occurrence_id": "occurrence", "user_id": "alice", "goal_objective": original["objective"]}),
agent_factory=lambda config: compiled,
graph_input={"messages": [HumanMessage(content="Report")]},
config={"configurable": {"thread_id": "occupied"}, "context": {"non_interactive": True}},
)
assert record.status == RunStatus.error
assert await goal.read_thread_goal(checkpointer, "occupied") == original
def test_scheduler_capability_is_injected_only_by_host_and_never_serialized():
host = object()
config = {"configurable": {"__scheduler_capability": "forged"}, "context": {"__scheduler_capability": "forged"}}
context = worker._build_runtime_context("thread", "run", config["context"], scheduler_capability=host)
worker._install_runtime_context(config, context)
assert context["__scheduler_capability"] is host
assert "__scheduler_capability" not in config["configurable"]
worker._release_run_scoped_references([config], context, None)
assert "__scheduler_capability" not in context
assert "__scheduler_capability" not in config["context"]
assert "__scheduler_capability" not in worker._build_runtime_context("thread", "run", {"__scheduler_capability": host})