Skip to content

chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist - #97

Merged
maltsev-dev merged 4 commits into
masterfrom
release/0.16.5
Sep 5, 2026
Merged

chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist#97
maltsev-dev merged 4 commits into
masterfrom
release/0.16.5

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Release 0.16.5

Two independent reliability fixes, no wire-format change on either.

1. @protect cancel-on-exception orphan leak (6076f87)

src/nullrun/decorators.py wraps both wrappers in try/except; on failure _safe_cancel_active_execution(reason='tool_exception') closes the in-flight /gate reservation instead of leaking to TTL expiry.

Three invariant pins:

  • Exception-scope asymmetry — async catches Exception (not BaseException) to keep cancellation handlers non-blocking; sync catches BaseException to match existing _protect_body semantics.
  • fn_completed sentinel — prevents cancel from firing after track_tool failure on a successfully-completed fn.
  • Fail-OPEN helper — cancel I/O failure never masks the original exception.

7 new regression tests in tests/test_protect_cancel_on_exception.py. The two regression guards (test_1 / test_2) pin the cancellation safety and the sentinel.

2. operation_id hoist (P0-26+P0-27, a9b398a)

P0-26_capture_server_minted_execution_id reads via _get_op_id_for_capture() (the SDK-minted contextvar value) instead of response.get('operation_id'). Asserts server_op_id != sdk_op_id with ERROR log on disagreement. idempotency_key derived from SDK-minted value.

P0-27_operation_id_var contextvar in context.py (name operation_id). check_workflow_budget mints once; execute reads via _get_op_id_for_execute() with single fallback mint+stash. Triple-mint collapsed to one.

8 new regression tests in tests/test_audit_p0_27_operation_id_hoist.py pin mint-site shapes, parity assertion, and forbids pre-fix str(uuid.uuid4()) mints outside the fallback branch.

Compatibility

Pure reliability fixes. No wire-format change. /gate, /track, /execute, /cancel payloads unchanged. The /cancel endpoint was already used by runtime.cancel_execution(...) from control-plane kill paths; the exception-cleanup helper is purely additive.

Verification

  • tests/test_protect_cancel_on_exception.py — 7/7 pass
  • tests/test_audit_p0_27_operation_id_hoist.py — 8/8 pass
  • pytest -q clean (prior 1613 + 7 + 8 = 1628)
  • ruff check clean on WIP files
  • mypy src/nullrun no issues reported in 37 source files

Files

ea8ae9b chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist
6076f87 fix(sdk): @protect cancel-on-exception orphan leak
a9b398a fix(sdk): hoist operation_id to contextvar + capture SDK-minted value (P0-26 + P0-27 / Sprint A6.5)

… (P0-26 + P0-27 / Sprint A6.5)

P0-26: _capture_server_minted_execution_id now reads via _get_op_id_for_capture()
(the SDK-minted contextvar value) instead of response.get('operation_id').
Asserts server_op_id != sdk_op_id with ERROR log on disagreement.
Idempotency_key derived from SDK-minted value, not server echo.

P0-27: Operation id hoisted to contextvar (_operation_id_var in context.py,
name 'operation_id'). check_workflow_budget mints once via
_get_op_id_for_check()/_set_op_id_for_check(); execute reads via
_get_op_id_for_execute() with single fallback mint+stash.
Triple-mint collapsed to one.

8 new regression tests in test_audit_p0_27_operation_id_hoist.py pin:
- contextvar name 'operation_id'
- mint-site shapes in check_workflow_budget + execute
- parity assertion in _capture_server_minted_execution_id
- forbids pre-fix str(uuid.uuid4()) mints outside fallback branch

Foreign WIP preserved: CHANGELOG.md, pyproject.toml, __version__.py,
decorators.py, tests/test_protect_cancel_on_exception.py untouched.
Pre-fix, ANY exception raised inside the decorated function (or by any
pre-execution gate — control_plane reject, workflow_budget block,
sensitive-tool reject) propagated out of the with-block without closing
the budget reservation that check_workflow_budget opened via /gate.
Each such exception orphaned a Redis envelope to TTL expiry, which
made reserved_total drift up monotonically at anti-DoS scale.

Source-side wiring (src/nullrun/decorators.py):

1. New helper _safe_cancel_active_execution(reason=None). Reads
   get_server_minted_execution_id(); no-op if None (pre-/gate failure).
   Best-effort runtime.cancel_execution(execution_id, reason=...) call.
   Catches everything (NullRunTransportError, NullRunBackendError,
   get_runtime() failure) so a cancel I/O failure never masks the
   original exception. Synchronous, blocking HTTP — caller is the
   @Protect context manager; same channel as check_workflow_budget.

2. async_wrapper and sync_wrapper now wrap the with-block in
   try/except. fn_completed sentinel: after fn(...) returns,
   fn_completed = True. If track_tool(...) then fails, fn_completed is
   True so cancel does NOT fire — side effects already happened and
   the right move is to retry track_tool, not cancel (cancel would
   tell the server "no side effects" — a lie that produces a phantom
   budget refund and breaks audit).

3. Asymmetry on exception scope:
   - async_wrapper catches Exception (NOT BaseException). asyncio
     .CancelledError / KeyboardInterrupt / SystemExit propagate without
     doing a synchronous blocking HTTP call inside a cancellation
     handler (5s timeout, would delay task cancellation, generate
     'Task was destroyed but pending' warnings, and in some shutdown
     paths get cancelled itself — server's /cancel is idempotent so
     orphan via TTL/reconciliation is the safety net).
   - sync_wrapper catches BaseException. Sync code has no event loop
     to delay; Ctrl+C during a long sync agent gets a few seconds of
     cancel I/O before exit. Matches existing _protect_body
     unify_block semantics.

