Skip to content

Commit b3fa899

Browse files
observability: phase 6.1 PR-C.2 — proposal 0013 + v0.10.0
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.
1 parent 5775fc5 commit b3fa899

10 files changed

Lines changed: 415 additions & 22 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Repository = "https://github.com/LunarCommand/openarmature-python"
3636
Specification = "https://github.com/LunarCommand/openarmature-spec"
3737

3838
[tool.openarmature]
39-
spec_version = "0.9.0"
39+
spec_version = "0.10.0"
4040

4141
[dependency-groups]
4242
dev = [

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""OpenArmature — workflow framework for LLM pipelines and tool-calling agents."""
22

33
__version__ = "0.4.0rc0"
4-
__spec_version__ = "0.9.0"
4+
__spec_version__ = "0.10.0"

src/openarmature/graph/compiled.py

Lines changed: 62 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@
6767
RuntimeGraphError,
6868
StateValidationError,
6969
)
70-
from .events import NodeEvent
71-
from .fan_out import FanOutNode
70+
from .events import FanOutEventConfig, NodeEvent
71+
from .fan_out import FanOutNode, _resolve_concurrency, _resolve_count
7272
from .middleware import ChainCall, Middleware, compose_chain
7373
from .nodes import Node
7474
from .observer import (
@@ -913,6 +913,28 @@ async def _step_fan_out_node(
913913
# hardcoded 0.
914914
attempt_counter: list[int] = [0]
915915

916+
# Resolve the fan-out config eagerly so the resolved values
917+
# ride on every fan-out node event (per spec proposal 0013,
918+
# v0.10.0: ``fan_out_config`` is populated on fan-out node
919+
# events including retried attempts). For ``items_field``
920+
# mode the count is ``len(parent_state.<items_field>)``; for
921+
# ``count`` mode it's ``_resolve_count``. ``_resolve_concurrency``
922+
# is pure regardless. Repeating these inside
923+
# ``FanOutNode.run_with_context`` is cheap and matches the
924+
# values surfaced here.
925+
if node.config.items_field is not None:
926+
items_attr: Any = getattr(state, node.config.items_field, [])
927+
item_count = len(cast("list[Any]", items_attr)) if isinstance(items_attr, list) else 0
928+
else:
929+
item_count = _resolve_count(current, node.config, state)
930+
concurrency_resolved: int | None = _resolve_concurrency(current, node.config, state)
931+
fan_out_event_config = FanOutEventConfig(
932+
item_count=item_count,
933+
concurrency=concurrency_resolved,
934+
error_policy=node.config.error_policy,
935+
parent_node_name=current,
936+
)
937+
916938
# Cell holding the FINAL successful attempt's
917939
# (attempt_index, pre_state, merged); see same comment in
918940
# ``_step_function_node``.
@@ -923,12 +945,32 @@ async def innermost(s: Any) -> Mapping[str, Any]:
923945
attempt_counter[0] += 1
924946
attempt_token = _set_attempt_index(attempt_index)
925947
try:
926-
self._dispatch_started(context, current, namespace, step, s, attempt_index=attempt_index)
948+
self._dispatch_started(
949+
context,
950+
current,
951+
namespace,
952+
step,
953+
s,
954+
attempt_index=attempt_index,
955+
fan_out_config=fan_out_event_config,
956+
)
927957
try:
928-
partial = await node.run_with_context(s, context)
958+
partial = await node.run_with_context(
959+
s,
960+
context,
961+
pre_resolved_count=item_count,
962+
pre_resolved_concurrency=(concurrency_resolved,),
963+
)
929964
except RuntimeGraphError as e:
930965
self._dispatch_completed(
931-
context, current, namespace, step, s, error=e, attempt_index=attempt_index
966+
context,
967+
current,
968+
namespace,
969+
step,
970+
s,
971+
error=e,
972+
attempt_index=attempt_index,
973+
fan_out_config=fan_out_event_config,
932974
)
933975
raise
934976
except Exception as e:
@@ -941,14 +983,22 @@ async def innermost(s: Any) -> Mapping[str, Any]:
941983
s,
942984
error=wrapped,
943985
attempt_index=attempt_index,
986+
fan_out_config=fan_out_event_config,
944987
)
945988
raise wrapped from e
946989

947990
try:
948991
merged = _merge_partial(s, partial, self.reducers, current)
949992
except (ReducerError, StateValidationError) as e:
950993
self._dispatch_completed(
951-
context, current, namespace, step, s, error=e, attempt_index=attempt_index
994+
context,
995+
current,
996+
namespace,
997+
step,
998+
s,
999+
error=e,
1000+
attempt_index=attempt_index,
1001+
fan_out_config=fan_out_event_config,
9521002
)
9531003
raise
9541004

@@ -1021,6 +1071,7 @@ def finalize_completed(edge_error: RuntimeGraphError | None) -> None:
10211071
final_pre_state,
10221072
post_state=final_merged,
10231073
attempt_index=final_attempt_index,
1074+
fan_out_config=fan_out_event_config,
10241075
)
10251076
else:
10261077
self._dispatch_completed(
@@ -1031,6 +1082,7 @@ def finalize_completed(edge_error: RuntimeGraphError | None) -> None:
10311082
final_pre_state,
10321083
error=edge_error,
10331084
attempt_index=final_attempt_index,
1085+
fan_out_config=fan_out_event_config,
10341086
)
10351087

10361088
return _StepResult(state=merged_outer, finalize_completed=finalize_completed)
@@ -1044,6 +1096,7 @@ def _dispatch_started(
10441096
pre_state: State,
10451097
*,
10461098
attempt_index: int = 0,
1099+
fan_out_config: FanOutEventConfig | None = None,
10471100
) -> None:
10481101
_dispatch(
10491102
context,
@@ -1058,6 +1111,7 @@ def _dispatch_started(
10581111
parent_states=context.parent_states_prefix,
10591112
attempt_index=attempt_index,
10601113
fan_out_index=context.fan_out_index,
1114+
fan_out_config=fan_out_config,
10611115
),
10621116
)
10631117

@@ -1072,6 +1126,7 @@ def _dispatch_completed(
10721126
post_state: State | None = None,
10731127
error: RuntimeGraphError | None = None,
10741128
attempt_index: int = 0,
1129+
fan_out_config: FanOutEventConfig | None = None,
10751130
) -> None:
10761131
_dispatch(
10771132
context,
@@ -1086,6 +1141,7 @@ def _dispatch_completed(
10861141
parent_states=context.parent_states_prefix,
10871142
attempt_index=attempt_index,
10881143
fan_out_index=context.fan_out_index,
1144+
fan_out_config=fan_out_config,
10891145
),
10901146
)
10911147

src/openarmature/graph/events.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,52 @@
1616
from .state import State
1717

1818

19+
@dataclass(frozen=True)
20+
class FanOutEventConfig:
21+
"""Spec §6 + §5.4 (per spec proposal 0013, v0.10.0):
22+
fan-out node events carry the resolved configuration so backend
23+
observers can attribute the fan-out node span (``item_count`` /
24+
``concurrency`` / ``error_policy``) and synthesize per-instance
25+
spans with the right ``parent_node_name``.
26+
27+
Populated ONLY on ``started`` and ``completed`` events for a
28+
fan-out node itself (partition by node type, not event category —
29+
INCLUDES retried attempts of a fan-out node when retry middleware
30+
wraps it). All other events leave ``NodeEvent.fan_out_config``
31+
null.
32+
33+
Field shapes:
34+
35+
- ``item_count`` — non-negative int. The resolved instance count
36+
per pipeline-utilities §9 (matches ``count_field`` value when
37+
configured; matches ``len(items_field)`` in items_field mode).
38+
- ``concurrency`` — positive int OR ``None`` (unbounded). Per
39+
pipeline-utilities §9.2: zero or negative is rejected at config
40+
resolution time as ``fan_out_invalid_concurrency``. Backend
41+
mappings translate ``None`` to a sentinel at the attribute layer
42+
(e.g., ``openarmature.fan_out.concurrency = 0`` per
43+
observability §5.4) — that translation is observer-internal,
44+
not engine-internal.
45+
- ``error_policy`` — one of ``"fail_fast"`` or ``"collect"`` per
46+
pipeline-utilities §9.4.
47+
- ``parent_node_name`` — the fan-out node's name in the parent
48+
graph. Carried here for caching by backend observers when
49+
attributing per-instance spans (§5.4 mandates
50+
``openarmature.fan_out.parent_node_name`` on per-instance spans;
51+
the engine surfaces the name once on the fan-out node's started
52+
event, the observer caches and applies on every per-instance
53+
span it synthesizes).
54+
55+
All four fields MUST be present when ``fan_out_config`` is
56+
populated. Only ``concurrency`` is nullable.
57+
"""
58+
59+
item_count: int
60+
concurrency: int | None
61+
error_policy: str
62+
parent_node_name: str
63+
64+
1965
@dataclass(frozen=True)
2066
class NodeEvent:
2167
"""A single node-boundary event delivered to observers.
@@ -55,6 +101,9 @@ class NodeEvent:
55101
retries. `0` for nodes not wrapped by retry middleware.
56102
- `fan_out_index` is the 0-based index of this fan-out instance among
57103
its siblings. `None` for nodes not inside a fan-out.
104+
- `fan_out_config` carries resolved fan-out configuration on events
105+
from a fan-out NODE itself (per spec proposal 0013, v0.10.0). See
106+
:class:`FanOutEventConfig`. ``None`` on every other event.
58107
59108
Invariants:
60109
- On `started` events, `post_state` and `error` MUST both be None.
@@ -72,6 +121,7 @@ class NodeEvent:
72121
parent_states: tuple[State, ...]
73122
attempt_index: int = 0
74123
fan_out_index: int | None = None
124+
fan_out_config: FanOutEventConfig | None = None
75125

76126

77-
__all__ = ["NodeEvent"]
127+
__all__ = ["FanOutEventConfig", "NodeEvent"]

src/openarmature/graph/fan_out.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,16 +107,28 @@ async def run_with_context(
107107
self,
108108
state: ParentT,
109109
context: _InvocationContext,
110+
*,
111+
pre_resolved_count: int | None = None,
112+
pre_resolved_concurrency: tuple[int | None] | None = None,
110113
) -> Mapping[str, Any]:
111114
"""Execute the fan-out and return the merged partial update.
112115
113116
Per spec §9.1–§9.5: snapshot, resolve count + concurrency,
114117
build per-instance states, run concurrently with the configured
115118
error policy, fan-in collected/extra fields, write count_field
116119
and errors_field if configured.
120+
121+
``pre_resolved_count`` / ``pre_resolved_concurrency`` are the
122+
proposal-0013 v0.10.0 hooks: when the engine has already
123+
resolved the config eagerly to populate
124+
``NodeEvent.fan_out_config`` for the fan-out node's events,
125+
it passes the resolved values in so callable resolvers
126+
aren't invoked twice. ``pre_resolved_concurrency`` is wrapped
127+
in a 1-tuple to disambiguate "caller passed ``None``
128+
(unbounded)" from "caller didn't pass anything."
117129
"""
118130
cfg = self.config
119-
instance_states = _build_instance_states(self.name, cfg, state)
131+
instance_states = _build_instance_states(self.name, cfg, state, pre_resolved_count=pre_resolved_count)
120132
instance_count = len(instance_states)
121133

122134
if instance_count == 0:
@@ -127,7 +139,10 @@ async def run_with_context(
127139
# reducer absorbs as a no-op).
128140
return _empty_fan_out_partial(cfg)
129141

130-
max_concurrency = _resolve_concurrency(self.name, cfg, state)
142+
if pre_resolved_concurrency is not None:
143+
max_concurrency = pre_resolved_concurrency[0]
144+
else:
145+
max_concurrency = _resolve_concurrency(self.name, cfg, state)
131146

132147
# Per-instance task: build the instance_middleware chain, run
133148
# the subgraph against it, and return the per-instance partial
@@ -187,13 +202,22 @@ def _build_instance_states(
187202
node_name: str,
188203
cfg: FanOutConfig,
189204
parent_state: Any,
205+
*,
206+
pre_resolved_count: int | None = None,
190207
) -> list[Any]:
191208
"""Project parent state to per-instance subgraph states.
192209
193210
Per spec §9.1:
194211
- items_field mode: one instance per item, item_field gets the item
195212
- count mode: ``count`` instances, item_field absent
196213
- both modes: inputs map parent fields onto subgraph state fields
214+
215+
``pre_resolved_count`` (proposal-0013 hook): if the engine has
216+
already resolved ``cfg.count`` to populate
217+
``NodeEvent.fan_out_config.item_count``, the resolved value is
218+
passed in here so the callable resolver isn't invoked twice.
219+
Only consulted in ``count`` mode; ``items_field`` mode reads
220+
from the state field unchanged.
197221
"""
198222
sub_state_cls = cfg.subgraph.state_cls
199223
parent_dump = parent_state.model_dump()
@@ -226,7 +250,10 @@ def _build_instance_states(
226250
return instances
227251

228252
# count mode
229-
resolved_count = _resolve_count(node_name, cfg, parent_state)
253+
if pre_resolved_count is not None:
254+
resolved_count = pre_resolved_count
255+
else:
256+
resolved_count = _resolve_count(node_name, cfg, parent_state)
230257
instances = []
231258
for _idx in range(resolved_count):
232259
init = {}

0 commit comments

Comments
 (0)