Orchestrata Ultimate › Plans › pipeline-event-convergence-design

Pipeline-Event Convergence — Design

Source: plans/pipeline-event-convergence-design.md at 8a2cb42ad · last changed 2026-08-19 · first committed 2026-08-05 · 415 lines.

Prose pending.

Plan text

Pipeline-Event Convergence — Design

Status: DESIGN ONLY (RUL-022 — no implementation in this artifact) Branch: feat/cxp-direct-transport · Date: 2026-08-05 · Author: analyst (design phase) Defect: two producers publish onto the UI topic pipeline.<thread_id> with two incompatible payload shapes. Producer B (kernel/pipeline_events.py::PipelineEventBus) is the SPEC-001-ratified taxonomy; Producer A (orchestrator/pipeline_kernel.py::PipelineKernelClient._emit_event) is an ad-hoc flat shape certified only by REQ-003-US-004-102, which contradicts SPEC-001. Every UI gadget and every shape-asserting test today matches Producer A. Producer A also carries a dead Redis fallback (pipeline:events) that nothing reads.

Every as-built claim below was grounded via the AST toolkit (scripts/serialize_codebase_ast.py {symbols,refs,source}) plus direct reads of the non-Python artifacts (gadget JS, specs, e2e). Specs are authoritative (RUL-013). Rulings consulted: RUL-002 (no mocks), RUL-003 (env from profile), RUL-006/024 (fix-on-sight; pre-existing is not a disposition — two extra defects found during grounding are IN scope: §1.5), RUL-009 (observed test runs), RUL-013 (specs win), RUL-022 (this document), RUL-023 (wired is done — §7).

Companion contract: plans/contracts/pipeline-event-convergence.mmd


1. AS-BUILT INVENTORY (verified, file:line)

1.1 Producer B — the ratified target

Component Location Facts
Taxonomy kernel/pipeline_events.py:33-51 PipelineEventType(StrEnum), @traces(spec="001", story="US-006", reqs=[REQ-006-031,032,033]) Exactly 9 members: pipeline.boot, .intent, .escalation, .swarm.start, .swarm.shard, .swarm.done, .review, .test.result, .complete. No members for start/stage/error/paused/resumed/cancelled.
Event shape kernel/pipeline_events.py:76-96 PipelineEvent dataclass {event_type, thread_id, volk, data: dict, timestamp: ISO-8601, iteration: int} — serialized with asdict() (emit, :138).
Emit + UI bridge PipelineEventBus.emit :131-162; _forward_to_ui :164-207 lpush to orchestrata:pipeline:events (audit list) then XADD flat fields (data JSON-encoded, iteration stringified :188) to gw:ringbuf:pipeline.<thread_id> (per-thread replay) and gw:ringbuf:pipeline.events (PIPELINE_STREAM_KEY, :67). All Redis calls are sync.
9 typed helpers emit_boot/intent/escalation/swarm_start/swarm_shard/swarm_done/review/test_result/complete :209-429 data payloads inventoried in §3.2.
Kernel wiring (live) refs PipelineEventBus: kernel/pipeline.py:129, kernel/pipeline_escalation.py:42, kernel/pipeline_session.py:81 The real kernel constructs and emits through the bus.
Gateway forwarder gateway/server.py:131-185 _pump_pipeline_events_once (test seam stream_key=, flake rationale :158-162); decoder _decode_pipeline_event :274-290; loop _consume_pipeline_events :841; init _init_pipeline_forwarder :633-641 Tails PIPELINE_STREAM_KEY, broadcasts each decoded payload on pipeline.<thread_id>. _init_pipeline_forwarder is called unconditionally in start() (:731), gated ONLY on the ring-buffer Redis client — it runs whether the kernel is gRPC or the in-process fallback.
Ratification specs/001-kernel/pipeline-event-type-addendum.md REQ-006-031: enum is the source of truth, "exactly 9". REQ-006-032: byte-for-byte wire compat (golden JSON). REQ-006-033: drift guard — emitted set == enum set exactly. Drift guard lives in tests/kernel/test_pipeline_events_enum.py (member map + len == 9 :55-72; reflective emitter completeness :~200-230; golden wire bytes :120-170).

