Skip to content

Commit 62f6c66

Browse files
Implement proposal 0044 (parallel-branches dispatch span)
Adds per-branch OTel dispatch spans to ParallelBranchesNode, mirroring the FanOut topology. Each branch gets its own dispatch span anchored on the parallel-branches node, with inner branch work parented under it. Augmentation and metadata-update lineage extend through these spans per the ancestor-chain rule (spec 0040 / 0045). Renames the existing node attribute `openarmature.branch_name` to `openarmature.node.branch_name` to align with the spec's attribute namespace conventions. Adds `openarmature.parallel_branches.branch_count` and `openarmature.parallel_branches.error_policy` to the node span. Bumps the spec pin to v0.36.0 and activates conformance fixture 038. Includes regression coverage for parent-resolution under the dispatch span, retry-middleware per-attempt synthesis, nested fan-out topology, and metadata augmentation with sibling-skip semantics.
1 parent fafe911 commit 62f6c66

13 files changed

Lines changed: 1051 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,14 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
1818

1919
The `Observer.__call__` signature widens to `NodeEvent | MetadataAugmentationEvent | InvocationStartedEvent | InvocationCompletedEvent`. The new `ObserverEvent` type alias (re-exported from `openarmature.graph`) gives observer authors a one-name handle on the union; existing observers that ignore non-`NodeEvent` variants early-return after an `isinstance(event, NodeEvent)` check.
2020
- **`LangfuseTrace.input` / `LangfuseTrace.output` dataclass fields** on the in-memory recorder, populated by the new observer paths.
21+
- **Parallel-branches OTel dispatch span synthesis** (proposal 0044, observability §5.7). Mirroring the fan-out per-instance dispatch synthesis (proposal 0013), the OTel observer now synthesizes a per-branch dispatch span between the parallel-branches NODE span and each branch's inner-node spans. New `ParallelBranchesEventConfig` payload on `NodeEvent` (`branch_names`, `branch_count`, `error_policy`, `parent_node_name`); engine populates it on the parallel-branches NODE's `started` / `completed` events. New OTel span attributes:
22+
- `openarmature.parallel_branches.branch_count` + `openarmature.parallel_branches.error_policy` on the parallel-branches NODE span.
23+
- `openarmature.node.branch_name` + `openarmature.parallel_branches.parent_node_name` on each per-branch dispatch span.
24+
- `openarmature.node.branch_name` on every inner-node span beneath a per-branch dispatch span.
25+
26+
### Changed (breaking, pre-1.0)
27+
28+
- **OTel span attribute `openarmature.branch_name` is renamed to `openarmature.node.branch_name`** to align with the spec §5.7 attribute namespace. Prior python releases emitted `openarmature.branch_name` as a workaround because the spec hadn't defined an OTel attribute carrying `branch_name` yet; proposal 0044 (v0.36.0) formalizes the namespace. **Downstream dashboards, queries, or alerts filtering on the old attribute name MUST update.** Pre-1.0 break; the prior name was python-implementation-only and was never spec-normative.
2129

2230
### Changed
2331

@@ -32,7 +40,7 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
3240

3341
### Notes
3442

35-
- **Pinned spec version bumped from v0.31.0 to v0.35.0.** Absorbs proposals 0042 (reserved-key extension), 0043 (Langfuse trace.input/output sourcing), and the textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented).
43+
- **Pinned spec version bumped from v0.31.0 to v0.36.0.** Absorbs proposals 0042 (reserved-key extension), 0043 (Langfuse trace.input/output sourcing), 0044 (parallel-branches OTel dispatch span), and the textual additions in v0.32.0 (Gemini wire-format mapping, 0038, not yet implemented) and v0.33.0 (sessions capability, 0020, not yet implemented).
3644
- `LangfuseSDKAdapter` now applies `trace.input` / `trace.output` to the live Langfuse Trace. Input lands on the first real observation under the trace via `set_trace_io`; output uses a synthetic short-lived `openarmature.trace_io` observation as the carrier. The InMemoryLangfuseClient used by tests applies the fields directly.
3745
- Conformance fixture `observability/conformance/037-langfuse-trace-input-output` activated for all five cases (default stub / `disable_state_payload=False` / hooks non-null / hooks null-fallthrough / resume re-fire). The langfuse harness grew per-case `checkpointer: in_memory` wiring, a compact `flaky:` test seam, and a two-phase resume-flow assertion path.
3846
- The Langfuse v4 SDK marks `set_current_trace_io` / `Span.set_trace_io` deprecated ("removal in a future major version"). Empirical verification against Langfuse Cloud (v4.7.1, 2026-05-29) confirms it remains the **only** path that populates the Traces list view's headline `Input` / `Output` columns; `propagate_attributes(metadata=...)` does not substitute for it in the current UI. We will revisit when Langfuse publishes a concrete migration guide for v5.

