Skip to content

Commit 7e611e5

Browse files
authored
feat: add task deadlines and cancellation (#49)
## Stack - Base: #48 - Next: none — stack halted after this PR as requested ## What this fixes The runtime protocol advertised cancellation, but every provider adapter implemented it as a no-op, and tasks had no portable execution deadline. That left callers unable to bound startup, process-reuse queueing, or in-flight provider work. ## Changes - add keyword-only, timezone-aware `AgentTask.deadline` - add `AgentKit.run(timeout=...)` as a finite non-negative seconds/`timedelta` convenience - add `AgentTaskTimeoutError` and `FinishReason.TIMED_OUT` - add immutable `CancellationReceipt` and explicit cancellation dispositions - make built-in Claude, Codex, Antigravity, and fake runtimes cancellation-aware - make `AgentKit.cancel()` target active cached runtimes without constructing new ones - preserve legacy third-party runtimes whose `cancel()` returns `None` - document cancellation's non-rollback semantics ## Adversarial race hardening Independent reproductions found concurrency defects beyond the initial green suite. Follow-up commits now: - bind cancellation to the exact asyncio task generation - reserve same-id tombstones while legacy task-id hooks settle, preventing ABA cancellation - remove cancellable lock windows from registration/unregistration - keep cancellation effective if the `AgentKit.cancel()` caller is cancelled - make completion-race receipts truthful - make timeout causality win once the deadline fires and emit at most one terminal - skip misleading started events for already-expired tasks - give provider/operation teardown a bounded five-second grace period - quarantine runtime instances while detached cleanup remains pending, rejecting overlap and recovering automatically when cleanup settles - validate deadline runtime types deliberately instead of leaking `AttributeError` ## Verification - all-extras full suite: 418 passed, 3 skipped - core-only full suite: 409 passed, 12 skipped - all-extras coverage: 90.33% (minimum 85%) - focused race/control suite: 22 passed - final independent quarantine reproductions: 3 passed; no pending-task warnings - `uv run ruff check .` - strict `mypy` - `uv lock --check` - `uv build` - `git diff --check` ## Stack health All 11 required GitHub checks pass on `bcd080c`; the opt-in upstream-latest workflow is skipped by design.
1 parent efd5153 commit 7e611e5

17 files changed

Lines changed: 1510 additions & 39 deletions

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
distinguish `READY_TO_ATTEMPT`, `NOT_READY`, and `INDETERMINATE` without
2929
running an agent task. Registry, `AgentKit`, and provider-diagnostics helpers
3030
expose the same async checks.
31+
- Absolute `AgentTask.deadline` values, `AgentKit.run(timeout=...)`, typed
32+
`AgentTaskTimeoutError`, and `FinishReason.TIMED_OUT` provide bounded task
33+
execution across provider startup and reused-process queueing.
34+
- Built-in runtimes and `AgentKit.cancel()` now return immutable
35+
`CancellationReceipt` values with explicit request dispositions; legacy
36+
third-party `cancel()` methods returning `None` remain supported.
3137

3238
### Changed
3339

@@ -69,6 +75,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6975
during static preflight instead of surfacing later or being silently ignored.
7076
- A configured model allow-list now fails closed when model selection is
7177
provider-native and therefore cannot be verified locally.
78+
- Cancellation now binds to one active task generation, unregisters without a
79+
cancellable lock window, emits at most one timeout/cancellation terminal, and
80+
gives reusable provider-process teardown a bounded grace period under repeated
81+
cancellation. Runtime instances stay quarantined from new work while detached
82+
teardown remains pending.
7283

7384
## 0.4.0 - 2026-07-02
7485

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,5 +178,6 @@ built-in runtime populates it yet, so it is always an empty tuple today.
178178
- [Capability matrix](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/capability-matrix.md)
179179
- [API stability](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/api-stability.md)
180180
- [Live smoke tests](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/live-smoke.md)
181+
- [Deadlines and cancellation](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/task-control.md)
181182
- [Mestre migration notes](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/mestre-migration.md)
182183
- [SDK evolution agent](https://github.com/ebarti/agent-runtime-kit/blob/main/docs/sdk-evolution-agent.md)

docs/api-stability.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ import the names from the top-level package instead.
2323
party can ship an adapter for a new runtime without forking the enum.
2424
- **Every runtime exposes the async lifecycle** (`aclose`, `async with`) declared
2525
by the `AgentRuntime` protocol. Stateless runtimes implement it as a no-op.
26+
- **Cancellation receipts describe requests, not rollback.** Built-in runtimes
27+
return an immutable `CancellationReceipt`; the protocol still permits `None`
28+
for existing third-party runtimes, which `AgentKit` reports as
29+
`LEGACY_UNCONFIRMED`. `AgentTask.deadline` is keyword-only and an expired task
30+
raises `AgentTaskTimeoutError` with `FinishReason.TIMED_OUT` event semantics.
2631
- **`finish_reason` values** come from `FinishReason`. The field is typed `str`
2732
for forward-compatibility, so new reasons can be added without a type break;
2833
compare against `FinishReason` members rather than bare literals.

docs/capability-matrix.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
| Permission mapping | `permission_mode` | approval mode + sandbox | capabilities + policies |
1313
| Streaming output events | Yes — incremental `output.delta` while the SDK runs | No — a single `output.delta` at the end (non-streaming SDK) | Yes — from response chunks |
1414
| Tool audit events | Yes — streamed from message blocks | Yes — emitted from parsed `TurnResult` items after the turn | Yes — from tool chunks |
15+
| Deadlines and cancellation | Absolute deadline + coroutine cancellation | Absolute deadline + coroutine cancellation | Absolute deadline + coroutine cancellation |
1516
| `vendor.turn` events | No | No | Yes — from thought/unknown chunks |
1617
| Package availability | Sync, side-effect-free, package-only | Sync, side-effect-free, package-only | Sync, side-effect-free, package-only; never calls ADC |
1718
| Async readiness | Direct API key/OAuth/Bedrock bearer signal → `READY_TO_ATTEMPT`; provider/local chains → `INDETERMINATE` | Supported `AsyncCodex.account(refresh_token=False)` probe; absent account → `NOT_READY` | API key or off-loop ADC/project probe; missing → `NOT_READY` |
@@ -33,6 +34,11 @@ For every provider, malformed schemas are rejected locally before dispatch and
3334
returned structured values are validated locally after the SDK completes.
3435
`parsed_output_available` distinguishes valid JSON `null` from no parsed value.
3536

37+
Deadlines cover provider startup and waits for a reused process. Cancellation
38+
receipts acknowledge a request at the coroutine boundary; they do not imply
39+
rollback of tool side effects that already completed. See
40+
[Deadlines and cancellation](task-control.md).
41+
3642
## Permission mapping
3743

3844
A single portable `PermissionMode` maps to each vendor's native controls. The

docs/task-control.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Deadlines and cancellation
2+
3+
`AgentTask.deadline` is an absolute, timezone-aware `datetime`. It applies to
4+
the whole provider operation, including SDK startup and waiting for a reused
5+
provider process. `AgentKit.run(timeout=...)` is a convenience that converts a
6+
finite, non-negative number of seconds (or `timedelta`) into one absolute
7+
deadline at call time.
8+
9+
```python
10+
from agent_runtime_kit import AgentKit, AgentTaskTimeoutError
11+
12+
try:
13+
result = await kit.run("codex", goal="Refactor the parser", timeout=30)
14+
except AgentTaskTimeoutError as exc:
15+
print(exc.task_id, exc.deadline)
16+
```
17+
18+
An expired deadline never starts the vendor SDK (and therefore emits no started
19+
event). A deadline expiry emits an `agent.task.failed` event with
20+
`finish_reason="timed_out"`, cancels the in-flight provider coroutine, gives its
21+
cleanup a bounded five-second grace period, and raises `AgentTaskTimeoutError`
22+
(which is both an `AgentRuntimeError` and `TimeoutError`). Direct adapter calls
23+
honor `AgentTask(deadline=...)` too. If provider teardown remains wedged after
24+
that grace, the runtime instance is quarantined and rejects new runs rather than
25+
overlapping them; it becomes reusable when the detached cleanup finally settles.
26+
27+
To cancel a task started through `AgentKit`, keep its task id and use the same
28+
runtime instance or cached kind:
29+
30+
```python
31+
import asyncio
32+
33+
task_id = "index-repository"
34+
running = asyncio.create_task(
35+
kit.run("claude", goal="Index the repository", task_id=task_id)
36+
)
37+
receipt = await kit.cancel("claude", task_id)
38+
39+
try:
40+
await running
41+
except asyncio.CancelledError:
42+
pass
43+
44+
print(receipt.disposition)
45+
```
46+
47+
`cancel()` does not construct a runtime that has not already been cached, and
48+
it does not wait for the cancelled run to settle. Built-in adapters also expose
49+
the same method directly. `CancellationReceipt.disposition` distinguishes a
50+
new request, a repeated request, an inactive id, an unsupported hook, a failed
51+
hook, and a legacy runtime that returned no receipt.
52+
53+
An active `(runtime, task_id)` identifies one run generation. If a legacy
54+
task-id-only cancellation hook is still settling, `AgentKit` keeps that id
55+
reserved rather than allowing the delayed hook to target a replacement run.
56+
57+
A `REQUESTED` receipt confirms only that cancellation was requested at the
58+
runtime coroutine boundary. It does not promise rollback: commands, network
59+
requests, or other tool side effects that completed before cancellation may be
60+
permanent. Await the original run task to observe completion of provider
61+
cleanup before reusing related resources.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ include = [
112112
"docs/quickstart.md",
113113
"docs/sdk-evolution-agent-design.md",
114114
"docs/sdk-evolution-agent.md",
115+
"docs/task-control.md",
115116
]
116117

117118
[tool.ruff]

src/agent_runtime_kit/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from agent_runtime_kit._errors import (
44
AgentRuntimeError,
55
AgentRuntimeUnavailableError,
6+
AgentTaskTimeoutError,
67
OutputSchemaError,
78
OutputTypeError,
89
RuntimeNotRegisteredError,
@@ -18,6 +19,8 @@
1819
AgentTask,
1920
ArtifactRef,
2021
AvailabilityReason,
22+
CancellationDisposition,
23+
CancellationReceipt,
2124
EventSink,
2225
FilesystemAccess,
2326
FinishReason,
@@ -66,8 +69,11 @@
6669
"AgentRuntimeKind",
6770
"AgentRuntimeUnavailableError",
6871
"AgentTask",
72+
"AgentTaskTimeoutError",
6973
"ArtifactRef",
7074
"AvailabilityReason",
75+
"CancellationDisposition",
76+
"CancellationReceipt",
7177
"COMPATIBILITY_MANIFEST",
7278
"DEFAULT_READINESS_TIMEOUT",
7379
"EventSink",

src/agent_runtime_kit/_control.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
"""Portable task deadline and cancellation control.
2+
3+
Cancellation is cooperative at the Python coroutine boundary. A successful
4+
receipt means that cancellation was requested; it cannot promise that vendor
5+
tools have rolled back side effects that already happened.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
from collections.abc import Awaitable, Callable
12+
from dataclasses import dataclass
13+
from datetime import datetime, timezone
14+
from typing import Any, TypeVar
15+
16+
from agent_runtime_kit._errors import AgentRuntimeError, AgentTaskTimeoutError
17+
from agent_runtime_kit._types import (
18+
AgentRuntimeKind,
19+
AgentTask,
20+
CancellationDisposition,
21+
CancellationReceipt,
22+
FinishReason,
23+
)
24+
from agent_runtime_kit.events import safe_emit, task_failed_event
25+
26+
_T = TypeVar("_T")
27+
_OPERATION_CLEANUP_TIMEOUT = 5.0
28+
29+
30+
@dataclass
31+
class _ActiveRun:
32+
task: asyncio.Task[Any]
33+
cancellation_requested: bool = False
34+
35+
36+
class RuntimeTaskController:
37+
"""Track active runs for one runtime and enforce their deadlines."""
38+
39+
def __init__(self, kind: AgentRuntimeKind | str) -> None:
40+
self.kind = AgentRuntimeKind.coerce(kind)
41+
self._active: dict[str, _ActiveRun] = {}
42+
self._quarantined: set[asyncio.Future[Any]] = set()
43+
44+
async def run(
45+
self,
46+
task: AgentTask,
47+
operation: Callable[[], Awaitable[_T]],
48+
) -> _T:
49+
"""Run ``operation`` under task-id registration and deadline control."""
50+
51+
current = asyncio.current_task()
52+
if current is None: # pragma: no cover - asyncio always supplies one here
53+
raise AgentRuntimeError("task control requires an active asyncio task")
54+
self._quarantined = {item for item in self._quarantined if not item.done()}
55+
if self._quarantined:
56+
raise AgentRuntimeError(
57+
f"{self.kind} has a cancelled operation still cleaning up; "
58+
"wait for cleanup before starting another run"
59+
)
60+
# These registry operations deliberately contain no await. asyncio tasks
61+
# share one event-loop thread, so the check-and-set is atomic without a
62+
# cancellable lock acquisition. The same is true of cleanup below.
63+
existing = self._active.get(task.task_id)
64+
if existing is not None:
65+
raise AgentRuntimeError(
66+
f"task_id {task.task_id!r} is already active for {self.kind}"
67+
)
68+
self._active[task.task_id] = _ActiveRun(current)
69+
70+
try:
71+
return await self._run_with_deadline(task, operation)
72+
finally:
73+
active = self._active.get(task.task_id)
74+
if active is not None and active.task is current:
75+
del self._active[task.task_id]
76+
77+
async def cancel(
78+
self,
79+
task_id: str,
80+
*,
81+
_expected_task: asyncio.Task[Any] | None = None,
82+
) -> CancellationReceipt:
83+
"""Request cancellation of an active coroutine without blocking on it."""
84+
85+
active = self._active.get(task_id)
86+
if active is None or (
87+
_expected_task is not None and active.task is not _expected_task
88+
):
89+
return self._receipt(task_id, CancellationDisposition.NOT_ACTIVE)
90+
if active.cancellation_requested:
91+
return self._receipt(task_id, CancellationDisposition.ALREADY_REQUESTED)
92+
active.cancellation_requested = True
93+
accepted = active.task.cancel()
94+
if not accepted:
95+
return self._receipt(
96+
task_id,
97+
CancellationDisposition.FAILED,
98+
"the active asyncio task had already completed",
99+
)
100+
return self._receipt(task_id, CancellationDisposition.REQUESTED)
101+
102+
async def _run_with_deadline(
103+
self,
104+
task: AgentTask,
105+
operation: Callable[[], Awaitable[_T]],
106+
) -> _T:
107+
deadline = task.deadline
108+
if deadline is None:
109+
try:
110+
return await operation()
111+
except asyncio.CancelledError:
112+
await self._emit_interrupted(task)
113+
raise
114+
115+
remaining = (deadline - datetime.now(tz=timezone.utc)).total_seconds()
116+
if remaining <= 0:
117+
# An already-expired task never starts, including its started event.
118+
# Emit the terminal first so cancellation of a blocking observability
119+
# sink cannot strand the lifecycle after a misleading start.
120+
await self._emit_timed_out(task, deadline)
121+
raise AgentTaskTimeoutError(self.kind, task.task_id, deadline)
122+
123+
operation_task: asyncio.Future[_T] = asyncio.ensure_future(operation())
124+
timed_out = False
125+
try:
126+
done, _ = await asyncio.wait(
127+
{operation_task},
128+
timeout=remaining,
129+
return_when=asyncio.FIRST_COMPLETED,
130+
)
131+
if operation_task in done:
132+
return await operation_task
133+
134+
# Transition before emitting: cancellation of a slow sink must never
135+
# turn one timeout into a second, contradictory interrupted terminal.
136+
timed_out = True
137+
if not await _cancel_and_settle(operation_task):
138+
self._quarantine(operation_task)
139+
await self._emit_timed_out(task, deadline)
140+
raise AgentTaskTimeoutError(self.kind, task.task_id, deadline)
141+
except asyncio.CancelledError:
142+
if not await _cancel_and_settle(operation_task):
143+
self._quarantine(operation_task)
144+
if not timed_out:
145+
await self._emit_interrupted(task)
146+
raise
147+
148+
async def _emit_interrupted(self, task: AgentTask) -> None:
149+
await safe_emit(
150+
task,
151+
task_failed_event(
152+
task,
153+
self.kind,
154+
error="task cancellation requested",
155+
finish_reason=FinishReason.INTERRUPTED.value,
156+
),
157+
)
158+
159+
async def _emit_timed_out(self, task: AgentTask, deadline: datetime) -> None:
160+
await safe_emit(
161+
task,
162+
task_failed_event(
163+
task,
164+
self.kind,
165+
error=f"task exceeded deadline {deadline.isoformat()}",
166+
finish_reason=FinishReason.TIMED_OUT.value,
167+
),
168+
)
169+
170+
def _receipt(
171+
self,
172+
task_id: str,
173+
disposition: CancellationDisposition,
174+
message: str | None = None,
175+
) -> CancellationReceipt:
176+
return CancellationReceipt(
177+
task_id=task_id,
178+
kind=self.kind,
179+
disposition=disposition,
180+
message=message,
181+
)
182+
183+
def _quarantine(self, operation: asyncio.Future[Any]) -> None:
184+
self._quarantined.add(operation)
185+
186+
def release(completed: asyncio.Future[Any]) -> None:
187+
self._quarantined.discard(completed)
188+
_consume_operation_result(completed)
189+
190+
operation.add_done_callback(release)
191+
192+
193+
async def run_legacy_with_deadline(
194+
task: AgentTask,
195+
kind: AgentRuntimeKind | str,
196+
operation: Callable[[], Awaitable[_T]],
197+
) -> _T:
198+
"""Enforce deadlines for third-party runtimes without native task control."""
199+
200+
# A fresh controller is intentional: AgentKit owns cancellation tracking for
201+
# legacy runtimes, while this helper owns only this invocation's deadline and
202+
# terminal event semantics.
203+
controller = RuntimeTaskController(kind)
204+
return await controller.run(task, operation)
205+
206+
207+
async def _cancel_and_settle(operation: asyncio.Future[Any]) -> bool:
208+
"""Give a cancelled operation bounded, repeat-cancel-safe cleanup time."""
209+
210+
operation.cancel()
211+
deadline = asyncio.get_running_loop().time() + _OPERATION_CLEANUP_TIMEOUT
212+
while not operation.done():
213+
remaining = deadline - asyncio.get_running_loop().time()
214+
if remaining <= 0:
215+
operation.cancel()
216+
return False
217+
try:
218+
await asyncio.wait_for(asyncio.shield(operation), timeout=remaining)
219+
except asyncio.CancelledError:
220+
continue
221+
except asyncio.TimeoutError:
222+
operation.cancel()
223+
return False
224+
except Exception:
225+
break
226+
_consume_operation_result(operation)
227+
return True
228+
229+
230+
def _consume_operation_result(operation: asyncio.Future[Any]) -> None:
231+
try:
232+
operation.exception()
233+
except BaseException:
234+
return

0 commit comments

Comments
 (0)