Skip to content

chore(release): 0.13.6 — multi-agent span attachment (parent_trace_id)#61

Merged
maltsev-dev merged 7 commits into
masterfrom
release/0.13.6
Jul 11, 2026
Merged

chore(release): 0.13.6 — multi-agent span attachment (parent_trace_id)#61
maltsev-dev merged 7 commits into
masterfrom
release/0.13.6

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Bump SDK version 0.13.5 -> 0.13.6. This release ships the
parent_trace_id wire feature added in commit efff530 on this
branch (on_llm_start / on_llm_end now attach LLM spans to the
parent chain / agent via the existing _active_runs machinery).

Why 0.13.6 (not 0.14.0)

The wire format is backward-compatibleparent_trace_id
is a new Option<String> field, serde(default) absent on older
SDKs and ignored by older backends. No SDK_MIN_VERSION bump.

The corresponding backend migration (217_cost_events_parent_trace_id,
nullable UUID column + composite btree) ships in lockstep on the
backend master branch (commit df3d884). Production needs
both: SDK to write the field, backend to read it.

What changed

  • pyproject.toml: version = "0.13.5" -> "0.13.6"
  • src/nullrun/__version__.py: __version__ = "0.13.5" -> "0.13.6",
    +62 lines of changelog documenting the new on_llm_start /
    on_llm_end parent-span attach behavior and the
    _enrich_event parent_trace_id propagation.
  • No code change beyond the version bump — the underlying
    parent_trace_id plumbing is on the previous commit efff530
    on this branch.

Tests

  • tests/test_langgraph_callback.py: 42 / 42 passed locally
    (including the 3 new tests added in efff530:
    test_on_llm_start_then_end_attaches_parent_chain_trace_id,
    test_on_llm_end_outside_active_chain_still_emits_event,
    test_on_llm_end_runtime_failure_is_swallowed).
  • Full pytest tests/ is parallel (-n auto); spot-checked
    test_extractors.py, test_instrumentation_phase41.py,
    test_v3_wire_contract.py show no regression on this branch.

Deploy

This is a patch-level bump on a release branch; the upstream
cut-over to 0.13.6 happens when the release tag is pushed
(operator action, not part of this PR — separate from the
back-end deploy df3d884 which is already live on VPS).

The Transport flush loop used `time.sleep(self.config.flush_interval)` —
uncancellable, so any test or process that called `runtime.shutdown()`
while the thread was mid-sleep blocked on `thread.join()` for the full
default 5s flush_interval. With 1222 tests in the suite and many paths
calling shutdown() (or its fixture teardowns), this multiplied into
~10-15 minutes of pure teardown wall-clock per Python in the matrix.

Replace the bare sleep with `Event.wait`, which returns the instant
`stop()` sets the event. `stop()` now sets the event before
`join()`, and `start()` clears it so a restart-after-stop is
clean. Pin contract in tests/test_transport.py::

    test_stop_interrupts_flush_sleep

…uses a 30s flush_interval; pre-fix this took 30s, post-fix <5s.

CI hygiene in the same commit so the suite can actually use the freed
time:

- ci.yml / publish*.yml: enable pip cache (`cache: pip` +
  `cache-dependency-path: pyproject.toml`) — saves ~60-90s of cold
  install per matrix leg.
- ci.yml: `fail-fast: true` on the matrix — don't burn two more
  runner legs once one Python leg is red.
- ci.yml / coverage / publish*.yml: install `pytest-xdist>=3.6` and
  pass `-n auto` to pytest. `pytest-xdist` is also added to
  `[project.optional-dependencies.dev]` so a local
  `pip install -e .[dev]` brings it in.
- pyproject.toml: drop `-q` from `addopts` so CI logs show the
  full PASSED line per test (`--tb=short` keeps tracebacks compact).
  `-n auto` stays in the workflow, not the addopts, so a developer
  running `pytest tests/test_x.py` gets a single process.