Tests (tests/test_protect_cancel_on_exception.py, 7 tests):

- test_1_async_cancelled_error_does_not_trigger_cancel — THE
  regression guard. If a future refactor reverts except Exception to
  except BaseException in async_wrapper, this test fails. Set
  server_minted_execution_id so the helper WOULD have run if the
  except had caught BaseException; cancel_calls must be empty.
- test_2_track_tool_failure_after_fn_completion_does_not_trigger_cancel
  — second regression guard. fn_completed sentinel must stay True past
  the successful fn() call. If someone removes the sentinel and
  'simplifies' the wrapper to always call cancel on Exception, this
  test fails.
- test_3_async_fn_raises_value_error_triggers_cancel — happy-path
  cancel. cancel_calls == [("exec-test-123", "tool_exception")].
- test_4_async_no_execution_id_skips_cancel — control_plane rejects
  pre-/gate, ContextVar stays None, no cancel (server-side orphan, if
  any, is reconciliation territory).
- test_5_sync_fn_raises_value_error_triggers_cancel — sync ValueError
  path mirrors async.
- test_6_sync_baseexception_also_triggers_cancel — sync KeyboardInterrupt
  cancels (sync has no event loop to delay).
- test_happy_path_no_cancel_called — sanity: success path produces
  no cancel and gate order stays control_plane, budget, track_tool.

Pattern borrowed from tests/test_preflight_fail_policy.py
(_RecordingRuntime) — extended with capture_execution_id so the
cancel helper sees a populated ContextVar after the simulated
check_workflow_budget. autouse fixture resets _server_minted_execution_id_var
between tests (ContextVar leak would make order-dependent assertions
flaky).

Verified: tests/test_protect_cancel_on_exception.py — 7/7 pass.
Wire-format: zero changes. /cancel endpoint was already used by
runtime.cancel_execution(...) from control-plane kill paths; the
exception-cleanup helper is purely additive.
… operation_id hoist

Two independent reliability fixes, no wire-format change on either:

1. fix(sdk): @Protect cancel-on-exception orphan leak (6076f87).
   src/nullrun/decorators.py wraps both wrappers in try/except; on
   failure _safe_cancel_active_execution(reason='tool_exception')
   closes the in-flight /gate reservation instead of leaking to TTL
   expiry. Exception-scope asymmetry (async catches Exception not
   BaseException to keep cancellation handler non-blocking; sync
   catches BaseException). fn_completed sentinel prevents cancel from
   firing after track_tool failure on a successfully-completed fn.
   Helper is fail-OPEN so cancel I/O failure never masks the original
   exception.

2. fix(sdk): hoist operation_id to contextvar + capture SDK-minted
   value (P0-26 + P0-27 / Sprint A6.5) (a9b398a, prior local WIP).
   P0-26: _capture_server_minted_execution_id now reads via
   _get_op_id_for_capture() (the SDK-minted contextvar value) instead
   of response.get('operation_id'). Asserts server_op_id != sdk_op_id
   with ERROR log on disagreement. Idempotency_key derived from
   SDK-minted value, not server echo. P0-27: Operation id hoisted to
   contextvar (_operation_id_var in context.py, name 'operation_id').
   check_workflow_budget mints once via _get_op_id_for_check()/
   _set_op_id_for_check(); execute reads via _get_op_id_for_execute()
   with single fallback mint+stash. Triple-mint collapsed to one.

Release bumps:

- pyproject.toml + src/nullrun/__version__.py: 0.16.4 -> 0.16.5.
- CHANGELOG.md: insert [0.16.5] - 2026-09-05 with full descriptions
  of both fixes, the test pin coverage (7 cancel tests + 8 operation_id
  hoist tests), the compatibility notes (zero wire-format change on
  either), and the verification status.

Verified: pytest -q clean (1613 prior + 7 cancel + 8 hoist = 1628);
ruff check on the WIP files clean (decorators.py + new test file);
mypy src/nullrun no issues reported in 37 source files. No new wire
fields, no hashing/computation changes — the fixes are purely SDK-local
(source-of-truth restructuring + cancel cleanup).
@codecov

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.82353% with 11 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/decorators.py 81.08% 6 Missing and 1 partial ⚠️
src/nullrun/runtime.py 85.71% 1 Missing and 2 partials ⚠️
src/nullrun/context.py 88.88% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

CI on PR #97 (test (3.11)) failed on `ruff check src/` with 2 I001
import-order errors in src/nullrun/runtime.py — the inline
`from nullrun.context import (...)` blocks introduced by the
operation_id hoist (a9b398a) at runtime.py:1865 (check side) and
:2791 (execute side) are correctly placed functionally but ruff
flags them as un-sorted because they shadow the module-level
context imports with a multi-name block.

The 3rd I001 hit locally (in tests/test_audit_p0_27_operation_id_hoist
.py:35) was a separate import-grouping issue from the same hoist
commit and was caught by `ruff check src tests` on CI as well.

All three are auto-fixable with `ruff check --fix`. Fix is purely
cosmetic — runtime semantics + the operation_id hoist itself
unchanged. CI now passes `ruff check src/` on PR #97.

Files: src/nullrun/runtime.py (2 fix sites), tests/test_audit_p0_27
_operation_id_hoist.py (1 fix site).

Verified: `ruff check src tests` all checks pass; `mypy src/nullrun`
no issues reported in 37 source files; `pytest tests/test_protect
_cancel_on_exception.py tests/test_audit_p0_27_operation_id_hoist.py
` — 15/15 pass.
@maltsev-dev
maltsev-dev merged commit ec4a6aa into master Sep 5, 2026
9 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