conformance.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232

3333
[manifest]
3434
implementation = "openarmature-python"
35-
spec_pin = "v0.35.0"
35+
spec_pin = "v0.36.0"
3636

3737
# Status values:
3838
# implemented — shipped behavior matches the proposal's contract
@@ -210,3 +210,8 @@ since = "0.11.0"
210210
[proposals."0043"]
211211
status = "implemented"
212212
since = "0.11.0"
213+
214+
# Spec v0.36.0 (proposal 0044).
215+
[proposals."0044"]
216+
status = "implemented"
217+
since = "0.11.0"

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Specification = "https://github.com/LunarCommand/openarmature-spec"
5858
openarmature = "openarmature.cli:main"
5959

6060
[tool.openarmature]
61-
spec_version = "0.35.0"
61+
spec_version = "0.36.0"
6262

6363
[dependency-groups]
6464
dev = [

src/openarmature/AGENTS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# OpenArmature — Agent documentation
22

3-
*This is the agent guide bundled with the openarmature Python package, version 0.10.0 (spec v0.35.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
3+
*This is the agent guide bundled with the openarmature Python package, version 0.10.0 (spec v0.36.0). For the full docs site see [openarmature.ai](https://openarmature.ai). For the canonical spec text see [openarmature.org/capabilities](https://openarmature.org/capabilities/). For project-specific conventions for the code you're editing, see the host project's `AGENTS.md` or `CLAUDE.md`.*
44

55
## TL;DR
66

@@ -10,7 +10,7 @@ OpenArmature is a workflow framework for LLM pipelines and tool-calling agents
1010

1111
## Capability contracts
1212

13-
_Sourced from openarmature-spec v0.35.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
13+
_Sourced from openarmature-spec v0.36.0. Each entry below reproduces §1 (Purpose) and §2 (Concepts) of the capability's `spec.md`. For the full spec text (execution model, error semantics, determinism, observer hooks, etc.) see the linked docs site._
1414

1515
### Capability: `graph-engine`
1616

src/openarmature/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,4 @@
2525
"""
2626

2727
__version__ = "0.10.0"
28-
__spec_version__ = "0.35.0"
28+
__spec_version__ = "0.36.0"

src/openarmature/graph/compiled.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@
100100
InvocationCompletedEvent,
101101
InvocationStartedEvent,
102102
NodeEvent,
103+
ParallelBranchesEventConfig,
103104
)
104105
from .middleware import ChainCall, Middleware, compose_chain
105106
from .nodes import Node
@@ -1985,6 +1986,29 @@ async def _step_parallel_branches_node(
19851986
attempt_counter: list[int] = [0]
19861987
deferred_info: list[tuple[int, StateT, StateT] | None] = [None]
19871988

1989+
# Per proposal 0044 (observability §5.7, v0.36.0): the
1990+
# resolved parallel-branches configuration is static at
1991+
# compile time (no count / concurrency resolvers like fan-out
1992+
# has), so build the event config once here and ship it on
1993+
# both started + completed events. ``branch_names`` mirrors
1994+
# the dispatch order ParallelBranchesNode uses internally
1995+
# (insertion order of the ``branches`` dict per pipeline-
1996+
# utilities §11.1).
1997+
#
1998+
# Python dicts preserve insertion order (PEP 468; guaranteed
1999+
# since 3.7), and YAML / direct-dict-literal ``branches``
2000+
# construction at the call site preserves the source order
2001+
# through into the dict's keys(). Spec §11.1 ties branch
2002+
# declaration order to dispatch order, so this tuple IS the
2003+
# declaration order observers should see.
2004+
branch_names: tuple[str, ...] = tuple(node.branches.keys())
2005+
parallel_branches_event_config = ParallelBranchesEventConfig(
2006+
branch_names=branch_names,
2007+
branch_count=len(branch_names),
2008+
error_policy=node.error_policy,
2009+
parent_node_name=current,
2010+
)
2011+
19882012
async def innermost(s: Any) -> Mapping[str, Any]:
19892013
attempt_counter[0] += 1
19902014
# Read from ContextVar — see ``_step_function_node``'s
@@ -1998,6 +2022,7 @@ async def innermost(s: Any) -> Mapping[str, Any]:
19982022
step,
19992023
s,
20002024
attempt_index=attempt_index,
2025+
parallel_branches_config=parallel_branches_event_config,
20012026
)
20022027
otel_token = _attach_active_observer_span()
20032028
try:
@@ -2012,6 +2037,7 @@ async def innermost(s: Any) -> Mapping[str, Any]:
20122037
s,
20132038
error=e,
20142039
attempt_index=attempt_index,
2040+
parallel_branches_config=parallel_branches_event_config,
20152041
)
20162042
raise
20172043
except Exception as e:
@@ -2024,6 +2050,7 @@ async def innermost(s: Any) -> Mapping[str, Any]:
20242050
s,
20252051
error=wrapped,
20262052
attempt_index=attempt_index,
2053+
parallel_branches_config=parallel_branches_event_config,
20272054
)
20282055
raise wrapped from e
20292056
finally:
@@ -2041,6 +2068,7 @@ async def innermost(s: Any) -> Mapping[str, Any]:
20412068
s,
20422069
error=e,
20432070
attempt_index=attempt_index,
2071+
parallel_branches_config=parallel_branches_event_config,
20442072
)
20452073
raise
20462074

@@ -2094,6 +2122,7 @@ def finalize_completed(edge_error: RuntimeGraphError | None) -> None:
20942122
final_pre_state,
20952123
post_state=final_merged,
20962124
attempt_index=final_attempt_index,
2125+
parallel_branches_config=parallel_branches_event_config,
20972126
)
20982127
else:
20992128
self._dispatch_completed(
@@ -2104,6 +2133,7 @@ def finalize_completed(edge_error: RuntimeGraphError | None) -> None:
21042133
final_pre_state,
21052134
error=edge_error,
21062135
attempt_index=final_attempt_index,
2136+
parallel_branches_config=parallel_branches_event_config,
21072137
)
21082138

21092139
return _StepResult(state=merged_outer, finalize_completed=finalize_completed)
@@ -2118,6 +2148,7 @@ def _dispatch_started(
21182148
*,
21192149
attempt_index: int = 0,
21202150
fan_out_config: FanOutEventConfig | None = None,
2151+
parallel_branches_config: ParallelBranchesEventConfig | None = None,
21212152
) -> None:
21222153
# Per graph-engine §6 + pipeline-utilities §11: read the
21232154
# active branch_name (set by ParallelBranchesNode inside
@@ -2140,6 +2171,7 @@ def _dispatch_started(
21402171
attempt_index=attempt_index,
21412172
fan_out_index=context.fan_out_index,
21422173
fan_out_config=fan_out_config,
2174+
parallel_branches_config=parallel_branches_config,
21432175
branch_name=current_branch_name(),
21442176
subgraph_identities=context.subgraph_identities,
21452177
caller_invocation_metadata=current_invocation_metadata(),
@@ -2158,6 +2190,7 @@ def _dispatch_completed(
21582190
error: RuntimeGraphError | None = None,
21592191
attempt_index: int = 0,
21602192
fan_out_config: FanOutEventConfig | None = None,
2193+
parallel_branches_config: ParallelBranchesEventConfig | None = None,
21612194
) -> None:
21622195
from openarmature.observability.correlation import current_branch_name # noqa: PLC0415
21632196

@@ -2175,6 +2208,7 @@ def _dispatch_completed(
21752208
attempt_index=attempt_index,
21762209
fan_out_index=context.fan_out_index,
21772210
fan_out_config=fan_out_config,
2211+
parallel_branches_config=parallel_branches_config,
21782212
branch_name=current_branch_name(),
21792213
subgraph_identities=context.subgraph_identities,
21802214
caller_invocation_metadata=current_invocation_metadata(),

src/openarmature/graph/events.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,52 @@ class FanOutEventConfig:
7777
parent_node_name: str
7878

7979

80+
# Spec: realizes observability §5.7 parallel-branches attributes via
81+
# the event-payload mechanism added by proposal 0044 (v0.36.0).
82+
# Backend observers cache ``parent_node_name`` off the parallel-
83+
# branches node's started event and apply it on every per-branch
84+
# dispatch span they synthesize (observability §5.7 mandates
85+
# ``openarmature.parallel_branches.parent_node_name`` on per-branch
86+
# dispatch spans). Mirrors :class:`FanOutEventConfig`'s shape.
87+
@dataclass(frozen=True)
88+
class ParallelBranchesEventConfig:
89+
"""Resolved parallel-branches configuration carried on a parallel-
90+
branches node's own events.
91+
92+
Parallel-branches node events carry the resolved configuration so
93+
backend observers can attribute the parallel-branches node span
94+
(``branch_count`` / ``error_policy``) and synthesize per-branch
95+
dispatch spans with the right ``branch_names`` + ``parent_node_name``.
96+
97+
Populated ONLY on ``started`` and ``completed`` events for a
98+
parallel-branches node itself (partition by node type, not event
99+
category; INCLUDES retried attempts of a parallel-branches node
100+
when retry middleware wraps it). All other events leave
101+
``NodeEvent.parallel_branches_config`` null.
102+
103+
Field shapes:
104+
105+
- ``branch_names``: non-empty ordered tuple of strings. The branch
106+
identifiers in declaration / dispatch order, as configured on
107+
the parallel-branches node (pipeline-utilities §11.1).
108+
- ``branch_count``: positive int. Equals ``len(branch_names)``.
109+
Surfaced explicitly so observers don't have to derive it.
110+
- ``error_policy``: one of ``"fail_fast"`` or ``"collect"`` (per
111+
pipeline-utilities §11.5).
112+
- ``parent_node_name``: the parallel-branches node's name in the
113+
parent graph. Carried here for caching by backend observers
114+
when attributing per-branch dispatch spans.
115+
116+
All four fields MUST be present when ``parallel_branches_config``
117+
is populated.
118+
"""
119+
120+
branch_names: tuple[str, ...]
121+
branch_count: int
122+
error_policy: str
123+
parent_node_name: str
124+
125+
80126
# Spec: realizes graph-engine §6 NodeEvent (started/completed pair
81127
# model from proposal 0005, v0.6.0). The ``checkpoint_saved`` phase
82128
# is the Python shape for §10.8 save events (§10.8 SHOULDs an event
@@ -190,6 +236,17 @@ class NodeEvent:
190236
attempt_index: int = 0
191237
fan_out_index: int | None = None
192238
fan_out_config: FanOutEventConfig | None = None
239+
# Per observability §5.7 / proposal 0044 (v0.36.0): resolved
240+
# parallel-branches configuration carried on the parallel-branches
241+
# NODE's own events (mirroring ``fan_out_config`` on a fan-out
242+
# NODE's events). Populated on both ``started`` and ``completed``
243+
# events for a parallel-branches NODE (including retried
244+
# attempts); absent on every other event. Carries the §5.7
245+
# branch_count + error_policy + parent_node_name surface so
246+
# backend observers can attribute the parallel-branches NODE span
247+
# and synthesize per-branch dispatch spans without re-reading the
248+
# graph's static config.
249+
parallel_branches_config: ParallelBranchesEventConfig | None = None
193250
# Per pipeline-utilities §11 / graph-engine §6 (proposal 0011):
194251
# optional non-empty string populated only on events from nodes
195252
# that execute inside a parallel-branches branch. The
@@ -358,4 +415,5 @@ class InvocationCompletedEvent:
358415
"InvocationStartedEvent",
359416
"MetadataAugmentationEvent",
360417
"NodeEvent",
418+
"ParallelBranchesEventConfig",
361419
]

0 commit comments

Comments
 (0)