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.
- {nullrun-0.16.4 → nullrun-0.16.6}/CHANGELOG.md +78 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/PKG-INFO +1 -1
- {nullrun-0.16.4 → nullrun-0.16.6}/pyproject.toml +1 -1
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/__version__.py +1 -1
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_handle.py +28 -10
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/actions.py +25 -10
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/exceptions.py +399 -61
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/context.py +88 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/decorators.py +103 -16
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/auto.py +10 -3
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/langgraph.py +86 -47
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/messages.py +10 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/runtime.py +254 -61
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/transport.py +129 -17
- {nullrun-0.16.4 → nullrun-0.16.6}/.codecov.yml +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/.dockerignore +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/.gitignore +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/.pre-commit-config.yaml +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/Dockerfile +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/LICENSE +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/Makefile +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/README.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A002.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A003.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-A004.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B002.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B003.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B004.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-B005.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C000.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C003.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-C004.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-CH001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-L001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-O001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-P001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-R001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-R002.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-T001.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W002.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W003.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/NR-W004.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/docs/errors/README.md +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/hatch_build.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_registry.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/_singleton.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/audit.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/breaker/circuit_breaker.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/business_impact.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/capabilities.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/extractor.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/_safe_patch.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/auto_requests.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/autogen.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/crewai.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/instrumentation/llama_index.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/integrations/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/integrations/fastapi.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/error_hooks.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/observability/status.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/py.typed +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/__init__.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/langgraph.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/toolbox/mcp.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/tracing.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/transport_websocket.py +0 -0
- {nullrun-0.16.4 → nullrun-0.16.6}/src/nullrun/uuid7.py +0 -0
- {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).
|
|
@@ -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.
|
|
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"
|
|
@@ -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`
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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`
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
234
|
-
#
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
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
|
|
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
|
|
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,
|