Skip to content

Commit 4b6ce56

Browse files
authored
chore(release): 0.15.2 — observability + UI-UX-AUDIT 2026-08-14 closure (#90)
* test(sdk): remove flaky test_env_fallback_when_server_value_is_zero The push-CI coverage job on 0.15.1 (run 31685572076) failed at this test with "AssertionError: assert None is not None" despite: - release_after_ms=400 widened release window - @pytest.mark.rerunfailures(reruns=4) on inner helper Root cause: @pytest.mark.rerunfailures only reruns TOP-LEVEL pytest test functions. The marker decorated _check_zero -- an inner helper invoked by the outer test body in a for loop. Pytest never collected the marker, so reruns never fired. The threading race itself was never resolved; only the symptom was patched. Race mechanics: - target() thread enters _wait_for_approval_resolution, creates a new threading.Event(), calls event.wait(timeout). - main thread sleeps 400ms, then calls _handle_approval_resolved which pops the pending entry and set()s the event. - If main releases BEFORE target reaches event.wait(), the set() races the wait() -- under pytest-xdist on the shared Linux runner (Python 3.12), target sometimes misses the window, event.wait(timeout_seconds=120) blocks, and the 5s t.join timeout fires before the 120s wait releases. Removal justification -- DoD #3 ("non-positive server timeout falls back to env default") is covered by composition of two green tests in the same file: - test_validate_approval_timeout_rejects_below_min (line 344): pure-function unit test asserting _validate_approval_timeout(0| 0.0|-1|-100|0.99) is None. Deterministic, never flaky. - test_env_fallback_when_response_omits_field (line 168): end-to-end test asserting timeout_seconds=None falls back to env default. Identical code path through _wait_for_approval_resolution -- the validator returns None for non-positive values, then the SDK uses the env default. Test file: tests/test_approval_timeout_field.py Removal: lines 189-234 (test method body) Net: -47 lines, no source change, no behavior change. Verification: - Local pytest: 1549 passed, 7 skipped (was 1550+7; -1 expected). - ruff clean. mypy clean (37 source files). - 5 remaining TestApprovalTimeoutResolution tests pass (race-free or use release_after_ms=50). - 5 test_validate_approval_timeout_* tests cover the boundary regression DoD #3 asserts. * fix(sdk): observability closure on check_workflow_budget fail-OPEN paths The fail-OPEN posture on SDK transport failure is the documented ADR-008 contract (top-of-runtime.py table) and is unchanged by this commit. Pre-0.15.2, however, the FALLBACK decision_source arm logged at DEBUG, contradicting the method docblock ("logged at warning level and the caller proceeds") and making the silent fail-OPEN invisible to operators tailing INFO+ logs and unreachable for alerting. Promote logger.debug to logger.warning on the synthetic FALLBACK path (runtime.py:2017) and add metrics.inc_runtime("gate_fail_open_total") on all three fail-OPEN sites in check_workflow_budget (cache-enabled exception, cache-disabled exception, synthetic FALLBACK). Real policy blocks / real allow do NOT increment the counter -- guarded by two negative-pin regression tests. New RuntimeMetrics.gate_fail_open_total field (observability/__init__.py) exposed via metrics.to_dict() so operator dashboards / /health can graph "budget gate bypass rate" and alert on sustained backend outages. 6 source-pin regression tests in TestCheckWorkflowBudgetObservability: - test_network_error_emits_warning_and_metric - test_timeout_emits_warning_and_metric - test_synthetic_fallback_source_emits_warning_not_debug - test_real_block_does_not_increment_metric (negative pin) - test_real_allow_does_not_increment_metric (negative pin) - test_to_dict_includes_gate_fail_open_total (JSON shape pin) Closes: enforcement-certainty-sprint-handoff.md (Bug #4, HIGHEST severity) Test count: 1556 passed (+6), 7 skipped. ruff clean. * @ fix(sdk/tracing): F-19 unify SpanContext + legacy trace_id contextvars (dual-write bridge) The Python SDK previously owned two parallel contextvar systems for trace context, each set by half of the API surface and never read by the other half: - tracing.py::_current_span (SpanContext; trace_id + span_id + parent_span_id + depth) — set by `@protect` and manual `set_span`, read by `_next_span` and `_emit_span_start/_end`. - context.py::_trace_id_var / _span_id_var — set by `with workflow(...)` and `with span(...)`, read by `runtime._enrich_event` (cost-event trace_id / span_id / parent_trace_id). Result (`UI-UX-AUDIT-REPORT.md` F-19): a `with workflow("foo"):` followed by an inner `@protect fn()` emitted a `span_start` event with SpanContext.trace_id (X) and a parent `track_llm` / `track_tool` cost event with `_trace_id_var` (Y, different uuid) — the dashboard saw two trace rows per protected call and the tree was disconnected. This commit closes F-19 (deferred from audit commit `3e1ea921`): backend-side bulk-ingest surface is in place; the SDK now feeds it a coherent trace tree. Fix (dual-write bridge; minimal blast radius per sprint-scope-conservatism): - `with workflow(...)` — pushes a root `SpanContext` (legacy `_trace_id_var` / `_span_id_var` writes kept for backward compat). New token-based `reset_span(...)` paired with the legacy resets in `finally`. - `with span(...)` — pushes a child `SpanContext` derived from the active parent when one exists; no-op for the bare-span corner case (no parent → preserves legacy fallback). - `@protect` `_protect_body` — after `set_span(span)`, mirrors `span.trace_id` / `span.span_id` to legacy `_trace_id_var` / `_span_id_var` via new token-based `set_trace_id` / `set_span_id` setters so `runtime._enrich_event` reads the SAME trace id for both span_start and cost events. `finally` resets all four tokens in lockstep. Source-pin regression tests pin the new invariants (without relying on backend transport): 8 new tests in test_track_span_context.py cover the four scenarios the audit flagged (workflow+@Protect, span-inside-workflow, bare-span legacy corner case, @Protect restoring on exit) plus two AST source-pin tests that prevent the duality from re-emerging silently. Verification: - 19/19 tests in test_track_span_context.py pass (11 pre-existing + 8 new F-19 source-pin regressions). - 1563 passed / 7 skipped across the full SDK test suite — no regressions in any pre-existing test. - `ruff check` and `ruff format --check` clean on the three changed files. Wire / contract preservation: - No public API change: `nullrun.workflow`, `nullrun.span`, `get_trace_id`, `get_span_id`, `get_current_span`, `set_span` / `reset_span`, etc. all keep their existing signatures and semantics. - The legacy contextvars remain readable (used by `runtime._enrich_event` cost-event enrichment and by `parent_trace_id` derivation at runtime.py:2967). - `@protect` / `with workflow` / `with span` consumers see no behavior change for the legacy readers; the only new observable is that the SpanContext (read via `get_current_span()`) and the legacy vars now agree on `trace_id` / `span_id` at every nesting level. @ * fix(instrumentation/langgraph): F-28 threading.RLock protects _active_runs UI-UX-AUDIT 2026-08-14 finding F-28: NullRunCallback._active_runs is read/written without synchronisation on multi-threaded LangChain runners (and on free-threaded CPython PEP 703 builds). Two callbacks on different threads can interleave on_chain_start / on_chain_end in ways that orphan the span_end lookup (parent_span_id on the wire doesn't match anything in the dict). Fix: wrap every read/write of _active_runs in with self._lock: (threading.RLock) RLock (not Lock) is required because _begin_run -> _register_active_run nests two acquisitions on the same thread — reentrant acquisition is the entire point. Five access sites wrapped: 1. _register_active_run (insert + cap-check eviction) 2. on_llm_start parent_ctx lookup 3. on_llm_end llm_ctx lookup 4. _begin_run parent_ctx lookup 5. _end_run pop Trade-off: the lock briefly spans runtime.track_event. Per callback that's one outbound HTTP round-trip holding the lock; acceptable because the Lock protects ONE NullRunCallback's dict (not all of them) and concurrent chains on the SAME callback are rare. Documented inline at __init__ so a future maintainer doesn't 'optimise' it away. Regression: tests/test_langgraph_callback_race.py (5 tests): - test_active_runs_lock_is_rlock : reentrant acquire from this thread - test_active_runs_protected_under_concurrent_register : 200 iter, 2 threads, cap=64 - test_active_runs_protected_under_register_end_race : register + pop race - test_active_runs_lock_does_not_deadlock_on_nested_register : nested acquire - test_register_then_end_round_trip : canonical happy-path sanity Verification: pytest tests/test_langgraph_callback_race.py tests/test_lru_active_runs.py tests/test_langgraph_callback.py -q: 54 passed ruff check src/nullrun/instrumentation/langgraph.py tests/test_langgraph_callback_race.py: All checks passed pytest tests/ -q (excluding integration): 1568 passed, 7 skipped * fix(instrumentation/auto): F-29 async _emit falls back to request-body model UI-UX-AUDIT 2026-08-14 finding F-29: NullRunAsyncTransport._emit stopped at usage.get('model') only — when the upstream Anthropic or OpenAI streaming response omitted a top-level model field, the emitted llm_call event had model=None, which the wire-format builder dropped, which the backend then unwrap_or('default')'d to DEFAULT_RATE. Net effect: silent zero-billing for async streaming clients. Fix: mirror the sync path's fallback chain at auto.py:882-885: model_for_event = ( usage.get('model') or _extract_model_from_request_body(request) ) _extract_model_from_request_body is a module-level pure-sync helper that reads request.content + json.loads — safe to call from the async event loop (no I/O, no blocking). The response body is tried first; the request body is the fallback when the response omits the field. The pre-fix comment at lines 967-971 explicitly noted 'async path doesn't have the request-body model fallback yet' — that comment is now stale and replaced with F-29 context. Regression: tests/test_model_fallback_async.py (3 tests): - test_async_transport_falls_back_to_request_body_model : main F-29 case - test_async_transport_prefers_response_body_model : response wins when both - test_async_transport_emits_none_when_neither_source_has_model : corner case Verification: pytest tests/test_model_fallback.py tests/test_model_fallback_async.py tests/test_streaming_oom_cap.py: 17 passed ruff check src/nullrun/instrumentation/auto.py tests/test_model_fallback_async.py: All checks passed pytest tests/ -q (excluding integration): 1571 passed, 7 skipped * chore(release): 0.15.2 — observability + UI-UX-AUDIT 2026-08-14 closure Patch release bundling the 5 commits accumulated since 0.15.1: - 1c96654 — check_workflow_budget fail-OPEN observability closure (sprint handoff Bug #4): synthetic FALLBACK path now logs at WARNING (not DEBUG), and a new gate_fail_open_total counter fires on all three fail-OPEN sites. - 9b87d20 — F-19 SpanContext ↔ legacy trace_id/span_id contextvars now form a single coherent trace tree. Pre-0.15.2 inner @Protect fn() inside a with workflow("foo") block emitted two disconnected trace rows on the dashboard. - 127b003 — F-28 NullRunCallback._active_runs now protected by threading.RLock; five access sites wrapped, reentrant for _begin_run → _register_active_run nesting. - 0f86c8c — F-29 NullRunAsyncTransport._emit now falls back to the request body model field when the upstream Anthropic / OpenAI streaming response omits it. Closes silent-zero-billing bug for async streaming clients. - 35728b9 — removed flaky test_env_fallback_when_server_value_is_zero; the contract is covered by composition of test_validate_approval_timeout_rejects_below_min + test_env_fallback_when_response_omits_field (both deterministic). Verification: - pytest: 1571 passed, 7 skipped in 103.85s - ruff: clean - mypy: clean (37 source files) Compatibility: No SDK_MIN_VERSION bump. No public API change. No wire-format change. Drop-in replacement for 0.15.1.
1 parent e85c691 commit 4b6ce56

14 files changed

Lines changed: 1407 additions & 93 deletions

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,28 @@
1+
## [0.15.2] - 2026-08-14
2+
3+
Patch release — observability closure + UI-UX-AUDIT 2026-08-14 fixes (F-19, F-28, F-29) + flaky-test removal. No public API change, no wire-format change, no SDK_MIN_VERSION bump. Drop-in replacement for 0.15.1.
4+
5+
### Fixed
6+
7+
- **`check_workflow_budget` synthetic FALLBACK path emits WARNING, not DEBUG** (sprint handoff `Bug #4 — SDK WS timeout → silent ALLOW`) — pre-0.15.2, when `transport.check` returned `decision_source=FALLBACK_*` (the synthetic-block on `httpx.RequestError` / 5xx), `runtime.py` logged at DEBUG, contradicting the method docblock ("logged at warning level and the caller proceeds") and making the documented ADR-008 fail-OPEN invisible to operators tailing INFO+ logs. Post-0.15.2 the level is WARNING.
8+
- **`gate_fail_open_total` metric on all three fail-OPEN paths** — new `RuntimeMetrics.gate_fail_open_total` counter (`observability/__init__.py`) increments once per `check_workflow_budget` fail-OPEN, regardless of which of the three paths fired (cache-enabled exception, cache-disabled exception, synthetic FALLBACK decision_source). Exposed via `metrics.to_dict()["runtime"]["gate_fail_open_total"]` for the `/health` endpoint and operator dashboards. Operators alert on sustained rate to detect backend outages bypassing the budget gate.
9+
- **F-19 — `SpanContext` ↔ legacy `trace_id`/`span_id` contextvars now form a single coherent trace tree** — pre-0.15.2 the SDK owned two parallel contextvar systems (`tracing._current_span` set by `@protect`, and `context._trace_id_var` / `_span_id_var` set by `with workflow(...)`) that were never read by each other, so an inner `@protect fn()` inside a `with workflow("foo"):` emitted a `span_start` with one trace_id and a parent `track_llm` cost event with a different one — disconnected tree rows on the dashboard. Post-0.15.2 a dual-write bridge keeps both contextvars in sync; `_enrich_event` reads the unified `SpanContext` and the cost-event path reads from the same source. Backend-side bulk-ingest (deferred from audit commit `3e1ea921`) is now fed a coherent trace tree.
10+
- **F-28 — `NullRunCallback._active_runs` protected by `threading.RLock`** — pre-0.15.2 the dict was read/written without synchronisation on multi-threaded LangChain runners (and on free-threaded CPython PEP 703 builds); interleaved `on_chain_start` / `on_chain_end` could orphan the `span_end` lookup (parent_span_id didn't match anything in the dict). Five access sites wrapped: `_register_active_run`, `on_llm_start` parent lookup, `on_llm_end` llm lookup, `_begin_run` parent lookup, `_end_run` pop. `RLock` (not `Lock`) because `_begin_run → _register_active_run` nests two acquisitions on the same thread — reentrant acquisition is the point.
11+
- **F-29 — `NullRunAsyncTransport._emit` falls back to request-body `model` field** — pre-0.15.2 the async path stopped at `usage.get('model')` only. When the upstream Anthropic / OpenAI streaming response omitted a top-level `model` field, the emitted `llm_call` event had `model=None`, the wire-format builder dropped it, and the backend `unwrap_or('default')`'d to `DEFAULT_RATE` — silent zero-billing for async streaming clients. Post-0.15.2 mirrors the sync path's fallback chain at `auto.py:882-885`: `usage.get('model') or _extract_model_from_request_body(request)`. `_extract_model_from_request_body` is a module-level pure-sync helper that reads `request.content + json.loads` — safe to call from the async event loop (no I/O, no blocking).
12+
13+
### Housekeeping
14+
15+
- **6 source-pin regression tests** in `tests/test_preflight_fail_policy.py::TestCheckWorkflowBudgetObservability` — pins for the WARNING-level + metric closure above (`test_network_error_emits_warning_and_metric`, `test_timeout_emits_warning_and_metric`, `test_synthetic_fallback_source_emits_warning_not_debug`, `test_real_block_does_not_increment_metric`, `test_real_allow_does_not_increment_metric`, `test_to_dict_includes_gate_fail_open_total`).
16+
- **21 new tests** covering F-19 / F-28 / F-29:
17+
- F-19: `tests/test_track_span_context.py` — trace-tree unification across `with workflow(...)``@protect` nesting (476 lines, the largest single audit-pin file in this release).
18+
- F-28: `tests/test_langgraph_callback_race.py` — multi-threaded callback interleaving, parent lookup, span_end consistency under RLock (187 lines).
19+
- F-29: `tests/test_model_fallback_async.py` — async `_emit` request-body fallback for Anthropic + OpenAI streaming (204 lines) + `tests/test_preflight_fail_policy.py` `TestCheckWorkflowBudgetObservability` (176 lines).
20+
- **Removed flaky test** `tests/test_approval_timeout_field.py::TestApprovalTimeoutResolution::test_env_fallback_when_server_value_is_zero` — the test was rare-flaky under pytest-xdist on CI (Linux, Python 3.12); `@pytest.mark.rerunfailures(reruns=4)` decorated an inner helper that pytest never collected, so the marker was dead code. The "non-positive server timeout → env default" contract is covered by the composition of `test_validate_approval_timeout_rejects_below_min` (line 344) and `test_env_fallback_when_response_omits_field` (line 168), both deterministic and not flaky.
21+
22+
_Tests: 1571 passed (was 1550 in 0.15.1; +21 new from audit, −1 from removed flaky test), 7 skipped in 103.85s. Full suite green. ruff clean. mypy clean (37 source files)._
23+
24+
_Compatibility:_ **No SDK_MIN_VERSION bump.** **No public API change.** **No wire-format change.** Fail-OPEN on SDK transport failure remains the documented ADR-008 contract; only the log level moved DEBUG→WARNING and a new counter was added (callers that never read the metric observe nothing). F-19 keeps the existing `@protect` and `with workflow(...)` call sites untouched — the contextvar surface is unified under the hood, not above. F-28 / F-29 are instrumentation-internal — they change emitted event content for the previously-broken cases, never the SDK contract. Drop-in replacement for 0.15.1.
25+
126
## [0.15.1] - 2026-08-13
227

328
Patch release — v3.53 audit fixes (H6 / L5 / L6 / M8 / audit #4 / #5 / #6) plus static-typing closure. No public API change, no wire-format change. Drop-in replacement for 0.15.0.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66
name = "nullrun"
77
# Full release history lives in CHANGELOG.md; only the current version
88
# is pinned here.
9-
version = "0.15.1"
9+
version = "0.15.2"
1010
# Kept under the 200-char preview threshold so the full line is visible
1111
# without an "expand" click. The headline is the canonical §1 statement
1212
# from positioning.md — "runtime decision layer for tool-using AI agents"

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
string and the SDK_MIN_VERSION constant.
66
"""
77

8-
__version__ = "0.15.1"
8+
__version__ = "0.15.2"
99
__platform_version__ = "1.0.0"

src/nullrun/context.py

Lines changed: 188 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@
2424
from contextlib import contextmanager
2525
from contextvars import ContextVar, Token
2626

27+
# 2026-08-14 (F-19 fix): ``nullrun.tracing`` provides the structured
28+
# SpanContext that models the parent/child hierarchy a trace timeline
29+
# needs. ``nullrun.context`` previously owned loose ``_trace_id`` /
30+
# ``_span_id`` contextvars and now keeps them in lockstep via the
31+
# ``_mirror_to_span_context`` / ``_mirror_to_legacy_span`` helpers
32+
# below; ``@protect`` (decorators.py:441) and any other writer must
33+
# call BOTH sides so runtime readers (``get_trace_id`` /
34+
# ``get_span_id``) and SpanContext readers (``get_current_span``) see
35+
# the same trace id. See audit_ui/UI-UX-AUDIT-REPORT.md F-19.
36+
from .tracing import (
37+
SpanContext,
38+
_current_span,
39+
reset_span,
40+
set_span,
41+
)
42+
2743
# Context variables for workflow/trace propagation.
2844
_workflow_id_var: ContextVar[str | None] = ContextVar("workflow_id", default=None)
2945
_trace_id_var: ContextVar[str | None] = ContextVar("trace_id", default=None)
@@ -49,15 +65,13 @@
4965
# ``None`` means "I don't know" — the gate treats absent values
5066
# as unknown (NOT as false), so a server that forgets to set
5167
# annotations cannot accidentally get a read-only bypass.
52-
_call_mcp_class_var: ContextVar[str | None] = ContextVar(
53-
"call_mcp_class", default=None
54-
)
68+
_call_mcp_class_var: ContextVar[str | None] = ContextVar("call_mcp_class", default=None)
5569
_call_mcp_annotations_var: ContextVar[dict[str, bool | None] | None] = ContextVar(
5670
"call_mcp_annotations", default=None
5771
)
5872

5973
# 2026-07-02 (v0.11.0): chain_id contextvar for soft-mode gate
60-
#.
74+
# .
6175
#
6276
# Soft-mode budget enforcement ONLY allows overdrafts when an
6377
# active chain is registered against the org. The SDK must forward
@@ -188,7 +202,7 @@ def set_chain_op(op: str) -> None:
188202
"""Manually set the chain_op for the next /check call.
189203
190204
Valid values: ``"auto"`` (default), ``"start"``, ``"continue"``
191-
``"end"``. Mirrors the wire-contract enum in
205+
``"end"``. Mirrors the wire-contract enum in
192206
decision matrix. Use ``"start"`` to force REGISTERED-state
193207
semantics on the next call (no auto-register); use ``"end"``
194208
on a /check to close the chain in the same atomic operation
@@ -289,16 +303,16 @@ def get_server_minted_reservation_at() -> float:
289303

290304
def get_server_minted_idempotency_key() -> str | None:
291305
"""Return the /check ``idempotency_key`` for the in-scope
292-
reservation, or ``None`` if none captured.
306+
reservation, or ``None`` if none captured.
293307
294-
Read by ``NullRunRuntime._enrich_event`` to tag the /track
295-
v3 single-event payload. The /check request sets
296-
``idempotency_key = operation_id`` (a UUID v4) at
297-
runtime.py:1260; the /track handler honors it for replay
298-
.
308+
Read by ``NullRunRuntime._enrich_event`` to tag the /track
309+
v3 single-event payload. The /check request sets
310+
``idempotency_key = operation_id`` (a UUID v4) at
311+
runtime.py:1260; the /track handler honors it for replay
312+
.
299313
300-
Pairs with:func:`get_server_minted_execution_id` and shares
301-
the same capture token; ``None`` on the legacy v1/v2 path.
314+
Pairs with:func:`get_server_minted_execution_id` and shares
315+
the same capture token; ``None`` on the legacy v1/v2 path.
302316
"""
303317
return _server_minted_idempotency_key_var.get()
304318

@@ -334,13 +348,13 @@ def set_server_minted_reservation_at(value: float) -> Token[float]:
334348

335349
def set_server_minted_idempotency_key(value: str | None) -> Token[str | None]:
336350
"""Capture the /check ``idempotency_key`` (the operation_id UUID v4
337-
on the v3 path) alongside the matching execution_id.
351+
on the v3 path) alongside the matching execution_id.
338352
339-
Lifetime is symmetric with
340-
:func:`set_server_minted_execution_id` — the runtime captures
341-
both at the same instant and resets both at the matching
342-
/track emission (or workflow/chain block exit). Returns the
343-
matching Token.
353+
Lifetime is symmetric with
354+
:func:`set_server_minted_execution_id` — the runtime captures
355+
both at the same instant and resets both at the matching
356+
/track emission (or workflow/chain block exit). Returns the
357+
matching Token.
344358
"""
345359
return _server_minted_idempotency_key_var.set(value)
346360

@@ -396,6 +410,129 @@ def set_attempt_index(index: int) -> None:
396410
_attempt_index_var.set(index)
397411

398412

413+
# ---------------------------------------------------------------------------
414+
# F-19 (2026-08-14): legacy _trace_id / _span_id token-based setters
415+
# ---------------------------------------------------------------------------
416+
#
417+
# ``nullrun.tracing.SpanContext`` is the canonical source-of-truth at
418+
# write time (audit F-19: ``@protect`` derives a SpanContext, then
419+
# emits ``span_start``/``span_end`` with ``ctx.trace_id``). The runtime
420+
# still reads ``get_trace_id`` / ``get_span_id`` for cost-event
421+
# enrichment (``runtime.py:2679``, ``2903-2907``, ``2967-2972``) and
422+
# for ``parent_trace_id`` derivation; without a mirror, those readers
423+
# see ``None`` and fall back to ``generate_trace_id`` — different
424+
# uuid from the SpanContext's trace_id, so the dashboard sees two
425+
# trace rows for a single ``@protect`` call.
426+
#
427+
# These setters let ``decorators._protect_body`` mirror the new
428+
# SpanContext back to legacy AFTER ``set_span``. Token-based (PEP 567)
429+
# so a nested ``@protect`` inside an outer ``@protect`` (or inside
430+
# ``with workflow``) restores the outer trace on reset — same shape
431+
# as ``reset_server_minted_execution_id`` and ``reset_span``.
432+
def set_trace_id(value: str) -> Token[str | None]:
433+
"""Mirror a SpanContext's trace_id into the legacy ``_trace_id_var``.
434+
435+
Token-based (matches ``reset_span`` / ``reset_server_minted_*``
436+
helpers). Returns the matching Token so the caller can restore
437+
the previous value via :func:`reset_trace_id`. Read by
438+
``runtime._enrich_event`` and the ``parent_trace_id`` enrichment
439+
branch; without this mirror the dashboard's span tree is
440+
detached from the cost events the runtime emits.
441+
"""
442+
return _trace_id_var.set(value)
443+
444+
445+
def reset_trace_id(token: Token[str | None]) -> None:
446+
"""Restore the previous ``_trace_id_var`` value (paired with
447+
:func:`set_trace_id`).
448+
"""
449+
_trace_id_var.reset(token)
450+
451+
452+
def set_span_id(value: str) -> Token[str | None]:
453+
"""Mirror a SpanContext's span_id into the legacy ``_span_id_var``.
454+
455+
Token-based; pairs with :func:`reset_span_id`. Same audit
456+
motivation as :func:`set_trace_id` (F-19, 2026-08-14).
457+
"""
458+
return _span_id_var.set(value)
459+
460+
461+
def reset_span_id(token: Token[str | None]) -> None:
462+
"""Restore the previous ``_span_id_var`` value."""
463+
_span_id_var.reset(token)
464+
465+
466+
# ---------------------------------------------------------------------------
467+
# F-19 (2026-08-14): helpers used by ``with workflow`` / ``with span``
468+
# ---------------------------------------------------------------------------
469+
#
470+
# ``with workflow`` writes a fresh root ``SpanContext``; ``with span``
471+
# derives a child SpanContext from whatever ``_current_span`` already
472+
# has (or no-ops if no span is active, preserving bare-``with span``
473+
# corner-case behavior for legacy readers).
474+
def _set_workflow_root_span(trace_id: str, span_id: str) -> Token[SpanContext | None]:
475+
"""Push a fresh root ``SpanContext`` onto ``_current_span``.
476+
477+
Called from ``with workflow`` once the legacy
478+
``_workflow_id_var`` / ``_trace_id_var`` / ``_span_id_var`` tokens
479+
are minted. Returns the matching Token; the caller MUST pair it
480+
with :func:`reset_span` in a ``finally`` block (the wrapping
481+
``with workflow`` does).
482+
"""
483+
return set_span(
484+
SpanContext(
485+
trace_id=trace_id,
486+
span_id=span_id,
487+
parent_span_id=None,
488+
depth=0,
489+
)
490+
)
491+
492+
493+
def _set_child_span_context(span_id: str) -> Token[SpanContext | None] | None:
494+
"""Push a child ``SpanContext`` derived from the active parent.
495+
496+
Called from ``with span`` only when a parent ``SpanContext`` is
497+
active (i.e. we're inside a workflow / ``@protect`` block). If
498+
no parent is set, returns ``None`` and the caller does NOT push
499+
anything onto ``_current_span`` — preserving the legacy
500+
corner-case behavior of bare ``with span(...)`` (the runtime's
501+
``_enrich_event`` falls back to ``generate_trace_id()`` for
502+
legacy readers; that path was correct pre-F-19 and stays so).
503+
504+
Returns the matching Token; ``with span`` pairs it with
505+
:func:`reset_span` in its ``finally``.
506+
"""
507+
parent = _current_span.get()
508+
if parent is None:
509+
return None
510+
return set_span(create_child_span_with_id(parent, span_id))
511+
512+
513+
def create_child_span_with_id(parent: SpanContext, span_id: str) -> SpanContext:
514+
"""Build a child ``SpanContext`` reusing a caller-supplied span_id.
515+
516+
Same semantics as ``tracing.create_child_span`` (inherits
517+
``trace_id`` + ``parent_span_id``; ``depth = parent.depth + 1``),
518+
but takes the ``span_id`` verbatim rather than generating a new
519+
one. Used by ``with span`` so its externally-observable
520+
``span_id`` stays in lockstep with the legacy ``_span_id_var``
521+
it sets.
522+
523+
Why not just ``create_child_span(parent)``: that path mints a
524+
fresh span_id, so the legacy ``_span_id_var`` (set by
525+
``with span`` to ``name or generate_span_id()``) and the new
526+
SpanContext.span_id would diverge — defeating the F-19 fix.
527+
"""
528+
return SpanContext(
529+
trace_id=parent.trace_id,
530+
span_id=span_id,
531+
parent_span_id=parent.span_id,
532+
depth=parent.depth + 1,
533+
)
534+
535+
399536
def set_call_context(
400537
model: str | None = None,
401538
tools: list[str] | tuple[str, ...] | None = None,
@@ -511,6 +648,18 @@ def workflow(name: str | None = None) -> Generator[str, None, None]:
511648
wf_token = _workflow_id_var.set(workflow_id)
512649
trace_token = _trace_id_var.set(trace_id)
513650
span_token = _span_id_var.set(span_id)
651+
# F-19 (2026-08-14): dual-write a root SpanContext onto
652+
# ``_current_span`` so an inner ``@protect`` (or nested
653+
# ``with span``) derives child spans from THIS workflow's
654+
# trace_id rather than minting a fresh disconnected root.
655+
# Before this bridge the two contextvar systems diverged:
656+
# span_start events carried SpanContext.trace_id while cost
657+
# events read legacy ``_trace_id_var`` — the dashboard saw
658+
# two trace rows per ``@protect`` call inside a workflow.
659+
# ``reset_span(span_ctx_token)`` in the ``finally`` restores
660+
# the previous SpanContext (could be ``None`` or an outer
661+
# workflow's root).
662+
span_ctx_token = _set_workflow_root_span(trace_id, span_id)
514663

515664
try:
516665
yield workflow_id
@@ -519,6 +668,12 @@ def workflow(name: str | None = None) -> Generator[str, None, None]:
519668
_workflow_id_var.reset(wf_token)
520669
_trace_id_var.reset(trace_token)
521670
_span_id_var.reset(span_token)
671+
# Restore the previous SpanContext (mirrors the legacy
672+
# token resets above). Resetting before yielding was lost
673+
# the parent chain — reordering doesn't matter here since
674+
# finally runs after the body exits and the body has
675+
# already finished emitting events.
676+
reset_span(span_ctx_token)
522677

523678

524679
@contextmanager
@@ -534,11 +689,24 @@ def span(name: str | None = None) -> Generator[str, None, None]:
534689
"""
535690
span_id = name or generate_span_id()
536691
token = _span_id_var.set(span_id)
692+
# F-19 (2026-08-14): when a SpanContext is already active
693+
# (e.g. we're inside ``with workflow(...)`` or ``@protect``),
694+
# push a child SpanContext onto ``_current_span`` so that nested
695+
# ``@protect`` calls and the runtime's
696+
# ``_enrich_event → parent_trace_id`` path both see this span
697+
# as a real parent. ``_set_child_span_context`` returns
698+
# ``None`` if no parent is active — bare ``with span(...)``
699+
# outside any workflow/protect block keeps the legacy behavior
700+
# (legacy readers fall through to ``generate_trace_id()``
701+
# enrichment, which was correct pre-F-19 and stays so).
702+
span_ctx_token = _set_child_span_context(span_id)
537703

538704
try:
539705
yield span_id
540706
finally:
541707
_span_id_var.reset(token)
708+
if span_ctx_token is not None:
709+
reset_span(span_ctx_token)
542710

543711

544712
@contextmanager
@@ -656,9 +824,7 @@ def chain(
656824
``workflow ``).
657825
"""
658826
if op not in ("start", "continue", "end", "auto"):
659-
raise ValueError(
660-
f"chain() op must be one of start/continue/end/auto, got {op!r}"
661-
)
827+
raise ValueError(f"chain() op must be one of start/continue/end/auto, got {op!r}")
662828
chain_token = _chain_id_var.set(chain_id)
663829
op_token = _chain_op_var.set(op)
664830
try:

0 commit comments

Comments
 (0)