mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-12 04:37:44 +02:00
[eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69)
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user