Skip to content

Commit 14c673c

Browse files
authored
chore(release): 0.14.2 - three runtime/transport hotfixes (#79)
* some clean up * fix(runtime): treat websocket cancellation as clean shutdown * fix(transport): approval_resolved callback is synchronous * SdkTrackRequest * chore(release): 0.14.2 - three runtime/transport hotfixes Three independent fixes that fell out of the 0.14.1 demo run, plus a stub refresh on the two _RecordingRuntime mocks whose shape the new @Protect emit broke. * fix(decorators): @Protect now emits a tools/track_tool event after the wrapped body returns. Pre-0.14.2 the protected decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a protect execution. The emit goes through the same sink as llm_call events so it picks up the dedup LRU at runtime.track() for free. * fix(runtime): track_tool event carries tokens: 0 and a fresh uuidv7 execution_id. The backend's SdkTrackRequest requires both fields as non-Optional u64 / string; pre-0.14.2 the event dict only carried type / tool_name / is_retry and the deserializer rejected it. Span lifecycle events get the same tokens: 0 default via _enrich_event. * fix(transport): approval-resolved WS callback is now a plain sync function. The WebSocket dispatch path invokes it as a dict -> None callable; the previous async-decorated coroutine was silently dropped, so the sync threading.Event inside _wait_for_approval_resolution never got set on the first approval round-trip - the demo's first approval hung forever. Caught 2026-07-24. * fix(runtime): treat websocket cancellation as a clean shutdown signal. WebSocketConnection.close() cancels the receive task to unblock the waiter during normal end of session; on Python 3.11+ CancelledError derives from BaseException, so the old except Exception branch re-raised it and produced a noisy debug line on every clean exit. The new except CancelledError branch is silent and the finally cleanup still runs. * test: refresh _RecordingRuntime in tests/test_protect.py and tests/test_preflight_fail_policy.py with a track_tool stub. The previous shape only mocked track_event, which is why the @Protect emit silently failed under the new decorator wiring. * chore: ruff format on the three source files touched by this release (decorators / runtime / transport). The format-only reformat of the 65 unrelated files is intentionally deferred to a separate PR. * docs: 0.14.2 changelog entry describing the four fixes end-to-end. No SDK_MIN_VERSION bump. No public API change. No on-wire breaking change. Backends on 1.0.0 keep working unchanged. Verification: - pytest -n auto -> 1369 passed, 7 skipped, 29 warnings. - ruff check src/ tests/ -> All checks passed. - mypy src/nullrun --strict -> Success: no issues found in 36 source files.
1 parent b72cf62 commit 14c673c

15 files changed

Lines changed: 622 additions & 661 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,4 @@ analyze.md
7272
docs/integration-baseline-2026-06-19.md
7373
audit.md
7474
docs/postman/
75+
.hermes/

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,32 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.14.2] - 2026-07-24
11+
12+
Three hotfixes that fell out of the 0.14.1 demo run. Each one is independently small but each one would have surfaced as a runtime crash on a real customer call, so they ship together as a patch. No on-wire breaking change. No SDK_MIN_VERSION bump. Backends on `1.0.0` keep working unchanged.
13+
14+
### Fixed
15+
16+
- **`@protect` decorator now emits a `tools/track_tool` event**`decorators.py:470` and `decorators.py:521` (sync + async wrappers) now call `runtime.track_tool(fn.__name__, metadata={"arguments": _safe_kwargs(kwargs)})` after the wrapped body returns. Pre-0.14.2 the `protected` decorator only fired the gate check and skipped the bookkeeping emit, so the dashboard never saw a `protect` execution even though the body ran. The new emit goes through the same sink as `llm_call` events, so it picks up the dedup LRU at `runtime.track()` for free.
17+
- **`track_tool` event carries `tokens: 0` and a fresh `uuidv7` `execution_id`**`runtime.py:3077` now stamps both fields onto every `tool_call` event. The backend's `SdkTrackRequest` requires `tokens: u64` (non-Optional) and a threadable `execution_id`; pre-0.14.2 the event dict only carried `type` / `tool_name` / `is_retry` and the deserializer rejected it. Span lifecycle events (`span_start` / `span_end`) get the same `tokens: 0` default via `runtime.py:2161`.
18+
- **Approval-resolved WS callback is now a plain sync function**`transport.py:1757` `wrapped_approval_resolved` was previously declared `async def` to be awaitable, but the WebSocket dispatch path invokes it as a plain function (the dispatch signature is `dict[str, Any] -> None`, not awaitable). The async-decorated coroutine was silently dropped, so the sync `threading.Event` inside `runtime._wait_for_approval_resolution` never got set on the first approval round-trip — the demo's first approval hung forever. Caught 2026-07-24 with the demo's first approval resolution.
19+
- **WebSocket cancellation is treated as a clean shutdown**`runtime.py:1160` now catches `asyncio.CancelledError` before the generic `except Exception` block. `WebSocketConnection.close()` cancels the receive task to unblock this waiter during normal shutdown; on Python 3.11+ `CancelledError` derives from `BaseException` (not `Exception`), so the old code re-raised it and produced a noisy `WS receive loop ended: <never logged>` debug line on every clean shutdown. The new branch is silent and the path stays contained.
20+
21+
### Tests
22+
23+
- `tests/test_approval_ws_sync_callback.py` — 103 lines of new coverage for the WS approval-resolved dispatch path: the callback is invoked as a sync function, the `threading.Event` is set, the wait returns within the timeout, and the previous async-decorated shape is asserted-not-present.
24+
- `tests/test_runtime_branches.py` — 36 lines of new coverage for the `await conn._receive_task` cancellation path: `CancelledError` is re-raised out of the block is no longer logged as a `WS receive loop ended: ...` debug line, and the `finally` cleanup still runs.
25+
- The existing `tests/test_sensitive_extractor.py` (5/5) and `tests/test_approval_money_flow.py` (18/18) pass unchanged — the new fields are additive on top of the 0.14.1 wire shape.
26+
27+
### Compatibility
28+
29+
- **Backward-compatible bug fix.** No SDK_MIN_VERSION bump. No public API change.
30+
- The new `tokens: 0` / `execution_id` fields on `track_tool` events are forwarded exactly as minted; the backend's `SdkTrackRequest` already accepts them (the 0.14.0 envelope contract).
31+
- The approval-resolved callback is the same public contract (`def on_approval_resolved(payload: dict) -> None`); only the in-transport wrapper changed from `async def` to `def`.
32+
- The WS cancellation handler is silent in the same way the previous `except Exception` was silent; the only user-visible delta is a removed debug log line on clean shutdown.
33+
34+
---
35+
1036
## [0.14.1] - 2026-07-24
1137

