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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,35 @@
## [0.16.6] - 2026-09-08

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.

### Fixed

- **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.
- **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).
- **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.
- **`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.

### Added

- **`tests/test_2026_09_08_gate_first_execute.py`** (9 tests). Source-pin regression for all three fixes:
- `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).
- `_enforce_sensitive_tool` displays via `runtime._resolve_workflow_id(...)` (forbids the pre-fix contextvar-only fallback).
- `Transport.execute` docstring references the post-fix contract (forbids the legacy misleading claim).
- `NullRunCallback.on_llm_start` calls `check_workflow_budget()` with a never-raise guard.
- Contextvar round-trip sanity (`set_server_minted_execution_id` / `get_server_minted_execution_id`).

### Compatibility

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.

### Why this is needed

**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.

**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.

**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.

## [0.16.5] - 2026-09-05

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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"
name = "nullrun"
# Full release history lives in CHANGELOG.md; only the current version
# is pinned here.
version = "0.16.5"
version = "0.16.6"
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. The headline is the canonical §1 statement
# from positioning.md — "runtime decision layer for tool-using AI agents"
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@
string and the SDK_MIN_VERSION constant.
"""

__version__ = "0.16.5"
__version__ = "0.16.6"
__platform_version__ = "1.0.0"
38 changes: 28 additions & 10 deletions src/nullrun/_handle.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@

All three translate any:class:`nullrun.NullRunError` into a single
``print(format_user_message(exc), file=sys.stderr)`` followed by
``sys.exit(1)``.:class:`nullrun.WorkflowKilledInterrupt` is a
``BaseException`` subclass and therefore propagates through all three
— the kill signal is never silently swallowed. Non-NullRun exceptions
also propagate unchanged.
``sys.exit(1)``.:class:`nullrun.WorkflowKilledInterrupt` now inherits
from :class:`nullrun.NullRunError` (the 2026-09-08 migration; see the
class docstring), so a bare ``except NullRunError`` would otherwise
swallow the kill signal. ``handle``/``guarded`` explicitly re-raise it
— the kill is a control-plane action, not an SDK failure, and must
reach the top of the agent loop. Non-NullRun exceptions also propagate
unchanged.

``init_or_die`` exists because:func:`nullrun.init` is typically
called at module top-level — before any ``with handle: `` block or
Expand Down Expand Up @@ -56,7 +59,7 @@
from contextlib import contextmanager
from typing import TypeVar

from nullrun.breaker.exceptions import NullRunError
from nullrun.breaker.exceptions import NullRunError, WorkflowKilledInterrupt
from nullrun.messages import format_user_message

T = TypeVar("T")
Expand All @@ -75,11 +78,16 @@ def handle(*, exit_code: int = 1):

Exceptions that propagate unchanged:

*:class:`nullrun.WorkflowKilledInterrupt` (``BaseException``) — kill
signals must reach the top of the agent loop, not be swallowed
into a graceful exit.
*:class:`nullrun.WorkflowKilledInterrupt` — kill signals must reach
the top of the agent loop, not be swallowed into a graceful exit.
Re-raised explicitly inside the ``except NullRunError`` branch
because the 2026-09-08 migration moved ``WorkflowKilledInterrupt``
onto the ``NullRunError`` MRO (Sentry/OTel ``except Exception``
handlers should now record kill events; this ``handle`` /
``guarded`` wrapper opts OUT of that recording on purpose).
*:class:`KeyboardInterrupt` /:class:`SystemExit` (``BaseException``) —
same reason as the kill signal.
same reason as the kill signal — never reach the
``except NullRunError`` branch anyway.
* Any non-NullRun exception — the user's own bugs are not handled
here; let them propagate for an honest traceback.

Expand All @@ -101,6 +109,15 @@ def handle(*, exit_code: int = 1):
try:
yield
except NullRunError as exc:
# 2026-09-08 migration: WorkflowKilledInterrupt moved onto
# the NullRunError MRO so Sentry/OTel `except Exception`
# handlers record kill events. ``handle``/``guarded`` are the
# friendly-exit pattern, NOT the user-callback pattern — kill
# is a control-plane action and must propagate so the agent
# loop / dashboard resume path can see it. Re-raise explicitly
# before the catalog print + sys.exit.
if isinstance(exc, WorkflowKilledInterrupt):
raise
print(format_user_message(exc), file=sys.stderr)
sys.exit(exit_code)

Expand All @@ -111,7 +128,8 @@ def guarded(fn: Callable[..., T]) -> Callable[..., T]:
Wrap a function so any:class:`nullrun.NullRunError` raised inside
it is caught, rendered as a user-facing message, and the process
exits with code ``1``. ``WorkflowKilledInterrupt`` and other
``BaseException`` subclasses propagate.
``BaseException`` subclasses propagate (``handle`` re-raises kill
explicitly, see the 2026-09-08 migration note).

Pair with:func:`nullrun.protect` for the standard agent loop::

Expand Down
35 changes: 25 additions & 10 deletions src/nullrun/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from nullrun.breaker.exceptions import (
NullRunBlockedException,
NullRunWorkflowKilledError,
WorkflowKilledInterrupt,
WorkflowPausedException,
)
Expand Down Expand Up @@ -69,7 +70,7 @@ class ActionHandler:
Handler for NullRun circuit breaker actions.

This executes protective actions when triggered:
- KILL: Immediately stops the workflow (raises WorkflowKilledInterrupt)
- KILL: Immediately stops the workflow (raises NullRunWorkflowKilledError, NR-W002)
- PAUSE: Temporarily halts the workflow (raises WorkflowPausedException)
- ALERT: Sends notification (can be customized)
- SNAPSHOT: Captures workflow state for debugging
Expand Down Expand Up @@ -179,7 +180,10 @@ def handle(
**details: Additional details about the action

Raises:
WorkflowKilledInterrupt: If action is "kill"
NullRunWorkflowKilledError: If action is "kill"
(2026-09-08 typed signal, NR-W002; subclass of
WorkflowKilledInterrupt which remains as the
back-compat name.)
WorkflowPausedException: If action is "pause"
NullRunBlockedException: If action is "block"
"""
Expand Down Expand Up @@ -230,12 +234,11 @@ def handle(
except BaseException as e:
# Don't let handler exceptions propagate. We catch
# `BaseException` (not just `Exception`) because
# `WorkflowKilledInterrupt` is intentionally a
# `BaseException` subclass — it's a non-recoverable
# control signal, but inside the ActionHandler dispatch
# loop we want the kill to be recorded in history
# (already done above) and swallowed, NOT re-raised into
# the caller's frame.
# kill signals (NullRunWorkflowKilledError, the
# 2026-09-08-migrated Exception subclass) and any
# third-party kill-shaped signals must be recorded
# in history (already done above) and swallowed,
# NOT re-raised into the caller's frame.
logger.error(f"Action handler error: {e}")

def _default_kill(
Expand All @@ -244,9 +247,21 @@ def _default_kill(
reason: str,
**details: Any,
) -> None:
"""Default kill handler - raises WorkflowKilledInterrupt."""
"""Default kill handler - raises NullRunWorkflowKilledError.

2026-09-08: typed kill signal (NR-W002). Cookbook code
can `except NullRunWorkflowKilledError` to react to
operator-initiated kills with structured error_code +
user_action. Legacy `except WorkflowKilledInterrupt`
still matches because NullRunWorkflowKilledError is a
subclass.
"""
logger.warning(f"KILL action for workflow {workflow_id}: {reason}")
raise WorkflowKilledInterrupt(workflow_id=workflow_id, reason=reason)
raise NullRunWorkflowKilledError(
workflow_id=workflow_id,
reason=reason,
kill_source="action_handler",
)

def _default_pause(
self,
Expand Down
Loading
Loading