Skip to content

release(0.13.2): per-file mypy overrides + _singleton / _registry split#56

Merged
maltsev-dev merged 7 commits into
masterfrom
release/0.13.1
Jul 6, 2026
Merged

release(0.13.2): per-file mypy overrides + _singleton / _registry split#56
maltsev-dev merged 7 commits into
masterfrom
release/0.13.1

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Drift-fixes continuation on top of 0.13.1 — typing-debt sweep + singleton/registry architectural split. No on-wire change; backends on 1.0.0 keep working unchanged.

This PR folds five commits together:

# Commit What
1 b0a3456 release(0.13.1): drift-fixes — B1 check_v3 + B3 chain_end + M3 approximate_budget + B2 track_single docstring
2 cf8ee4f chore(gitignore): exclude docs/postman/ collection exports
3 f93d77a fix typos
4 9608b6e chore(release): bump version 0.13.1 → 0.13.2
5 5761aaf release(0.13.2): per-file mypy overrides + _singleton / _registry split

What's new in 0.13.2

1. Per-file mypy overrides (replaces blanket ignore_errors = true)

pyproject.toml previously swallowed 102 errors across 12 files with a single ignore_errors = true. That hid the moment any new typing debt appeared in those modules — CI was green but the debt only grew.

The new config replaces that with per-file [[tool.mypy.overrides]] blocks. Every legacy module declares the exact error codes it carries, so CI breaks the moment a new code appears in that module. 14 modules already clean enough 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

Modules still carrying debt opt in via targeted disable_error_code lists. The header comment in pyproject.toml documents the contract: when a file's count drops to 0, remove its override row — the table stays in lockstep with the debt tracker.

2. Singleton state split into _singleton + _registry

runtime.py was the orchestrator and the singleton holder and the registry of runtime capabilities (chain-mode gate cache, LRU fingerprints, websocket handles). All three concerns glued into one file broke the moment you tried to enforce strict typing on it.

The split:

  • nullrun._singletonNullRunRuntimeMeta metaclass descriptor backing the canonical _instance class attribute. 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 (e.g. copy.deepcopy, direct test construction).

  • nullrun._registry — per-process registry of runtime capabilities. Previously inlined as module globals in runtime.py; now centralised so the orchestrator module stays under the strict-mypy umbrella, and external test code can swap or inspect the registry without monkeypatching the orchestrator.

Backwards-compat: NullRunRuntime._instance = runtime retained at the bottom of __init__ so external callers that read the class attribute directly keep working.

3. Ruff cleanup

Dropped F821 (undefined name) from the ruff ignore list — the one site was a typo fixed by the fix typos commit on this branch. Remaining five (S110 / E501 / F841 / E402 / F401) are pre-existing and explicitly tracked in the pyproject comment block for a future cleanup PR.

Files

  • pyproject.toml (+235, mostly per-file override blocks)
  • src/nullrun/runtime.py (+141, mostly delegation to _singleton / _registry)
  • src/nullrun/transport.py (+87)
  • src/nullrun/decorators.py (+60)
  • src/nullrun/breaker/circuit_breaker.py (+43)
  • NEW src/nullrun/_singleton.py (178)
  • NEW src/nullrun/_registry.py (156)
  • NEW tests/test_registry.py (221, 12 tests)
  • 18 other files (small fixes, mypy-driven strictness tweaks, import reorderings)

Tests

  • NEW 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 through the new module
    • legacy _instance read path still returns the live singleton after the split
  • Existing suite untouched: 1037 lib tests still pass on the dev box.

Pinning

Unchanged: SDK_MIN_VERSION_FOR_V3 = "0.12.0". Recommended upgrade path: 0.13.1 → 0.13.2 (typing-only change for end users; visible delta is the per-file mypy table in pyproject.toml).

🤖 Generated with Claude Code

typing-debt sweep + singleton/registry split. No on-wire change.

pyproject.toml: aligned version with __version__.py and extended
the version comment block to summarise the 0.13.2 delta
(per-file mypy overrides + the _singleton/_registry split).

__version__.py: added the 0.13.2 entry to the cumulative
docstring changelog and bumped __version__ to "0.13.2".

Backends on 1.0.0 keep working unchanged. Pinning unchanged:
SDK_MIN_VERSION_FOR_V3 = "0.12.0".
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'.
@maltsev-dev maltsev-dev merged commit 9f6de64 into master Jul 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant