deer-flow/backend/tests/test_workflow_recovery.py
Xuehao Xu b6ce047510
feat(workflows): add durable restart recovery (Phase 3) (#7075)
* feat(workflows): recover durable workflows after Gateway restart

* docs(workflows): keep recovery guidance within inherited budget

* fix(workflows): retain cancellation ownership and stabilize turn-limit test

* fix(workflows): log service-owned action failures
2026-10-19 15:24:53 +08:00

409 lines
18 KiB
Python

"""Recovery exercises the isolated script worker and durable SQLite boundaries."""
import asyncio
import copy
import os
from dataclasses import replace
import pytest
import test_workflow_plugin as fixtures
from test_workflow_plugin import context, done, launch, waiting
plugin = fixtures.plugin
pytestmark = [pytest.mark.asyncio, pytest.mark.skipif(os.name != "posix", reason="Workflow execution requires POSIX")]
async def restart_snapshot(plugin, run_id):
"""Retain the last committed crash image, without shutdown's cleanup writes."""
service = plugin[2]
record = await asyncio.to_thread(service.store.get, "alice", run_id)
await service.stop()
current = await asyncio.to_thread(service.store.get, "alice", run_id)
record["revision"] = current.get("revision", 0)
await asyncio.to_thread(service.store.put, "alice", record)
await service.start(None)
return record
async def test_restart_approval_reuses_outputs_and_exact_version(plugin):
_, actions, service, runs = plugin
result = await launch(
plugin,
"const a=await agent({stepId:'a',agentRef:'lead_agent',task:1}); await humanCheckpoint({stepId:'review',sourceStepId:'a',artifactVersion:a.artifactVersion}); return await agent({stepId:'b',agentRef:'reviewer',task:a.output});",
)
run_id = result["run_id"]
paused = await waiting(plugin, run_id, "waiting_approval")
await restart_snapshot(plugin, run_id)
assert (await actions["get"]({"run_id": run_id}, context(runs)))["status"] == "recoverable"
session = replace(context(runs), interaction="session")
await actions["recover"]({"run_id": run_id}, session)
recovered = await waiting(plugin, run_id, "waiting_approval")
assert recovered["checkpoints"] == paused["checkpoints"]
checkpoint = recovered["checkpoints"][0]
payload = dict(run_id=run_id, step_id="review", attempt=1, artifact_version=checkpoint["artifact_version"], decision="approve")
await asyncio.gather(actions["decide"](payload, session), actions["decide"](payload, session))
final = await done(plugin, run_id)
assert final["status"] == "completed", final
assert len(runs.starts) == 2
async def test_recovery_rejects_changed_config_and_revoked_permission(plugin):
from deerflow_extension_api import AgentRunError
_, actions, service, runs = plugin
run_id = (await launch(plugin, "await pause({stepId:'p'}); return 1;"))["run_id"]
await waiting(plugin, run_id, "paused")
await restart_snapshot(plugin, run_id)
session = replace(context(runs), interaction="session")
runs.denied = True
with pytest.raises(AgentRunError):
await actions["recover"]({"run_id": run_id}, session)
runs.denied = False
runs.revision = "changed"
with pytest.raises(ValueError, match="configuration"):
await actions["recover"]({"run_id": run_id}, session)
assert not runs.starts
async def test_store_rejects_stale_revision(plugin):
service = plugin[2]
run_id = (await launch(plugin, "return 1;"))["run_id"]
await done(plugin, run_id)
first = await asyncio.to_thread(service.store.get, "alice", run_id)
stale = copy.deepcopy(first)
await asyncio.to_thread(service.store.put, "alice", first)
with pytest.raises(ValueError, match="revision"):
await asyncio.to_thread(service.store.put, "alice", stale)
async def install_snapshot(plugin, record):
service = plugin[2]
await service.stop()
latest = await asyncio.to_thread(service.store.get, "alice", record["id"])
record = copy.deepcopy(record)
record["revision"] = latest.get("revision", 0)
await asyncio.to_thread(service.store.put, "alice", record)
await service.start(None)
async def test_approval_committed_before_dispatch_recovers_once(plugin):
_, actions, service, runs = plugin
run_id = (
await launch(
plugin, "const a=await agent({stepId:'a',agentRef:'lead_agent',task:1}); await humanCheckpoint({stepId:'c',sourceStepId:'a',artifactVersion:a.artifactVersion}); return await agent({stepId:'b',agentRef:'reviewer',task:2});"
)
)["run_id"]
await waiting(plugin, run_id, "waiting_approval")
record = await asyncio.to_thread(service.store.get, "alice", run_id)
record["checkpoints"][0].update(status="approved", decided_by="alice", decided_at=1)
await install_snapshot(plugin, record)
session = replace(context(runs), interaction="session")
await asyncio.gather(actions["recover"]({"run_id": run_id}, session), actions["recover"]({"run_id": run_id}, session))
result = await done(plugin, run_id)
assert result["status"] == "completed", result
assert len(runs.starts) == 2
@pytest.mark.parametrize("boundary", ["admission", "result"])
async def test_child_crash_boundaries_never_repeat_dispatch(plugin, monkeypatch, boundary):
_, actions, service, runs = plugin
snapshots = []
original = runs.start if boundary == "admission" else runs.configuration_evidence
async def capture(**kw):
value = await original(**kw)
entry = next(iter(service.entries.values()))
snapshots.append(await asyncio.to_thread(service.store.get, "alice", entry["record"]["id"]))
return value
monkeypatch.setattr(runs, "start" if boundary == "admission" else "configuration_evidence", capture)
run_id = (await launch(plugin, "return await agent({stepId:'a',agentRef:'lead_agent',task:1});"))["run_id"]
await done(plugin, run_id)
await install_snapshot(plugin, snapshots[0])
session = replace(context(runs), interaction="session")
result = await actions["recover"]({"run_id": run_id}, session)
if boundary == "admission":
assert result["status"] == "needs_reconciliation"
else:
final = await done(plugin, run_id)
assert final["status"] == "completed", final
assert final["result"]["output"] == {"echo": 1}
assert len(runs.starts) == 1
async def test_parallel_results_and_file_approval_survive_restart(plugin):
_, actions, service, runs = plugin
script = (
"const results=await parallel([()=>agent({stepId:'a',agentRef:'lead_agent',task:1}),()=>agent({stepId:'b',agentRef:'reviewer',task:2})]); "
"const file=await captureArtifact({stepId:'f',sourceStepId:'a',path:'report.txt'}); "
"await humanCheckpoint({stepId:'c',sourceStepId:'f',artifactVersion:file.artifactVersion}); "
"return await readArtifact({artifactVersion:file.artifactVersion});"
)
run_id = (await launch(plugin, script))["run_id"]
paused = await waiting(plugin, run_id, "waiting_approval")
await restart_snapshot(plugin, run_id)
runs.file_content = b"changed source must not be read"
session = replace(context(runs), interaction="session")
await actions["recover"]({"run_id": run_id}, session)
await waiting(plugin, run_id, "waiting_approval")
await actions["decide"](dict(run_id=run_id, step_id="c", attempt=1, artifact_version=paused["checkpoints"][0]["artifact_version"], decision="approve"), session)
final = await done(plugin, run_id)
assert final["status"] == "completed", final
import base64
assert base64.b64decode(final["result"]["content_base64"]) == b"original file"
assert len(runs.starts) == 2
async def test_real_process_kill_during_review(plugin):
import json
import os
import sys
from pathlib import Path
_, actions, service, runs = plugin
await service.stop()
root = Path(__file__).resolve().parents[2]
program = r"""
import asyncio,json,sys
from deerflow_extension_workflows.service import Workflows
from test_workflow_plugin import Runs,context
async def main():
service=Workflows(json.loads(sys.argv[1]));await service.start(None)
runs=Runs()
result=await service.launch({'request_id':'kill-test','script':("const a=await agent({stepId:'a',agentRef:'lead_agent',task:1}); "
"await humanCheckpoint({stepId:'c',sourceStepId:'a',artifactVersion:a.artifactVersion}); return a.output;"),'args':{}},context(runs))
while True:
state=await service.get({'run_id':result['run_id']},context(runs))
if state['status']=='waiting_approval':break
await asyncio.sleep(.01)
print(result['run_id'],flush=True)
await asyncio.Event().wait()
asyncio.run(main())
"""
env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(root / "backend/tests"), str(root / "backend"), str(root / "examples/deerflow-extension-workflows")])}
child = await asyncio.create_subprocess_exec(sys.executable, "-c", program, json.dumps(service.config), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, env=env)
try:
async with asyncio.timeout(30):
run_id = (await child.stdout.readline()).decode().strip()
assert len(run_id) == 32, (await child.stderr.read()).decode() if child.returncode is not None else run_id
child.kill()
await child.wait()
await service.start(None)
state = await actions["get"]({"run_id": run_id}, context(runs))
assert state["status"] == "recoverable"
session = replace(context(runs), interaction="session")
await actions["recover"]({"run_id": run_id}, session)
state = await waiting(plugin, run_id, "waiting_approval")
await actions["decide"](dict(run_id=run_id, step_id="c", attempt=1, artifact_version=state["checkpoints"][0]["artifact_version"], decision="approve"), session)
final = await done(plugin, run_id)
assert final["status"] == "completed", final
assert final["result"] == {"echo": 1}
assert not runs.starts
finally:
if child.returncode is None:
child.kill()
await child.wait()
async def test_graceful_shutdown_pause_and_cancel_recovery(plugin):
_, actions, service, runs = plugin
run_id = (await launch(plugin, "await pause({stepId:'p'}); return 7;"))["run_id"]
await waiting(plugin, run_id, "paused")
await service.stop()
await service.start(None)
session = replace(context(runs), interaction="session")
assert (await actions["get"]({"run_id": run_id}, session))["status"] == "recoverable"
await actions["cancel"]({"run_id": run_id}, session)
with pytest.raises(ValueError, match="not recoverable"):
await actions["recover"]({"run_id": run_id}, session)
assert not runs.starts
@pytest.mark.parametrize("change", ["runtime", "journal", "expiry", "owner", "tool"])
async def test_recovery_guards_prevent_new_dispatch(plugin, change):
from deerflow_extension_api import ExtensionPrincipal
from deerflow_extension_api.plugins import ToolContext
_, actions, service, runs = plugin
run_id = (await launch(plugin, "await pause({stepId:'p'}); return await agent({stepId:'a',agentRef:'lead_agent',task:1});"))["run_id"]
await waiting(plugin, run_id, "paused")
record = await asyncio.to_thread(service.store.get, "alice", run_id)
if change == "runtime":
record["runtime_version"] = "incompatible"
if change == "journal":
record["journal"][0]["frame"]["messages"][0]["payload"]["stepId"] = "changed"
if change == "expiry":
record["pause_expires_at"] = 1
await install_snapshot(plugin, record)
session = replace(context(runs), interaction="session")
if change == "owner":
session = replace(session, principal=ExtensionPrincipal("bob"))
if change == "tool":
session = ToolContext(session.principal, session.settings, "thread", agent_runs=runs)
if change in ("runtime", "owner", "tool"):
with pytest.raises(ValueError):
await actions["recover"]({"run_id": run_id}, session)
else:
await actions["recover"]({"run_id": run_id}, session)
final = await done(plugin, run_id)
assert final["status"] == "failed", final
assert not runs.starts
async def test_file_capture_commit_before_metadata_reuses_original_bytes(plugin, monkeypatch):
_, actions, service, runs = plugin
snapshots = []
capture = service.store.capture_artifact
def capture_before_metadata(owner, workflow, *args):
result = capture(owner, workflow, *args)
snapshots.append(service.store.get(owner, workflow))
return result
monkeypatch.setattr(service.store, "capture_artifact", capture_before_metadata)
run_id = (await launch(plugin, "await agent({stepId:'a',agentRef:'lead_agent',task:1}); return await captureArtifact({stepId:'f',sourceStepId:'a',path:'report.txt'});"))["run_id"]
final = await done(plugin, run_id)
assert final["status"] == "completed"
version = final["result"]["artifactVersion"]
await install_snapshot(plugin, snapshots[0])
runs.file_content = b"new mutable source"
await actions["recover"]({"run_id": run_id}, replace(context(runs), interaction="session"))
recovered = await done(plugin, run_id)
assert recovered["status"] == "completed", recovered
assert recovered["result"]["artifactVersion"] == version
assert (await asyncio.to_thread(service.store.artifact, "alice", run_id, version))[1] == b"original file"
assert len(runs.starts) == 1
async def test_recovered_interrupted_child_cannot_dispatch_even_when_script_catches(plugin, monkeypatch):
from deerflow_extension_api import AgentRun
_, actions, service, runs = plugin
snapshots = []
original = runs.configuration_evidence
async def snapshot(**kwargs):
entry = next(iter(service.entries.values()))
snapshots.append(await asyncio.to_thread(service.store.get, "alice", entry["record"]["id"]))
return await original(**kwargs)
monkeypatch.setattr(runs, "configuration_evidence", snapshot)
run_id = (await launch(plugin, "try {await agent({stepId:'a',agentRef:'lead_agent',task:1});} catch(e) {} await pause({stepId:'p'}); return await agent({stepId:'b',agentRef:'reviewer',task:2});"))["run_id"]
await waiting(plugin, run_id, "paused")
record = snapshots[0]
row = record["steps"][0]
runs.runs[row["run_id"]] = AgentRun(row["thread_id"], row["run_id"], "interrupted")
await install_snapshot(plugin, record)
await actions["recover"]({"run_id": run_id}, replace(context(runs), interaction="session"))
final = await done(plugin, run_id)
assert final["status"] == "needs_reconciliation", final
assert len(runs.starts) == 1
async def test_recovered_cancel_survives_caller_cancellation(plugin, monkeypatch):
_, actions, service, runs = plugin
run_id = (await launch(plugin, "return await agent({stepId:'a',agentRef:'lead_agent',task:1});"))["run_id"]
await done(plugin, run_id)
record = await asyncio.to_thread(service.store.get, "alice", run_id)
record["status"] = "running"
record["steps"][0]["status"] = "running"
await install_snapshot(plugin, record)
child_id = record["steps"][0]["run_id"]
runs.runs[child_id] = replace(runs.runs[child_id], status="running")
entered, release = asyncio.Event(), asyncio.Event()
original = service.cancel_child
async def blocked_cancel(capability, row):
entered.set()
await release.wait()
await original(capability, row)
monkeypatch.setattr(service, "cancel_child", blocked_cancel)
caller = asyncio.create_task(actions["cancel"]({"run_id": run_id}, context(runs)))
try:
await asyncio.wait_for(entered.wait(), 5)
caller.cancel()
with pytest.raises(asyncio.CancelledError):
await caller
assert service.admissions, "Service must retain ownership of child cleanup"
finally:
release.set()
await asyncio.gather(caller, *list(service.admissions), return_exceptions=True)
final = await actions["get"]({"run_id": run_id}, context(runs))
assert final["status"] == "cancelled", final
assert final["steps"][0]["child_status"] == "interrupted"
assert (await actions["cancel"]({"run_id": run_id}, context(runs)))["status"] == "cancelled"
async def test_save_drains_repeated_cancellation_before_unlocking(plugin, monkeypatch):
import threading
service = plugin[2]
run_id = (await launch(plugin, "return 1;"))["run_id"]
await done(plugin, run_id)
record = await asyncio.to_thread(service.store.get, "alice", run_id)
revision = record["revision"]
entered, release = asyncio.Event(), threading.Event()
loop = asyncio.get_running_loop()
original = service.store.put
def blocked_put(owner, snapshot):
loop.call_soon_threadsafe(entered.set)
assert release.wait(10), "Test did not release SQLite write"
return original(owner, snapshot)
monkeypatch.setattr(service.store, "put", blocked_put)
caller = asyncio.create_task(service.save("alice", record))
try:
await asyncio.wait_for(entered.wait(), 5)
for _ in range(2):
caller.cancel()
for _ in range(3):
await asyncio.sleep(0)
assert service.write_lock.locked(), "Do not unlock while SQLite is still committing"
assert not caller.done(), "Cancellation must wait for revision publication"
finally:
release.set()
await asyncio.gather(caller, return_exceptions=True)
assert caller.cancelled()
assert record["revision"] == revision + 1
assert (await asyncio.to_thread(service.store.get, "alice", run_id))["revision"] == record["revision"]
await service.save("alice", record)
assert record["revision"] == revision + 2
async def test_disconnected_cancel_logs_terminal_save_failure(plugin, monkeypatch, caplog):
_, actions, service, runs = plugin
run_id = (await launch(plugin, "await pause({stepId:'p'}); return 1;"))["run_id"]
await waiting(plugin, run_id, "paused")
await restart_snapshot(plugin, run_id)
entered, release = asyncio.Event(), asyncio.Event()
original = service.save
async def fail_terminal_save(owner, record):
if record["status"] == "cancelled":
entered.set()
await release.wait()
raise OSError("synthetic storage failure")
await original(owner, record)
monkeypatch.setattr(service, "save", fail_terminal_save)
caller = asyncio.create_task(actions["cancel"]({"run_id": run_id}, context(runs)))
try:
await asyncio.wait_for(entered.wait(), 5)
caller.cancel()
with pytest.raises(asyncio.CancelledError):
await caller
finally:
release.set()
await asyncio.gather(caller, *list(service.admissions), return_exceptions=True)
final = await actions["get"]({"run_id": run_id}, context(runs))
assert final["status"] == "cancelling"
assert not service.admissions
errors = [r for r in caplog.records if r.levelname == "ERROR" and r.name == type(service).__module__]
assert len(errors) == 1
assert "OSError" in errors[0].message