Commit ec4a6aa
authored
chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 operation_id hoist (#97)
* fix(sdk): hoist operation_id to contextvar + capture SDK-minted value (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.
* fix(sdk): @Protect cancel-on-exception orphan leak
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.
* chore(release): 0.16.5 — cancel-on-exception orphan fix + P0-26+P0-27 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).
* style(sdk): ruff import-order fix in runtime.py + hoist test file
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.1 parent 8670d53 commit ec4a6aa
8 files changed
Lines changed: 963 additions & 22 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
1 | 47 | | |
2 | 48 | | |
3 | 49 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
9 | | - | |
| 9 | + | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
328 | 328 | | |
329 | 329 | | |
330 | 330 | | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
331 | 350 | | |
332 | 351 | | |
333 | 352 | | |
| |||
502 | 521 | | |
503 | 522 | | |
504 | 523 | | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
505 | 593 | | |
506 | 594 | | |
507 | 595 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
52 | 52 | | |
53 | 53 | | |
54 | 54 | | |
| 55 | + | |
55 | 56 | | |
56 | 57 | | |
57 | 58 | | |
| |||
383 | 384 | | |
384 | 385 | | |
385 | 386 | | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
| 407 | + | |
| 408 | + | |
| 409 | + | |
| 410 | + | |
| 411 | + | |
| 412 | + | |
| 413 | + | |
| 414 | + | |
| 415 | + | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
386 | 423 | | |
387 | 424 | | |
388 | 425 | | |
| |||
557 | 594 | | |
558 | 595 | | |
559 | 596 | | |
560 | | - | |
561 | | - | |
562 | | - | |
563 | | - | |
564 | | - | |
565 | | - | |
566 | | - | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
567 | 624 | | |
568 | 625 | | |
569 | 626 | | |
570 | 627 | | |
571 | 628 | | |
572 | | - | |
573 | | - | |
574 | | - | |
575 | | - | |
576 | | - | |
577 | | - | |
578 | | - | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
| 644 | + | |
| 645 | + | |
| 646 | + | |
| 647 | + | |
579 | 648 | | |
580 | 649 | | |
581 | 650 | | |
| |||
0 commit comments