1.2 Producer A — the retirement target

Component Location Facts
Emitter orchestrator/pipeline_kernel.py:479-513 _emit_event(thread_id, data), class @traces(spec="003", story="US-004", reqs=[REQ-003-US-004-102]) on _run_pipeline :160 If self._broadcast injected → await broadcast(f"pipeline.{thread_id}", data) and return. Else if self._redis → XADD {"data": json, "thread_id": ...} to PIPELINE_STREAM_KEY = "pipeline:events" (:63).
Emission sites (7 names, 10 sites) pipeline.error :182 (_run_pipeline), :296 (_execute_resume); pipeline.start :212; pipeline.stage :229, :271; pipeline.complete :243, :283; pipeline.paused :353; pipeline.resumed :387; pipeline.cancelled :413 Flat shape; exact fields per site inventoried in §3.1. Stage events are emitted retrospectively after runner.run() returns (:224-238) — pre-existing limitation, see §10.
Dead fallback :63 + :503-513 grep -rn "pipeline:events" over src/tests/scripts/vizint-ui: zero readers. The gateway forwarder reads gw:ringbuf:pipeline.events, not pipeline:events. Confirmed dead.
Production construction (the only one) refs PipelineKernelClient: gateway/server.py:613 (import), :618 (PipelineKernelClient(pubsub=self._pubsub, broadcast=self.broadcast)) No redis_client is ever passed in production — the Redis fallback branch is doubly dead. self._redis is used nowhere else in the class.
Certification specs/003-intent/checklists/requirements.md:260 REQ-003-US-004-102 Names the bespoke events and the pipeline:events stream verbatim — contradicts SPEC-001's "enum is the source of truth". Amended in Packet 4.

1.3 Delivery plumbing (shared, out of the fork)

1.4 Consumers (all match Producer A today)

Consumer Location Dispatch
CXPFeed gadget vizint-ui/gadgets/cxp-feed/gadget.js:366-421 _subscribePipeline payload.event ∈ {pipeline.stage, pipeline.complete, pipeline.error}; reads flat stage/status/duration_ms/artifact_count/overall_status/error/timestamp (+ never-emitted summary, details).
DebugPanel gadget vizint-ui/gadgets/debug-panel/gadget.js:57-97 _subscribePipeline Same three names; reads flat stage/status/duration_ms/intent/overall_status. payload.intent is read in the stage branch but only ever emitted on pipeline.start — a dead read (fixed by Packet 2b).
Orchestrator gadget vizint-ui/gadgets/orchestrator/gadget.js:49-52 (bus hook) + :104-127 _onPipelineEvent Same three names; reads flat stage/status/duration_ms/artifact_count/error/overall_status.
e2e shape assert vizint-ui/tests/e2e/pipeline-e2e.spec.js:180 expect(events[0].params.event).toMatch(/^pipeline\./) — and the whole spec runs with GATEWAY_GRPC_READY_TIMEOUT_S: '1' (:36), i.e. it only ever exercises the fallback producer.
Producer A unit tests tests/orchestrator/test_pipeline_kernel.py :207, :239, :287, :324-363 (full-pipeline shape block), :368-380 (_emit_event passthrough), :393-433 (Redis-fallback + noop tests) Assert event/stage/status/duration_ms/artifact_count/overall_status/stages_completed/total_artifacts/error flat.

1.5 Additional defects found while grounding (in scope per RUL-006/024)

# Defect Evidence
G1 tests/gateway/handlers/test_workflow.py also asserts Producer A's shape — missed by the source audit. :73 (data.get("event") == "pipeline.start" gate in the shared broadcast helper), :141, :202, :229, :265. → Packet 3b.
G2 iteration type forks by path: direct broadcast delivers int (asdict), the forwarder path delivers str (_forward_to_ui :188 stringifies; _decode_pipeline_event :283 _s() leaves it a string). gateway/server.py:281-284; seeded as "iteration": "0" in tests/kernel/test_pipeline_event_forwarder.py:128. → Packet 1f.

2. DECISION LOG

