Skip to content

observability: phase 6.1 PR-C.2 — proposal 0013 + v0.10.0#26

Merged
chris-colinsky merged 6 commits into
mainfrom
feature/phase-6-1-pr-c2-fan-out-per-instance
May 10, 2026
Merged

observability: phase 6.1 PR-C.2 — proposal 0013 + v0.10.0#26
chris-colinsky merged 6 commits into
mainfrom
feature/phase-6-1-pr-c2-fan-out-per-instance

Conversation

@chris-colinsky

Copy link
Copy Markdown
Member

Summary

PR-C.2 of Phase 6.1: implement spec graph-engine + observability
v0.10.0 (proposal 0013). Non-detached fan-out instances now
synthesize per-instance dispatch spans nested between the
fan-out node span and the inner-node spans (mirroring the
detached path's layout, but landing in the parent trace rather
than a fresh trace_id). The fan-out node span carries the
four §5.4 attributes (item_count / concurrency /
error_policy / parent_node_name) sourced from the new
NodeEvent.fan_out_config field.

Mechanism

New FanOutEventConfig typed dataclass on the canonical event
payload (graph/events.py). Engine populates eagerly at
fan-out entry in _step_fan_out_node — resolves
item_count (from count mode or
len(items_field)) + concurrency once, threads through
every dispatch site (started, all four error-path completed,
deferred success-case completed). Pre-resolved values flow into
FanOutNode.run_with_context via new pre_resolved_count
/ pre_resolved_concurrency kwargs so callable resolvers fire
at most once per fan-out attempt scope.

Observer (observability/otel/observer.py):

  • _InvState gains fan_out_instance_spans (per-instance
    dispatch span store) + fan_out_parent_node_name (cache).
  • New _open_fan_out_instance_dispatch_span helper
    synthesizes per-instance dispatch spans as children of the
    fan-out node span, attaches parent_node_name from cache.
  • _sync_subgraph_spans routes non-detached fan-out instance
    namespaces through the new helper.
  • _resolve_parent_context finds the per-instance dispatch
    span before falling through, so inner-node events parent
    under their own per-instance dispatch span.
  • _node_attrs populates the three §5.4 fan-out node span
    attributes from event.fan_out_config.
  • _drain_inv_state extended for child→parent close
    ordering.

Four contract corrections

All four corrections from PR #34's CoPilot review on the spec
proposal (per coord thread phase-6-1-pr-c2-fan-out-per-instance/03)
honored cleanly:

  1. concurrency: int | None on the canonical payload (not
    int with 0=unbounded). The None → 0 translation lives
    only at the OTel attribute layer.
  2. All four FanOutEventConfig fields structurally
    required
    (frozen dataclass; partial resolution would raise
    on construction).
  3. Retried fan-out attempts carry fan_out_config
    resolved config is constructed once at fan-out entry and
    re-used by retry middleware re-entries.
  4. parent_node_name cached and applied per-instance
    sourced from the fan-out node's started event, applied at
    per-instance dispatch span synthesis, cleared at the fan-out's
    completed event.

Submodule + version pins

Three-place sync to v0.10.0 per CLAUDE.md:

  • openarmature-spec submodule pointer → ff86945 (v0.10.0).
  • tool.openarmature.spec_version in pyproject.toml.
  • __spec_version__ in src/openarmature/__init__.py.
  • Drift-guard literal in tests/test_smoke.py.

OTelObserver.spec_version updates automatically via PR-A's
_read_spec_version — every invocation span now reports
openarmature.graph.spec_version = "0.10.0".

Tests

  • Fixture 006
    (observability/006-otel-fan-out-instance-attribution)
    removed from _DEFERRED_FIXTURES and driven via
    _run_fixture_006 in test_observability.py. Verifies
    the fixture's expected tree: 1 fan-out NODE span with
    item_count=3 / concurrency=2 / error_policy="collect";
    3 per-instance dispatch spans with fan_out_index 0..2 and
    parent_node_name="process"; 3 compute spans (one per
    instance) parented under their own per-instance dispatch
    span.
  • Adapter compatibility shim:
    _TracingFanOutNode.run_with_context accepts and forwards
    the new pre-resolved kwargs.
  • Existing fan-out tests stay green
    test_count_callable_resolved_exactly_once_at_entry /
    test_concurrency_callable_resolved_exactly_once_at_entry
    pass (validates the pre-resolved-values plumbing didn't
    introduce double-resolution).