1238
Decimal JSON serialization patch. `track_tool` event payloads that contain a `Decimal` value (e.g. `refund_amount` from a `@sensitive(impact=money_outflow(units="major"))` body) used to raise `TypeError: Object of type Decimal is not JSON serializable` from the inner `json.dumps` call. The exception was raised in both the canonical signed-body serializer and the on-disk WAL fallback log; both silently dropped the event, so the dashboard showed no `refund_customer` cost_events even though the body ran successfully.

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@
186186
same "printed page" as the copyright notice for easier
187187
identification within third-party archives.
188188

189-
Copyright 2026 Maltsev Anatolii
189+
Copyright 2026 Anatolii Maltsev
190190

191191
Licensed under the Apache License, Version 2.0 (the "License");
192192
you may not use this file except in compliance with the License.

examples/async_usage.py

Lines changed: 0 additions & 36 deletions
This file was deleted.

examples/basic.py

Lines changed: 0 additions & 27 deletions
This file was deleted.

examples/basic_observe.py

Lines changed: 0 additions & 49 deletions
This file was deleted.

examples/cost_dashboard.py

Lines changed: 0 additions & 95 deletions
This file was deleted.

pyproject.toml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,17 @@ name = "nullrun"
130130
# cleanly still serialise to the same bytes because
131131
# ``default=`` is only consulted when the default encoder
132132
# fails. No SDK_MIN_VERSION bump. No public API change.
133-
version = "0.14.1"
133+
#
134+
# 0.14.2 (2026-07-24): Three runtime / transport hotfixes
135+
# living on the archive/cleanup-attempted-1c1e326 branch.
136+
# Approval-resolved WS callback was an async-decorated coroutine
137+
# the dispatcher silently dropped (sync threading.Event never
138+
# got set); asyncio.CancelledError escaping the WS await caused
139+
# noisy debug logs on normal shutdown; track_tool events emitted
140+
# by ``@protect`` were missing ``tokens``/``execution_id`` so
141+
# the backend's SdkTrackRequest rejected them. See CHANGELOG.md
142+
# for the full per-commit description.
143+
version = "0.14.2"
134144
# Kept under the 200-char preview threshold so the full line is visible
135145
# without an "expand" click. Keywords are matched against likely search
136146
# queries ("AI agent cost control", "LLM circuit breaker", etc.).

0 commit comments

Comments
 (0)