Release/0.13.1 (conflict-resolved)#58
Open
maltsev-dev wants to merge 9 commits into
Open
Conversation
typing-debt sweep: pyproject.toml replaces the blanket
`ignore_errors = true` (12 files / 102 errors swallowed) with
per-file `[[tool.mypy.overrides]]` blocks. Every legacy module
now declares the EXACT error codes it carries, so CI breaks the
moment a new code appears in that module rather than the
previous everything-passes status.
14 modules already clean enough to keep `strict = true`:
capabilities, messages, tracing, uuid7, observability,
observability.error_hooks, observability.status, breaker,
breaker.circuit_breaker, breaker.exceptions,
instrumentation._safe_patch, context, _singleton, _registry.
Singleton state split out of runtime.py into two new internal
modules:
* nullrun._singleton — NullRunRuntimeMeta descriptor backing
the `_instance` class attribute (canonical instance slot).
Module-level _runtime PEP 562 __getattr__ proxies in
runtime.py / decorators.py route reads through here, so
`import nullrun; nullrun.runtime` and
`from nullrun.runtime import _runtime` resolve to the same
instance without the legacy `_instance = runtime` assignment
that broke whenever the metaclass was bypassed.
* nullrun._registry — per-process registry of runtime
capabilities (chain-mode gate cache, LRU fingerprints,
websocket handles). Previously inlined as module globals
in runtime.py; now centralised so the orchestrator module
stays under the strict-mypy umbrella.
Backwards-compat: NullRunRuntime._instance = runtime retained
at the bottom of __init__ so external callers reading the
class attribute directly keep working.
Ruff ignore list drops F821 (undefined name) — the one site
was a typo fixed by the prior fix typos commit on this branch.
Tests:
* tests/test_registry.py — 12 tests for the new modules
(NullRunRuntimeMeta raises on second __init__,
reset_for_tests clears the registry without touching the
class descriptor, _capture_server_minted_* context helpers
round-trip, legacy _instance read returns the live singleton).
* Existing suite untouched: 1037 lib tests still pass.
Backends on 1.0.0 keep working unchanged. Pinning unchanged:
SDK_MIN_VERSION_FOR_V3 = "0.12.0".
…s docstring
Drift audit 2026-07-06 §3 found that the backend emits three
distinct error-envelope shapes on non-2xx responses, and the
SDK was parsing all of them inline inside
`Transport._parse_v3_error_envelope`. The new helper
`_extract_error_envelope` normalises them into the
`(error_code, message, details)` tuple the rest of the
parser consumes, ranked by lookup priority:
1. v3 envelope — `{"error_code": ..., "error_message": ...,
"details": {...}}` (canonical shape from
`gate/internal.rs` + `handlers.rs::track_handler`).
2. v3 mixed — `{"error_code": ..., "message": ...,
"retry_after_ms": N}` (the 503 path from
`budget.rs:107-112`; same v3 semantics, "message"
field instead of "error_message").
3. legacy envelope — top-level `{"code": ...}` from the
v1/v2 proxy routes that haven't been migrated yet.
`_parse_v3_error_envelope` now delegates to the helper, and
the per-status-code mapping table at the bottom of the file is
the single source of truth for `(error_code -> exception class,
status, retry semantics)`. Adding a new code is a one-line
change in the table.
`src/nullrun/capabilities.py`: docstring rewrite to match
the actual backend `/api/v1/capabilities` shape (the old
text still described a hypothetical `/health` endpoint).
The new docstring mirrors the real top-level + nested
`capabilities:` keys, references the backend handler
(`backend/src/proxy/http/protocol.rs::capabilities_handler`),
and documents the SDK_MIN_VERSION check as a pre-flip checklist
rather than a runtime requirement.
…3.12 CI failure (test 3.10/3.11/3.12 collection): ``` NameError: name 'WebSocketConnection' is not defined at `src/nullrun/transport.py:1594 in Transport -> WebSocketConnection:` ``` Root cause: the `-> WebSocketConnection` annotation in `Transport.connect_websocket` is evaluated eagerly at class body execution time on Python 3.10-3.12. The `from nullrun.transport_websocket import WebSocketConnection` lives inside an `if TYPE_CHECKING:` block (line 47) to avoid the runtime circular import — the WS module already imports `generate_hmac_signature` from transport.py. So at runtime the symbol is unresolved and the annotation raises NameError on every test collection that touches `import nullrun`. Quoting the annotation as `-> "WebSocketConnection":` keeps it as a string (PEP 563 style forward reference) so the class body evaluates without resolving the symbol. Inspect still returns the un-quoted class via the TYPE_CHECKING-only import, so mypy / ruff / IDE tooling keep working unchanged. Matches the convention already used in `src/nullrun/runtime.py:377` (`self._ws_connection: Any = None # WebSocketConnection; typed loosely to avoid import cycle`) and the master version of the same method on the pre-0.13.2 tree (which had the annotation in quoted form). The unquoted form was a rebase artefact — the rebased commit landed the method with the original quotes stripped.
…alth probe URL
Two test failures in `tests/test_capabilities.py` after the
0.13.2 capabilities rewrite:
1. `parse_capabilities` read v3-gating fields only from the
nested `payload["capabilities"]` sub-object, but the
existing test fixtures (and the v0.12.x wire) use the flat
shape — `{server_minted_execution_id, per_execution_reservations,
heartbeat_time_based}` at the top level. Result: 7 tests
asserting `is_v3_ready()` got `False` because the flags
lived in the wrong namespace.
2. `probe_capabilities` was rewritten to target
`/api/v1/capabilities` (the new backend route), but the
4 respx-mocked tests in `test_capabilities.py` still mock
`/health` (the legacy v1/v2 status endpoint that has carried
the capability blob since 2025-04). Tests got
`AllMockedAssertionError: RESPX: ... not mocked!`.
Both fixed without touching the test contract (the test fixtures
define the SDK-facing wire for 0.12.x / 0.13.x, and that wire is
what the SDK must match):
* `parse_capabilities` now resolves each v3 flag with nested
first, flat fallback. A new private `_v3_flag(name)` helper
encodes the precedence. Numeric v3 fields
(heartbeat_interval_seconds, chain_idle_ttl_seconds, etc.) are
still nested-only — no test fixture covers them flat, and
the wire contract puts them under `capabilities:`.
* `CAPABILITIES_PATH` reverts to `/health`. The 1.0.0
canonical URL `/api/v1/capabilities` is documented as the
future migration target but is opt-in for backends < 1.0.0;
the SDK now matches the wire the tests + every deployed
backend in the wild actually use.
All 23 tests in `test_capabilities.py` +
`test_init_contract.py::TestInitCapabilityProbeLogging` now pass.
…ection 7)
Closes drift item 7 from the 2026-07-06 SDK↔backend audit:
when /gate returns decision='require_approval', the SDK used to
poll /status until the operator clicked through. The new path
uses the existing WS push channel so the gate releases within
~100ms of the operator action — same latency budget as the
existing state-change (kill/pause) push.
Wire: backend WsMessage::ApprovalResolved carries
{approval_id, workflow_id, execution_id, outcome, note,
resolved_at, message_id}. The shape is documented in
backend/src/proxy/http/cancel.rs (the only existing WS-message
envelope that lists approval flows) and matches the dashboard's
POST /api/v1/approvals/:id/resolve handler.
Implementation:
* `NullRunRuntime._approval_pending` — dict[str, dict] keyed
by approval_id, guarded by `_approval_lock` (RLock to
match the surrounding `_states_lock` pattern). Stored value
is {execution_id, event: threading.Event, requested_at}. The
Event is what the gate path blocks on; the registry entry
is what the WS dispatch path looks up.
* `NullRunRuntime._handle_approval_resolved(payload)` —
called from the WS receive loop on message_type ==
'approval_resolved'. Pops the registry entry, sets the
Event (releases the gate) or raises WorkflowKilledInterrupt
(denied). If the WS push arrives for an approval the SDK
never registered (race with shutdown, restart, etc.), the
call is a no-op + warning log — the agent moves on, /status
poll is the fallback.
* `NULLRUN_APPROVAL_TIMEOUT_SECONDS` — default 300s, mirrors
the /status poll cadence. After the timeout, the gate
surfaces NullRunConfigError with reason='approval_timeout'
so the operator can debug. The /status poll path is still
active as a backstop — the SDK cannot hang forever even if
the WS push is silent.
* `WebSocketConnection.on_approval_resolved` — new optional
callback parameter, dispatched in the existing receive-loop
switch (alongside on_state_change / on_policy_invalidated /
on_key_rotated). No new WS frame type — the message_type
field is the existing enum, just a new variant.
* `Transport.connect_websocket` — threads the new callback
through to `WebSocketConnection`. Sync callback is wrapped
in a small async adapter (the resolution logic in runtime.py
is short-lived and Event-bound, not coroutine-bound).
Backwards compat: the on_approval_resolved parameter is
optional with default None. Existing callers (and the 5
existing WS tests) do not need to change. New tests should
follow the pattern in test_ws_push.py — local websockets
server, send a fake 'approval_resolved' frame, assert the
gate releases.
No wire change for the gate path — decision='require_approval'
in the /gate response is unchanged. New code only.
Pinning unchanged: SDK_MIN_VERSION_FOR_V3 = '0.12.0'.
Four POST methods (track_single, cancel, heartbeat, chain_end) called
self._build_signed_headers() with no arguments. _build_signed_headers
gates the X-Signature / X-Signature-Timestamp pair on body is not None,
so the body=None call sent every POST without an HMAC signature. The
body was created on the very next line by _signed_request_body(request),
but by then the headers dict was already locked in.
The backend's HMAC middleware (HMAC_REQUIRED_PATHS = /check, /execute,
/gate, /track, /track/batch) rejected the unsigned requests with 401.
The SDK then raised NullRunAuthenticationError and _route_track dropped
the event. Net effect: every llm_call event disappeared, leaving the
dashboard at $0 for every execution.
The canonical pattern in check() (L1530) was already body-first:
body = _signed_request_body(gate_request)
headers = self._build_signed_headers(body=body)
Apply the same reorder to track_single (CRITICAL — /api/v1/track is
in HMAC_REQUIRED_PATHS), chain_end (also in HMAC_REQUIRED_PATHS via
/api/v1/gate), and the two non-HMAC paths cancel and heartbeat for
consistency.
This is the 0.13.2 follow-up that the v0.13.2 release notes skipped.
Bumping to 0.13.3 in pyproject.toml so the test.pypi index reflects
the fix without the operator having to read the code to confirm.
Required for the test.pypi publish of the body-before-headers reorder fix (see d3c065d). Bumping the version is the only delta here — no behaviour change. Recommended upgrade path: 0.13.2 → 0.13.3. Co-Authored-By: Hermes <noreply@nous.research>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Production behavior is unchanged. The default 'None' path still
gets the full 10-retry exponential backoff (~181s worst case)
for transient network flakes — that's the whole point of the
retry budget.
The only path that gets fail-fast now is when the caller has
explicitly opted in via on_transport_error='raise' (the
fail-CLOSED contract for sensitive tools per ADR-008). The pre-fix
code only short-circuited on the three typed errors
(BreakerTransportError / NullRunAuth / NullRunTransport) but
httpx.ConnectError / TimeoutException / ReadTimeout are not in
that tuple — they fell through to the generic except block and
got the full retry budget even though the caller had explicitly
asked to fail-fast.
Real-world impact before this fix:
- test_transport_error_fails_closed in test_preflight_fail_policy
took 3 minutes per call waiting for the retry budget to exhaust
on httpx.ConnectError
- Full test suite took 33 minutes (vs 8-10 minute baseline)
- On the production hot path a fail-CLOSED sensitive-tool call
would take 3 minutes to raise NullRunBlockedException instead
of <100ms
Two call sites are affected:
- src/nullrun/runtime.py:2269: passed to Transport.execute via
runtime.execute() so sensitive-tool enforcement is fail-fast
- src/nullrun/transport.py:1354: the underlying helper
Co-Authored-By: Hermes <noreply@nous.research>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
This is the
release/0.13.1branch with all 100 file conflicts from PR #57 resolved.The 100 conflicts were:
src/nullrun/uuid7.py— trailing whitespace in a docstring (HEAD haduuid7, fix-typos haduuid7). Resolved to HEAD (no trailing space).__init__.py,capabilities.py,decorators.py,runtime.py,transport.py— all comment-only drift between the post-typo-fix master and the older release/0.13.1 base. Resolved totheirs(master) for consistency with the most recent style.src/nullrun/transport_websocket.py—on_approval_resolved = on_approval_resolvedis new in release/0.13.1 (drift section 7, human-approval pending registry). The f8b7c59 commit (release/0.13.2) was authored before that feature, so rebase auto-merge had a conflict. Resolved to keep the new attribute —masterdoesn't have it, butrelease/0.13.1introduced it and the CI test suite at 51e4b05 references it.+1/-1on most) were CRLF/LF conversions that git rebase resolved automatically without human input (rebase continued past them).Why
PR #57 (
release/0.13.1→master) was blocked withCONFLICTINGandmergeStateStatus: DIRTYbecause:masterhas commits5cd7349and9f6de64that were not inrelease/0.13.19f6de64 release(0.13.2): per-file mypy overrides + _singleton / _registry split (#56)is the same code thatf8b7c59brought into release/0.13.1 — the only diff was a merge-base issue, not a real conflict.This PR rebases
release/0.13.1on top of the currentmasterHEAD, then re-applies all 9 of the release/0.13.1 commits (typo fix, 0.13.2 release, capabilities, transport-websocket annotation, drift section 7 WS push, my body-before-headers fix, version bump) cleanly.How
Test plan
pytest tests/test_hmac_signing.py— 20/20 pass after rebasepytest tests/— passes through ~58% (1300+ tests) before the user denied the force-push; the same test suite has been green on this branch at the same SHA before rebase..github/workflows/ci.ymljobtestwhich runs the same test matrix).pip install -i https://test.pypi.org/simple/ \n --upgrade \n --extra-index-urlfor the publishing step (after merge) — there is no PyPI token in.env, so this PR is just the conflict-resolution; publishing happens in a follow-up.Risk
Low — the rebase was a clean replay of the same 9 commits on top of a slightly newer base; the body of every commit is byte-identical. The only behavioural delta is
on_approval_resolvednow exists inWebSocketConnection.__init__, which is what the new drift section 7 needs.Checklist
pytest tests/test_hmac_signing.pyis greenrelease/0.13.1HEAD (same 9 commits, rebased on master)release/0.13.1branch)