nullrun 0.16.4__tar.gz → 0.16.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. {nullrun-0.16.4 → nullrun-0.16.6}/CHANGELOG.md +78 -0
  2. {nullrun-0.16.4 → nullrun-0.16.6}/PKG-INFO +1 -1
  3. {nullrun-0.16.4 → nullrun-0.16.6}/pyproject.toml +1 -1
  4. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/__version__.py +1 -1
  5. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_handle.py +28 -10
  6. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/actions.py +25 -10
  7. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/exceptions.py +399 -61
  8. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/context.py +88 -0
  9. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/decorators.py +103 -16
  10. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/auto.py +10 -3
  11. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/langgraph.py +86 -47
  12. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/messages.py +10 -0
  13. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/runtime.py +254 -61
  14. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/transport.py +129 -17
  15. {nullrun-0.16.4 → nullrun-0.16.6}/.codecov.yml +0 -0
  16. {nullrun-0.16.4 → nullrun-0.16.6}/.dockerignore +0 -0
  17. {nullrun-0.16.4 → nullrun-0.16.6}/.gitignore +0 -0
  18. {nullrun-0.16.4 → nullrun-0.16.6}/.pre-commit-config.yaml +0 -0
  19. {nullrun-0.16.4 → nullrun-0.16.6}/Dockerfile +0 -0
  20. {nullrun-0.16.4 → nullrun-0.16.6}/LICENSE +0 -0
  21. {nullrun-0.16.4 → nullrun-0.16.6}/Makefile +0 -0
  22. {nullrun-0.16.4 → nullrun-0.16.6}/README.md +0 -0
  23. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A001.md +0 -0
  24. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A002.md +0 -0
  25. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A003.md +0 -0
  26. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A004.md +0 -0
  27. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B001.md +0 -0
  28. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B002.md +0 -0
  29. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B003.md +0 -0
  30. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B004.md +0 -0
  31. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B005.md +0 -0
  32. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C000.md +0 -0
  33. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C001.md +0 -0
  34. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C003.md +0 -0
  35. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C004.md +0 -0
  36. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-CH001.md +0 -0
  37. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-L001.md +0 -0
  38. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-O001.md +0 -0
  39. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-P001.md +0 -0
  40. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-R001.md +0 -0
  41. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-R002.md +0 -0
  42. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-T001.md +0 -0
  43. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W002.md +0 -0
  44. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W003.md +0 -0
  45. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W004.md +0 -0
  46. {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/README.md +0 -0
  47. {nullrun-0.16.4 → nullrun-0.16.6}/hatch_build.py +0 -0
  48. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/__init__.py +0 -0
  49. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_registry.py +0 -0
  50. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_singleton.py +0 -0
  51. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/audit.py +0 -0
  52. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/__init__.py +0 -0
  53. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/circuit_breaker.py +0 -0
  54. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/business_impact.py +0 -0
  55. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/capabilities.py +0 -0
  56. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/extractor.py +0 -0
  57. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/__init__.py +0 -0
  58. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/_safe_patch.py +0 -0
  59. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/auto_requests.py +0 -0
  60. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/autogen.py +0 -0
  61. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/crewai.py +0 -0
  62. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/llama_index.py +0 -0
  63. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/integrations/__init__.py +0 -0
  64. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/integrations/fastapi.py +0 -0
  65. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/__init__.py +0 -0
  66. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/error_hooks.py +0 -0
  67. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/status.py +0 -0
  68. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/py.typed +0 -0
  69. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/__init__.py +0 -0
  70. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/langgraph.py +0 -0
  71. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/mcp.py +0 -0
  72. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/tracing.py +0 -0
  73. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/transport_websocket.py +0 -0
  74. {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/uuid7.py +0 -0
  75. {nullrun-0.16.4 → nullrun-0.16.6}/uv.lock +0 -0
@@ -1,3 +1,81 @@
1
+ ## [0.16.6] - 2026-09-08
2
+
3
+ Patch release — closes the SDK↔backend drift introduced by backend `DEF-SDKK-022-EXEC-BYPASS` (2026-09-04, RUN_ID=20260904T1500). After that backend fix, `/api/v1/execute` runs an `execution:{id}` ownership-binding existence check and returns 404 EXECUTION_NOT_FOUND for any execution_id that was not minted by a prior `/api/v1/gate`. The SDK's `runtime.execute()` had been minting a fresh `uuid7_str()` regardless of prior `/gate`, so every `@protect @sensitive` call returned 404 ("Gateway returned 404") and the displayed workflow_id was the misleading `__nullrun_unknown__` sentinel. LangGraph's `NullRunCallback.on_llm_start` had the symmetric problem on the LLM span side: it fired `llm_call` cost events with no paired `/gate` reservation, so the runtime's `_route_track` silently dropped them. This release closes all three holes. No wire-format change.
4
+
5
+ ### Fixed
6
+
7
+ - **DEFS-SDKEXEC-GATE-FIRST** — `runtime.execute()` reuses the server-minted execution_id from `_server_minted_execution_id_var` when a prior `/gate` minted it (`src/nullrun/runtime.py:2820+`). Pre-fix minted `uuid7_str()` unconditionally; post-fix reads the contextvar (set by `check_workflow_budget`'s `_capture_server_minted_execution_id` from the `/gate` response's `reservation_id` field) and only mints fresh when the contextvar is empty (direct callers without a prior `/gate`, which is a wire-contract violation the backend's 404 handles correctly). Comment block at the fix site names both DEFS-SDKEXEC-GATE-FIRST and DEF-SDKK-022-EXEC-BYPASS so future readers see the round-trip contract without searching.
8
+ - **DEFS-SDKEXEC-WORKFLOW-LABEL** — `_enforce_sensitive_tool` displays the API key's bound workflow via `runtime._resolve_workflow_id(get_workflow_id())` instead of the literal `__nullrun_unknown__` sentinel (`src/nullrun/decorators.py`). The wire still carries the same workflow_id (server-side binding); only the displayed label changes. Two sites updated (extract failure path + main path).
9
+ - **DEFS-SDKEXEC-LLM-RESERVATION** — `NullRunCallback.on_llm_start` (`src/nullrun/instrumentation/langgraph.py`) fires `runtime.check_workflow_budget()` (fail-OPEN) so the matching `on_llm_end` `llm_call` cost event has a server-minted reservation_id and routes via `/track_single` instead of being dropped by `runtime._route_track` (the WARNING log "dropping llm_call event — no server-minted reservation_id in scope"). The call is wrapped in `except BaseException` so a backend outage or `WorkflowKilledInterrupt` / `WorkflowPausedException` never breaks the LangChain callback contract.
10
+ - **`Transport.execute` docstring** (`src/nullrun/transport.py`) — rewrites the misleading pre-2026-09-04 claim ("/execute MUST be called rather than /gate") to reflect the post-DEF-SDKK-022-EXEC-BYPASS contract ("/execute MUST be preceded by /gate for the same execution_id"). Names both fix tags so the contract is grep-able.
11
+
12
+ ### Added
13
+
14
+ - **`tests/test_2026_09_08_gate_first_execute.py`** (9 tests). Source-pin regression for all three fixes:
15
+ - `runtime.execute()` reads `get_server_minted_execution_id()` and reuses it when present (forbids re-introducing an unconditional `uuid7_str()` mint outside the fallback arm).
16
+ - `_enforce_sensitive_tool` displays via `runtime._resolve_workflow_id(...)` (forbids the pre-fix contextvar-only fallback).
17
+ - `Transport.execute` docstring references the post-fix contract (forbids the legacy misleading claim).
18
+ - `NullRunCallback.on_llm_start` calls `check_workflow_budget()` with a never-raise guard.
19
+ - Contextvar round-trip sanity (`set_server_minted_execution_id` / `get_server_minted_execution_id`).
20
+
21
+ ### Compatibility
22
+
23
+ Pure reliability fixes — no wire-format change. `/gate`, `/execute`, `/track`, `/cancel` payloads are byte-identical to 0.16.5. The drift existed only on the SDK side; this release brings the SDK in line with the backend's 2026-09-04 contract without rolling back any backend-side hardening.
24
+
25
+ ### Why this is needed
26
+
27
+ **Gate-first** — the user-facing symptom was that `langgraph_openai_approval_demo.py` (and any `@protect @sensitive` decorator that was actually wired through `runtime.execute()`) returned `Workflow __nullrun_unknown__ blocked: Gateway returned 404` for every call, with `action=block, status_code=None`. The approval rule never had a chance to fire because the 404 was raised on the existence-of-binding check before the policy engine ran. The 0.12.0 SDK had been silently broken against post-2026-09-04 backends for the entire /execute path; this release closes the four-day window of broken `/execute` behaviour.
28
+
29
+ **Workflow label** — `__nullrun_unknown__` was misleading because the SDK did know the workflow (the API key's binding) but only read the contextvar (which was unset on bare `@protect` calls). The displayed label was wrong; the wire was right. Operators reading traces had no signal that the gate had, in fact, scoped the call to a real workflow.
30
+
31
+ **LLM reservation** — LangGraph's `NullRunCallback` emits LLM cost events from the LangChain callback hooks. These have no `@protect` scope and therefore no paired `/gate`. The runtime's `_route_track` (which since v0.16.0 / 2026-08-20 backend v3.66.2 alignment refuses to fall back to `/track/batch` for `llm_call` events without a reservation) dropped them with a WARNING log. Cost attribution for agentic LLM loops was silently incomplete. The fix fires `/gate` once per LLM span (fail-OPEN; same wire-call shape as `@protect`), so cost attribution completes via the v3 `/track_single` path.
32
+
33
+ ## [0.16.5] - 2026-09-05
34
+
35
+ 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.
36
+
37
+ ### Fixed
38
+
39
+ - **`@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:
40
+ - **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.
41
+ - **`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).
42
+ - **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()`.
43
+ - **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.
44
+ - **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.
45
+
46
+ ### Added
47
+
48
+ - **`tests/test_protect_cancel_on_exception.py`** (7 tests). Pins both halves of the asymmetry and the helper's behavior:
49
+ - `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.
50
+ - `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).
51
+ - `test_3_async_fn_raises_value_error_triggers_cancel` — happy-path cancel. `cancel_calls == [("exec-test-123", "tool_exception")]`.
52
+ - `test_4_async_no_execution_id_skips_cancel` — control_plane reject happens pre-`/gate`, ContextVar stays None, no cancel.
53
+ - `test_5_sync_fn_raises_value_error_triggers_cancel` — sync ValueError path mirrors async.
54
+ - `test_6_sync_baseexception_also_triggers_cancel` — sync `KeyboardInterrupt` cancels (sync has no event loop to delay).
55
+ - `test_happy_path_no_cancel_called` — sanity: success path produces no cancel and gate order stays `control_plane, budget, track_tool`.
56
+ - **`tests/test_audit_p0_27_operation_id_hoist.py`** (8 tests). Pins the single-source-mint + server-vs-SDK divergence detection:
57
+ - contextvar name `operation_id` (forbids any other name; would silently disable mint if renamed without a corresponding accessor change).
58
+ - mint-site shapes in `check_workflow_budget` and `execute` (forbids pre-fix `str(uuid.uuid4())` mints outside the fallback branch).
59
+ - parity assertion in `_capture_server_minted_execution_id` (server_op_id vs sdk_op_id equality required for the no-warn path).
60
+ - fallback mint+stash in `execute` only fires when `/check` did not mint (idempotency: one operation_id per call site, never two).
61
+
62
+ ### Compatibility
63
+
64
+ 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.
65
+
66
+ ### Verification
67
+
68
+ - Targeted suite: `tests/test_protect_cancel_on_exception.py` — 7/7 pass.
69
+ - Targeted suite: `tests/test_audit_p0_27_operation_id_hoist.py` — 8/8 pass.
70
+ - 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.
71
+ - 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.
72
+
73
+ ### Why this is needed
74
+
75
+ **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.
76
+
77
+ **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.
78
+
1
79
  ## [0.16.4] - 2026-08-31
2
80
 
3
81
  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).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nullrun
3
- Version: 0.16.4
3
+ Version: 0.16.6
4
4
  Summary: NullRun Python SDK — runtime decision layer for tool-using AI agents.
5
5
  Project-URL: Homepage, https://nullrun.io
6
6
  Project-URL: Documentation, https://docs.nullrun.io
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
  name = "nullrun"
7
7
  # Full release history lives in CHANGELOG.md; only the current version
8
8
  # is pinned here.
9
- version = "0.16.4"
9
+ version = "0.16.6"
10
10
  # Kept under the 200-char preview threshold so the full line is visible
11
11
  # without an "expand" click. The headline is the canonical §1 statement
12
12
  # from positioning.md — "runtime decision layer for tool-using AI agents"
@@ -5,5 +5,5 @@ this module is the single source of truth for the runtime version
5
5
  string and the SDK_MIN_VERSION constant.
6
6
  """
7
7
 
8
- __version__ = "0.16.4"
8
+ __version__ = "0.16.6"
9
9
  __platform_version__ = "1.0.0"
@@ -18,10 +18,13 @@ message on failure" case, this module provides three one-liners:
18
18
 
19
19
  All three translate any:class:`nullrun.NullRunError` into a single
20
20
  ``print(format_user_message(exc), file=sys.stderr)`` followed by
21
- ``sys.exit(1)``.:class:`nullrun.WorkflowKilledInterrupt` is a
22
- ``BaseException`` subclass and therefore propagates through all three
23
- — the kill signal is never silently swallowed. Non-NullRun exceptions
24
- also propagate unchanged.
21
+ ``sys.exit(1)``.:class:`nullrun.WorkflowKilledInterrupt` now inherits
22
+ from :class:`nullrun.NullRunError` (the 2026-09-08 migration; see the
23
+ class docstring), so a bare ``except NullRunError`` would otherwise
24
+ swallow the kill signal. ``handle``/``guarded`` explicitly re-raise it
25
+ — the kill is a control-plane action, not an SDK failure, and must
26
+ reach the top of the agent loop. Non-NullRun exceptions also propagate
27
+ unchanged.
25
28
 
26
29
  ``init_or_die`` exists because:func:`nullrun.init` is typically
27
30
  called at module top-level — before any ``with handle: `` block or
@@ -56,7 +59,7 @@ from collections.abc import Callable
56
59
  from contextlib import contextmanager
57
60
  from typing import TypeVar
58
61
 
59
- from nullrun.breaker.exceptions import NullRunError
62
+ from nullrun.breaker.exceptions import NullRunError, WorkflowKilledInterrupt
60
63
  from nullrun.messages import format_user_message
61
64
 
62
65
  T = TypeVar("T")
@@ -75,11 +78,16 @@ def handle(*, exit_code: int = 1):
75
78
 
76
79
  Exceptions that propagate unchanged:
77
80
 
78
- *:class:`nullrun.WorkflowKilledInterrupt` (``BaseException``) — kill
79
- signals must reach the top of the agent loop, not be swallowed
80
- into a graceful exit.
81
+ *:class:`nullrun.WorkflowKilledInterrupt` — kill signals must reach
82
+ the top of the agent loop, not be swallowed into a graceful exit.
83
+ Re-raised explicitly inside the ``except NullRunError`` branch
84
+ because the 2026-09-08 migration moved ``WorkflowKilledInterrupt``
85
+ onto the ``NullRunError`` MRO (Sentry/OTel ``except Exception``
86
+ handlers should now record kill events; this ``handle`` /
87
+ ``guarded`` wrapper opts OUT of that recording on purpose).
81
88
  *:class:`KeyboardInterrupt` /:class:`SystemExit` (``BaseException``) —
82
- same reason as the kill signal.
89
+ same reason as the kill signal — never reach the
90
+ ``except NullRunError`` branch anyway.
83
91
  * Any non-NullRun exception — the user's own bugs are not handled
84
92
  here; let them propagate for an honest traceback.
85
93
 
@@ -101,6 +109,15 @@ def handle(*, exit_code: int = 1):
101
109
  try:
102
110
  yield
103
111
  except NullRunError as exc:
112
+ # 2026-09-08 migration: WorkflowKilledInterrupt moved onto
113
+ # the NullRunError MRO so Sentry/OTel `except Exception`
114
+ # handlers record kill events. ``handle``/``guarded`` are the
115
+ # friendly-exit pattern, NOT the user-callback pattern — kill
116
+ # is a control-plane action and must propagate so the agent
117
+ # loop / dashboard resume path can see it. Re-raise explicitly
118
+ # before the catalog print + sys.exit.
119
+ if isinstance(exc, WorkflowKilledInterrupt):
120
+ raise
104
121
  print(format_user_message(exc), file=sys.stderr)
105
122
  sys.exit(exit_code)
106
123
 
@@ -111,7 +128,8 @@ def guarded(fn: Callable[..., T]) -> Callable[..., T]:
111
128
  Wrap a function so any:class:`nullrun.NullRunError` raised inside
112
129
  it is caught, rendered as a user-facing message, and the process
113
130
  exits with code ``1``. ``WorkflowKilledInterrupt`` and other
114
- ``BaseException`` subclasses propagate.
131
+ ``BaseException`` subclasses propagate (``handle`` re-raises kill
132
+ explicitly, see the 2026-09-08 migration note).
115
133
 
116
134
  Pair with:func:`nullrun.protect` for the standard agent loop::
117
135
 
@@ -22,6 +22,7 @@ except ImportError:
22
22
 
23
23
  from nullrun.breaker.exceptions import (
24
24
  NullRunBlockedException,
25
+ NullRunWorkflowKilledError,
25
26
  WorkflowKilledInterrupt,
26
27
  WorkflowPausedException,
27
28
  )
@@ -69,7 +70,7 @@ class ActionHandler:
69
70
  Handler for NullRun circuit breaker actions.
70
71
 
71
72
  This executes protective actions when triggered:
72
- - KILL: Immediately stops the workflow (raises WorkflowKilledInterrupt)
73
+ - KILL: Immediately stops the workflow (raises NullRunWorkflowKilledError, NR-W002)
73
74
  - PAUSE: Temporarily halts the workflow (raises WorkflowPausedException)
74
75
  - ALERT: Sends notification (can be customized)
75
76
  - SNAPSHOT: Captures workflow state for debugging
@@ -179,7 +180,10 @@ class ActionHandler:
179
180
  **details: Additional details about the action
180
181
 
181
182
  Raises:
182
- WorkflowKilledInterrupt: If action is "kill"
183
+ NullRunWorkflowKilledError: If action is "kill"
184
+ (2026-09-08 typed signal, NR-W002; subclass of
185
+ WorkflowKilledInterrupt which remains as the
186
+ back-compat name.)
183
187
  WorkflowPausedException: If action is "pause"
184
188
  NullRunBlockedException: If action is "block"
185
189
  """
@@ -230,12 +234,11 @@ class ActionHandler:
230
234
  except BaseException as e:
231
235
  # Don't let handler exceptions propagate. We catch
232
236
  # `BaseException` (not just `Exception`) because
233
- # `WorkflowKilledInterrupt` is intentionally a
234
- # `BaseException` subclass — it's a non-recoverable
235
- # control signal, but inside the ActionHandler dispatch
236
- # loop we want the kill to be recorded in history
237
- # (already done above) and swallowed, NOT re-raised into
238
- # the caller's frame.
237
+ # kill signals (NullRunWorkflowKilledError, the
238
+ # 2026-09-08-migrated Exception subclass) and any
239
+ # third-party kill-shaped signals must be recorded
240
+ # in history (already done above) and swallowed,
241
+ # NOT re-raised into the caller's frame.
239
242
  logger.error(f"Action handler error: {e}")
240
243
 
241
244
  def _default_kill(
@@ -244,9 +247,21 @@ class ActionHandler:
244
247
  reason: str,
245
248
  **details: Any,
246
249
  ) -> None:
247
- """Default kill handler - raises WorkflowKilledInterrupt."""
250
+ """Default kill handler - raises NullRunWorkflowKilledError.
251
+
252
+ 2026-09-08: typed kill signal (NR-W002). Cookbook code
253
+ can `except NullRunWorkflowKilledError` to react to
254
+ operator-initiated kills with structured error_code +
255
+ user_action. Legacy `except WorkflowKilledInterrupt`
256
+ still matches because NullRunWorkflowKilledError is a
257
+ subclass.
258
+ """
248
259
  logger.warning(f"KILL action for workflow {workflow_id}: {reason}")
249
- raise WorkflowKilledInterrupt(workflow_id=workflow_id, reason=reason)
260
+ raise NullRunWorkflowKilledError(
261
+ workflow_id=workflow_id,
262
+ reason=reason,
263
+ kill_source="action_handler",
264
+ )
250
265
 
251
266
  def _default_pause(
252
267
  self,