mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-20 11:42:23 +02:00
* [eric] ci: gitleaks-ignore the known historical secrets so our branch stops failing on leaks it didnt add * [eric] workflows: restore scheduled-tasks on the workflow line (revert removal, keep windows fixes + 1.1.69) * [eric] workflows: re-apply uncommitted scheduling wip (schedule pill, calendar view, slice) * [eric] ops: gitignore dev-team local state files * [eric] ops: backlog item for download-tracking visibility * [eric] ci: allowlist the cdp-routes redaction-test token in gitleaks * [aidan] feat/scheduled-tasks: keep step labels in sync on edit and show chevron on every step * [aidan] fix: schedule time in chat * [aidan] ux/workflows: add workflow step removal (#91) * [aidan] feat/scheduled-tasks: remember workflow tool permissions across runs * [aidan] feat/task-scheduling: add hourly and minute (15-min minimum) schedule intervals (#93) * [aidan] feat/scheduled-tasks: calendar, rename, and edit workflows (#94) * [aidan] bug: fix schedule button * [aidan] fix/agent-errors: surface provider rate limits * [aidan] ux/cards: click-to-rename for chat and workflow titles Single-click a card's title to enter edit mode inline. Commit on Enter/blur, cancel on Escape. Rename persists via PATCH for workflows and sessions. * [aidan] feat/workflows: seed build prompt for zero-step workflows When a new workflow has no steps, seed the agent with a prompt asking the user to describe what the workflow should do, rather than starting blank. * [aidan] feat/workflows: add-to-schedule popover for unscheduled workflows Clicking the "+" on an unscheduled workflow row opens a popover with two options: - Keep this schedule: enables the workflow's existing cadence and moves it to Scheduled - Change schedule: opens the scheduling editor to pick a different time * [aidan] ux/workflows: wire add-to-schedule popover and simplify New button - Made the "+" icon on unscheduled workflow rows clickable, opening a popover to keep or change the schedule - Removed AddIcon from toolbar "New" button (now reads "New" instead of "+ New") * [aidan] fix/scheduled-tasks: open schedule calendar when Schedule pill clicked Fixed the Schedule pill click being swallowed by the toolbar's dismiss handler. Exempted the toolbar pills via data-toolbar-pills so their click handlers fire. * [aidan] ux/workflows: open New workflow in agent build chat instead of empty card When creating a new workflow from the hub, open it in edit_agent view (with the agent builder chat) instead of a preview card. The workflow is created on the backend first so the embedded session has a real ID. * [aidan] feat/workflow-edit: add draft testing save flow * [aidan] ux/chat: remove continue chat button * [aidan] ux/workflows: polish workflow card interactions * [aidan] fix/workflow-scheduling: save unscheduled workflows as drafts * aidan ui: schedule naming changes * [aidan] ui: tool calling desc/naming * [aidan] ui: calendar sidebar naming * [aidan] ui: fix stop viewing closing chat * [aidan] feat/workflows: auto-name workflows and polish the build flow (#95) * [aidan] feat/workflow-auto-naming: auto-generate workflow titles from steps Generate a title + description from a workflow's steps (one aux call, reused for step labels) whenever it is still auto_named, so a workflow built in the Edit Agent names itself on commit instead of staying "New workflow". A manual rename sets auto_named=False and is never overwritten. Stream the aux call (non-streaming drops content on some 9router lanes) and fall back to a step-derived title when the model is unavailable. * [aidan] feat/workflows: hide unsaved new workflows until first save A brand-new "+ New" workflow is created with unsaved=true and kept out of the hub's scheduled/unscheduled lists while the user is still building it in the Edit Agent. The first commit (Save) clears the flag and the workflow appears. Every other create path stays visible immediately. * [aidan] ux/workflows: remove redundant save workflow button The Edit Agent already has Discard/Save controls in its strip, so the header "Save Workflow" button was a duplicate save path. Remove it and its pulse/edit-session-id wiring; the model/time subtitle stays. * [aidan] ux/workflows: animate title on auto-rename Wrap the workflow card title in the same Typewriter the chat card uses, so when the auto-generated name replaces the placeholder after Save it retypes letter-by-letter. Gated on a real (non-placeholder) title so it never animates on mount or for already-named workflows. * [aidan] ux/workflows: animate sidebar title on auto-rename Wrap the calendar hub's sidebar row title in the same Typewriter the workflow card uses, so a title that auto-renames retypes letter-by-letter in the sidebar too. Extract the placeholder/isRealTitle guard into the shared workflowVisuals so the card and sidebar stay in sync. * [aidan] fix/workflows: connect watch tether, keep watched chat open, wire draft run/history * [aidan] ui: grey out chat pill when not selected * [aidan] ui: fix running agent display * [aidan] feat/history-popover: add chat history and scheduled tasks run log tabs (#96) * [aidan] ux/schedule: toast when calendar view already open on expand * [aidan] ui: fix popover descs * [aidan] feat/workflow-runs: add pause, resume, and stop controls for live runs * [aidan] feat/schedule-calendar: add calendar occurrences endpoint and concrete timezones * [aidan] feat/workflows: require at least one step to save a workflow * [aidan] ux/edit-agent: hide Discard for an unsaved new workflow * [aidan] ux/edit-agent: move fix-prefix card below the step list * [aidan] feat/workflows: toast when an unattended scheduled run starts * [aidan] fix/dashboard-tethers: anchor workflow-sidecar tethers to measured card rects * [aidan] feat/workflows: validate steps before scheduling and keep chat tool memory * [aidan] feat/mcp-suggestions: dismissable integration banner with per-session cooldown * [aidan] feat/workflows: add scheduled-run "running now" toast with click-to-view (#97) * [aidan] ux/workflows: surface paused state on card, sidebar, and calendar; tidy run history * [aidan] feat/mcp-suggestions: suggest both Google and Microsoft when provider is ambiguous * [aidan] fix/agent-tokens: friendly out-of-tokens card across all agent surfaces * [aidan] feat/workflow-model: persist edit-agent model on save with switch notice and fresh drafts * [aidan] fix/workflow-chat: force stop on watched run mirrors workflow card stop * [aidan] fix/workflow-cards: keep watched run tethered on finish to avoid duplicate chat * [aidan] feat/schedule-calendar: mark current time with a now line in week view * [aidan] refactor/private-names: rename error and schedule classifiers from _ to p_ * [aidan] feat/scheduled-tasks: agent workflow scheduling and in-chat convert (#98) * [aidan] feat/workflow-suggest: nudge user to convert repeatable chat to workflow Add SuggestConvertToWorkflow MCP tool that agents call at the end of a task when they've completed something worth repeating (daily report, weekly check, recurring data pull). Frontend detects the tool call and glows the "Convert to workflow" button 3 times to draw the eye. When user clicks it, the suggested cadence (e.g. "every weekday at 9am") is stored in the draft and seeded into the scheduling agent's first prompt, so the agent can act on the suggestion rather than asking the user again. Tool is never auto-called — agents decide when a task is genuinely repeatable (not debugging, creative work, one-off lookup). Tool description emphasizes sparse, high-confidence use only (once per session max). Files changed: - backend/apps/agents/schedule_mcp_server.py: add SuggestConvertToWorkflow tool - frontend/src/shared/mcpToolMeta.ts: add label for new tool - frontend/src/app/pages/Dashboard/cards/AgentCard.tsx: detect suggestion in session messages, show+glow "Convert to workflow" button, pass cadence to draft - frontend/src/shared/state/workflowsSlice.ts: add suggested_cadence field to Workflow interface - frontend/src/app/pages/Workflows/SchedulingView.tsx: seed scheduling agent prompt with suggested cadence hint * [aidan] feat/agent-scheduling: route recurring asks through native workflows, deny claude cron skill * [aidan] feat/workflow-convert: in-chat convert popup and auto-open scheduled workflow card * [aidan] ux/calendar-page: schedule calendar restyle + popover fixes (#99) * [aidan] fix/dashboard-delete: remove workflows calendar panel on delete key * [aidan] ux/workflows-calendar: restyle hub, fix today highlight, add toolbar toggle * [aidan] ux/schedule-popover: compact density, fix sticky header bleed, add header spacing * [aidan] ux/schedule-calendar: hollow ring dot for past fires in month view * [aidan] ux/schedule-calendar: clickable +N more opens day's full run list * [aidan] feat/run-log-filters: add success and skipped pills to scheduled task history * [aidan] fix/convert-button: stop drag capture so convert-to-workflow click fires * [aidan] ux/calendar-card: match border color and radius to chat and workflow cards * [aidan] fix/minimap: render missed-runs card on the minimap * [aidan] ux/run-sparkline: simplify tooltip to plain run tally * [aidan] ux/run-history: collapse expanded run view to one clickable line * [aidan] ux/calendar-card: match corner radius to browser cards * [aidan] feat/workflows: launch-time scheduling UX and workflow-card polish (#101) * [aidan] feat/schedule-list: lazy-load list view via scroll sentinel * [aidan] feat/missed-runs: launch toast with per-workflow counts and pan-to-card * [aidan] fix/dashboard-tethers: keep watching line anchored on canvas zoom * [aidan] feat/scheduled-tasks: review missed runs at launch instead of auto-firing on_missed * [aidan] refactor/workflow-cards: use radius and status design tokens, polish card chrome * [aidan] ux/agent-card: keep convert-to-workflow visible during runs with mid-turn toast * [aidan] ux/mcp-bubble: drop redundant verb label when a workflow label is shown * [aidan] chore/backend: remove stale explanatory comments * [aidan] fix/workflows-hub: load workflows on hub mount so calendar fills at launch * [aidan] feat/workflows: generate title, description, step labels at convert time * [aidan] fix/tidy-layout: include workflows hub in tidy and fit-to-view * [aidan] feat/schedule-list: window long list via measured-height virtualizer * [aidan] ux/workflows-hub: remove time-saved badge from calendar header * [aidan] fix/types: add missing semantic-type labels and drop stray fade arg * [aidan] feat/schedule: pin monthly day-of-month and honor repeat-every intervals * [aidan] feat/schedule: inherit source-session tool surface for scheduled runs * [aidan] ux/calendar: restack hour-cell events as bars with overflow affordance * [aidan] feat/calendar: open the run card when clicking a scheduled occurrence * [aidan] ux/missed-runs: add per-group select-all toggle and rename skip action * [aidan] feat: new scheduled task design ported * [aidan] ui: sidebar reorder, repeat controls on schedule card * [aidan] ui: sidebar, scheduling time * [aidan] feat/schedule: pin monthly last-day-of-month * [aidan] feat/steps: per-step enable toggle * [aidan] feat/workflows: per-workflow color swatch * [aidan] feat/trash: soft-delete workflows with restore and purge * [aidan] feat/run-monitor: live run monitor card on the canvas * [aidan] feat/run-context: attach a run as removable chat context * [aidan] feat/compose: new-workflow landing page and auto-commit build flow * [aidan] ui/workflows: dark mode and design-system cohesion * [aidan] ui/calendar: overflow popover, condensed week view, scroll fix * [aidan] feat/home: ongoing runs, missed review, and accurate Coming-up counts * [aidan] fix/run-status: sync ongoing runs and heal stuck/interrupted runs * [aidan] ux/schedule: last-day-of-month UI, Run-at time typing, interval input * [aidan] ux/workflows: default window size and toolbar icon * [aidan] fix/schedule: measure ran_late from start and anchor recurrences to created_at * [aidan] feat/calendar: render fire times from backend, drop JS recurrence reimpl * [aidan] chore/dashboard: drop dead configure/missed-run cards, refetch on reconnect * [aidan] chore/agent-card: remove unreachable convert-to-workflow action * [aidan] fix/workflows: don't bump updated_at on a no-op draft commit so viewing a workflow doesn't reorder the sidebar * [aidan] feat/schedule: warn when scheduling a workflow that has no steps * [aidan] fix/selection-tool: never select the workflows app, and exit the tool on Escape without dropping selections * [aidan] ux/compose: diversify new-workflow starter prompts across personas * [aidan] ux/run-monitor: spawn the run card a bit farther right of the workflows app * [aidan] fix/schedule: harden run recovery and storage writes against crashes * [aidan] ux/compose: restyle new-workflow starters as a clean pill cluster with rich prompts * [aidan] fix/workflows: optimistically apply edits so the schedule banner updates instantly * [aidan] ui/workflows: three-tone surface depth so the window lifts off the canvas in both themes * [aidan] ui/workflows: close buttons turn red on hover, matching the chat card * [aidan] test/schedule: cover executor pipeline, storage durability, and recurrence gaps * [aidan] fix: remove package-lock json * [aidan] fix/workflows-compose: keep compose view until edit agent replies * [aidan] feat/workflows: auto-generate workflow + step titles with typewriter animation * [eric] deps: restore frontend/package-lock.json (PR #105 deletion broke npm ci) --------- Co-authored-by: Eric <ciregenz@berkeley.edu> Co-authored-by: cire <134991075+ciregenz@users.noreply.github.com>
506 lines
21 KiB
Python
506 lines
21 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 p_ran_late(started_at: datetime, scheduled_for: datetime) -> bool:
|
|
"""Late means the run STARTED well after its slot (app was closed, event
|
|
loop backed up), not that it ran long. Measured from started_at so a
|
|
punctual run that simply takes a while isn't mislabeled. Both sides
|
|
normalized to UTC; a naive started_at is host-local."""
|
|
delta = started_at.astimezone(timezone.utc) - scheduled_for.astimezone(timezone.utc)
|
|
return delta.total_seconds() > 300
|
|
|
|
|
|
# run_id -> "stop". Set by the stop endpoint so the executor loop, not the
|
|
# HTTP handler, owns the run's terminal write. Without this the still-running
|
|
# executor task could overwrite a "Stopped by user" failure with success.
|
|
# Pause is NOT in here: it rides the agent session's own "stopped" status,
|
|
# which the step loop waits out (see _await_session_idle).
|
|
_run_control: dict[str, str] = {}
|
|
_run_pause_override: dict[str, tuple[bool, float]] = {}
|
|
|
|
|
|
def request_stop(run_id: str) -> None:
|
|
_run_control[run_id] = "stop"
|
|
|
|
|
|
def set_pause_override(run_id: str, paused: bool, ttl_s: float = 5.0) -> None:
|
|
"""Keep an explicit pause/resume control state authoritative briefly.
|
|
|
|
The tool watcher normally derives paused from the agent session status,
|
|
but pause/resume endpoints now return before the slower agent_manager call
|
|
finishes. This prevents the watcher from broadcasting the pre-control
|
|
status during that handoff window.
|
|
"""
|
|
_run_pause_override[run_id] = (paused, asyncio.get_event_loop().time() + ttl_s)
|
|
|
|
|
|
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) -> Optional[list[str]]:
|
|
if not wf.actions.freeze:
|
|
return None
|
|
return list(wf.actions.configured_sets)
|
|
|
|
|
|
def p_make_remember_approval(workflow_id: str):
|
|
def p_remember_approval(tool_name: str, behavior: str) -> None:
|
|
fresh = storage.get_workflow(workflow_id)
|
|
if fresh is None:
|
|
return
|
|
fresh.remembered_approvals = {**fresh.remembered_approvals, tool_name: behavior}
|
|
storage.save_workflow(fresh)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
asyncio.get_running_loop().create_task(ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": fresh.id,
|
|
"workflow": fresh.model_dump(mode="json"),
|
|
}))
|
|
except Exception:
|
|
pass
|
|
return p_remember_approval
|
|
|
|
|
|
def p_persist_step_tool_usage(
|
|
workflow_id: str,
|
|
step_usage: dict[str, dict[str, bool]],
|
|
tested_signature: Optional[str] = None,
|
|
) -> None:
|
|
fresh = storage.get_workflow(workflow_id)
|
|
if fresh is None:
|
|
return
|
|
live_ids = {s.id for s in fresh.steps}
|
|
draft = getattr(fresh, "draft_steps", None)
|
|
if draft is not None:
|
|
live_ids.update(s.id for s in draft)
|
|
fresh.step_tool_usage = {
|
|
sid: dict(tools)
|
|
for sid, tools in (step_usage or {}).items()
|
|
if sid in live_ids and isinstance(tools, dict)
|
|
}
|
|
if tested_signature is not None:
|
|
fresh.tested_signature = tested_signature
|
|
storage.save_workflow(fresh)
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager
|
|
asyncio.get_running_loop().create_task(ws_manager.broadcast_global("workflow:updated", {
|
|
"workflow_id": fresh.id,
|
|
"workflow": fresh.model_dump(mode="json"),
|
|
}))
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
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,
|
|
tested_signature: Optional[str] = None,
|
|
) -> WorkflowRun:
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
from backend.apps.agents.manager.permissions.workflow_approval import (
|
|
clear_workflow_approval_memory,
|
|
get_workflow_step_usage,
|
|
set_workflow_approval_memory,
|
|
set_workflow_approval_step,
|
|
)
|
|
|
|
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
|
|
|
|
session = None
|
|
try:
|
|
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,
|
|
})
|
|
|
|
# Announce the run as running the instant it claims execution, not at
|
|
# the first step. Without this a run that fails fast (e.g. no runnable
|
|
# steps) or hasn't streamed yet never hits the Home "Ongoing runs" list.
|
|
# Both this and the persist above sit inside the try whose finally frees
|
|
# _running, so a persist/broadcast failure can't strand the workflow as
|
|
# permanently "running" (which would block every future fire).
|
|
try:
|
|
from backend.apps.agents.core.ws_manager import ws_manager as _wsm_start
|
|
await _wsm_start.broadcast_global("workflow:run", {
|
|
"workflow_id": wf.id,
|
|
"run": run.model_dump(mode="json"),
|
|
})
|
|
except Exception:
|
|
pass
|
|
|
|
steps = [s for s in wf.steps if s.enabled and s.text and s.text.strip()]
|
|
if not steps:
|
|
raise ValueError("Workflow has no steps")
|
|
|
|
resolved_allowed_tools = _resolve_allowed_tools(wf)
|
|
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=resolved_allowed_tools if resolved_allowed_tools is not None else [
|
|
"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)
|
|
|
|
# Reuse the user's earlier allow/deny answers so an unattended fire
|
|
# doesn't park on a permission prompt. Scheduled runs prompt for an
|
|
# unseen tool only briefly (30s) before failing; manual/test runs are
|
|
# attended, so keep the roomy window. Sensitive-path prompts are never
|
|
# remembered (handled by the gate); they keep prompting every run.
|
|
set_workflow_approval_memory(
|
|
session.id,
|
|
decisions=dict(wf.remembered_approvals),
|
|
step_usage={sid: dict(tools) for sid, tools in wf.step_tool_usage.items()},
|
|
remember=p_make_remember_approval(wf.id),
|
|
ask_timeout=30.0 if triggered_by == "schedule" else 600.0,
|
|
)
|
|
|
|
# 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 = ""
|
|
last_paused = False
|
|
while True:
|
|
try:
|
|
await asyncio.sleep(1.5)
|
|
sess = agent_manager.sessions.get(session.id)
|
|
if not sess:
|
|
return
|
|
now = asyncio.get_event_loop().time()
|
|
override = _run_pause_override.get(run.id)
|
|
if override and override[1] >= now:
|
|
paused_now = override[0]
|
|
else:
|
|
if override:
|
|
_run_pause_override.pop(run.id, None)
|
|
paused_now = getattr(sess, "status", None) == "stopped"
|
|
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
|
|
label_changed = bool(label) and label != last_seen
|
|
if label_changed or paused_now != last_paused:
|
|
if label_changed:
|
|
last_seen = label
|
|
run.last_tool_label = label
|
|
last_paused = paused_now
|
|
run.paused = paused_now
|
|
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):
|
|
if _run_control.get(run.id) == "stop":
|
|
step_error = "Stopped by user"
|
|
break
|
|
# Broadcast the step bump before sending so RunningView flips
|
|
# the disc immediately, not after the agent finishes the step.
|
|
# Advancing means we're not paused; keep the broadcast authoritative
|
|
# so it never races a stale paused=True from the watcher.
|
|
run.active_step_idx = idx
|
|
run.last_tool_label = None
|
|
run.paused = False
|
|
set_workflow_approval_step(session.id, step.id)
|
|
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.text)
|
|
disp = await _await_session_idle(session.id, run.id)
|
|
if disp == "stopped":
|
|
step_error = "Stopped by user"
|
|
# Pin active step so FailedView renders the X on the right row.
|
|
break
|
|
if disp == "error":
|
|
step_error = "Agent session entered error state"
|
|
break
|
|
|
|
run.finished_at = datetime.now()
|
|
run.paused = False
|
|
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 p_ran_late(run.started_at, scheduled_for):
|
|
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
|
|
run_fields = {
|
|
"last_run_at": run.finished_at,
|
|
"last_run_status": wf.last_run_status,
|
|
}
|
|
if triggered_by == "manual" and run.status in ("success", "ran_late") and isinstance(tested_signature, str):
|
|
run_fields["tested_signature"] = tested_signature
|
|
_persist_run_fields(wf, run_fields, 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()
|
|
run.paused = False
|
|
storage.record_run(run)
|
|
wf.last_run_status = "failure"
|
|
_persist_run_fields(wf, {
|
|
"last_run_status": "failure",
|
|
"last_run_at": run.finished_at,
|
|
})
|
|
finally:
|
|
_run_control.pop(run.id, None)
|
|
_run_pause_override.pop(run.id, None)
|
|
# 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:
|
|
p_persist_step_tool_usage(wf.id, get_workflow_step_usage(session.id))
|
|
except Exception:
|
|
logger.exception("persist step tool usage failed for workflow %s", wf.id)
|
|
set_workflow_approval_step(session.id, None)
|
|
clear_workflow_approval_memory(session.id)
|
|
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, run_id: Optional[str] = None, timeout_s: float = 600.0) -> str:
|
|
"""Wait out the current step's agent turn. Returns a disposition:
|
|
'idle' turn finished, advance to the next step
|
|
'error' the agent session errored
|
|
'stopped' the run was manually stopped (full stop)
|
|
|
|
For a real run (run_id given) a user PAUSE shows up as the session going
|
|
'stopped' WITHOUT a stop signal; that is not terminal, so we hold here
|
|
until Resume or Stop, keeping the step deadline fresh so a long pause
|
|
doesn't fail the step. The attended test-run driver passes no run_id and
|
|
treats 'stopped' as terminal (no pause/resume there).
|
|
|
|
Polls cheaply since agent_manager doesn't expose a per-session completion
|
|
future. Bounded by timeout_s so a stuck step can't hang the runner forever.
|
|
"""
|
|
from backend.apps.agents.agent_manager import agent_manager
|
|
|
|
hold_on_pause = run_id is not None
|
|
deadline = asyncio.get_event_loop().time() + timeout_s
|
|
while True:
|
|
if run_id is not None and _run_control.get(run_id) == "stop":
|
|
return "stopped"
|
|
sess = agent_manager.sessions.get(session_id)
|
|
if not sess:
|
|
return "idle"
|
|
status = getattr(sess, "status", None)
|
|
if status == "stopped":
|
|
if not hold_on_pause:
|
|
return "stopped"
|
|
# Paused. Hold, and reset the deadline so paused wall-time
|
|
# doesn't count against the step timeout.
|
|
deadline = asyncio.get_event_loop().time() + timeout_s
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
if status == "error":
|
|
return "error"
|
|
if status == "completed":
|
|
return "idle"
|
|
task = agent_manager.tasks.get(session_id)
|
|
if task is not None and task.done() and status not in ("running", "waiting_approval"):
|
|
return "idle"
|
|
if asyncio.get_event_loop().time() > deadline:
|
|
raise TimeoutError(f"Step exceeded {timeout_s}s on session {session_id}")
|
|
await asyncio.sleep(0.05)
|