D1 — Fold mechanism: real PipelineEvents over the existing _broadcast seam (option b)

PipelineKernelClient constructs genuine PipelineEvent objects and delivers event.to_payload() over its already-injected self._broadcast — it does not instantiate a PipelineEventBus.

Rejecting option (a) (client owns a bus + gateway Redis, forwarder delivers) on three verified grounds:

  1. The forwarder is unconditional (gateway/server.py:731 → :633-641, gated only on the ring-buffer client), so a bus-writing fallback client is only dedup-safe if it stops broadcasting directly — at which point every fallback event pays a Redis round-trip + a pump poll window, and dies entirely when Redis is down (the direct path works Redis-less today).
  2. The production construction site passes no Redis client (gateway/server.py:618), and PipelineEventBus's Redis calls are synchronous (emit :149-155, _forward_to_ui :193-205) — calling them from the gateway's event loop would block it; the gateway's own Redis client is async and incompatible.
  3. Ordering: the direct path awaits each broadcast inline; the stream path adds a consumer hop. Stage-event ordering is a UI correctness property.

Dedup guarantee after this design: the fallback client only broadcasts (never writes the stream); the kernel bus only writes the stream (forwarder broadcasts). One event, one delivery, on either path. Packet 3a pins this with an exactly-once assertion.

D2 — Delete the dead fallback-fallback

PIPELINE_STREAM_KEY = "pipeline:events" (orchestrator/pipeline_kernel.py:63), the XADD branch (:503-513), the redis_client constructor parameter and self._redis attribute are all deleted. Evidence: zero readers repo-wide (§1.2); the only production constructor passes no Redis; cross-process delivery is Producer B's job (the kernel's own bus → gw:ringbuf:pipeline.events → forwarder). The three tests that exist solely to exercise the dead branch are deleted with it (Packet 3a).

D3 — Taxonomy: extend by 4 members (9 → 13); map start→INTENT and error→COMPLETE

The enum lacks homes for stage/paused/resumed/cancelled, and mapping them onto existing members would falsify their ratified semantics (ESCALATION carries from/to/subject, not status/duration_ms). Conversely start and error map faithfully onto existing members, so no START/ERROR members are added:

New members: STAGE = "pipeline.stage", PAUSED = "pipeline.paused", RESUMED = "pipeline.resumed", CANCELLED = "pipeline.cancelled". This is executed under the addendum's own drift rules: REQ-006-031's member list is amended to "exactly 13" (Packet 1d), the drift-guard test's member map / len / reflective-completeness blocks are extended (Packet 1c), and four new emit_* helpers keep the REQ-006-033 invariant (emitted set == enum set, no helper left behind). REQ-006-032's golden wire bytes are untouched (additive members only).

D4 — The shared serializer, named and placed

Two single-source seams, both in kernel/pipeline_events.py:

  1. PipelineEvent.to_payload(self) -> dict[str, Any] — the canonical broadcast serialization, implemented as exactly asdict(self) so the REQ-006-032 golden-bytes test also pins it. PipelineEventBus.emit :138 switches from inline asdict(event) to event.to_payload(); PipelineKernelClient._emit_event uses the same call.
  2. Module-level event factories build_intent_event, build_stage_event, build_complete_event, build_paused_event, build_resumed_event, build_cancelled_event — each returns a PipelineEvent with the canonical data shape. The bus helpers (emit_intent, emit_complete, and the four new ones) delegate to these factories; PipelineKernelClient constructs its events through the same factories. Data-shape drift between the two producers becomes structurally impossible.

D5 — Compat window: dual-emit legacy flat fields, fallback client only, one deploy cycle

D6 — Normalize iteration to int in the forwarder decoder

_decode_pipeline_event (gateway/server.py:274-290) gains an int(...)-with-fallback-0 cast for iteration, closing the G2 path fork (§1.5). The direct path already delivers int. Consumers found reading iteration: none — the fix is contract hygiene ahead of Packet 2's data.* convention.

D7 — Shipping-shape proof: a real-infra Python test of the Producer-B pipeline, plus the e2e flip