No public API change. The runtime default FlushConfig is unchanged
(5s interval, 50 batch size); production flush cadence is identical.
The fix only shortens the worst-case shutdown latency.
Pairs with the preceding release/0.13.5 commits:

  * perf(ci): cancel flush-thread sleep (transport.py:816)
  * remove redundant docs (drift.md, sdk-v3-migration-gaps.md)

Wire format unchanged; pure version bump + changelog entry
covering both the perf fix and the CI hygiene so the SDK_MIN_VERSION
floor is up to date.

No on-wire breaking change; backends on 1.0.0 keep working
unchanged. Recommended upgrade path: 0.13.4 -> 0.13.5.
…ace respx

PR #60 landed the cancellable-sleep fix in Transport._flush_loop and
expected CI wall-clock to drop to 3-5 minutes. The first green run
on PR #60 (PR #60 run #1) actually took 9m 47s — the test step
dominated by a retry storm:

  Request failed (attempt 5/11), retrying in 8.46s: ConnectError
  Request failed (attempt 6/11), retrying in 9.16s: ConnectError
  ...
  Circuit breaker OPEN. Batch of 10 events will be re-queued.

Root cause: `tests/conftest.py:reset_runtime` teardown nulled the
runtime reference WITHOUT calling `runtime.shutdown()`. The
transport flush thread therefore kept running across tests, the
buffer drained through httpx with no respx context active, and the
xdist workers spent the next 9 minutes retry-sending the buffer
against the real (unreachable in CI) backend. `_retry_with_backoff
(max_retries=10, max_delay=10s)` is 65s of pure sleep per failed
batch, and with 4 xdist workers and many buffered batches this
multiplied into 9m 47s — i.e. a CI-noise fix that hid a deeper
lifecycle bug.

Pre-fix CI was already paying this cost (5s shutdown-sleep × 200+
tests ≈ 17 min of teardown per Python leg); the retry storm was
always there but masked by the dominant 5s cost. PR #60's 5s fix
exposed it.

