Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/pydo/agents/custom_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ------------------------------------------------------------------
Expand Down
24 changes: 24 additions & 0 deletions src/pydo/aio/agents/custom_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
30 changes: 30 additions & 0 deletions tests/agents/test_async_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions tests/agents/test_triggers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down
Loading