The only shape assertion today (pipeline-e2e.spec.js:180) runs with GATEWAY_GRPC_READY_TIMEOUT_S='1' (:36) — it can only ever see the fallback producer. Two-part fix (Packet 3c/3d):

D8 — Gadget dispatch rules (Packet 2 contract)


3. EVENT-NAME + PAYLOAD MAPPING

3.1 Producer A → canonical (the Packet 1e fold)

Legacy event (site) Canonical event_type volk Canonical data Legacy compat fields merged during window (D5)
pipeline.start (:212) INTENT "U:Cx" {intent: <text[:200]>} event="pipeline.start", stage="U:Cx", intent
pipeline.stage (:229, :271) STAGE (new) <stage alias> `{stage, status, duration_ms, artifact_count, error: str None}`
pipeline.complete (:243, :283) COMPLETE "" {success: overall_status=="resolved", reason: <overall_status.value>, stages_completed, total_artifacts} event="pipeline.complete", overall_status, stages_completed, total_artifacts
pipeline.error (:182, :296) COMPLETE "" {success: False, reason: "error", error: <str(e)[:500]>} event="pipeline.error", error
pipeline.paused (:353) PAUSED (new) "" {} event="pipeline.paused"
pipeline.resumed (:387) RESUMED (new) "" {} event="pipeline.resumed"
pipeline.cancelled (:413) CANCELLED (new) "" {} event="pipeline.cancelled"

Top-level on every event (from PipelineEvent): event_type, thread_id, volk, iteration (int, 0 here), timestamp (ISO-8601, dataclass default replaces _now_iso() — delete _now_iso if unused confirms no other caller).

3.2 Existing canonical data shapes (unchanged, for gadget follow-ups)

event_type volk data (from the emit_* bodies, :209-429)
pipeline.boot "" {volks: [..], count}
pipeline.intent "" (bus) / "U:Cx" (fallback) {intent}
pipeline.escalation from_volk {from, to, subject}
pipeline.swarm.start "U:Cd" {shard_count}
pipeline.swarm.shard "J:Junior" {shard_id, file, success}
pipeline.swarm.done "U:Cd" {succeeded, total}
pipeline.review reviewer {type, passed, cycle, issues}
pipeline.test.result "U:Qa" {passed, pass_count, fail_count, coverage_pct, duration_s}
pipeline.complete "" {success, reason} (+ fallback extras per §3.1)

3.3 Per-gadget field mapping (Packet 2)

CXPFeed (cxp-feed/gadget.js:366-421):

Old read New read
payload.event === 'pipeline.stage' payload.event_type === 'pipeline.stage'
payload.stage payload.data.stage
payload.status ('resolved'/'failed' → delivered/failed/pending) payload.data.status (same value mapping)
payload.duration_ms / payload.artifact_count payload.data.duration_ms / payload.data.artifact_count
payload.event === 'pipeline.complete' + payload.overall_status payload.event_type === 'pipeline.complete' && payload.data.success === true; subject from payload.data.reason; body {reason, stages_completed, total_artifacts}
payload.event === 'pipeline.error' + payload.error / payload.details event_type === 'pipeline.complete' && payload.data.success !== true; render payload.data.error || payload.data.reason; drop details (never emitted)
payload.summary (never emitted) drop
payload.timestamp payload.timestamp (unchanged)
— SHOULD: pipeline.paused/resumed/cancelled → feed rows, status pending/pending/failed

DebugPanel (debug-panel/gadget.js:57-97):

Old read New read
payload.event dispatch payload.event_type
stage push {stage, status, duration_ms} payload.data.stage/.status/.duration_ms
payload.intent inside the stage branch (dead read) NEW branch: event_type === 'pipeline.intent' → thread.intent = payload.data.intent
complete: payload.overall_status === 'resolved' payload.data.success === true
'pipeline.error' branch → failed 'pipeline.complete' with data.success !== true → failed
— NEW: paused/resumed/cancelled → thread.status = 'paused'/'running'/'cancelled'

Orchestrator (orchestrator/gadget.js:104-127 _onPipelineEvent(topic, data)):