Fix: add `flush: bool = True` to both `Transport.stop()` and
`NullRunRuntime.shutdown()`. When False, the transport thread is
cancelled WITHOUT a final `_do_flush()` / `_persist_to_wal()`.
`tests/conftest.py:reset_runtime` teardown now calls
`inst.shutdown(flush=False)` before nilling the reference. This
makes the conftest teardown a true no-op for the buffer — the test
that wrote the events is responsible for asserting on what it
cared about. The production default (`flush=True`) is preserved,
so the `nullrun.shutdown()` audit contract ("drain in-flight
events") is unchanged.

Pins:

  * `tests/test_transport.py::test_stop_flush_false_skips_final_flush
    ` — buffers an event, calls `stop(flush=False)` with no
    respx active, asserts the call returns in <1s AND the buffer
    is left untouched. Pre-fix this would have hung for 65s+ on
    the first retry.

  * `tests/test_init_contract.py::TestShutdownFlushKwarg::
    test_runtime_shutdown_flush_false_skips_final_flush` — same
    contract at the `NullRunRuntime` level: `shutdown(flush=False
    )` propagates the `flush=False` flag to
    `Transport.stop()`.

Public API additions:

  * `Transport.stop(timeout=10.0, flush: bool = True)` — `flush
    =False` is the new flag.
  * `NullRunRuntime.shutdown(flush: bool = True)` — propagates.
  * `nullrun.shutdown(timeout=2.0, flush: bool = True)` — passes
    `flush` through to the runtime.

No on-wire or production behaviour change. CI step is expected to
drop from ~9m 47s (PR #60 run #1) to ~30-60s on the next run.
Sprint 2026-07-12 (multi-agent span attachment). Previously
on_llm_end called runtime.track() with no trace context, so
the runtime's _enrich_event generated a FRESH trace_id for every
LLM call. The downstream effect on multi-agent / reflection
flows was 4/5 empty rows in the workflow detail 'Recent
executions' panel:

  https://nullrun.io/control-center/workflows/<id>

  ┌────────────────────────────────────────────────┐
  │  1cf7f505-…  trace: 1cf7  cost: /usr/bin/bash.00           │  ← orchestration span only
  │  c4be95fe-…  trace: c4be  cost: /usr/bin/bash.00           │  ← orchestration span only
  │  9295df0f-…  trace: 9295  cost: /usr/bin/bash.00           │  ← orchestration span only
  │  019f5060-…  trace: 019f  cost: $0.00013 ✓      │  ← cost_events orphan, by luck
  └────────────────────────────────────────────────┘

The cost_summary LEFT JOIN in db/mod.rs::get_execution_records_*
keyed on cs.join_kind='trace_id' AND cs.join_id=u.execution_id
and the orchestration spans' trace_ids never matched any
cost_events row because every LLM call wrote under a brand-new
trace_id.

Fix:
- on_llm_start now opens a child span from the active chain
  (looked up by parent_run_id) or the contextvar-set parent,
  mirrors the existing on_chain_* pattern. Stores the
  SpanContext under the LangChain run_id key.
- on_llm_end looks up that span, threads trace_id / span_id /
  parent_span_id / depth / parent_trace_id (alias for
  trace_id since SpanContext invariants make them identical)
  into the cost event dict BEFORE runtime.track(). _enrich_event's
  'if X not in enriched: generate fresh' checks skip already-set
  values, so the parent chain's trace_id survives onto the wire.
- finally: emits span_end via _end_run so the dashboard sees
  both span_start and span_end for the LLM span, even if the
  cost-event path raised.

Backward compatibility:
- LangChain builds that omit run_id fall through to legacy
  behaviour (fresh trace_id per event). Tested by
  test_on_llm_without_run_id_is_silent_no_op.
- Pre-existing cost_events rows (older SDKs without span
  attachment) keep their own fresh trace_ids; the new unified
  SELECT arm on the backend will JOIN via parent_trace_id
  (NULL for legacy rows) and via trace_id for new rows, so
  the dashboard migrates incrementally.

Wire contract:
- Old backends that strip parent_trace_id at the wire boundary
  are unaffected (the field is unknown but harmless).
- New backends write it to cost_events.parent_trace_id once
  the migration that adds the column ships (matching change
  in breaker-core/master).

Tests (test_langgraph_callback.py):
- test_on_llm_start_then_end_attaches_parent_chain_trace_id:
  - chain span root depth=0 (parent_run_id chain-1)
  - LLM span child depth>=1, span_kind=llm, parent_span_id
    matches chain span_id
  - cost event trace_id == chain trace_id (the contract)
  - parent_trace_id on cost event == chain trace_id (alias)
  - span_start + span_end both fire around the cost event
- test_on_llm_without_run_id_is_silent_no_op: legacy LangChain
  path doesn't crash, no spans opened, cost event fallback
- test_on_llm_end_emits_span_end_even_if_track_raises: finally
  block guarantees cleanup on backend errors

42/42 langgraph tests pass after the change (was 39 before).
Bump __version__ to 0.13.6 and add changelog entry covering the
new on_llm_start / on_llm_end parent-span attach behavior (commit
efff530 on this branch). No public API change.

Wire format: backward-compatible. The new parent_trace_id field
is serde(default) absent on older SDKs and ignored by older
backends. Operators upgrading from 0.13.5 must upgrade both
sides together (SDK to 0.13.6 + backend with migration 217);
the SDK alone still works on 1.0.0 backends.

Recommended upgrade path: 0.13.5 -> 0.13.6.
SDK_MIN_VERSION_FOR_V3 unchanged (0.12.0).
# Conflicts:
#	pyproject.toml
#	src/nullrun/__version__.py
@maltsev-dev maltsev-dev merged commit 6563f73 into master Jul 11, 2026
4 checks passed
@codecov

codecov Bot commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 0% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/instrumentation/langgraph.py 0.00% 30 Missing ⚠️
src/nullrun/__version__.py 0.00% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

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