diff --git a/backend/apps/agents/schedule_mcp_server.py b/backend/apps/agents/schedule_mcp_server.py index 7b7c399c..fe74d732 100644 --- a/backend/apps/agents/schedule_mcp_server.py +++ b/backend/apps/agents/schedule_mcp_server.py @@ -440,6 +440,19 @@ def handle_run_now(args: dict) -> dict: wid = args.get("workflow_id") or "" if not wid: return _err("workflow_id is required.") + # A human clicking Run Now on a paused workflow can see it is paused and chose anyway, so the + # route ignores `enabled` on purpose. An agent reaching the same route is NOT the same act: the + # user never asked, and a workflow they deliberately switched off starting itself is the field + # report ("a workflow that had been toggled off just started running again"). + info = _call("GET", f"/{wid}") + if "_error" not in info: + sched = info.get("schedule") or {} + if isinstance(sched, dict) and sched.get("enabled") is False: + title = info.get("title") or wid + return _err( + f"'{title}' is paused, so I did not run it. Tell the user it is switched off and ask " + "them to turn it back on (or to confirm they want a one-off run) before trying again." + ) r = _call("POST", f"/{wid}/run") if "_error" in r: return _err(r["_error"]) diff --git a/backend/tests/test_agent_wont_run_paused_workflow.py b/backend/tests/test_agent_wont_run_paused_workflow.py new file mode 100644 index 00000000..4348dd1d --- /dev/null +++ b/backend/tests/test_agent_wont_run_paused_workflow.py @@ -0,0 +1,47 @@ +"""An agent must not start a workflow the user switched off. + +Field report (Haik, 1.7.4): "a workflow that had been toggled off just started running again". +The route ignores `schedule.enabled` for manual runs on purpose, because a human clicking Run Now +can see the paused state. An agent reaching that same route is a different act. +""" + +from unittest.mock import patch + +import backend.apps.agents.schedule_mcp_server as mod + + +def p_calls(get_result): + seen = {"ran": False} + def fake(method, path, body=None, timeout=None): + if method == "GET": + return get_result + seen["ran"] = True + return {"run_id": "r1"} + return fake, seen + + +def test_agent_refuses_to_run_a_paused_workflow(): + fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": False}}) + with patch.object(mod, "_call", side_effect=fake): + out = mod.handle_run_now({"workflow_id": "w1"}) + assert seen["ran"] is False, "the run must never be dispatched" + assert out.get("isError") is True + text = out["content"][0]["text"] + assert "paused" in text and "Nightly report" in text + + +def test_agent_runs_an_enabled_workflow_normally(): + fake, seen = p_calls({"title": "Nightly report", "schedule": {"enabled": True}}) + with patch.object(mod, "_call", side_effect=fake): + out = mod.handle_run_now({"workflow_id": "w1"}) + assert seen["ran"] is True + assert not out.get("isError") + + +def test_an_unreadable_workflow_does_not_block_the_run(): + """Fail open: if we cannot read the workflow, behave as before rather than refusing everything.""" + fake, seen = p_calls({"_error": "boom"}) + with patch.object(mod, "_call", side_effect=fake): + out = mod.handle_run_now({"workflow_id": "w1"}) + assert seen["ran"] is True + assert not out.get("isError")