From 9c11b93dac410a54c4d3b65885236f4e5b31cd41 Mon Sep 17 00:00:00 2001 From: Polur Sai Sankeerth Rao Date: Thu, 24 Sep 2026 22:04:38 +0530 Subject: [PATCH] Add cancel_execution to the Hosted Agents trigger operations harness-trigger now exposes POST .../executions/{id}/cancel (a supported way to end a stuck webhook/cron execution instead of waiting on the platform's reclaim sweep or destroying the session by hand). Adds cancel_execution to both TriggersOperations (sync) and AsyncTriggersOperations, hand-written like the rest of custom_triggers.py (this API is not in the public OpenAPI spec pydo otherwise generates from). The force query param is passed as the literal string "true" rather than a bare Python bool -- HttpRequest serializes bool params as "True"/ "False", which the server's exact-match check on "true" would silently never see as forced. Covered by test_cancel_execution_force_serializes_lowercase / test_async_cancel_execution. --- src/pydo/agents/custom_triggers.py | 37 ++++++++++++++++++++++++ src/pydo/aio/agents/custom_triggers.py | 24 ++++++++++++++++ tests/agents/test_async_triggers.py | 30 ++++++++++++++++++++ tests/agents/test_triggers.py | 39 ++++++++++++++++++++++++++ 4 files changed, 130 insertions(+) diff --git a/src/pydo/agents/custom_triggers.py b/src/pydo/agents/custom_triggers.py index a900f050..a2307ae6 100644 --- a/src/pydo/agents/custom_triggers.py +++ b/src/pydo/agents/custom_triggers.py @@ -223,6 +223,43 @@ def get_execution(self, trigger_id: str, execution_id: str) -> Any: ), ) + def cancel_execution( + self, + trigger_id: str, + execution_id: str, + *, + force: Optional[bool] = None, + ) -> Any: + """End a running execution (``POST .../executions/{id}/cancel``). + + Fails the execution and frees the trigger's in-flight slot. A + fresh-mode session is destroyed outright; a reuse-mode session is + only paused, since the API has no run-interrupt call -- the agent's + turn may still be in flight server-side. Cancelling a non-running + execution is a no-op that still succeeds. + + A freshly-claimed execution briefly has no run yet while the worker + dispatches it; cancelling in that window is guarded against a race + with the in-flight dispatch and raises for ``409`` unless + ``force=True`` is passed. That window self-resolves within 15 + minutes regardless, via the platform's own reclaim sweep. + """ + return self._parse_json( + self._send( + "POST", + ( + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}" + f"/executions/{_quote(execution_id)}/cancel" + ), + # The API matches the literal lowercase "true"; a bare Python + # bool would serialize as "True"/"False" via HttpRequest's + # query encoding and the server would silently never see it + # as forced. Omit the param instead of sending force=false -- + # absence already means "don't force". + params={"force": "true" if force else None}, + ), + ) + # ------------------------------------------------------------------ # Lookups & helpers # ------------------------------------------------------------------ diff --git a/src/pydo/aio/agents/custom_triggers.py b/src/pydo/aio/agents/custom_triggers.py index 65e93e39..8356e466 100644 --- a/src/pydo/aio/agents/custom_triggers.py +++ b/src/pydo/aio/agents/custom_triggers.py @@ -178,6 +178,30 @@ async def get_execution(self, trigger_id: str, execution_id: str) -> Any: ), ) + async def cancel_execution( + self, + trigger_id: str, + execution_id: str, + *, + force: Optional[bool] = None, + ) -> Any: + """End a running execution (``POST .../executions/{id}/cancel``). + + See :meth:`~pydo.agents.custom_triggers.TriggersOperations.cancel_execution` + for the full behavior (fresh vs. reuse mode, the 409/``force`` guard). + """ + return await self._parse_json( + await self._send( + "POST", + ( + f"{_TRIGGERS_PATH}/{_quote(trigger_id)}" + f"/executions/{_quote(execution_id)}/cancel" + ), + # See the sync twin for why this can't be a bare Python bool. + params={"force": "true" if force else None}, + ), + ) + async def get_by_session(self, session_id: str) -> Any: """Reverse-look-up the trigger that produced or binds a session.""" return await self._parse_json( diff --git a/tests/agents/test_async_triggers.py b/tests/agents/test_async_triggers.py index 9bc07adc..e996edf1 100644 --- a/tests/agents/test_async_triggers.py +++ b/tests/agents/test_async_triggers.py @@ -188,3 +188,33 @@ async def test_async_rotate_secret_zero_grace(): assert call.request.method == "POST" assert "grace_period_seconds=0" in call.request.url assert rotated.previous_secret_revoked is True + + +@pytest.mark.asyncio +async def test_async_cancel_execution(): + resources = _make_async_resources( + [ + _FakeAsyncResponse( + 200, {"execution": {"execution_id": "e1", "status": "failed"}} + ), + _FakeAsyncResponse( + 200, {"execution": {"execution_id": "e2", "status": "failed"}} + ), + ] + ) + + plain = await resources.triggers.cancel_execution("t1", "e1") + plain_call = resources._proxy._original._pipeline.calls[0] + assert plain_call.request.method == "POST" + assert plain_call.request.url.split("?", 1)[0].endswith( + "/v2/agents/triggers/t1/executions/e1/cancel" + ) + assert "force" not in plain_call.request.url + assert plain.execution.status == "failed" + + # See the sync test of the same name: a bare Python bool would serialize + # as "True", which the server does not match. + await resources.triggers.cancel_execution("t1", "e2", force=True) + forced_call = resources._proxy._original._pipeline.calls[1] + assert "force=true" in forced_call.request.url + assert "force=True" not in forced_call.request.url diff --git a/tests/agents/test_triggers.py b/tests/agents/test_triggers.py index fa1a8df0..a297c97e 100644 --- a/tests/agents/test_triggers.py +++ b/tests/agents/test_triggers.py @@ -281,6 +281,45 @@ def test_get_execution_includes_payload(): assert resp.execution.output_text == "done" +def test_cancel_execution_plain(): + body = {"execution": {"execution_id": "e1", "status": "failed"}} + resources = _make_resources([_FakeResponse(200, body)]) + + resp = resources.triggers.cancel_execution("t1", "e1") + + call = _last_call(resources) + assert call.request.method == "POST" + assert _path(call.request.url).endswith( + "/v2/agents/triggers/t1/executions/e1/cancel" + ) + assert "force" not in call.request.url + assert resp.execution.status == "failed" + + +def test_cancel_execution_force_serializes_lowercase(): + # HttpRequest serializes a bare Python bool as "True"/"False"; the API + # matches the literal lowercase "true" only. A regression here means + # force=True silently stops forcing anything. + body = {"execution": {"execution_id": "e1", "status": "failed"}} + resources = _make_resources([_FakeResponse(200, body)]) + + resources.triggers.cancel_execution("t1", "e1", force=True) + + call = _last_call(resources) + assert "force=true" in call.request.url + assert "force=True" not in call.request.url + + +def test_cancel_execution_omits_force_when_falsy(): + body = {"execution": {"execution_id": "e1", "status": "failed"}} + resources = _make_resources([_FakeResponse(200, body)]) + + resources.triggers.cancel_execution("t1", "e1", force=False) + + call = _last_call(resources) + assert "force" not in call.request.url + + def test_get_by_session(): body = {"trigger": {"trigger_id": "t1", "bound_session_id": "s1"}} resources = _make_resources([_FakeResponse(200, body)])