Old read New read
data.event dispatch data.event_type
stage push {stage, status, duration_ms, artifact_count, error} data.data.stage/.status/.duration_ms/.artifact_count/.error
complete: data.overall_status === 'resolved' data.data.success === true
'pipeline.error' branch 'pipeline.complete' with data.data.success !== true (render data.data.error)
— NEW: paused/resumed/cancelled → thread.status = 'paused'/'running'/'cancelled'

4. PACKET BRIEFS (each ≤5-min dispatch, one-file-one-fix; dependency order as listed)

Atomicity constraint: 1a-1d must land in ONE commit — the enum/drift-guard/ addendum triangle (REQ-006-031's "exactly N" appears in all three) goes red in any partial state. 1e/1f may follow in separate commits. Packets 2 and 3 depend on 1; 2a/2b/2c and 3a/3b/3c/3d are mutually independent (parallel-safe). Packet 4 depends only on 1e. Packet 5 is deferred one deploy cycle.

Packet 1 — root fix (kernel taxonomy + producer fold)

Packet 2 — gadget convergence (one file each, mapping tables §3.3)

Packet 3 — test convergence

Packet 4 — REQ-003-US-004-102 amendment (specs/003-intent/checklists/requirements.md:260), replacement text:

REQ-003-US-004-102: _run_pipeline and the workflow-control facade publish their lifecycle events as SPEC-001 PipelineEvents — event_type drawn from the PipelineEventType taxonomy (specs/001-kernel/pipeline-event-type-addendum.md, REQ-006-031): pipeline.intent at run start, pipeline.stage per stage result, pipeline.complete on finish (success or failure, discriminated by data.success / data.error), and pipeline.paused / pipeline.resumed / pipeline.cancelled from pause/resume/cancel — serialized via the shared PipelineEvent.to_payload() and broadcast in-process on pipeline.<thread_id> through the gateway-injected broadcast callable. No bespoke event names; no parallel Redis stream.

Gate: python scripts/check_req_format.py, python scripts/trace_intel.py untraced-reqs.

Packet 5 — compat removal (NEXT deploy cycle, trigger per D5) — orchestrator/pipeline_kernel.py: delete _legacy_compat_fields + _EMIT_LEGACY_COMPAT; re-run 3a + 3d gates asserting canonical-only.


5. WIRING PLAN (RUL-023 / Hard Rule 8)

No new production module is introduced; every edit lands inside code that already has production importers, verified via refs/who-imports:

6. RISK REGISTER

# Risk Mitigation Owner packet
R1 Double delivery during/after the compat window (forwarder is always on) D1 topology: fallback never writes the stream, bus never direct-broadcasts; exactly-once assertion pinned in tests 1e / 3a
R2 Stale browser tabs (old gadget JS) render nothing after the producer flip D5 full flat-field compat merge for one deploy cycle; removal only via Packet 5 trigger 1e / 5
R3 Drift-guard/spec/enum triangle goes red in a partial landing 1a-1d single-commit atomicity constraint (§4 header) 1a-1d
R4 to_payload() drifts from the ratified wire bytes Implemented as asdict(self); REQ-006-032 golden-bytes tests unchanged and must stay green 1a / 1c
R5 iteration int-cast breaks a consumer relying on the string form Sweep found zero iteration consumers; forwarder test updated to pin int 1f
R6 Missed legacy-shape consumer beyond the audit Repo-wide sweep §1.5 found exactly one (test_workflow.py, now Packet 3b); Packet 3d keeps the browser-level assert on the canonical field so any straggler surfaces in e2e 3b / 3d
R7 New lifecycle members emitted by the fallback but never by the real kernel → taxonomy members without kernel-side emitters Acceptable by REQ-006-033 (the guard binds enum ↔ bus helpers, exercised in tests, not kernel call sites); kernel-side pause/resume/cancel adoption noted as follow-up §10 —
R8 e2e still only proves the fallback path D7: 3c proves the Producer-B halves (bus serializer + production pump) on real Redis in the default suite — which always runs the real kernel (start_test_services required=True) 3c

7. OUT OF SCOPE / NAMED FOLLOW-UPS