Test plan

  • uv run pytest -q — 399 passed (was 392; net +7 from
    new fan-out path verification + fixture 006 going from
    skip to pass + harness work), 3 skipped (was 4; only
    fixture 010 remains, which is PR-C.3's scope).
  • uv run pyright — 0 errors.
  • uv run ruff check . && uv run ruff format — clean.
  • All existing fixtures stay green under the new payload
    shape.

Phase 6.1 status after this PR

  • PR-A (concurrency-safe observer) ✅ merged
  • PR-B (Logs SDK migration + filter fix) ✅ merged
  • PR-C (4 conformance fixtures) ✅ merged
  • PR-C.1 (proposal 0012 + fixtures 004/020) ✅ merged
  • PR-C.2 (this PR): proposal 0013 + fixture 006
  • PR-C.3 (observer prepare_sync + fixture 010) — pending; no spec gate

PR-C.3 is independent and can land in either order. Phase 6.1
closes when both merge.

Implement spec graph-engine + observability v0.10.0 (proposal
0013): non-detached fan-out instances now synthesize per-instance
dispatch spans nested between the fan-out node span and the
inner-node spans (mirroring the detached path's layout, but
landing in the parent trace rather than a fresh trace_id). The
fan-out node span carries the four §5.4 attributes (item_count /
concurrency / error_policy / parent_node_name) sourced from the
new ``NodeEvent.fan_out_config`` field.

Mechanism: typed ``FanOutEventConfig`` dataclass on the canonical
event payload (events.py). Engine populates eagerly at fan-out
entry in ``_step_fan_out_node`` — resolves item_count (from
``count`` mode or ``len(items_field)``) + concurrency once,
threads through every dispatch site (started, all four error-path
completed, deferred success-case completed). Pre-resolved values
flow into ``FanOutNode.run_with_context`` via new
``pre_resolved_count`` / ``pre_resolved_concurrency`` kwargs so
callable resolvers fire at most once per fan-out attempt scope
(retry middleware re-uses the outer-scope resolution).

The four contract corrections from PR #34's CoPilot review:

1. ``concurrency: int | None`` on the canonical payload (positive
   int or None for unbounded per pipeline-utilities §9.2). The
   ``None → 0`` translation lives only at the OTel attribute
   layer in ``_node_attrs`` per observability §5.4's bare-int
   sentinel convention.
2. All four ``FanOutEventConfig`` fields structurally required
   (frozen dataclass; partial resolution would raise on
   construction). The implementation-defined corner from
   spec-thread 03 (config resolution itself failing) doesn't
   surface in current fixtures; natural error propagation
   covers it.
3. Retried fan-out attempts carry ``fan_out_config``: the
   resolved config is constructed in the outer scope of
   ``_step_fan_out_node``; retry middleware re-enters
   ``innermost`` and re-uses the same outer-scope reference. No
   per-attempt re-resolution; partition is by node type, not
   event category.
4. ``parent_node_name`` is on per-instance spans only; the
   observer caches it from the fan-out node's ``started`` event
   in ``_InvState.fan_out_parent_node_name`` keyed by namespace
   prefix, applies at per-instance dispatch span synthesis, and
   clears at the fan-out node's ``completed`` event.

Observer changes (``observer.py``):

- ``_InvState`` gains ``fan_out_instance_spans`` (per-instance
  dispatch span store, keyed by ``prefix + (str(fan_out_index),)``)
  and ``fan_out_parent_node_name`` (cache).
- ``_handle_started`` populates the cache when fan-out node
  events land.
- ``_handle_completed`` closes per-instance dispatch spans on the
  fan-out node's own completion (children-before-parents
  ordering) and clears the cache entry.
