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

860 lines
41 KiB
Python

"""Supervised workflow service using only the public AgentRuns capability."""
import asyncio
import copy
import hashlib
import json
import logging
import os
import re
import sys
import tempfile
import time
import uuid
from pathlib import Path
from deerflow_extension_api import AgentRunError
from .artifacts import Artifacts
from .cluster import Cluster
from .constants import NAMESPACE as NAMESPACE
from .lifecycle import Lifecycle
from .postgres import LeaseLost
from .recovery import RECOVERY_VERSION, Recovery
from .store import TERMINAL, Store
FRAME = 48 * 1024
TURN_LIMIT = 10000
AGENT_TASK_BYTES = 16000
AGENT_OUTPUT_BYTES = 12000
logger = logging.getLogger(__name__)
def encoded(value, limit=FRAME - 1):
raw = json.dumps(value, ensure_ascii=True, allow_nan=False, sort_keys=True)
if len(raw.encode("utf-8")) > limit:
raise ValueError("JSON byte limit exceeded")
return raw
def identity(value):
return hashlib.sha256(encoded(value).encode()).hexdigest()
def text(value, limit):
if not isinstance(value, str) or not value.strip() or len(value.encode("utf-8")) > limit:
raise ValueError("Invalid text or UTF-8 byte limit")
return value
def pending_input(messages):
# Any unanswered human-input artifact in this one-step conversation stops the script.
return any(isinstance(m.get("artifact"), dict) and isinstance(m["artifact"].get("human_input"), dict) and m["artifact"]["human_input"].get("kind") == "human_input_request" for m in messages if m.get("type", m.get("role")) == "tool")
def project(record, detail=True):
result = {
key: record[key]
for key in (
"id",
"status",
"created_at",
"script_hash",
"args_hash",
"limits",
"error",
)
}
result["run_id"] = record["id"]
result.update(
fingerprint=record["fingerprint"],
definition=record.get("definition"),
configuration_revision=record.get("configuration_revision"),
)
if detail:
result.update(
checkpoints=copy.deepcopy(record.get("checkpoints", [])),
artifacts=copy.deepcopy(record.get("artifacts", [])),
pause_expires_at=record.get("pause_expires_at"),
script=record["script"],
args=record["args"],
logs=record["logs"],
result=record.get("result"),
steps=[
{
k: v
for k, v in step.items()
if k
not in (
"input",
"output",
"configuration_evidence",
"input_artifacts",
)
}
for step in record["steps"]
],
)
encoded(result, 60 * 1024)
return result
class Workflows(Cluster, Recovery, Lifecycle, Artifacts):
def __init__(self, config):
self.config = config
self.clustered = bool(config.get("storage_url"))
self.holder_id = uuid.uuid4().hex
self.heartbeats = {}
self.store = None
self.available = False
self.workers = {}
self.processes = {}
self.entries = {}
self.admissions = set()
self.launch_lock = asyncio.Lock()
self.control_lock = asyncio.Lock()
self.write_lock = asyncio.Lock()
self.global_slots = asyncio.Semaphore(config["global_concurrency"])
self.lockfile = None
async def start(self, deps):
def initialize():
import fcntl
path = Path(self.config["storage_path"])
path.parent.mkdir(parents=True, exist_ok=True)
lock = None if self.clustered else path.with_suffix(path.suffix + ".lock").open("a+b")
try:
if self.clustered:
from .postgres import PostgresStore
store = PostgresStore(self.config["storage_url"], self.holder_id, self.config)
else:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
store = Store(path)
store.interrupt_unfinished()
# Include worker code and engine version in the conservative
# replay compatibility guard; upgrades never silently replay.
from importlib.metadata import version
signature = hashlib.sha256()
for name in (
"constants.py",
"worker.py",
"service.py",
"recovery.py",
"lifecycle.py",
"artifacts.py",
"cluster.py",
"postgres.py",
):
signature.update(Path(__file__).with_name(name).read_bytes())
signature.update(version("quickjs").encode())
return lock, store, signature.hexdigest()
except BaseException:
if lock:
lock.close()
raise
self.lockfile, self.store, self.runtime_version = await asyncio.to_thread(initialize)
if self.clustered and deps is not None:
from sqlalchemy import text as sql_text
if deps.session_factory is None:
raise ValueError("Cluster workflows require the host PostgreSQL session factory")
async with deps.session_factory() as session:
if session.get_bind().dialect.name != "postgresql":
raise ValueError("Cluster workflows require host PostgreSQL persistence")
host_identity = tuple((await session.execute(sql_text("SELECT current_database(),current_schema(),inet_server_addr()::text,inet_server_port()"))).one())
if host_identity != await asyncio.to_thread(self.store.database_identity):
raise ValueError("Workflow and host must share the same PostgreSQL database and schema")
self.available = True
async def stop(self):
async with self.launch_lock:
self.available = False
for entry in self.entries.values():
entry["suspending"] = entry["record"]["status"] != "cancelling"
entry["cancel"].set()
workers = list(self.workers.values())
# Admissions waiting for launch_lock see unavailable; committed admissions
# held the lock through worker publication and are included above.
if self.admissions:
await asyncio.gather(*list(self.admissions), return_exceptions=True)
if workers:
await asyncio.gather(*workers, return_exceptions=True)
if self.heartbeats:
heartbeats = list(self.heartbeats.values())
for heartbeat in heartbeats:
heartbeat.cancel()
await asyncio.gather(*heartbeats, return_exceptions=True)
self.heartbeats.clear()
if self.lockfile:
await asyncio.to_thread(self.lockfile.close)
self.lockfile = None
async def wait_idle(self, run_id):
worker = self.workers.get(run_id)
if worker:
await asyncio.shield(worker)
def owner(self, context):
if not self.available or context.settings.get("enabled") is not True:
raise ValueError("Workflow service unavailable or disabled")
return context.principal.user_id
async def save(self, owner, record):
# SQLite commits survive cancellation of to_thread. Keep the lock and
# observe the new revision before propagating cancellation to cleanup.
task = asyncio.create_task(self.save_drained(owner, record))
try:
await asyncio.shield(task)
except asyncio.CancelledError:
while not task.done():
try:
await asyncio.wait({task})
except asyncio.CancelledError:
continue
if not task.cancelled():
task.exception()
raise
async def save_drained(self, owner, record):
async with self.write_lock:
try:
record["revision"] = await asyncio.to_thread(self.store.put, owner, copy.deepcopy(record))
except LeaseLost:
entry = self.entries.get(record["id"])
if entry:
entry["lease_lost"] = True
entry["cancel"].set()
raise
async def launch(self, payload, context):
owner = self.owner(context)
if context.agent_runs is None:
raise ValueError("Session-bound AgentRuns required; PAT/internal callers unsupported")
if set(payload) - {"request_id", "script", "args", "definition"}:
raise ValueError("Unexpected launch fields")
definition = payload.get("definition")
if definition is not None:
if "script" in payload or not isinstance(definition, dict):
raise ValueError("Choose a script or an exact definition version")
definition = await self.get_definition(definition, context)
script = definition.pop("script")
else:
script = text(payload.get("script"), 16000)
encoded(script, 16000)
request_id = text(payload.get("request_id"), 128)
args = copy.deepcopy(payload.get("args", {}))
if not isinstance(args, dict):
raise ValueError("args must be a JSON object")
encoded(args, 4000)
limits = {
k: self.config[k]
for k in (
"concurrency",
"global_concurrency",
"max_workflows",
"max_workflows_per_owner",
"admission_timeout_seconds",
"max_calls",
"timeout_seconds",
"pause_timeout_seconds",
"cpu_seconds",
"memory_bytes",
)
}
script_hash = hashlib.sha256(script.encode()).hexdigest()
args_hash = identity(args)
fingerprint = identity(
{
"script_hash": script_hash,
"args_hash": args_hash,
"limits": limits,
"agents": sorted(self.config["agents"]),
"definition": definition,
}
)
record = {
"id": uuid.uuid4().hex,
"recovery_version": RECOVERY_VERSION,
"runtime_version": self.runtime_version,
"journal": [],
"definition": definition,
"configuration_revision": await context.agent_runs.configuration_revision(assistant_ids=sorted(self.config["agents"]), durable=True) if hasattr(context.agent_runs, "configuration_revision") else None,
"operator_revision": identity({**self.config, "agents": sorted(self.config["agents"])}),
"checkpoints": [],
"artifacts": [],
"request_id": request_id,
"fingerprint": fingerprint,
"script_hash": script_hash,
"args_hash": args_hash,
"script": script,
"args": args,
"limits": limits,
"created_at": time.time(),
"status": "queued",
"error": None,
"steps": [],
"logs": [],
"result": None,
}
# SQLite threads survive caller cancellation. Retain a service-owned task
# through commit AND supervisor publication, shielding only the caller's wait.
admission = asyncio.create_task(self.admit(owner, record, context))
self.admissions.add(admission)
admission.add_done_callback(self.admission_finished)
return await asyncio.shield(admission)
def admission_finished(self, task):
self.admissions.discard(task)
if not task.cancelled():
error = task.exception()
if error is not None:
logger.error(
"Workflow background action failed (%s); inspect retained workflow and child records",
type(error).__name__,
)
async def admit(self, owner, record, context):
async with self.launch_lock:
self.owner(context)
request_id, fingerprint = record["request_id"], record["fingerprint"]
current = [e for e in self.entries.values() if e["owner"] == owner]
# Capacity admission happens before persist, but matching retries bypass it.
retry = await asyncio.to_thread(self.store.by_request, owner, request_id)
if retry:
if retry["fingerprint"] != fingerprint:
raise ValueError("request_id already used with different script or arguments")
return {"run_id": retry["id"], "status": retry["status"]}
if len(self.entries) >= self.config["max_workflows"] or len(current) >= self.config["max_workflows_per_owner"]:
raise ValueError("Active workflow limit")
record, created = await asyncio.to_thread(self.store.create, owner, record)
if created:
self.publish(self.new_entry(owner, record, context.agent_runs))
return {"run_id": record["id"], "status": record["status"]}
def new_entry(self, owner, record, runs):
return {
"owner": owner,
"record": record,
"runs": runs,
"cancel": asyncio.Event(),
"tasks": set(),
"slots": asyncio.Semaphore(self.config["concurrency"]),
"fault": None,
}
def publish(self, entry):
run_id = entry["record"]["id"]
self.entries[run_id] = entry
if self.clustered:
self.heartbeats[run_id] = asyncio.create_task(self.follow_lease(entry))
task = asyncio.create_task(self.execute(entry))
self.workers[run_id] = task
task.add_done_callback(lambda t: self.worker_finished(run_id, t))
def worker_finished(self, run_id, task):
heartbeat = self.heartbeats.pop(run_id, None)
if heartbeat:
heartbeat.cancel()
self.workers.pop(run_id, None)
self.entries.pop(run_id, None)
if not task.cancelled() and task.exception() is not None:
logger.error(
"Workflow %s supervisor failed (%s); inspect retained child records",
run_id,
type(task.exception()).__name__,
)
async def get(self, payload, context):
owner = self.owner(context)
return project(await asyncio.to_thread(self.store.get, owner, text(payload.get("run_id"), 64)))
async def list(self, payload, context):
owner = self.owner(context)
records = await asyncio.to_thread(self.store.list, owner)
return {"runs": [project(r, False) for r in records], "limit": 20}
async def cancel(self, payload, context):
task = asyncio.create_task(self.apply_cancel(payload, context))
self.admissions.add(task)
task.add_done_callback(self.admission_finished)
return await asyncio.shield(task)
async def apply_cancel(self, payload, context):
# Serialize against recovery publication. A recovered record cannot be
# published after a concurrent cancellation has committed.
async with self.launch_lock:
owner = self.owner(context)
run_id = text(payload.get("run_id"), 64)
record = await asyncio.to_thread(self.store.get, owner, run_id)
entry = self.entries.get(run_id)
if self.clustered and record["status"] not in TERMINAL:
await self.remote_command(owner, record, context, {"kind": "cancel"})
return {"run_id": run_id, "status": "cancelling"}
if self.clustered and record["status"] == "recoverable":
return await self.cancel_recovered(owner, run_id, context)
if entry:
entry["cancel"].set()
entry["suspending"] = False
if entry["record"]["status"] not in TERMINAL:
entry["record"]["status"] = "cancelling"
await self.save(owner, entry["record"])
return {"run_id": run_id, "status": "cancelling"}
if record["status"] == "recoverable":
if context.agent_runs is None:
raise ValueError("Session-bound AgentRuns required")
record["status"] = "cancelling"
await self.save(owner, record)
for row in record["steps"]:
if row.get("run_id") and row["status"] != "completed":
await self.cancel_child(context.agent_runs, row)
uncertain = any(s["status"] not in ("completed", "queued", "cancelled") for s in record["steps"])
record.update(
status="needs_reconciliation" if uncertain else "cancelled",
error="Inspect uncertain child effects" if uncertain else None,
)
await self.save(owner, record)
return {"run_id": run_id, "status": record["status"]}
async def delete(self, payload, context):
owner = self.owner(context)
run_id = text(payload.get("run_id"), 64)
if run_id in self.entries:
raise ValueError("Workflow is still cleaning up")
await asyncio.to_thread(self.store.delete, owner, run_id)
return {"deleted": True}
async def step(self, entry, message, *, replay=False):
owner, record, runs = entry["owner"], entry["record"], entry["runs"]
payload = message.get("payload")
response = {"id": message["id"]}
row = None
try:
if not isinstance(payload, dict) or not {"stepId", "agentRef", "task"} <= set(payload) or set(payload) - {"stepId", "agentRef", "task", "artifacts"}:
raise ValueError("agent requires stepId, agentRef, task")
step_id = text(payload["stepId"], 80)
if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", step_id):
raise ValueError("stepId must contain ASCII letters, digits, underscores or hyphens")
if payload["agentRef"] not in self.config["agents"]:
raise ValueError("Agent reference not permitted by operator")
versions = payload.get("artifacts", [])
if not isinstance(versions, list) or len(versions) > 16 or any(not isinstance(v, str) for v in versions):
raise ValueError("artifacts must contain at most 16 version IDs")
references = []
for version in versions:
if not self.approved_artifact(record, version):
raise ValueError("File version has not been approved")
info, _ = await asyncio.to_thread(self.store.artifact, owner, record["id"], version)
references.append(
{
"run_id": record["id"],
**{k: info[k] for k in ("version", "sha256", "name", "size")},
}
)
task = payload["task"]
if references:
task = {
"task": task,
"approved_artifacts": references,
"artifact_instruction": "Read these exact versions using read_workflow_artifact (run_id, version, offset). Follow next_offset until null. Do not use source file paths.",
}
# Stable identity/input are recorded BEFORE thread creation or run admission.
thread_id = "workflow-" + identity([record["id"], step_id])[:32]
message_id = "workflow-" + identity([record["id"], step_id, 1])[:32]
input = {
"messages": [
{
"role": "user",
"id": message_id,
"content": encoded(task, AGENT_TASK_BYTES),
}
]
}
existing = next((s for s in record["steps"] if s["step_id"] == step_id), None) if replay else None
if existing:
row = existing
if row["input"] != input or row["agent_ref"] != payload["agentRef"]:
raise ValueError("Recovery step input changed")
if row["status"] == "completed":
response["value"] = self.step_result(row)
return response
if row["status"] not in ("queued", "running"):
raise ValueError("Child outcome requires reconciliation")
# Revalidate after file IO; validation and reservation must not yield.
if not existing:
if any(s["step_id"] == step_id for s in record["steps"] + record.get("checkpoints", []) + record.get("artifacts", [])):
raise ValueError("Step ID already used")
if len(record["steps"]) >= self.config["max_calls"]:
entry["fault"] = "Total Agent call limit"
raise ValueError(entry["fault"])
row = {
"step_id": step_id,
"attempt": 1,
"agent_ref": payload["agentRef"],
"thread_id": thread_id,
"run_id": None,
"status": "queued",
"input": input,
"input_artifacts": references,
"idempotency_key": f"{record['id']}:{message_id}:1",
}
record["steps"].append(row)
await self.save(owner, record)
async with entry["slots"], self.global_slots, self.cluster_slot(entry, row):
if entry["cancel"].is_set() or entry["fault"]:
row["status"] = "cancelled"
raise ValueError("Workflow stopping")
if record.get("configuration_revision") is not None:
try:
await self.check_configuration(entry)
except Exception:
entry["fault"] = "Agent configuration changed or unavailable"
row["status"] = "failed"
if row.get("run_id") is None:
row["admission_rejected"] = True
raise
if row["status"] == "queued":
row["status"] = "creating_thread"
await self.save(owner, record)
# Never cancel these awaits just because a workflow cancel was requested.
# Keep tracking the response so an admitted child cannot disappear.
async with asyncio.timeout(self.config["admission_timeout_seconds"]):
await runs.create_thread(
assistant_id=payload["agentRef"],
thread_id=thread_id,
metadata={"title": f"Workflow {record['id'][:8]} / {step_id}"},
)
if entry["cancel"].is_set():
row["status"] = "cancelled"
raise ValueError("Workflow stopping")
row["status"] = "admitting"
await self.save(owner, record)
run = await runs.start(
thread_id=thread_id,
input=input,
idempotency_key=row["idempotency_key"],
**self.dispatch_fence(record),
)
row.update(
run_id=run.run_id,
status="running",
effective_assistant_id=run.assistant_id,
)
await self.save(owner, record)
else:
run = await runs.get(thread_id=thread_id, run_id=row["run_id"])
while True:
if entry["cancel"].is_set() or entry["fault"]:
if not entry.get("lease_lost"):
await self.cancel_child(runs, row, **({"record": record} if self.clustered else {}))
raise ValueError("Workflow stopping")
async with asyncio.timeout(5):
run = await runs.get(thread_id=thread_id, run_id=run.run_id)
if run.status not in ("pending", "running"):
break
await asyncio.sleep(0.02)
row.update(child_status=run.status, stop_reason=run.stop_reason)
async with asyncio.timeout(5):
state = await runs.get_state(thread_id=thread_id)
messages = state.get("values", {}).get("messages", [])
if state.get("next") or state.get("interrupts") or pending_input(messages):
row["status"] = "waiting_input"
entry["fault"] = "waiting_input"
raise ValueError("Child requires input; workflow resume cannot answer child interrupts")
if run.status != "success":
row["status"] = "failed"
raise ValueError("Agent run did not succeed")
# Dynamic context keeps X on a hidden system reminder and moves
# the visible human input to X__user. Never anchor on the reminder
# or hidden memory; genuinely later human messages still conflict.
marker = next(
(
i
for i, m in enumerate(messages)
if m.get("type", m.get("role")) in ("human", "user")
and not m.get("additional_kwargs", {}).get("hide_from_ui")
and (m.get("id") in (message_id, f"{message_id}__user") or m.get("additional_kwargs", {}).get("run_id") == run.run_id)
),
None,
)
if marker is None or any(m.get("type", m.get("role")) in ("human", "user") for m in messages[marker + 1 :]):
row["status"] = "result_conflict"
raise ValueError("Thread advanced before result collection")
answers = [m for m in messages[marker + 1 :] if m.get("type", m.get("role")) in ("ai", "assistant") and not m.get("tool_calls")]
if not answers:
raise ValueError("Agent completed without an answer")
content = answers[-1].get("content")
if isinstance(content, str):
try:
output = json.loads(content)
except ValueError:
output = content
else:
output = content
encoded(output, AGENT_OUTPUT_BYTES)
# Evidence comes from the run's completed host assembly, never a
# fresh read of mutable configuration after the Agent finishes.
async with asyncio.timeout(5):
proof = await runs.configuration_evidence(thread_id=thread_id, run_id=run.run_id)
if proof.get("run_id") != run.run_id or proof.get("thread_id") != thread_id or proof.get("source") != "host_assembly":
raise ValueError("Host configuration evidence does not match child run")
row["configuration_evidence"] = proof
row["configuration_version"] = proof["version"]
# Capture before any later turn can alter the thread checkpoint.
row.update(
status="completed",
output=copy.deepcopy(output),
artifact_version=identity([record["id"], step_id, row["attempt"], output]),
)
response["value"] = self.step_result(row)
except TimeoutError:
if row and row["run_id"] is not None:
# Admission is known. Final cleanup interrupts/observes the tracked
# run and escalates to reconciliation only if that cleanup fails.
row["status"] = "failed"
entry["fault"] = "Child observation timed out; interrupting tracked run"
else:
if row:
row["status"] = "needs_reconciliation"
entry["fault"] = "Admission timed out; inspect the stable child thread, do not replay"
response["error"] = entry["fault"]
except AgentRunError as exc:
if row:
if row.get("run_id") is None and exc.status_code in (400, 403, 404, 409, 422):
row["admission_rejected"] = True
row["status"] = "failed" if exc.status_code in (400, 403, 404, 409, 422) else "needs_reconciliation"
response["error"] = f"AgentRuns rejected operation ({exc.status_code})"
entry["fault"] = response["error"]
except Exception as exc:
if row and row["status"] in ("creating_thread", "admitting"):
row["status"] = "needs_reconciliation"
entry["fault"] = "Uncertain admission; inspect the child thread, do not replay"
elif row and row["status"] == "running":
row["status"] = "failed"
response["error"] = str(exc)[:240] if isinstance(exc, ValueError) else "Agent step failed"
finally:
if replay and "error" in response:
entry["fault"] = "Recovered child outcome requires reconciliation"
if row:
row["status"] = "needs_reconciliation"
if row and "error" in response:
row["error"] = response["error"]
await self.save(owner, record)
return response
@staticmethod
def step_result(row):
return {
"artifactVersion": row["artifact_version"],
"output": copy.deepcopy(row["output"]),
"threadId": row["thread_id"],
"runId": row["run_id"],
"status": "success",
}
async def cancel_child(self, runs, row, record=None):
try:
async with asyncio.timeout(5):
run = await runs.get(thread_id=row["thread_id"], run_id=row["run_id"])
if run.status in ("pending", "running"):
await runs.cancel(thread_id=row["thread_id"], run_id=row["run_id"], **(self.dispatch_fence(record) if record is not None else {}))
run = await runs.wait(thread_id=row["thread_id"], run_id=row["run_id"], timeout=4)
row["child_status"] = run.status
if row["status"] == "running":
row["status"] = "cancelled"
except Exception:
row["status"] = "needs_reconciliation"
async def execute(self, entry):
owner, record, runs = entry["owner"], entry["record"], entry["runs"]
process = None
workspace = None
final_state = {}
try:
if record.get("budget_deadline") is not None:
record["remaining_seconds"] = max(0, record["budget_deadline"] - time.time())
record["budget_deadline"] = time.time() + record.get("remaining_seconds", self.config["timeout_seconds"])
record["status"] = "running"
await self.save(owner, record)
workspace = await asyncio.to_thread(tempfile.TemporaryDirectory, prefix="deerflow-workflow-")
directory = workspace.name
# -I excludes cwd/PYTHONPATH and the environment contains no host credentials.
env = {"PATH": os.defpath, "LANG": "C.UTF-8"}
process = await asyncio.create_subprocess_exec(
sys.executable,
"-I",
str(Path(__file__).with_name("worker.py")),
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.DEVNULL,
cwd=directory,
env=env,
limit=FRAME,
)
self.processes[record["id"]] = process
await self.send(
process,
{
"script": record["script"],
"args": record["args"],
"cpu_seconds": self.config["cpu_seconds"],
"memory_bytes": self.config["memory_bytes"],
},
)
async with asyncio.timeout(record.get("remaining_seconds", self.config["timeout_seconds"])) as deadline:
entry["deadline"] = deadline
turns = 0
while True:
if entry["cancel"].is_set() or entry["fault"]:
raise ValueError(entry["fault"] or "Cancelled")
turns += 1
if turns > TURN_LIMIT:
raise ValueError("Worker turn limit")
raw = await process.stdout.readline()
if not raw or len(raw) > FRAME:
raise ValueError("Worker exited or exceeded frame limit")
if entry["cancel"].is_set() or entry["fault"]:
raise ValueError(entry["fault"] or "Cancelled")
frame = json.loads(raw)
messages = frame["messages"]
if not isinstance(messages, list) or len(messages) > 64:
raise ValueError("Worker message limit")
replayed = await self.begin_frame(entry, frame)
if replayed is not None:
await self.send(process, {"responses": replayed})
continue
if any(m.get("type") in ("pause", "humanCheckpoint") for m in messages):
if len(messages) != 1 or entry["tasks"] or frame["outcome"] is not None:
raise ValueError("Checkpoint must be awaited alone, after all Agent calls")
response = await self.checkpoint(entry, messages[0], deadline)
await self.deliver_frame(entry, process, [response])
continue
if any(m.get("type") in ("captureArtifact", "readArtifact") for m in messages):
if len(messages) != 1 or entry["tasks"] or frame["outcome"] is not None:
raise ValueError("Artifact operations must be awaited alone")
operation = self.capture if messages[0]["type"] == "captureArtifact" else self.consume
response = await operation(entry, messages[0])
await self.deliver_frame(entry, process, [response])
continue
for message_index, message in enumerate(messages):
if message.get("type") == "log":
log_id = f"{entry['frame_index']}:{message_index}"
if log_id in record.get("log_ids", []):
continue
if len(record["logs"]) >= 20:
raise ValueError("Log limit")
log = text(message.get("text"), 160)
encoded(log, 160)
record["logs"].append(log)
record.setdefault("log_ids", []).append(log_id)
await self.save(owner, record)
elif message.get("type") == "agent":
if type(message.get("id")) is not int or len(entry["tasks"]) >= self.config["max_calls"]:
raise ValueError("Pending Agent call limit")
entry["tasks"].add(
asyncio.create_task(
self.step(
entry,
message,
replay=entry.get("recover_frame", False),
)
)
)
else:
raise ValueError("Unsupported worker message")
if frame["outcome"] is not None:
outcome = frame["outcome"]
if entry["tasks"]:
raise ValueError("Script returned before awaiting Agent calls")
if outcome.get("type") != "result":
message = outcome.get("message")
raise ValueError(message[:240] if isinstance(message, str) else "Script failed or exceeded limits")
encoded(outcome["value"], 12000)
final_state.update(status="completed", result=outcome["value"])
break
# Poll cancel/fault/worker exit while a response is pending. An idle worker can only progress
# on a response, so keep waiting here instead of sending empty frames (turns).
if entry["tasks"]:
while True:
finished, _ = await asyncio.wait(
entry["tasks"],
timeout=0.02,
return_when=asyncio.FIRST_COMPLETED,
)
if process.returncode is not None:
raise ValueError("Worker exited while waiting for Agent calls")
if finished or not frame.get("idle", False) or entry["cancel"].is_set() or entry["fault"]:
break
else:
await asyncio.sleep(0.002)
finished = [t for t in entry["tasks"] if t.done()]
responses = [t.result() for t in finished]
entry["tasks"].difference_update(finished)
await self.deliver_frame(entry, process, responses)
except TimeoutError:
# Keep this outside asyncio.timeout: its own cancellation must first
# leave the context manager so it can be translated to TimeoutError.
final_state.update(
status="failed",
error="Checkpoint wait deadline exceeded" if record["status"] in ("paused", "waiting_approval") else "Execution deadline exceeded",
)
except asyncio.CancelledError:
entry["cancel"].set()
final_state.update(status="interrupted", error="Host interrupted; child cleanup required")
except Exception as exc:
final_state.update(
status="cancelled" if entry["cancel"].is_set() else "waiting_input" if entry["fault"] == "waiting_input" else "failed",
error=str(exc)[:240],
)
finally:
entry["cancel"].set()
if process:
if process.returncode is None:
try:
process.kill()
except ProcessLookupError:
pass
await process.wait()
self.processes.pop(record["id"], None)
if workspace:
await asyncio.to_thread(workspace.cleanup)
# Admission tasks run to a bounded completion; no lost response on workflow cancel.
if entry["tasks"]:
await asyncio.gather(*entry["tasks"], return_exceptions=True)
if entry.get("lease_lost"):
return
cleanup = [self.cancel_child(runs, row, **({"record": record} if self.clustered else {})) for row in record["steps"] if row.get("run_id") and row["status"] not in ("completed", "waiting_input", "cancelled")]
if cleanup:
await asyncio.gather(*cleanup)
if self.clustered:
for row in record["steps"]:
if self.slot_releasable(row):
await asyncio.to_thread(self.store.release_slot, owner, record, row["step_id"])
# Child saves must not publish a terminal workflow and retire its
# lease before all fenced cleanup and reservation releases finish.
record.update(final_state)
if any(s["status"] == "needs_reconciliation" for s in record["steps"]):
record.update(
status="needs_reconciliation",
error="Some child outcomes are uncertain; inspect their threads/runs before new work",
)
if entry.get("suspending") and record["status"] not in (
"completed",
"needs_reconciliation",
):
record.update(status="recoverable", error="Gateway stopped; sign in to recover")
if any(s["status"] not in ("queued", "completed", "running") for s in record["steps"]):
record.update(
status="needs_reconciliation",
error="Gateway stopped with unfinished child effects; inspect child runs",
)
await self.save(owner, record)
self.entries.pop(record["id"], None)
async def send(self, process, message):
process.stdin.write((encoded(message) + "\n").encode())
await process.stdin.drain()