diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 27f22ca5..bb54a0cc 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -8,6 +8,7 @@ routing, retries, and history all aligned with the rest of the app. import asyncio import time +from uuid import uuid4 import logging from datetime import datetime, timedelta, timezone from typing import Optional @@ -86,6 +87,44 @@ def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]: return list(wf.actions.configured_sets) +def rescue_missing_steps(wf: Workflow) -> bool: + """A run on a stepless workflow used to be a guaranteed failure, and the builder agent could + manufacture that state by answering the user's request without ever staging steps (ENG-335: + "Ran 3 steps", saved zero, scheduled every 15 minutes into a wall). Prompt rules did not hold, + so the seal is mechanical, at the one gate every run passes: promote an uncommitted draft + (activating a workflow IS committing to it), else synthesize one step from the build chat's + first real user message, which is a perfectly runnable workflow (the runner is a full agent). + Returns whether live steps now exist.""" + from backend.apps.workflows.models import WorkflowStep + try: + if wf.draft_steps: + live = [s for s in wf.draft_steps if s.enabled and s.text and s.text.strip()] + if live: + wf.steps = list(wf.draft_steps) + wf.draft_steps = None + storage.save_workflow(wf) + logger.warning(f"[workflow {wf.id}] rescued stepless run by committing the pending draft ({len(live)} steps)") + return True + edit_sid = getattr(wf, "edit_agent_session_id", None) + if edit_sid: + from backend.apps.agents.manager.session.session_store import load_session_data + data = load_session_data(edit_sid) or {} + first = next( + (m for m in data.get("messages", []) + if m.get("role") == "user" and not m.get("hidden") and str(m.get("content") or "").strip()), + None, + ) + if first: + text = str(first["content"]).strip() + wf.steps = [WorkflowStep(id=uuid4().hex, text=text, label=text[:48], enabled=True)] + storage.save_workflow(wf) + logger.warning(f"[workflow {wf.id}] rescued stepless run by synthesizing a step from the build chat's request") + return True + except Exception: + logger.exception(f"[workflow {wf.id}] stepless-rescue failed; the honest no-steps error stands") + return False + + def resolve_workflow_dashboard_id(wf: Workflow) -> Optional[str]: """Pick the dashboard this run's agent attaches to, so browser tools work like in chat. @@ -323,6 +362,8 @@ async def execute( pass steps = [s for s in wf.steps if s.enabled and s.text and s.text.strip()] + if not steps and rescue_missing_steps(wf): + 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") diff --git a/backend/tests/test_workflow_stepless_rescue.py b/backend/tests/test_workflow_stepless_rescue.py new file mode 100644 index 00000000..1c7a4f29 --- /dev/null +++ b/backend/tests/test_workflow_stepless_rescue.py @@ -0,0 +1,47 @@ +"""ENG-335: the builder agent can save a workflow with ZERO steps after visibly doing the work +("Ran 3 steps", saved none), turning every scheduled run into "Workflow has no steps". Prompt +rules didn't hold, so the seal is mechanical at the run gate: promote an uncommitted draft, else +synthesize one step from the build chat's first user message. These pin all three ladder rungs.""" + +from backend.apps.workflows import executor, storage +from backend.apps.workflows.models import Workflow, WorkflowStep + + +def p_wf(**kw) -> Workflow: + wf = Workflow(title="T", description="", system_prompt="", steps=[], **kw) + return wf + + +def test_pending_draft_is_promoted(monkeypatch, tmp_path): + saved = {} + monkeypatch.setattr(storage, "save_workflow", lambda w: saved.setdefault("wf", w)) + wf = p_wf() + wf.draft_steps = [WorkflowStep(id="d1", text="check example.com", label="Check", enabled=True)] + assert executor.rescue_missing_steps(wf) is True + assert [s.text for s in wf.steps] == ["check example.com"] + assert wf.draft_steps is None, "activating the workflow IS the commit" + assert saved["wf"] is wf + + +def test_step_synthesized_from_build_chat_first_message(monkeypatch): + monkeypatch.setattr(storage, "save_workflow", lambda w: None) + from backend.apps.agents.manager.session import session_store + monkeypatch.setattr(session_store, "load_session_data", lambda sid: { + "messages": [ + {"role": "assistant", "content": "hello"}, + {"role": "user", "hidden": True, "content": "[Automated message] continue"}, + {"role": "user", "content": "plan my lunch near 501 folsom st"}, + ], + }) + wf = p_wf() + wf.edit_agent_session_id = "sess-1" + assert executor.rescue_missing_steps(wf) is True + assert wf.steps[0].text == "plan my lunch near 501 folsom st" + assert wf.steps[0].enabled is True + + +def test_nothing_to_rescue_keeps_the_honest_error(monkeypatch): + monkeypatch.setattr(storage, "save_workflow", lambda w: None) + wf = p_wf() + assert executor.rescue_missing_steps(wf) is False + assert wf.steps == []