- ``_sync_subgraph_spans`` routes non-detached fan-out instance
  namespaces through the new
  ``_open_fan_out_instance_dispatch_span`` helper instead of
  opening a shared subgraph span at the prefix.
- ``_resolve_parent_context`` finds the per-instance dispatch
  span before falling through, so inner-node events parent
  under their own per-instance dispatch span (not the shared
  fan-out node span).
- ``_node_attrs`` populates the three §5.4 fan-out node span
  attributes from ``event.fan_out_config`` when present.
- ``_drain_inv_state`` extended to drain
  ``fan_out_instance_spans`` in child→parent order.

Pin sites bumped to v0.10.0 (three-place sync per CLAUDE.md):
``openarmature-spec`` submodule pointer to ``ff86945``,
``pyproject.toml`` ``tool.openarmature.spec_version``,
``src/openarmature/__init__.py`` ``__spec_version__``,
``tests/test_smoke.py`` drift-guard literal.
``OTelObserver.spec_version`` updates automatically via PR-A's
``_read_spec_version``.

Tests:

- ``tests/conformance/test_observability.py`` removes
  ``006-otel-fan-out-instance-attribution`` from
  ``_DEFERRED_FIXTURES`` and adds ``_run_fixture_006`` driver.
  Verifies the fixture's expected tree: 1 fan-out NODE span
  with item_count/concurrency/error_policy attributes; 3
  per-instance dispatch spans with fan_out_index 0..2 and
  parent_node_name="process"; 3 compute spans (one per
  instance) parented under their own per-instance dispatch
  span.
- ``tests/conformance/adapter.py``:
  ``_TracingFanOutNode.run_with_context`` accepts and forwards
  the new pre-resolved kwargs (pyright override-compatibility).
- Existing fan-out tests (resolution-once-per-entry,
  concurrency-callable-once-per-entry, instance-middleware
  retry, fan-in ordering, etc.) stay green — pre-resolved-
  values plumbing didn't introduce double-resolution.

