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
115 changes: 115 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,118 @@
## [0.16.8] - 2026-09-11

Patch release — closes the NR-A015 wire-shape gap on the SDK side. The
`/execute` `require_approval` arm (backend v3.79+) mints a fresh
server-side execution_id for the approval row and echoes it via
`reservation_id`. Pre-fix `runtime.execute` captured that id into the
contextvar AFTER `/gate` calls but not after `/execute`, so the post-
approval `/execute` re-fire sent the stale pre-arm execution_id;
`consume_approved`'s `WHERE execution_id = $3` predicate missed the
freshly-stamped row and fell through to the terminal
`APPROVAL_REPLAY_REJECTED` branch. This release wires the
post-`/execute` capture and syncs the kwargs dict so the re-fire uses
the freshly-minted id.

Also includes the AUTH-01 / HEART-01 sweep from the 24-driver pass:
reclassification of httpx transport errors on the auth path, plus a
public `Runtime.heartbeat()` wrapper.

### Fixed

- **DEF-EXECUTE-CAPTURE-WIRING** — `runtime.execute` now calls
`_capture_server_minted_execution_id(result)` immediately after
`_transport.execute(...)` and syncs the re-fire kwargs dict to the
captured id (`src/nullrun/runtime.py`). The post-approval re-fire
now sends the freshly-minted execution_id stamped on the approval
row, so `consume_approved`'s `WHERE execution_id = $3` predicate
matches. Closes the SDK-side leg of NR-A015 on the `/execute`
require_approval arm.
- **DEF-WAIT-FOR-APPROVAL-EXEC-ID** — both
`_wait_for_approval_resolution` call-sites (`check_workflow_budget`
+ `runtime.execute`) now pass the captured server-minted
execution_id from the contextvar (with the prior
`org_id`/`workflow_id` sentinel as fallback) instead of the
`workflow_id` sentinel (`src/nullrun/runtime.py`). Diagnostic
improvement only — the WS handler matches on `approval_id` — but
log lines + entry metadata now reflect the server-minted id.
- **DEF-AUTH-01** — `NullRunRuntime.__init__` auth path no longer
reclassifies `httpx.RequestError` as `NullRunAuthenticationError`.
The defensive duplicate arm in `__init__` (backstop for a code path
that no longer exists) is removed; the real arm in `_authenticate`
now raises `NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
— matching the convention used by `Transport.heartbeat` for the
same condition on `/heartbeat`. The previous wrap misled operators:
a network failure looked like an auth failure, even though the
message itself acknowledged "this is a transport failure (not an
auth failure)".

**Back-compat**: `NullRunTransportError` and `NullRunAuthenticationError`
are siblings under `NullRunInfrastructureError`, so the parent class
still catches both. Cookbook code that branches on
`except NullRunAuthenticationError:` for retry will need to also
catch `NullRunTransportError`. Two existing tests
(`test_authenticate_network_error_raises` in `test_runtime.py` and
`test_runtime_branches.py`) were locking in the old misclassification
and have been updated to assert the correct class.

### Added

- **DEF-HEART-01** — `NullRunRuntime.heartbeat(chain_id)` public method
added (thin forwarder to `Transport.heartbeat`). Mirrors the
`chain_end` / `cancel_execution` pattern. Use for single-shot chain
TTL extensions; `Runtime.ping_chain()` remains the wall-clock
scheduler variant. Pure addition — no existing API surface changes.

- **`tests/test_2026_09_11_execute_capture_wires_execution_id.py`** (220 lines). Two regression tests pinning the fix:
- `test_execute_captures_reservation_id_from_response` — verifies the contextvar updates from the `/execute` response and the re-fire uses the captured id (not the stale pre-call one).
- `test_execute_wait_for_approval_receives_captured_eid` — verifies the WS resolution handler receives the captured execution_id.
- **`tests/test_2026_09_11_auth_heartbeat_sweep.py`** (~190 lines, 7 tests). Regression tests for AUTH-01 + HEART-01:
- AUTH-01: `test_auth_connect_error_raises_transport_error_not_auth`, `test_auth_timeout_raises_transport_error_not_auth`, `test_auth_error_class_no_longer_catches_network_error` (back-compat parent-class check).
- HEART-01: `test_heartbeat_method_exists_on_public_api`, `test_heartbeat_forwards_chain_id_to_transport`, `test_heartbeat_passes_through_transport_error`, `test_ping_chain_still_works_after_heartbeat_added`.

### Compatibility

Pure reliability fix — no wire-format change. `/gate`, `/execute`,
`/track`, `/cancel` payloads are byte-identical to 0.16.7. Backend
v3.79+ is required for the wire-shape contract (the `reservation_id`
echo is the v3.79+ field that closes the gap); pre-v3.79 backends
silently fall through the capture (helper is fail-OPEN on malformed
values), preserving the pre-fix behaviour for un-deployed backends.

### Why this is needed

**NR-A015 (execute capture)** — the user-facing symptom was a
post-approval `/execute` re-fire landing on `APPROVAL_REPLAY_REJECTED`
because the SDK stamped the pre-arm `execution_id` into the
re-fire's kwargs dict, but `consume_approved`'s `WHERE execution_id
= $3` predicate had to match the freshly-minted id from the
approval-row bind (backend v3.79+). The terminal error was a
typed `NullRunApprovalReplayRejectedError(NR-A015)` — operators had
no signal that the re-fire was sending a stale id rather than a
truly-replayed call. 0.16.8 captures the `reservation_id` echo
from `/execute`'s response into the same contextvar that `/gate`
already uses, and re-emits the captured id on the re-fire kwargs
dict.

**AUTH-01 (transport reclassification)** — operators reading
`NullRunAuthenticationError` from a failed `__init__` were led to
rotate the API key because the class name suggested auth failure.
Pre-fix, the duplicate arm in `NullRunRuntime.__init__` rewrapped
`httpx.RequestError` as `NullRunAuthenticationError` (with the
"this is a transport failure (not an auth failure)" wording in the
message itself — a smoke signal the wrap was wrong). 0.16.8 raises
`NullRunTransportError(source=NETWORK_ERROR, endpoint="auth")`
matching the `Transport.heartbeat` convention. Catch-block semantics
in cookbooks now need `except (NullRunAuthenticationError,
NullRunTransportError):` for full coverage under
`NullRunInfrastructureError`.

**HEART-01 (public API)** — single-shot chain TTL extensions had to
reach through `runtime._transport.heartbeat(...)` because
`NullRunRuntime` exposed only the wall-clock `ping_chain()` scheduler.
0.16.8 adds `NullRunRuntime.heartbeat(chain_id)` as a thin
forwarder to `Transport.heartbeat`, matching the chain_end /
cancel_execution pattern.

## [0.16.7] - 2026-09-10

Patch release — closes the typed-exception / catalog-coverage gaps surfaced by the 0.16.6 backend hardening. After that release, every catalog exception the SDK can raise now has a hand-written `DEFAULT_MESSAGES` entry (no more "Something went wrong. Please try again." fallback), and `@protect`-decorated sites surface the real exception type instead of rewriting it into a generic `NullRunBlockedException`. The `@protect` block path in `runtime.execute` now dispatches the actual catalog code through `format_user_message`, so wire-error codes (NR-A012, NR-A016, NR-EX01, …) reach users with actionable wording. No wire-format change.
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.7"
version = "0.16.8"
# 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.7"
__version__ = "0.16.8"
__platform_version__ = "1.0.0"
146 changes: 123 additions & 23 deletions src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -767,15 +767,14 @@ def __init__(
# Test mode: skip all network calls
self._transport.start()
else:
try:
self._authenticate()
except NullRunAuthenticationError:
raise # Re-raise auth errors immediately - don't continue in unprotected mode
except httpx.RequestError as e:
raise NullRunAuthenticationError(
f"Auth request failed: {e}. Cannot establish secure connection to NullRun. "
f"Refusing to operate in unprotected mode."
) from e
# AUTH-01 (2026-09-11): previously this arm caught ``httpx.RequestError``
# and re-raised ``NullRunAuthenticationError``, misclassifying network
# failures as auth failures. Arm B in ``_authenticate`` already catches
# the same condition with the correct class (``NullRunTransportError``);
# this defensive duplicate was a backstop for a code path that no longer
# exists between ``_authenticate()`` and ``self._transport.start()``.
# Remove: arm B is sufficient.
self._authenticate()
self._transport.start()
# Start remote polling unless disabled (internal `polling=False`
# for tests/CI). Production always polls.
Expand Down Expand Up @@ -1252,19 +1251,22 @@ def _authenticate(self) -> None:
)
raise err
except httpx.RequestError as e:
# Network error - raise exception, do not fall back silently
err = NullRunAuthenticationError(
# AUTH-01 (2026-09-11): reclassify httpx.RequestError as
# ``NullRunTransportError`` instead of ``NullRunAuthenticationError``.
# The previous wrap misled operators — the same condition (DNS failure,
# connection refused, TLS handshake error, request timeout) is correctly
# classified as ``NullRunTransportError(NETWORK_ERROR, "auth")`` by
# ``Transport.heartbeat`` (transport.py:2077) and the rest of the SDK.
# The ``user_action`` previously embedded here noted "This is a
# transport failure (not an auth failure)" — the class should match
# the message. ``NullRunTransportError.__init__`` already sets
# ``error_code="NR-B001"`` (transport.py:233) and the standard
# retryable ``user_action`` (transport.py:234-237).
err = NullRunTransportError(
f"Auth request failed: {e}. Cannot establish secure connection to NullRun. "
f"Refusing to operate in unprotected mode.",
error_code="NR-B001",
user_action=(
"Could not reach the NullRun backend at "
f"{self.api_url}. Check network connectivity and the "
"configured api_url. This is a transport failure (not "
"an auth failure) — the API key may be valid, the "
"backend is just unreachable."
),
cause=e,
source=TransportErrorSource.NETWORK_ERROR,
endpoint="auth",
)
self._emit_sdk_error(err, stage="auth")
raise err from e
Expand Down Expand Up @@ -2207,10 +2209,26 @@ def check_workflow_budget(self) -> None:
f"check_workflow_budget: require_approval id={approval_id} -- "
f"waiting for WS push (timeout={server_timeout if server_timeout is not None else 'env-default'})"
)
# 2026-09-11: pass the actual server-minted execution_id
# (captured above from the /gate response) into the WS
# wait so the entry's metadata + diagnostic log lines
# reflect the same id the server stamped on the
# approval row. Pre-fix this string fell back to
# ``str(self.organization_id)`` which made the
# ``__nullrun_unknown__`` sentinel leak into exception
# payloads (demo
# langgraph_openai_approval_demo.py prints
# ``execution_id=exc.workflow_id``). The handler matches
# purely on ``approval_id``, so this is diagnostic-only
# — captured on the consumer side.
from nullrun.context import get_server_minted_execution_id

_captured_eid = get_server_minted_execution_id()
result = self._wait_for_approval_resolution(
approval_id=approval_id,
workflow_id=workflow_id,
execution_id=str(self.organization_id or "local"),
execution_id=_captured_eid
or str(self.organization_id or "local"),
timeout_seconds=server_timeout,
)
outcome = (result.get("outcome") or "").lower()
Expand Down Expand Up @@ -2342,6 +2360,35 @@ def stop() -> None:

return stop

def heartbeat(self, chain_id: str) -> dict[str, Any]:
"""POST /api/v1/heartbeat — extend a chain's idle TTL.

Single-shot wrapper around ``Transport.heartbeat`` matching the
``chain_end`` / ``cancel_execution`` public-API pattern
(DEF-HEART-01, 2026-09-11). Use this for one-off TTL extensions;
use ``ping_chain`` when you want a wall-clock scheduler that calls
this method every N seconds.

The wire body is ``{"chain_id": chain_id}`` — HMAC headers carry
``organization_id`` + ``trace_id`` automatically via
``_build_signed_headers``, so no extra kwargs are needed at the
transport layer (mirrors the simpler heartbeat shape vs. chain_end's
``organization_id``/``trace_id`` injection).

The transport layer already raises ``NullRunTransportError(
NETWORK_ERROR, "heartbeat")`` for network errors (transport.py:2077),
so no reclassification is needed at this layer.

Args:
chain_id: Active chain_id (UUID v4) registered via
``with chain(chain_id, op="start")``.

Returns:
Parsed JSON dict (typically ``{"status": "ok", "chain_id": ...,
"last_active": ts}``).
"""
return self._transport.heartbeat(chain_id)

def cancel_execution(self, execution_id: str, reason: str | None = None) -> dict[str, Any]:
"""Cancel an in-flight execution via /api/v1/cancel
.
Expand Down Expand Up @@ -2379,7 +2426,21 @@ def chain_end(self, chain_id: str) -> dict[str, Any]:
Returns:
Parsed JSON dict.
"""
return self._transport.chain_end(chain_id)
# DEF-CHAIN-END-ORG-ID (2026-09-11): ``Transport.chain_end`` pre-fix
# sent only ``{chain_id, chain_op, execution_id}`` to /gate and the
# backend rejected with 422 ``missing field 'organization_id'``.
# Fix: forward ``self.organization_id`` (set in ``_authenticate``)
# and the contextvar trace_id so the SDK builds a complete
# ``GateRequest`` body. ``trace_id`` is sourced from the contextvar
# to match the rest of the SDK's wire-shape policy (one trace id
# per logical chain).
from nullrun.context import get_trace_id

return self._transport.chain_end(
chain_id,
organization_id=self.organization_id,
trace_id=get_trace_id(),
)

def approximate_budget(self) -> dict[str, Any]:
"""UI-only budget estimate via GET /api/v1/budget/approximate
Expand Down Expand Up @@ -2963,6 +3024,34 @@ def execute(
execute_kwargs["action_digest"] = action_digest
result = self._transport.execute(**execute_kwargs)

# 2026-09-11: DEF-EXECUTE-CAPTURE-WIRING. The /execute
# require_approval arm mints a FRESH execution_id (server-
# side) for the approval row + writes the binding, then
# echoes the new id back via ``reservation_id`` (mirrored by
# the backend's GateResponse::require_approval constructor —
# see backend/src/enforcement/gate_wire_adapter.rs v3.79+).
# Without this capture below, the contextvar stays at the
# /gate-minted value, and the post-approval /execute re-fire
# (line ~3037) sends the OLD execution_id back to the
# server. ``consume_approved``'s ``WHERE execution_id = $3``
# predicate then misses the row stamped with the freshly-
# minted one; the diagnostic SELECT walks all alternatives
# without match and falls through to the terminal
# replay-race branch (``APPROVAL_REPLAY_REJECTED``) — the
# SDK raises NR-A015. Capture here is fail-OPEN (drops
# malformed values silently via the helper's UUID parse),
# matching ``check_workflow_budget``'s behaviour.
_capture_server_minted_execution_id(result)

# Sync the kwargs dict to the captured id so the post-approval
# re-fire (line ~3083, ``self._transport.execute(**execute_kwargs)``)
# uses the freshly-minted execution_id. The contextvar update
# alone is not enough — the re-fire path does NOT re-read from
# the contextvar; it reuses the kwargs built before /execute.
_captured_after_execute = get_server_minted_execution_id()
if _captured_after_execute is not None:
execute_kwargs["execution_id"] = _captured_after_execute

# Update metrics (thread-safe)
metrics.inc_runtime("execute_calls")

Expand All @@ -2986,10 +3075,21 @@ def execute(
log_prefix="runtime.execute",
)

# 2026-09-11: same rationale as in
# ``check_workflow_budget`` above — the entry's
# ``execution_id`` slot should reflect the server-minted
# id stamped on the approval row (captured via
# ``_capture_server_minted_execution_id(result)`` at line
# ~2968), not the workflow_id sentinel. The WS handler
# matches on approval_id only, so this is diagnostic.
from nullrun.context import get_server_minted_execution_id

_captured_eid = get_server_minted_execution_id()
approval_result = self._wait_for_approval_resolution(
approval_id=str(approval_id),
workflow_id=workflow_id or UNKNOWN_WORKFLOW_ID,
execution_id=str(workflow_id or UNKNOWN_WORKFLOW_ID),
execution_id=_captured_eid
or str(workflow_id or UNKNOWN_WORKFLOW_ID),
timeout_seconds=server_timeout,
)
outcome = str(approval_result.get("outcome") or "").lower()
Expand Down
Loading
Loading