deer-flow/backend/tests/test_workflow_gateway_acceptance.py
Xuehao Xu cfe2bec371
feat(workflows): add multi-Gateway coordination and session-authorized failover (#7138)
* feat(workflows): add leased multi-Gateway coordination and fenced dispatch

* test(workflows): validate deployed failover and canonicalize config revisions

* fix(workflows): share namespace across registration and lease fencing

* fix(workflows): recover interrupted cancellation without admission caps

* fix(workflows): retain execution lease through child cleanup

* docs: trim guidance padding to fit merged instruction budget

* fix(workflows): release rejected admission slots and normalize gateway routing

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 23:31:43 +08:00

582 lines
26 KiB
Python

"""Opt-in real two-Gateway acceptance with disposable Docker Postgres/Redis/nginx.
Run with DEERFLOW_WORKFLOW_ACCEPTANCE=1 and a working Docker CLI. Only model
answers and fault delays are synthetic. All HTTP/auth/host/store paths are real.
"""
import base64
import json
import os
import shutil
import signal
import socket
import subprocess
import sys
import time
import uuid
from pathlib import Path
import httpx
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[2]
EXAMPLE = ROOT / "examples/deerflow-extension-workflows"
pytestmark = pytest.mark.skipif(os.name != "posix" or os.getenv("DEERFLOW_WORKFLOW_ACCEPTANCE") != "1", reason="Opt-in POSIX two-Gateway Docker acceptance")
def until(fn, seconds=60):
end = time.monotonic() + seconds
while time.monotonic() < end:
value = fn()
if value:
return value
time.sleep(0.1)
raise AssertionError(f"Timed out waiting for {fn}")
def port():
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]
class Cluster:
def __init__(self, root):
self.root = root
self.docker = os.getenv("DEERFLOW_TEST_DOCKER", "docker")
self.containers = []
self.processes = {}
self.logs = []
self.ports = {key: port() for key in ("a", "b", "proxy", "pg", "redis")}
self.http = httpx.Client(timeout=35, trust_env=False)
self.cookies = {}
def docker_run(self, image, *args, options=()):
name = "workflow-acceptance-" + uuid.uuid4().hex[:12]
self.containers.append(name)
subprocess.run([self.docker, "run", "--detach", "--name", name, *options, image, *args], check=True, capture_output=True, text=True)
return name
def setup(self):
import importlib.util
spec = importlib.util.spec_from_file_location("workflow_multi_helper", ROOT / "scripts/dev_multi_instance.py")
helper = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = helper
try:
spec.loader.exec_module(helper)
finally:
sys.modules.pop(spec.name, None)
build_multi_instance_config, generate_shared_secrets = helper.build_multi_instance_config, helper.generate_shared_secrets
pg = self.docker_run("postgres:17-alpine", options=("-e", "POSTGRES_HOST_AUTH_METHOD=trust", "-p", f"127.0.0.1:{self.ports['pg']}:5432"))
self.docker_run("redis:7-alpine", options=("-p", f"127.0.0.1:{self.ports['redis']}:6379"))
until(lambda: subprocess.run([self.docker, "exec", pg, "pg_isready", "-U", "postgres"], capture_output=True).returncode == 0)
self.dsn = f"postgresql://postgres@127.0.0.1:{self.ports['pg']}/postgres"
config, _ = build_multi_instance_config({}, database_url=self.dsn, redis_url=f"redis://127.0.0.1:{self.ports['redis']}/0", sandbox_container_prefix="workflow-acceptance")
config.update(
config_version=61,
models=[{"name": "acceptance", "use": "support.workflow_gateway:AcceptanceModel", "model": "synthetic", "supports_tool_calling": True}],
sandbox={"use": "deerflow.sandbox.local:LocalSandboxProvider"},
tools=[{"name": "write_file", "group": "file:write", "use": "deerflow.sandbox.tools:write_file_tool"}],
tool_groups=[{"name": "file:write"}],
title={"enabled": False},
memory={"enabled": False},
summarization={"enabled": False},
plugins=[
{
"name": "workflows",
"use": "deerflow_extension_workflows:install",
"enabled": True,
"config": {
"enabled": True,
"agents": ["lead_agent"],
"storage_path": str(self.root / "workflows.sqlite"),
"storage_url": self.dsn,
"lease_seconds": 5,
"max_workflows": 8,
"max_workflows_per_owner": 4,
"timeout_seconds": 120,
},
}
],
)
self.config = config
(self.root / "config.yaml").write_text(yaml.safe_dump(config))
(self.root / "extensions.json").write_text("{}")
self.env = {
**os.environ,
**generate_shared_secrets(),
"DEER_FLOW_CONFIG_PATH": str(self.root / "config.yaml"),
"DEER_FLOW_EXTENSIONS_CONFIG_PATH": str(self.root / "extensions.json"),
"DEER_FLOW_HOME": str(self.root / "home"),
"DEER_FLOW_PROJECT_ROOT": str(ROOT),
"DEER_FLOW_ENV_FILE": str(self.root / "empty.env"),
"DEER_FLOW_AUTH_DISABLED": "0",
"GATEWAY_WORKERS": "1",
"WEB_CONCURRENCY": "1",
}
(self.root / "empty.env").touch()
self.start("a")
self.start("b")
# Linux host networking keeps both Gateway listeners loopback-only.
# OrbStack/Docker Desktop exposes host loopback via host.docker.internal.
linux = sys.platform == "linux"
upstream = "127.0.0.1" if linux else "host.docker.internal"
listen = f"127.0.0.1:{self.ports['proxy']}" if linux else "80"
conf = (
"events {}\nhttp { upstream gateways { "
+ " ".join(f"server {upstream}:{self.ports[k]};" for k in ("a", "b"))
+ f" }} server {{ listen {listen};"
+ " location / { proxy_pass http://gateways; proxy_buffering off; add_header X-Acceptance-Upstream $upstream_addr always; } } }"
)
(self.root / "nginx.conf").write_text(conf)
network = ("--network", "host") if linux else ("-p", f"127.0.0.1:{self.ports['proxy']}:80", "--add-host", "host.docker.internal:host-gateway")
self.docker_run("nginx:alpine", options=(*network, "-v", f"{self.root / 'nginx.conf'}:/etc/nginx/nginx.conf:ro"))
until(lambda: self.healthy("proxy"))
self.credentials = {"email": "acceptance@example.com", "password": uuid.uuid4().hex}
response = self.request("a", "POST", "/api/v1/auth/initialize", json=self.credentials, expected=201)
self.cookies = dict(response.cookies)
def start(self, name, extension=EXAMPLE):
fault = self.root / name
fault.mkdir(exist_ok=True)
for item in fault.iterdir():
item.unlink()
env = {**self.env, "WORKFLOW_FAULT_DIR": str(fault), "PYTHONHASHSEED": "1" if name == "a" else "2", "PYTHONPATH": os.pathsep.join((str(extension), str(ROOT / "backend/tests"), str(ROOT / "backend")))}
log = (self.root / f"{name}.log").open("a")
self.logs.append(log)
self.processes[name] = subprocess.Popen([sys.executable, str(ROOT / "backend/tests/support/workflow_gateway.py"), str(self.ports[name])], cwd=ROOT / "backend", env=env, stdout=log, stderr=subprocess.STDOUT, start_new_session=True)
def ready():
if self.processes[name].poll() is not None:
raise AssertionError((self.root / f"{name}.log").read_text()[-10000:])
return self.healthy(name)
until(ready, 120)
def healthy(self, name):
try:
return self.http.get(f"http://127.0.0.1:{self.ports[name]}/health").status_code == 200
except httpx.TransportError:
return False
def kill(self, name):
p = self.processes.pop(name, None)
if p and p.poll() is None:
os.killpg(p.pid, signal.SIGKILL)
p.wait(timeout=10)
def close(self):
for name in list(self.processes):
self.kill(name)
self.http.close()
for log in self.logs:
log.close()
for name in self.containers:
subprocess.run([self.docker, "rm", "-f", "-v", name], capture_output=True)
def request(self, target, method, path, expected=200, **kwargs):
headers = {"Cookie": "; ".join(f"{k}={v}" for k, v in self.cookies.items())}
if self.cookies.get("csrf_token"):
headers["X-CSRF-Token"] = self.cookies["csrf_token"]
r = self.http.request(method, f"http://127.0.0.1:{self.ports[target]}{path}", headers=headers, **kwargs)
self.http.cookies.clear()
assert r.status_code == expected, (r.status_code, r.text)
return r
def action(self, target, name, payload, expected=200):
return self.request(target, "POST", f"/api/plugins/community.workflows/actions/{name}", json=payload, expected=expected).json()
def launch(self, script, target="a"):
return self.action(target, "launch", {"request_id": uuid.uuid4().hex, "script": script, "args": {}})["run_id"]
def get(self, run_id, target="b"):
return self.action(target, "get", {"run_id": run_id})
def wait(self, run_id, status, target="b"):
def check():
record = self.get(run_id, target)
if record["status"] in ("failed", "needs_reconciliation") and status != record["status"]:
raise AssertionError((record, self.sql("SELECT run_id,status,error FROM runs WHERE thread_id=ANY(%s)", ([s["thread_id"] for s in record["steps"]],))))
return record if record["status"] == status else None
return until(check)
def arm(self, boundary, target="a"):
for name in ("reached.json", "release"):
(self.root / target / name).unlink(missing_ok=True)
(self.root / target / "arm.json").write_text(json.dumps({"boundary": boundary}))
def reached(self, target="a"):
until(lambda: (self.root / target / "reached.json").exists())
def recover(self, run_id):
self.wait(run_id, "recoverable")
return self.action("b", "recover", {"run_id": run_id})
def resume(self, run_id, target="b"):
record = self.wait(run_id, "paused", target)
return self.action(target, "resume", {"run_id": run_id, "fingerprint": record["fingerprint"], "step_id": record["checkpoints"][-1]["step_id"], "attempt": 1})
def decide(self, run_id, target="b"):
record = self.wait(run_id, "waiting_approval", target)
checkpoint = record["checkpoints"][-1]
payload = {"run_id": run_id, "step_id": checkpoint["step_id"], "attempt": 1, "artifact_version": checkpoint["artifact_version"], "decision": "approve"}
self.action(target, "decide", payload)
return payload
def sql(self, statement, params=()):
import psycopg
with psycopg.connect(self.dsn) as db:
return db.execute(statement, params).fetchall()
@pytest.fixture(scope="module")
def cluster(tmp_path_factory):
c = Cluster(tmp_path_factory.mktemp("workflow-gateway"))
print(f"Acceptance logs: {c.root}")
try:
c.setup()
yield c
finally:
c.close()
def final_gateway_port(response):
# nginx records all attempts, including failed IPv6 -> IPv4 retries.
destination = response.headers["x-acceptance-upstream"].rsplit(",", 1)[-1].strip()
return int(destination.rsplit(":", 1)[-1])
def test_authenticated_cross_gateway_control_without_affinity(cluster):
c = cluster
for target in ("a", "b"):
c.request(target, "GET", "/health/ready")
upstreams = {final_gateway_port(c.request("proxy", "GET", "/api/plugins")) for _ in range(6)}
assert upstreams == {c.ports["a"], c.ports["b"]}
run = c.launch("await pause({stepId:'p'}); return await agent({stepId:'child',agentRef:'lead_agent',task:'hello'});")
routed = [c.request("proxy", "POST", "/api/plugins/community.workflows/actions/get", json={"run_id": run}) for _ in range(6)]
assert {r.json()["run_id"] for r in routed} == {run}
assert {final_gateway_port(r) for r in routed} == {c.ports["a"], c.ports["b"]}
c.resume(run)
result = c.wait(run, "completed")
assert len(result["steps"]) == 1
assert result["steps"][0]["configuration_version"]
run = c.launch("await pause({stepId:'p'}); return 1;")
c.wait(run, "paused")
c.action("b", "cancel", {"run_id": run})
c.wait(run, "cancelled")
@pytest.mark.parametrize("boundary", ["admission", "result_before", "result", "approval"])
def test_sigkill_at_persistence_boundaries(cluster, boundary):
c = cluster
c.arm(boundary)
script = "const a=await agent({stepId:'first',agentRef:'lead_agent',task:'first'});"
if boundary == "approval":
script += "await humanCheckpoint({stepId:'review',sourceStepId:'first',artifactVersion:a.artifactVersion});"
script += "return await agent({stepId:'second',agentRef:'lead_agent',task:'second'});"
run = c.launch(script)
if boundary == "approval":
c.decide(run)
c.reached()
# Confirm the real boundary before killing, then inspect the shared host DB.
before = c.get(run)
thread = before["steps"][0]["thread_id"]
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=%s", (thread,))[0][0] == 1
if boundary == "admission":
assert before["steps"][0]["run_id"] is None
if boundary == "result":
assert before["steps"][0]["status"] == "completed"
if boundary == "approval":
assert before["checkpoints"][0]["status"] == "approved"
c.kill("a")
try:
recovered = c.recover(run)
if boundary == "admission":
assert recovered["status"] == "needs_reconciliation"
assert len(c.get(run)["steps"]) == 1
assert c.sql("SELECT count(*) FROM workflow_slots WHERE workflow=%s", (run,))[0][0] == 1
else:
record = c.wait(run, "completed")
assert len(record["steps"]) == 2
if boundary == "approval":
assert record["checkpoints"][0]["status"] == "approved"
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=%s", (thread,))[0][0] == 1
finally:
c.start("a")
def test_file_approval_survives_owner_death_and_source_mutation(cluster):
from support.workflow_gateway import EVIDENCE
c = cluster
run = c.launch(
"await agent({stepId:'writer',agentRef:'lead_agent',task:'CREATE_EVIDENCE'});"
"const f=await captureArtifact({stepId:'file',sourceStepId:'writer',path:'/mnt/user-data/outputs/evidence.txt'});"
"await humanCheckpoint({stepId:'review',sourceStepId:'file',artifactVersion:f.artifactVersion});"
"const bytes=await readArtifact({artifactVersion:f.artifactVersion});"
"return await agent({stepId:'reader',agentRef:'lead_agent',task:bytes});"
)
record = c.wait(run, "waiting_approval")
version = record["checkpoints"][0]["artifact_version"]
chunk = c.action("b", "get_artifact", {"run_id": run, "version": version})
assert base64.b64decode(chunk["content_base64"]) == EVIDENCE.encode()
files = list((c.root / "home").glob("**/outputs/evidence.txt"))
assert len(files) == 1
files[0].write_text("MUTATED after capture")
c.kill("a")
try:
c.recover(run)
c.decide(run)
record = c.wait(run, "completed")
reader = record["result"]
# The real model echoes the downstream human input; verify exact captured bytes.
payload = json.loads(reader["output"]["echo"])
assert base64.b64decode(payload["content_base64"]) == EVIDENCE.encode()
assert payload["version"] == version
assert len(record["steps"]) == 2
finally:
c.start("a")
def test_sigstop_stale_owner_cannot_admit_after_takeover(cluster):
c = cluster
c.arm("before_admission")
run = c.launch("return await agent({stepId:'first',agentRef:'lead_agent',task:'stale'});")
c.reached()
record = c.get(run)
thread = record["steps"][0]["thread_id"]
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=%s", (thread,))[0][0] == 0
p = c.processes["a"]
os.kill(p.pid, signal.SIGSTOP)
try:
assert c.recover(run)["status"] == "needs_reconciliation"
after = c.sql("SELECT data FROM workflows WHERE id=%s", (run,))[0][0]
(c.root / "a/release").touch()
finally:
os.kill(p.pid, signal.SIGCONT)
until(lambda: (c.root / "a/admission_error").exists())
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=%s", (thread,))[0][0] == 0
assert c.sql("SELECT data FROM workflows WHERE id=%s", (run,))[0][0] == after
c.kill("a")
c.start("a")
def test_distinct_runtime_rollout_drains_old_and_rejects_incompatible_takeover(cluster):
c = cluster
alternate = c.root / "alternate"
shutil.copytree(EXAMPLE, alternate, ignore=shutil.ignore_patterns("__pycache__"))
runtime = alternate / "deerflow_extension_workflows/worker.py"
runtime.write_text(runtime.read_text().replace("MAX_FRAME = 48 * 1024", "MAX_FRAME = 47 * 1024"))
c.kill("b")
c.start("b", alternate)
new_run = c.launch("return 11;", "b")
assert c.wait(new_run, "completed")["result"] == 11
run = c.launch("await pause({stepId:'p'}); return 17;")
c.wait(run, "paused")
assert c.sql("SELECT data::jsonb->>'runtime_version' FROM workflows WHERE id=%s", (run,)) != c.sql("SELECT data::jsonb->>'runtime_version' FROM workflows WHERE id=%s", (new_run,))
c.resume(run) # New replica can control the old live runtime; owner A drains it.
assert c.wait(run, "completed")["result"] == 17
run = c.launch("await pause({stepId:'p'}); return 19;")
c.wait(run, "paused")
c.kill("a")
try:
c.wait(run, "recoverable")
before = c.sql("SELECT epoch FROM extension_execution_leases WHERE resource_id=%s", (run,))
c.action("b", "recover", {"run_id": run}, expected=422)
assert c.sql("SELECT epoch FROM extension_execution_leases WHERE resource_id=%s", (run,)) == before
finally:
c.start("a")
c.kill("b")
c.start("b")
c.recover(run)
c.resume(run)
assert c.wait(run, "completed")["result"] == 19
def test_concurrent_duplicate_launch_and_approval(cluster):
from concurrent.futures import ThreadPoolExecutor
c = cluster
payload = {
"request_id": uuid.uuid4().hex,
"args": {},
"script": (
"const a=await agent({stepId:'a',agentRef:'lead_agent',task:'first'});"
"await humanCheckpoint({stepId:'review',sourceStepId:'a',artifactVersion:a.artifactVersion});"
"return await agent({stepId:'b',agentRef:'lead_agent',task:'second'});"
),
}
with ThreadPoolExecutor(2) as pool:
ids = list(pool.map(lambda target: c.action(target, "launch", payload)["run_id"], ("a", "b")))
assert ids[0] == ids[1]
run = ids[0]
record = c.wait(run, "waiting_approval")
checkpoint = record["checkpoints"][0]
decision = {"run_id": run, "step_id": "review", "attempt": 1, "artifact_version": checkpoint["artifact_version"], "decision": "approve"}
with ThreadPoolExecutor(2) as pool:
replies = list(pool.map(lambda target: c.action(target, "decide", decision), ("a", "b")))
assert all(r["status"] == "approved" for r in replies)
record = c.wait(run, "completed")
threads = [s["thread_id"] for s in record["steps"]]
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=ANY(%s)", (threads,))[0][0] == 2
assert c.sql("SELECT count(*) FROM workflow_controls WHERE workflow=%s", (run,))[0][0] == 1
def test_shared_owner_limit_across_gateways(cluster):
from concurrent.futures import ThreadPoolExecutor
c = cluster
runs = []
def launch(i):
try:
return c.launch("await pause({stepId:'p'}); return 1;", "a" if i % 2 else "b")
except AssertionError as exc:
assert "422" in str(exc)
return None
try:
with ThreadPoolExecutor(5) as pool:
runs = [r for r in pool.map(launch, range(5)) if r]
assert len(runs) == 4
finally:
for run in runs:
c.wait(run, "paused")
c.action("b", "cancel", {"run_id": run})
c.wait(run, "cancelled")
def test_revoked_session_and_changed_configuration_block_takeover(cluster):
c = cluster
run = c.launch("await pause({stepId:'p'}); return 23;")
c.wait(run, "paused")
c.kill("a")
try:
c.wait(run, "recoverable")
epoch = c.sql("SELECT epoch FROM extension_execution_leases WHERE resource_id=%s", (run,))
c.sql("UPDATE users SET token_version=token_version+1 RETURNING id")
c.action("b", "recover", {"run_id": run}, expected=401)
c.cookies = {}
login = c.request("b", "POST", "/api/v1/auth/login/local", data={"username": c.credentials["email"], "password": c.credentials["password"]})
c.cookies = dict(login.cookies)
original = (c.root / "config.yaml").read_text()
config = yaml.safe_load(original)
config["models"][0]["temperature"] = 0.7
(c.root / "config.yaml").write_text(yaml.safe_dump(config))
try:
c.action("b", "recover", {"run_id": run}, expected=422)
assert c.sql("SELECT epoch FROM extension_execution_leases WHERE resource_id=%s", (run,)) == epoch
finally:
(c.root / "config.yaml").write_text(original)
c.recover(run)
c.resume(run)
assert c.wait(run, "completed")["result"] == 23
finally:
c.start("a")
def test_sigstop_stale_artifact_writer_is_fenced(cluster):
c = cluster
c.arm("capture")
run = c.launch(
"await agent({stepId:'writer',agentRef:'lead_agent',task:'CREATE_EVIDENCE'});"
"const f=await captureArtifact({stepId:'file',sourceStepId:'writer',path:'/mnt/user-data/outputs/evidence.txt'});"
"await humanCheckpoint({stepId:'review',sourceStepId:'file',artifactVersion:f.artifactVersion}); return 31;"
)
c.reached()
p = c.processes["a"]
os.kill(p.pid, signal.SIGSTOP)
try:
c.recover(run)
c.wait(run, "waiting_approval")
assert c.sql("SELECT epoch > 1 AND expires_at > extract(epoch from clock_timestamp()) FROM extension_execution_leases WHERE resource_id=%s", (run,))[0][0]
after = c.sql("SELECT data FROM workflows WHERE id=%s", (run,))
artifact = c.sql("SELECT version,content FROM workflow_artifacts WHERE workflow=%s", (run,))
(c.root / "a/release").touch()
finally:
os.kill(p.pid, signal.SIGCONT)
until(lambda: (c.root / "a/capture_error").exists())
assert (c.root / "a/capture_error").read_text() == "LeaseLost"
assert c.sql("SELECT data FROM workflows WHERE id=%s", (run,)) == after
assert c.sql("SELECT version,content FROM workflow_artifacts WHERE workflow=%s", (run,)) == artifact
c.decide(run)
assert c.wait(run, "completed")["result"] == 31
c.kill("a")
c.start("a")
def test_shared_child_limit_with_real_host_admissions(cluster):
c = cluster
reserved = c.sql("SELECT count(*) FROM workflow_slots")[0][0]
assert 0 <= reserved < 4
runs = []
for target in ("a", "b"):
(c.root / target / "hold_model").touch()
try:
for target in ("a", "b"):
runs.append(c.launch("return await parallel([()=>agent({stepId:'x',agentRef:'lead_agent',task:'HOLD_MODEL'}),()=>agent({stepId:'y',agentRef:'lead_agent',task:'HOLD_MODEL'})]);", target))
until(lambda: all(len(c.get(run)["steps"]) == 2 for run in runs))
threads = [s["thread_id"] for run in runs for s in c.get(run)["steps"]]
until(lambda: c.sql("SELECT count(*) FROM runs WHERE thread_id=ANY(%s)", (threads,))[0][0] == 4 - reserved)
assert c.sql("SELECT count(*) FROM workflow_slots")[0][0] == 4
assert sum(s["status"] == "queued" for run in runs for s in c.get(run)["steps"]) == reserved
finally:
for target in ("a", "b"):
(c.root / target / "hold_model").unlink(missing_ok=True)
for run in runs:
assert len(c.wait(run, "completed")["steps"]) == 2
assert c.sql("SELECT count(*) FROM workflow_slots")[0][0] == reserved
def test_peer_cancel_reaches_real_active_child(cluster):
c = cluster
(c.root / "a/hold_model").touch()
try:
run = c.launch("return await agent({stepId:'child',agentRef:'lead_agent',task:'HOLD_MODEL'});")
until(lambda: c.get(run)["steps"] and c.get(run)["steps"][0]["status"] == "running")
thread = c.get(run)["steps"][0]["thread_id"]
c.action("b", "cancel", {"run_id": run})
until(lambda: c.sql("SELECT status FROM runs WHERE thread_id=%s", (thread,))[0][0] == "interrupted", 30)
# The extension has a bounded child-observation window; if the host's
# heartbeat consumes cancellation later, uncertainty must stay explicit.
until(lambda: c.get(run)["status"] in ("cancelled", "needs_reconciliation"))
assert c.sql("SELECT count(*) FROM runs WHERE thread_id=%s", (thread,))[0][0] == 1
finally:
(c.root / "a/hold_model").unlink(missing_ok=True)
def test_sigkill_during_cancel_takeover_can_finish_and_delete(cluster):
from concurrent.futures import ThreadPoolExecutor
c = cluster
run = c.launch("await pause({stepId:'p'}); return 1;")
c.wait(run, "paused")
c.kill("a")
try:
c.wait(run, "recoverable")
c.arm("cancelling", "b")
with ThreadPoolExecutor(1) as pool:
request = pool.submit(c.action, "b", "cancel", {"run_id": run})
c.reached("b")
assert c.sql("SELECT status FROM workflows WHERE id=%s", (run,))[0][0] == "cancelling"
c.kill("b")
with pytest.raises(httpx.TransportError):
request.result(timeout=10)
c.start("a")
c.wait(run, "recoverable", "a")
# A pending cancellation must never replay the workflow body.
c.action("a", "recover", {"run_id": run}, expected=422)
assert c.action("a", "cancel", {"run_id": run})["status"] == "cancelled"
c.action("a", "delete", {"run_id": run})
assert not c.sql("SELECT id FROM workflows WHERE id=%s", (run,))
finally:
if "a" not in c.processes:
c.start("a")
if "b" not in c.processes:
c.start("b")