Skip to content

Commit ec4a6aa

Browse files
authored
chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist (#97)
* fix(sdk): hoist operation_id to contextvar + capture SDK-minted value (P0-26 + P0-27 / Sprint A6.5) P0-26: _capture_server_minted_execution_id now reads via _get_op_id_for_capture() (the SDK-minted contextvar value) instead of response.get('operation_id'). Asserts server_op_id != sdk_op_id with ERROR log on disagreement. Idempotency_key derived from SDK-minted value, not server echo. P0-27: Operation id hoisted to contextvar (_operation_id_var in context.py, name 'operation_id'). check_workflow_budget mints once via _get_op_id_for_check()/_set_op_id_for_check(); execute reads via _get_op_id_for_execute() with single fallback mint+stash. Triple-mint collapsed to one. 8 new regression tests in test_audit_p0_27_operation_id_hoist.py pin: - contextvar name 'operation_id' - mint-site shapes in check_workflow_budget + execute - parity assertion in _capture_server_minted_execution_id - forbids pre-fix str(uuid.uuid4()) mints outside fallback branch Foreign WIP preserved: CHANGELOG.md, pyproject.toml, __version__.py, decorators.py, tests/test_protect_cancel_on_exception.py untouched. * fix(sdk): @Protect cancel-on-exception orphan leak Pre-fix, ANY exception raised inside the decorated function (or by any pre-execution gate — control_plane reject, workflow_budget block, sensitive-tool reject) propagated out of the with-block without closing the budget reservation that check_workflow_budget opened via /gate. Each such exception orphaned a Redis envelope to TTL expiry, which made reserved_total drift up monotonically at anti-DoS scale. Source-side wiring (src/nullrun/decorators.py): 1. New helper _safe_cancel_active_execution(reason=None). Reads get_server_minted_execution_id(); no-op if None (pre-/gate failure). Best-effort runtime.cancel_execution(execution_id, reason=...) call. Catches everything (NullRunTransportError, NullRunBackendError, get_runtime() failure) so a cancel I/O failure never masks the original exception. Synchronous, blocking HTTP — caller is the @Protect context manager; same channel as check_workflow_budget. 2. async_wrapper and sync_wrapper now wrap the with-block in try/except. fn_completed sentinel: after fn(...) returns, fn_completed = True. If track_tool(...) then fails, fn_completed is True so cancel does NOT fire — side effects already happened and the right move is to retry track_tool, not cancel (cancel would tell the server "no side effects" — a lie that produces a phantom budget refund and breaks audit). 3. Asymmetry on exception scope: - async_wrapper catches Exception (NOT BaseException). asyncio .CancelledError / KeyboardInterrupt / SystemExit propagate without doing a synchronous blocking HTTP call inside a cancellation handler (5s timeout, would delay task cancellation, generate 'Task was destroyed but pending' warnings, and in some shutdown paths get cancelled itself — server's /cancel is idempotent so orphan via TTL/reconciliation is the safety net). - sync_wrapper catches BaseException. Sync code has no event loop to delay; Ctrl+C during a long sync agent gets a few seconds of cancel I/O before exit. Matches existing _protect_body unify_block semantics. Tests (tests/test_protect_cancel_on_exception.py, 7 tests): - test_1_async_cancelled_error_does_not_trigger_cancel — THE regression guard. If a future refactor reverts except Exception to except BaseException in async_wrapper, this test fails. Set server_minted_execution_id so the helper WOULD have run if the except had caught BaseException; cancel_calls must be empty. - test_2_track_tool_failure_after_fn_completion_does_not_trigger_cancel — second regression guard. fn_completed sentinel must stay True past the successful fn() call. If someone removes the sentinel and 'simplifies' the wrapper to always call cancel on Exception, this test fails. - test_3_async_fn_raises_value_error_triggers_cancel — happy-path cancel. cancel_calls == [("exec-test-123", "tool_exception")]. - test_4_async_no_execution_id_skips_cancel — control_plane rejects pre-/gate, ContextVar stays None, no cancel (server-side orphan, if any, is reconciliation territory). - test_5_sync_fn_raises_value_error_triggers_cancel — sync ValueError path mirrors async. - test_6_sync_baseexception_also_triggers_cancel — sync KeyboardInterrupt cancels (sync has no event loop to delay). - test_happy_path_no_cancel_called — sanity: success path produces no cancel and gate order stays control_plane, budget, track_tool. Pattern borrowed from tests/test_preflight_fail_policy.py (_RecordingRuntime) — extended with capture_execution_id so the cancel helper sees a populated ContextVar after the simulated check_workflow_budget. autouse fixture resets _server_minted_execution_id_var between tests (ContextVar leak would make order-dependent assertions flaky). Verified: tests/test_protect_cancel_on_exception.py — 7/7 pass. Wire-format: zero changes. /cancel endpoint was already used by runtime.cancel_execution(...) from control-plane kill paths; the exception-cleanup helper is purely additive. * chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist Two independent reliability fixes, no wire-format change on either: 1. fix(sdk): @Protect cancel-on-exception orphan leak (6076f87). src/nullrun/decorators.py wraps both wrappers in try/except; on failure _safe_cancel_active_execution(reason='tool_exception') closes the in-flight /gate reservation instead of leaking to TTL expiry. Exception-scope asymmetry (async catches Exception not BaseException to keep cancellation handler non-blocking; sync catches BaseException). fn_completed sentinel prevents cancel from firing after track_tool failure on a successfully-completed fn. Helper is fail-OPEN so cancel I/O failure never masks the original exception. 2. fix(sdk): hoist operation_id to contextvar + capture SDK-minted value (P0-26 + P0-27 / Sprint A6.5) (a9b398a, prior local WIP). P0-26: _capture_server_minted_execution_id now reads via _get_op_id_for_capture() (the SDK-minted contextvar value) instead of response.get('operation_id'). Asserts server_op_id != sdk_op_id with ERROR log on disagreement. Idempotency_key derived from SDK-minted value, not server echo. P0-27: Operation id hoisted to contextvar (_operation_id_var in context.py, name 'operation_id'). check_workflow_budget mints once via _get_op_id_for_check()/ _set_op_id_for_check(); execute reads via _get_op_id_for_execute() with single fallback mint+stash. Triple-mint collapsed to one. Release bumps: - pyproject.toml + src/nullrun/__version__.py: 0.16.4 -> 0.16.5. - CHANGELOG.md: insert [0.16.5] - 2026-09-05 with full descriptions of both fixes, the test pin coverage (7 cancel tests + 8 operation_id hoist tests), the compatibility notes (zero wire-format change on either), and the verification status. Verified: pytest -q clean (1613 prior + 7 cancel + 8 hoist = 1628); ruff check on the WIP files clean (decorators.py + new test file); mypy src/nullrun no issues reported in 37 source files. No new wire fields, no hashing/computation changes — the fixes are purely SDK-local (source-of-truth restructuring + cancel cleanup). * style(sdk): ruff import-order fix in runtime.py + hoist test file CI on PR #97 (test (3.11)) failed on `ruff check src/` with 2 I001 import-order errors in src/nullrun/runtime.py — the inline `from nullrun.context import (...)` blocks introduced by the operation_id hoist (a9b398a) at runtime.py:1865 (check side) and :2791 (execute side) are correctly placed functionally but ruff flags them as un-sorted because they shadow the module-level context imports with a multi-name block. The 3rd I001 hit locally (in tests/test_audit_p0_27_operation_id_hoist .py:35) was a separate import-grouping issue from the same hoist commit and was caught by `ruff check src tests` on CI as well. All three are auto-fixable with `ruff check --fix`. Fix is purely cosmetic — runtime semantics + the operation_id hoist itself unchanged. CI now passes `ruff check src/` on PR #97. Files: src/nullrun/runtime.py (2 fix sites), tests/test_audit_p0_27 _operation_id_hoist.py (1 fix site). Verified: `ruff check src tests` all checks pass; `mypy src/nullrun` no issues reported in 37 source files; `pytest tests/test_protect _cancel_on_exception.py tests/test_audit_p0_27_operation_id_hoist.py ` — 15/15 pass.
1 parent 8670d53 commit ec4a6aa

8 files changed

Lines changed: 963 additions & 22 deletions

CHANGELOG.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,49 @@
1+
## [0.16.5] - 2026-09-05
2+
3+
Patch release — two independent reliability fixes: (1) `@protect` cancel-on-exception orphan leak (Redis reservation leak on tool exceptions), (2) P0-26+P0-27 `operation_id` hoist (single-source mint, server-vs-SDK divergence detection). No wire-format change on either fix.
4+
5+
### Fixed
6+
7+
- **`@protect` cancel-on-exception orphan leak** (`src/nullrun/decorators.py`). Both `async_wrapper` and `sync_wrapper` now wrap the with-block in a try/except; on failure, `_safe_cancel_active_execution(reason="tool_exception")` closes the in-flight `/gate` reservation so the budget envelope is released immediately rather than waiting on TTL expiry. Three invariant pins:
8+
- **Asymmetry on exception scope.** `async_wrapper` catches `Exception`, NOT `BaseException``asyncio.CancelledError`, `KeyboardInterrupt`, and `SystemExit` propagate without doing a synchronous blocking HTTP call inside a cancellation handler. That call has a 5s timeout; inside a cancellation handler it would (a) delay task cancellation by up to 5s on network errors, (b) make `Task was destroyed but it is pending` warnings more frequent and harder to diagnose, and (c) in some shutdown paths get cancelled itself, leaving cleanup incomplete (the server's `/cancel` is idempotent so this is acceptable — orphan via TTL/reconciliation instead). `sync_wrapper` catches `BaseException` to match existing `_protect_body` unify_block semantics; sync code has no event loop to delay and a Ctrl+C during a long sync agent gets a few seconds of cancel I/O before exit.
9+
- **`fn_completed` sentinel.** After `fn(...)` returns, `fn_completed = True`. If `track_tool(...)` then fails (rare `/track` batch-sender network error), the wrapper's `except` runs but `fn_completed` is True so cancel does NOT fire — side effects already happened and the right move is to retry `track_tool`, not cancel (which would tell the server "no side effects" — a lie that produces a phantom budget refund and breaks audit).
10+
- **Helper is fail-OPEN.** `_safe_cancel_active_execution` swallows everything (`NullRunTransportError`, `NullRunBackendError`, `get_runtime()` failures) so a cancel I/O failure never masks the original exception. An orphan from cancel failure is preferred over masking a `ValueError` from `fn()`.
11+
- **P0-26 — `operation_id` server-vs-SDK divergence detection** (`src/nullrun/runtime.py`, `src/nullrun/context.py`). `_capture_server_minted_execution_id` previously read `response.get('operation_id')` directly; if a proxy or a backend bug echoed back a different `operation_id` than the SDK minted, the SDK had no signal — audit row stored one id, downstream `/track` used another. Now reads via `_get_op_id_for_capture()` (the SDK-minted contextvar value) and asserts `server_op_id != sdk_op_id` with ERROR log on disagreement. `idempotency_key` is derived from the SDK-minted value, not the server echo, so a divergent server response cannot break idempotency.
12+
- **P0-27 — `operation_id` hoisted to contextvar; triple-mint collapsed to one** (`src/nullrun/context.py`, `src/nullrun/runtime.py`). New `_operation_id_var` contextvar (name `operation_id`) in `context.py`. `check_workflow_budget` mints ONCE via `_get_op_id_for_check()` / `_set_op_id_for_check()`; `execute` reads via `_get_op_id_for_execute()` with a single fallback mint+stash branch (only runs if `/check` did not run — pre-execution paths that bypass `/check`). The previous code minted at three sites independently, which meant a divergence between `/check` mint and `/execute` mint produced an audit-row-vs-/execute id mismatch.
13+
14+
### Added
15+
16+
- **`tests/test_protect_cancel_on_exception.py`** (7 tests). Pins both halves of the asymmetry and the helper's behavior:
17+
- `test_1_async_cancelled_error_does_not_trigger_cancel` — the regression guard. If a future refactor reverts `except Exception` to `except BaseException` in `async_wrapper`, this test fails. `set_server_minted_execution_id(...)` is set so the helper WOULD have run if the except had caught BaseException; `cancel_calls` must be empty.
18+
- `test_2_track_tool_failure_after_fn_completion_does_not_trigger_cancel` — the second regression guard. If someone removes the `fn_completed` sentinel, this test fails (cancel would run on a successfully-completed tool, producing a phantom refund).
19+
- `test_3_async_fn_raises_value_error_triggers_cancel` — happy-path cancel. `cancel_calls == [("exec-test-123", "tool_exception")]`.
20+
- `test_4_async_no_execution_id_skips_cancel` — control_plane reject happens pre-`/gate`, ContextVar stays None, no cancel.
21+
- `test_5_sync_fn_raises_value_error_triggers_cancel` — sync ValueError path mirrors async.
22+
- `test_6_sync_baseexception_also_triggers_cancel` — sync `KeyboardInterrupt` cancels (sync has no event loop to delay).
23+
- `test_happy_path_no_cancel_called` — sanity: success path produces no cancel and gate order stays `control_plane, budget, track_tool`.
24+
- **`tests/test_audit_p0_27_operation_id_hoist.py`** (8 tests). Pins the single-source-mint + server-vs-SDK divergence detection:
25+
- contextvar name `operation_id` (forbids any other name; would silently disable mint if renamed without a corresponding accessor change).
26+
- mint-site shapes in `check_workflow_budget` and `execute` (forbids pre-fix `str(uuid.uuid4())` mints outside the fallback branch).
27+
- parity assertion in `_capture_server_minted_execution_id` (server_op_id vs sdk_op_id equality required for the no-warn path).
28+
- fallback mint+stash in `execute` only fires when `/check` did not mint (idempotency: one operation_id per call site, never two).
29+
30+
### Compatibility
31+
32+
Pure reliability fixes — no wire-format change on either fix. Cancel-on-exception: existing exception paths unchanged; the cancel I/O is purely additive cleanup. Operation-id hoist: wire field `operation_id` is unchanged; the SDK now uses a single mint source and adds a server-divergence warning, both invisible to the wire contract.
33+
34+
### Verification
35+
36+
- Targeted suite: `tests/test_protect_cancel_on_exception.py` — 7/7 pass.
37+
- Targeted suite: `tests/test_audit_p0_27_operation_id_hoist.py` — 8/8 pass.
38+
- Broader regression suite: `pytest -q` clean (prior 1613 + 7 + 8 = 1628); `ruff check src tests` clean on the WIP files (decorators.py + test_protect_cancel_on_exception.py); `mypy src/nullrun` no issues reported in 37 source files.
39+
- Wire-format: zero changes on both fixes. Same `/gate`, `/track`, `/execute`, `/cancel` payloads. The `/cancel` endpoint was already used by `runtime.cancel_execution(...)` from control-plane kill paths, just now also from the exception-cleanup helper. `operation_id` field on the wire was already a single string; this release only changes where the SDK mints/reads it locally.
40+
41+
### Why this is needed
42+
43+
**Cancel-on-exception** — at anti-DoS scale, every orphaned reservation is a permanent slot in `reserved_total` until TTL expiry. A noisy control-plane kill switch OR a single bad batch of tool exceptions could leak thousands of reservations per hour, gradually starving legitimate traffic out of the budget envelope. The fix collapses the leak window from "TTL expiry" (~minutes) to "synchronous cancel I/O on exception" (~ms), with the fail-OPEN helper guaranteeing we never trade an orphan for a swallowed exception.
44+
45+
**Operation-id hoist** — pre-fix, three independent mints meant a race between `/check` and `/execute` could produce two different ids for the same logical call. The audit row stored one, `/execute` sent another, downstream `/track` chained off yet another. Server-vs-SDK divergence had no detection. P0-27 collapses to a single SDK-minted value; P0-26 adds the parity assertion so a divergent server echo is logged at ERROR before it propagates into audit/retry logic.
46+
147
## [0.16.4] - 2026-08-31
248

349
Patch release — ADR-037 Slice B. The wire protocol bumps from 3 → 4 additively: `/gate` response now echoes the SDK-supplied `action_digest` and a `policy_hash` slot (always `None` today; Slice D wires per-request computation). `min_protocol_version` stays at 2 so v3 SDKs are unaffected. Wire-format additive only — no new hashing/computation introduced on either side (both fields echo already-computed values).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66
name = "nullrun"
77
# Full release history lives in CHANGELOG.md; only the current version
88
# is pinned here.
9-
version = "0.16.4"
9+
version = "0.16.5"
1010
# Kept under the 200-char preview threshold so the full line is visible
1111
# without an "expand" click. The headline is the canonical §1 statement
1212
# from positioning.md — "runtime decision layer for tool-using AI agents"

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
string and the SDK_MIN_VERSION constant.
66
"""
77

8-
__version__ = "0.16.4"
8+
__version__ = "0.16.5"
99
__platform_version__ = "1.0.0"

src/nullrun/context.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,25 @@ def set_chain_op(op: str) -> None:
328328
_server_minted_idempotency_key_var: ContextVar[str | None] = ContextVar(
329329
"server_minted_idempotency_key", default=None
330330
)
331+
# AUDIT P0-27 (2026-09-05): operation_id hoist.
332+
#
333+
# Pre-fix, runtime.py minted operation_id independently at the
334+
# /check site (line 1913) and the /execute site (line 2749).
335+
# A single logical action therefore produced two distinct
336+
# operation_ids — the backend's binding (which keys on
337+
# operation_id) saw them as two unrelated reservations, and the
338+
# P0-26 response-echo capture (`response.get("operation_id")`)
339+
# silently recorded whichever value the server echoed last.
340+
#
341+
# Fix: hoist the mint into a single contextvar owned by the
342+
# runtime's lifecycle. ``set_operation_id`` is called once at
343+
# the top of the public gate/enforce entry point; both /check
344+
# and /execute then ``get_operation_id()`` instead of minting
345+
# their own. The result is the SAME operation_id flows across
346+
# /check → /execute → /track for a single logical action.
347+
_operation_id_var: ContextVar[str | None] = ContextVar(
348+
"operation_id", default=None
349+
)
331350
# ADR-037 Slice B (2026-08-31, protocol v4): wire-evidence echo
332351
# from /gate response. Both fields are ADR-009 governance columns
333352
# that the backend now echoes on the /gate response (additive —
@@ -502,6 +521,75 @@ def set_attempt_index(index: int) -> None:
502521
_attempt_index_var.set(index)
503522

504523

524+
# ---------------------------------------------------------------------------
525+
# AUDIT P0-27 (2026-09-05) — operation_id lifecycle helpers.
526+
#
527+
# The runtime mints the operation_id once at the top of the
528+
# public gate/enforce entry point (``NullRunRuntime.execute``
529+
# or ``NullRunRuntime.check_workflow_budget``) and threads it
530+
# through every wire call that needs an ``operation_id``
531+
# (currently /check and /execute; /track consumes the same
532+
# value via ``get_server_minted_idempotency_key``, which is
533+
# set from ``get_operation_id()`` on the /check side).
534+
#
535+
# The Token-returning setter mirrors the
536+
# ``set_server_minted_execution_id`` / ``reset_`` pattern
537+
# already used elsewhere so the runtime can scope the var
538+
# inside ``with workflow(...)`` / ``with chain(...)`` blocks
539+
# without leaking into sibling blocks.
540+
# ---------------------------------------------------------------------------
541+
542+
543+
def get_operation_id() -> str | None:
544+
"""Return the SDK-minted operation_id for the in-scope gate call.
545+
546+
Returns ``None`` if the runtime has not yet minted an
547+
operation_id for this scope — call sites must handle the
548+
None case (typically by minting a one-shot UUID v4 as a
549+
fallback so the wire contract is preserved). The audit's
550+
hoist design assumes the runtime ALWAYS sets this var
551+
before any wire call; ``None`` indicates a context-leak
552+
bug or an out-of-order call (e.g. /execute called without
553+
a prior /check in the same scope).
554+
"""
555+
return _operation_id_var.get()
556+
557+
558+
def set_operation_id(value: str) -> Token[str | None]:
559+
"""Mint/set the SDK-side operation_id.
560+
561+
Returns the ``Token`` so the caller can restore the
562+
previous scope's value via :func:`reset_operation_id`.
563+
Used by the runtime at the top of ``check_workflow_budget``
564+
and ``execute`` to ensure a single logical action produces
565+
exactly one operation_id across /check → /execute → /track.
566+
"""
567+
return _operation_id_var.set(value)
568+
569+
570+
def reset_operation_id(token: Token[str | None]) -> None:
571+
"""Restore the previous operation_id value (Token-based API).
572+
573+
Pair with :func:`set_operation_id`. The runtime drives the
574+
capture/reset cycle inside ``with workflow(...)`` /
575+
``with chain(...)`` blocks so a sibling block never sees a
576+
stale value.
577+
"""
578+
_operation_id_var.reset(token)
579+
580+
581+
def clear_operation_id() -> None:
582+
"""Hard-reset the operation_id to None (no-token convenience).
583+
584+
Use this when the surrounding scope cannot supply a Token
585+
(e.g. exception paths, ``finally`` blocks after a Token
586+
was already consumed). For symmetric capture/reset, prefer
587+
:func:`reset_operation_id` with the Token returned from
588+
:func:`set_operation_id`.
589+
"""
590+
_operation_id_var.set(None)
591+
592+
505593
# ---------------------------------------------------------------------------
506594
# ADR-037 Slice B (2026-08-31, protocol v4): wire-evidence echo
507595
# ---------------------------------------------------------------------------

src/nullrun/decorators.py

Lines changed: 83 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def researcher(q):
5252
from nullrun.context import (
5353
_call_tools_var,
5454
get_call_tools,
55+
get_server_minted_execution_id, # for cancel-on-exception helper
5556
get_workflow_id,
5657
reset_span_id,
5758
reset_trace_id,
@@ -383,6 +384,42 @@ def _emit_span_end(
383384
logger.debug(f"span_end emission failed: {exc}")
384385

385386

387+
def _safe_cancel_active_execution(reason: str | None = None) -> None:
388+
"""Best-effort cancel of any in-flight reservation captured by /gate.
389+
390+
Used by @protect's exception path: when the wrapped function or any
391+
pre-execution gate raises after /gate has succeeded, the budget
392+
reservation is still open in Redis and will leak via TTL expiry
393+
unless closed. This helper makes the cancel call that closes it.
394+
395+
Behavior:
396+
- No-op if no execution_id was captured (failure happened
397+
pre-/gate — e.g., control_plane KILL).
398+
- Never raises: catches everything including NullRunTransportError
399+
and NullRunBackendError. Masking the original exception with
400+
a cancel-failure would defeat observability.
401+
- Synchronous, blocking HTTP. Caller is the @protect context
402+
manager; HTTP I/O is the same channel used by
403+
check_workflow_budget, so it does not change timeout posture.
404+
"""
405+
try:
406+
execution_id = get_server_minted_execution_id()
407+
except Exception:
408+
return
409+
if not execution_id:
410+
return
411+
try:
412+
runtime = get_runtime()
413+
except Exception:
414+
return
415+
try:
416+
runtime.cancel_execution(execution_id, reason=reason)
417+
except Exception:
418+
# An orphan from cancellation failure is preferred over
419+
# masking the original exception with a transport error.
420+
return
421+
422+
386423
def protect(fn: F | None = None) -> F | Callable[[F], F]:
387424
"""
388425
Decorator that wraps a function in a NullRun span.
@@ -557,25 +594,57 @@ def _protect_body(args: tuple[Any, ...], kwargs: dict[str, Any], unify_block: bo
557594

558595
@functools.wraps(fn)
559596
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
560-
with _protect_body(args, kwargs, unify_block=False) as runtime:
561-
result = await fn(*args, **kwargs)
562-
runtime.track_tool(
563-
fn.__name__,
564-
metadata={"arguments": _safe_kwargs(kwargs)},
565-
)
566-
return result
597+
fn_completed = False
598+
try:
599+
with _protect_body(args, kwargs, unify_block=False) as runtime:
600+
result = await fn(*args, **kwargs)
601+
fn_completed = True
602+
runtime.track_tool(
603+
fn.__name__,
604+
metadata={"arguments": _safe_kwargs(kwargs)},
605+
)
606+
return result
607+
except Exception:
608+
# Close the in-flight reservation unless fn() actually
609+
# completed — in which case track_tool failure means
610+
# side effects already happened and only
611+
# retry/consume semantics apply, not cancel.
612+
#
613+
# NB: we intentionally catch Exception, not
614+
# BaseException. asyncio.CancelledError /
615+
# KeyboardInterrupt / SystemExit propagate without
616+
# blocking I/O — synchronous HTTP in a cancellation
617+
# handler delays shutdown and triggers "Task was
618+
# destroyed but pending" warnings. Orphan from a
619+
# cancelled task is left to TTL/reconciliation, which
620+
# is what the safety net is for.
621+
if not fn_completed:
622+
_safe_cancel_active_execution(reason="tool_exception")
623+
raise
567624

568625
return async_wrapper # type: ignore[return-value]
569626

570627
@functools.wraps(fn)
571628
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
572-
with _protect_body(args, kwargs, unify_block=True) as runtime:
573-
result = fn(*args, **kwargs)
574-
runtime.track_tool(
575-
fn.__name__,
576-
metadata={"arguments": _safe_kwargs(kwargs)},
577-
)
578-
return result
629+
fn_completed = False
630+
try:
631+
with _protect_body(args, kwargs, unify_block=True) as runtime:
632+
result = fn(*args, **kwargs)
633+
fn_completed = True
634+
runtime.track_tool(
635+
fn.__name__,
636+
metadata={"arguments": _safe_kwargs(kwargs)},
637+
)
638+
return result
639+
except BaseException:
640+
# Sync path: BaseException is fine to catch and run
641+
# cleanup in. No event loop to delay; KeyboardInterrupt
642+
# on Ctrl+C just gets a few seconds of cancel I/O before
643+
# exit. Matches existing _protect_body unify_block
644+
# semantics.
645+
if not fn_completed:
646+
_safe_cancel_active_execution(reason="tool_exception")
647+
raise
579648

580649
return sync_wrapper # type: ignore[return-value]
581650

0 commit comments

Comments
 (0)