mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-21 20:22:22 +02:00
335 lines
14 KiB
Python
335 lines
14 KiB
Python
"""Run a workflow by launching an agent session and feeding it the steps.
|
|
|
|
The executor is intentionally thin: it leans entirely on agent_manager's
|
|
existing launch + send_message path so a scheduled run looks identical to
|
|
a manual chat. That keeps the MCP gate, action filtering, provider
|
|
routing, retries, and history all aligned with the rest of the app.
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Optional
|
|
|
|
from backend.apps.agents.core.models import AgentConfig
|
|
from backend.apps.workflows.models import Workflow, WorkflowRun
|
|
from backend.apps.workflows import storage
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# In-process map: workflow_id -> currently running run id. Prevents two
|
|
# overlapping fires for the same workflow (e.g. cron tick races a manual
|
|
# Run button) without serializing across the whole executor.
|
|
_running: dict[str, str] = {}
|
|
_running_lock = asyncio.Lock()
|
|
|
|
|
|
def _resolve_system_prompt(wf: Workflow) -> Optional[str]:
|
|
if wf.use_synced_prompt:
|
|
return None
|
|
return wf.system_prompt or None
|
|
|
|
|
|
def _resolve_allowed_tools(wf: Workflow) -> list[str]:
|
|
if not wf.actions.freeze:
|
|
return []
|
|
return list(wf.actions.configured_sets)
|
|
|
|
|
|
def _persist_run_fields(wf: Workflow, run_fields: dict, schedule_runs_count_delta: int = 0) -> None:
|
|
"""Merge run-side fields into the current on-disk workflow.
|
|
|
|
The executor holds the `wf` it was launched with; meanwhile the user
|
|
may have PATCHed unrelated fields (title, schedule, permissions...).
|
|
Saving our captured `wf` would clobber those edits. Re-read the
|
|
authoritative record from storage and only mutate the run-side fields
|
|
we own. If the workflow has been deleted while we ran, silently skip
|
|
the save so we don't resurrect a deleted record.
|
|
|
|
schedule_runs_count_delta is a small int (0 or 1) that we add to the
|
|
on-disk schedule.runs_count to avoid the same race overwriting an
|
|
in-flight bump on the user's PATCH path.
|
|
"""
|
|
fresh = storage.get_workflow(wf.id)
|
|
if fresh is None:
|
|
# Deleted while we ran. Don't resurrect.
|
|
return
|
|
for k, v in run_fields.items():
|
|
setattr(fresh, k, v)
|
|
if schedule_runs_count_delta:
|
|
fresh.schedule.runs_count = fresh.schedule.runs_count + schedule_runs_count_delta
|
|
storage.save_workflow(fresh)
|
|
|
|
|
|
def _monthly_spend_so_far(wf: Workflow) -> float:
|
|
"""Sum cost_usd across runs of `wf` started in the last 30 days.
|
|
|
|
Reads the bounded run log (200 rows max per workflow), so this is
|
|
O(history) and runs once per fire. Naive datetimes (legacy rows) are
|
|
treated as host-local then normalized to UTC by Python's astimezone.
|
|
"""
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=30)
|
|
total = 0.0
|
|
for r in storage.list_runs(wf.id, limit=200):
|
|
started = r.started_at
|
|
if started is None:
|
|
continue
|
|
if started.tzinfo is None:
|
|
started = started.astimezone(timezone.utc)
|
|
else:
|
|
started = started.astimezone(timezone.utc)
|
|
if started >= cutoff:
|
|
total += float(r.cost_usd or 0.0)
|
|
return total
|
|
|
|
|
|
async def execute(wf: Workflow, triggered_by: str = "schedule", scheduled_for: Optional[datetime] = None) -> WorkflowRun:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
|
|
run = WorkflowRun(
|
|
workflow_id=wf.id,
|
|
status="running",
|
|
scheduled_for=scheduled_for,
|
|
started_at=datetime.now(),
|
|
triggered_by=triggered_by,
|
|
)
|
|
|
|
# Cost cap pre-check happens before claiming `_running` so a capped
|
|
# workflow doesn't block its own next fire. We still record the run so
|
|
# the user sees it in History with a clear reason.
|
|
if wf.cost_cap_usd_monthly is not None:
|
|
spent = _monthly_spend_so_far(wf)
|
|
if spent >= wf.cost_cap_usd_monthly:
|
|
run.status = "skipped"
|
|
run.error = f"Monthly cost cap reached (${spent:.2f} / ${wf.cost_cap_usd_monthly:.2f})"
|
|
run.finished_at = datetime.now()
|
|
storage.record_run(run)
|
|
_persist_run_fields(wf, {
|
|
"last_run_at": run.finished_at,
|
|
"last_run_status": "skipped",
|
|
"last_run_id": run.id,
|
|
})
|
|
return run
|
|
|
|
storage.record_run(run)
|
|
|
|
async with _running_lock:
|
|
if wf.id in _running:
|
|
run.status = "skipped"
|
|
run.error = "Previous run still active"
|
|
run.finished_at = datetime.now()
|
|
storage.record_run(run)
|
|
return run
|
|
_running[wf.id] = run.id
|
|
|
|
wf.last_run_at = run.started_at
|
|
wf.last_run_status = "running"
|
|
wf.last_run_id = run.id
|
|
_persist_run_fields(wf, {
|
|
"last_run_at": run.started_at,
|
|
"last_run_status": "running",
|
|
"last_run_id": run.id,
|
|
})
|
|
|
|
session = None
|
|
try:
|
|
steps = [s.text for s in wf.steps if s.text and s.text.strip()]
|
|
if not steps:
|
|
raise ValueError("Workflow has no steps")
|
|
|
|
config = AgentConfig(
|
|
name=wf.title or "Workflow",
|
|
model=wf.model or "sonnet",
|
|
mode=wf.mode or "agent",
|
|
provider=wf.provider or "anthropic",
|
|
system_prompt=_resolve_system_prompt(wf),
|
|
allowed_tools=_resolve_allowed_tools(wf) or [
|
|
"Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion",
|
|
],
|
|
dashboard_id=wf.dashboard_id,
|
|
)
|
|
|
|
session = await agent_manager.launch_agent(config)
|
|
run.session_id = session.id
|
|
storage.record_run(run)
|
|
|
|
# Background poller: surface the latest tool-call name as a
|
|
# live "what's the agent doing" subtitle on the workflow:run
|
|
# ws event. Cheap enough to run at 1.5s cadence; nothing else
|
|
# is watching session.messages from here. Cancelled in the
|
|
# finally block alongside _running cleanup.
|
|
async def _watch_tool_calls() -> None:
|
|
last_seen = ""
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(1.5)
|
|
sess = agent_manager.sessions.get(session.id)
|
|
if not sess:
|
|
return
|
|
msgs = getattr(sess, "messages", []) or []
|
|
label = ""
|
|
for m in reversed(msgs):
|
|
if getattr(m, "role", None) != "tool_call":
|
|
continue
|
|
content = getattr(m, "content", None)
|
|
# Content can be a string, a dict with "name", or
|
|
# a list of blocks. Pick the first tool_use name.
|
|
if isinstance(content, list):
|
|
for b in content:
|
|
if isinstance(b, dict) and b.get("type") == "tool_use":
|
|
label = str(b.get("name") or "")
|
|
break
|
|
elif isinstance(content, dict):
|
|
label = str(content.get("name") or "")
|
|
elif isinstance(content, str):
|
|
label = content[:60]
|
|
if label:
|
|
break
|
|
if label and label != last_seen:
|
|
last_seen = label
|
|
run.last_tool_label = label
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:run", {
|
|
"workflow_id": wf.id,
|
|
"run": run.model_dump(mode="json"),
|
|
})
|
|
except Exception:
|
|
pass
|
|
except asyncio.CancelledError:
|
|
return
|
|
except Exception:
|
|
return
|
|
|
|
watcher_task = asyncio.create_task(_watch_tool_calls())
|
|
|
|
# Send each step sequentially. agent_manager.send_message is a no-op
|
|
# while a prior turn is still streaming, so we await until the
|
|
# session is idle before posting the next step. Keeps the runner
|
|
# safe regardless of how long each turn takes.
|
|
step_error: Optional[str] = None
|
|
for idx, step in enumerate(steps):
|
|
# Broadcast the step bump before sending so RunningView flips
|
|
# the disc immediately, not after the agent finishes the step.
|
|
run.active_step_idx = idx
|
|
run.last_tool_label = None
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager as _wsm
|
|
await _wsm.broadcast_global("workflow:run", {
|
|
"workflow_id": wf.id,
|
|
"run": run.model_dump(mode="json"),
|
|
})
|
|
except Exception:
|
|
pass
|
|
await agent_manager.send_message(session.id, step)
|
|
await _await_session_idle(session.id)
|
|
sess_state = agent_manager.sessions.get(session.id)
|
|
if sess_state is not None and getattr(sess_state, "status", None) == "error":
|
|
step_error = "Agent session entered error state"
|
|
# Pin active step so FailedView can render the X on the
|
|
# right row. error_step_idx == active_step_idx at fail time.
|
|
break
|
|
|
|
run.finished_at = datetime.now()
|
|
sess_state = agent_manager.sessions.get(session.id)
|
|
if sess_state is not None:
|
|
run.cost_usd = float(getattr(sess_state, "cost_usd", 0.0) or 0.0)
|
|
|
|
if step_error is not None:
|
|
run.status = "failure"
|
|
run.error = step_error
|
|
wf.last_run_status = "failure"
|
|
elif scheduled_for is not None and (run.finished_at.replace(tzinfo=None) - scheduled_for.replace(tzinfo=None)).total_seconds() > 300:
|
|
# Started more than 5 minutes after its slot (app was closed,
|
|
# event loop backed up, etc.). Surface in History as ran_late
|
|
# so the user can tell apart "fired on time" from "caught up".
|
|
# Strip tz before the subtraction so a UTC-aware scheduled_for
|
|
# (new code path) and a naive finished_at don't raise.
|
|
run.status = "ran_late"
|
|
wf.last_run_status = "ran_late"
|
|
else:
|
|
run.status = "success"
|
|
wf.last_run_status = "success"
|
|
# Bump runs_count for scheduled fires that reached a terminal state
|
|
# other than "skipped". Manual runs don't count against max_runs.
|
|
runs_delta = 1 if (triggered_by == "schedule" and run.status in ("success", "ran_late", "failure")) else 0
|
|
storage.record_run(run)
|
|
wf.last_run_at = run.finished_at
|
|
_persist_run_fields(wf, {
|
|
"last_run_at": run.finished_at,
|
|
"last_run_status": wf.last_run_status,
|
|
}, schedule_runs_count_delta=runs_delta)
|
|
except Exception as e:
|
|
logger.exception("Workflow run failed: %s", e)
|
|
run.status = "failure"
|
|
run.error = str(e)[:500]
|
|
run.finished_at = datetime.now()
|
|
storage.record_run(run)
|
|
wf.last_run_status = "failure"
|
|
_persist_run_fields(wf, {
|
|
"last_run_status": "failure",
|
|
"last_run_at": run.finished_at,
|
|
})
|
|
finally:
|
|
# Cancel the tool-call watcher before we tear the session down so
|
|
# the next poll doesn't race close_session.
|
|
try:
|
|
watcher_task.cancel() # type: ignore[name-defined]
|
|
except Exception:
|
|
pass
|
|
# Close the workflow's agent session so closed_at is set and the
|
|
# run shows up in chat history (get_history sorts by closed_at;
|
|
# sessions with closed_at=None sort to the bottom and fall off
|
|
# the first page). close_session also drops in-memory state and
|
|
# persists the final snapshot to disk.
|
|
if session is not None:
|
|
try:
|
|
await agent_manager.close_session(session.id)
|
|
except Exception:
|
|
logger.exception("close_session failed for workflow run %s", run.id)
|
|
async with _running_lock:
|
|
_running.pop(wf.id, None)
|
|
|
|
try:
|
|
from backend.apps.workflows.notifier import notify_run_complete
|
|
await notify_run_complete(wf, run)
|
|
except Exception:
|
|
logger.debug("notifier failed", exc_info=True)
|
|
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
await ws_manager.broadcast_global("workflow:run", {
|
|
"workflow_id": wf.id,
|
|
"run": run.model_dump(mode="json"),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
return run
|
|
|
|
|
|
async def _await_session_idle(session_id: str, timeout_s: float = 600.0) -> None:
|
|
"""Block until the agent session reaches a non-running terminal state.
|
|
|
|
Polls cheaply (50ms) since the agent_manager doesn't expose a per-session
|
|
completion future. Bounded by timeout_s so a stuck step doesn't hang the
|
|
runner forever.
|
|
"""
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
|
|
deadline = asyncio.get_event_loop().time() + timeout_s
|
|
while True:
|
|
sess = agent_manager.sessions.get(session_id)
|
|
if not sess:
|
|
return
|
|
task = agent_manager.tasks.get(session_id)
|
|
if task is not None and task.done():
|
|
return
|
|
status = getattr(sess, "status", None)
|
|
if status in ("completed", "error", "stopped"):
|
|
return
|
|
if asyncio.get_event_loop().time() > deadline:
|
|
raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}")
|
|
await asyncio.sleep(0.05)
|