399 tests pass (was 392; net +7 from new fan-out path
verification + fixture 006 going from skip to pass + harness
work). 3 skipped (was 4; only fixture 010 remains, which is
PR-C.3's scope). Pyright clean.

PR-C.3 (observer ``prepare_sync`` + fixture 010) sits
independently behind its own architectural piece; lands in
either order. Phase 6.1 closes when both merge.
Copilot AI review requested due to automatic review settings May 10, 2026 01:45
Comment thread src/openarmature/graph/compiled.py Fixed
Comment thread src/openarmature/graph/compiled.py Fixed
Comment thread src/openarmature/graph/compiled.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Implements spec proposal 0013 / v0.10.0 fan-out observability semantics: fan-out node events now carry resolved fan-out configuration, and the OTel observer synthesizes non-detached per-instance dispatch spans so inner-node spans parent under their instance dispatch span.

Changes:

  • Add FanOutEventConfig to the canonical NodeEvent payload and populate it for fan-out node started/completed events (including retries).
  • Thread pre-resolved fan-out count/concurrency into FanOutNode.run_with_context to avoid double-resolving callables.
  • Update the OTel observer to create and parent non-detached per-instance dispatch spans; enable conformance fixture 006 and bump spec version pins to 0.10.0.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/test_smoke.py Updates drift-guard spec version assertion to 0.10.0.
tests/conformance/test_observability.py Enables and implements fixture 006 verification for per-instance fan-out attribution.
tests/conformance/adapter.py Extends tracing adapter to accept/forward pre-resolved fan-out config kwargs.
src/openarmature/observability/otel/observer.py Adds per-instance dispatch span synthesis for non-detached fan-outs and §5.4 fan-out attributes.
src/openarmature/graph/fan_out.py Accepts pre-resolved count/concurrency to prevent double resolution.
src/openarmature/graph/events.py Introduces FanOutEventConfig and adds it to NodeEvent.
src/openarmature/graph/compiled.py Eagerly resolves fan-out config and threads it through dispatch + node execution.
src/openarmature/init.py Bumps __spec_version__ to 0.10.0.
pyproject.toml Bumps tool.openarmature.spec_version to 0.10.0.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/openarmature/observability/otel/observer.py
Comment thread tests/conformance/test_observability.py Outdated
- Move fan_out runtime imports from compiled.py module top into
  function scope to break the textual import cycle CodeQL's
  py/cyclic-import rule flagged. fan_out has a TYPE_CHECKING
  back-reference to compiled (no runtime issue), but the static
  analyzer doesn't see the gate. Lazy-imports inside _invoke
  (FanOutNode for the isinstance check) and _step_fan_out_node
  (_resolve_concurrency / _resolve_count). Type-only FanOutNode
  reference moves to TYPE_CHECKING with `from __future__ import
  annotations` so signatures resolve without the runtime import.
- Fix _drain_inv_state ordering: per-instance dispatch spans in
  fan_out_instance_spans are children of the fan-out NODE span
  (which lives in open_spans at depth 1). The previous shape
  closed all of open_spans before draining
  fan_out_instance_spans, ending the parent before its children
  during shutdown/abandon. Now drains open_spans in two phases
  (deep >= 2 / shallow = 1) with the per-instance drain between,
  so children-before-parents holds.
- Remove dead `by_id` map from _run_fixture_006_case —
  scaffolding from an earlier draft; current assertions don't
  use it.
Comment thread src/openarmature/graph/compiled.py Fixed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Comment thread src/openarmature/graph/compiled.py Outdated
Comment thread src/openarmature/graph/compiled.py Outdated
Both compiled.py and fan_out.py reference each other's types
only via TYPE_CHECKING blocks. CodeQL's analyzer flags the
textual cycle regardless of the gate; no runtime cycle exists.
Removing either side breaks pyright's type resolution for
generics across the boundary. Pyright's reportImportCycles
already covers genuine runtime cycles at type-check time, so
dropping this CodeQL rule loses no signal.
PR-C.2 hoisted item_count / concurrency resolution out of
``run_with_context`` to step entry so the eager
``FanOutEventConfig`` could ride on every fan-out node event.
That moved resolver failures (callable raises, ``getattr`` on
malformed state, items_field non-list) outside ``innermost``'s
``except Exception → NodeException`` block, dropping the
started/completed event pair and the NodeException wrap that
the inner path used to provide.

Re-establish the contract by wrapping the resolution block:
any failure now dispatches a started+completed pair with
``fan_out_config=None`` (we never built one) and surfaces as
NodeException. Also drops the silent 0-coercion when
``items_field`` resolves to a non-list — raises with the same
TypeError text ``_build_instance_states`` produces, so
fan_out_config never reports a misleading ``item_count=0``.
The ``_: ProjectionStrategy[S, Inner] = BoomProjection()`` line was
a defensive belt-and-suspenders structural-conformance check, but
the call-site ``add_subgraph_node(projection=BoomProjection())``
exercises the same Protocol check via its parameter annotation —
pyright catches a mismatch there without needing the explicit
assignment. Drop the line and update the comment to point at the
call site.
Copilot AI review requested due to automatic review settings May 10, 2026 02:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

- Annotate ``step=-1`` in the synthetic LLM-event observer test
  to point readers at ``OpenAIProvider._llm_event`` (openai.py:643)
  where the same sentinel is minted for production LLM-provider
  span events that aren't tied to graph step sequencing.
- ``_ParentState`` in the fan-out gating test now uses
  ``Field(default_factory=list[int])`` instead of the bare ``[]``
  default, matching the parametrized factory shape used in
  ``test_checkpoint.py``'s ``_ParentState`` and the surrounding
  pyright-strict expectation that field types are fully known.
@chris-colinsky chris-colinsky merged commit 9a29795 into main May 10, 2026
5 checks passed
@chris-colinsky chris-colinsky deleted the feature/phase-6-1-pr-c2-fan-out-per-instance branch May 10, 2026 02:58
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.

3 participants