Hierarchical-supervisor workflow model — independent alternative proposal
Status: independent second-opinion design (Fable, xhigh reasoning). This document was produced as a deliberately independent analysis, from the source code only, to compare against a separate proposal authored in parallel by another model. It has not read that proposal or its PR. Bare module paths below (e.g.
services/orchestrator.py,workflow/engine.py) live underapps/api/src/bloom/in the monorepo.
0. TL;DR
A Bloom project is modeled as one workflow-graph instance with one current_node pointer
(workflow/engine.py:29), which is structurally unable to represent steady-state execution — N
tickets coding, M PRs in review, a plan revision pending, all at once. Meanwhile the backend
already persists a concurrent per-entity state machine for each of those work items
(engine_runs, specialist_reviews, pending_plan_change, pending_decision,
pending_client_approvals). The single graph is a lossy 1-D projection of an N-dimensional
reality the system already tracks.
This proposal's core claim, and where it may differ from the expected approach: the
hierarchical-supervisor model should be introduced as a read model first, not as a runtime.
Formalize a supervisor flow plus per-work-item child flow instances as declared specs +
deterministic projections of the state that already exists, ship that end-to-end (API + UI +
events), and only then — behind an ADR gate with explicit criteria — move the one genuinely
imperative control-flow loop (review → rework → escalate) onto real child-flow runtime machinery.
The recommended eventual runtime is the built-in engine's own reserved subworkflow node, not
LangGraph subgraphs, not Temporal, and not an actor framework. The just-shipped #464 fix already
chose "derive graph truth from persisted state" over "move the pointer"; this design is the
systematic completion of that choice.
1. Problem statement, grounded in the code
1.1 One run, one pointer
The whole project lifecycle is a single RunState:
class RunState(BaseModel): # workflow/engine.py:29
thread_id: str
current_node: str # exactly one pointer
status: RunStatus = RunStatus.RUNNING # running | suspended | done
state: dict[str, Any]
waiting_on: str | None # node id the run is suspended on
processed_keys: list[str] # idempotency fence
It is persisted as one JSONB row per project in bloom_runs
(persistence/supabase_store.py, _SCHEMA), and driven by the ~100-line built-in interpreter
(workflow/engine.py:79, Engine.advance) over the declarative spec
apps/server/workflows/bloom-sdlc.workflow.json.
The spec's front half is genuinely sequential and the model fits it perfectly: await_message → requirement_discovery → ask_clarification⟲ → prd_authoring → request_prd_approval → freeze_prd → milestone_planning → ticket_generation → sync_milestones → sync_tickets → notify_planning_ready.
Then the project enters steady state, and the model stops fitting:
// bloom-sdlc.workflow.json:359-366
{
"id": "await_project_event",
"type": "event_wait",
"title": "Await project activity",
"phase": "execution",
"description": "Parks the long-lived run between planning and ongoing lifecycle events
(PRs, issue changes, or a new user message)."
}
Post-planning, waiting_on == "await_project_event" for the rest of the project's life. It is
even the durable definition of "active project": the scheduler discovers tickable projects with
WHERE state->>'waiting_on' = 'await_project_event' (supabase_store.py, _LIST_ACTIVE;
store.py, list_active_threads). The pointer never represents steady-state work; it represents
the absence of a representation.
1.2 The system already tracks the N-dimensional reality
Everything that actually happens mid-project is recorded in per-entity state machines inside
run.state, mutated by orchestrator code paths that never move the pointer:
| Persisted field (spec decl) | Keyed by | Per-entry state machine | Written by |
|---|---|---|---|
engine_runs (workflow.json:116, merge reducer) | issue number | EngineRunState: ready → in_progress → pr_opened ⇄ changes_requested → done, plus blocked, failed, needs_attention; with attempt, retries, failure_kind, last_attempt_at (engine/base.py:39-55,163-213) | _record_engine_run (orchestrator.py:2924), _apply_engine_report (:2885) |
specialist_reviews (workflow.json:115, merge reducer) | PR number | {marker, round, github_output_marker} — content-addressed review rounds per PR (_run_specialist_reviews, orchestrator.py:2252) | review path |
pending_plan_change (workflow.json:122) | singleton | proposed → approved/discarded (_propose_plan_change writes it at orchestrator.py:3936; _resolve_plan_change/_apply_plan_change clear it at :3962/:3995) | monitoring chat path |
pending_decision (workflow.json:123) + decision_log (:124) | singleton + append log | drafted → approved/declined → committed to docs/decisions/ | decision gates (M14) |
pending_client_approvals | undeclared (see §1.4) | list of {number, title, kind, pr_number, ...} sign-offs parked for the owner | _park_for_client / _resolve_client_approval (orchestrator.py:1363,1525) |
pending_lifecycle (workflow.json:121) | singleton | archive/delete awaiting confirmation | lifecycle path |
Concurrency in the write path already exists too: engine runs execute as durable
engine_implement jobs without the thread lock (perform_engine_implement,
orchestrator.py:3177-3219, "the engine run happens WITHOUT the thread lock … the lock is taken
only to record the run"), fenced by per-attempt event keys
(engine_implement:{thread}:{issue}:{seq}, orchestrator.py:3156). Multiple tickets genuinely
run at once; multiple PRs genuinely sit in review rounds at once.
So the premise of a hierarchical model is not "we need to build concurrent state." It is: the concurrent state exists, is durable, and is invisible in the workflow representation.
1.3 What #464 fixed, and the ceiling it hit
Commit e67bd03 (#464) made the Workflow tab honest at node granularity.
_active_node_ids (orchestrator.py:5027-5055) derives the active set from persisted signals
instead of the parked pointer:
prd.status == "revising"orpending_plan_change→ lightsprd_authoringprd.status == "awaiting_approval"orpending_decision→ lightsrequest_prd_approval- any open PR in the live VCS snapshot → lights
review_pull_requests - any open issue with
status:in-progressor aneng:*label → lightstrack_progress - the bare park node reads active only when the project is genuinely idle
_node_status (orchestrator.py:5058) then colors each node active/done/pending, with
done derived from a phase index (_current_phase_index, :5019, over _PHASE_ORDER, :5016)
so completed phases never regress. _build_graph (:4705) serves this through
project_detail (:4668) → GET /api/projects/{thread_id} (api/routes/projects.py:137), and
the frontend renders exactly what it is handed — SdlcGraph
(apps/web/src/components/sdlc-graph.tsx) maps node.status straight to styles, with no
client-side inference. test_concurrent_activity_lights_multiple_nodes_at_once
(tests/unit/test_graph_status.py:96) pins the multi-active behavior.
The ceiling: #464 lights category nodes, not instances. Three tickets implementing + two PRs in specialist review + one plan revision pending renders as three highlighted nodes — identical to one ticket + one PR + one revision. Cardinality, identity, and per-item position (attempt 2 of 3, rework round 2, escalated-awaiting-human) are all persisted (§1.2) and all invisible. That is precisely the lossy 1-D projection this design removes.
1.4 The model is already leaking
Two code facts show the declared single-graph model losing to reality, not just failing to render it:
- Schema drift.
apply_writerefuses writes to undeclared state fields (workflow/reducers.py:48-50,KeyError("write to undeclared state field")) — yetpending_client_approvals, a durable per-entity approval queue, is absent from the spec's 37 declared fields and is mutated by direct dict assignment to bypass the reducer layer (state["pending_client_approvals"] = pending,orchestrator.py:1363,1525). A whole class of concurrent work item exists outside the workflow model entirely. - The engine reserves the concept but refuses it. The spec schema defines a
subworkflownode type with{subworkflow, ref, input, output}config (workflows/schema/workflow.schema.json:355-365), and the built-in engine explicitly raisesNotImplementedError("subworkflow nodes are not supported by this engine")(workflow/engine.py:164-165). The architecture anticipated hierarchical composition; it was never wired.
2. Target model: supervisor + child flow instances
2.1 Shape
One supervisor flow per project — the existing bloom-sdlc graph, unchanged in the front half
— plus child flow instances, one per durable work item, each an instance of a small declared
child spec:
project (thread_id)
└─ supervisor: bloom-sdlc (the existing RunState; pointer semantics unchanged)
├─ ticket:12 (bloom-ticket) queued → implementing → in_review ⇄ rework → done
│ ↘ blocked / failed(retrying) / escalated
├─ ticket:17 (bloom-ticket) …
├─ pr:34 (bloom-pr-review) opened → specialist_round(n) → verdict → merged/changes/escalated
├─ plan:rev-5 (bloom-plan-change) proposed → awaiting_approval → applied | discarded
├─ decision:d7 (bloom-decision) drafted → awaiting_sign_off → committed | declined
└─ approval:26 (bloom-client-approval) parked → approved | changes_requested
A child flow instance is (kind, key) → (spec_id, current_node, status, attempt/round, failure_kind, links, updated_at). The supervisor's steady-state park node gains an explicit
invariant it already has implicitly (asserted in test_graph_status.py:30-34,96-104):
await_project_eventis active iff the project has zero live child flow instances.
2.2 Mapping to existing state (the load-bearing table)
The child instances are a deterministic projection of state that already exists. No new write path is required to produce them:
| Child kind | Source of truth today | Instance key | Node derivation |
|---|---|---|---|
ticket | engine_runs[str(issue)] (+ live labels snapshot for cross-check) | issue number | EngineRunState → child node 1:1: ready→queued, in_progress→implementing, pr_opened→in_review, changes_requested→rework, blocked→blocked, failed→failed (with retries vs policy → "retrying"), needs_attention→escalated, done→done |
pr_review | open PRs in _StatusSnapshot + specialist_reviews[str(pr)] + review-round count (_count_changes_requested, cf. _apply_review_to_github, orchestrator.py:2361-2438) | PR number | opened → specialist_round (round from cached marker/round) → verdict; round cap review_max_rounds (+minor extra) → escalated |
plan_change | pending_plan_change (+ prd.revision, decision_log) | rev-{revision} | non-empty → awaiting_approval; cleared with prd bump → applied; cleared without → discarded |
decision | pending_decision + decision_log | decision id | pending → awaiting_sign_off; in log → committed |
client_approval | pending_client_approvals[] | issue number | present → parked; resolved via _resolve_client_approval → terminal |
lifecycle | pending_lifecycle | op name | present → awaiting_confirmation |
Two entries in this table deserve emphasis because they shape the runtime decision later:
- The ticket child's state machine is already a formal enum with documented transitions
(
EngineRunState,engine/base.py:39-55— the docstring literally narrates the graph: "ready→in_progress→pr_openedis the happy path…"). The child spec is a transcription, not an invention. - The coordination truth for tickets is GitHub labels, not run state —
status:*/eng:*labels driveCoordinator.reconcile(services/coordinator.py, and the LangGraph spikeservices/langgraph_coordinator.py). The existing sketch calls this out as the central impedance mismatch (docs/design/langgraph-sketch.md§7: "the source of truth for ticket status is GitHub issue labels, notBloomState"). Any child-flow design that tries to make child runtime state the truth for tickets will fight the reconciler; a projection that reads both and renders disagreement (see §6.3) works with it.
2.3 What "child flow" means at each maturity level
To keep the debate honest, this design distinguishes three levels, and recommends stopping at L1 until an explicit gate is passed:
- L0 — projected instance (read model). Child specs exist as data; instances are computed at read time from §2.2. Zero new write-path machinery.
- L1 — declared + validated. Child specs are first-class
*.workflow.jsondocuments validated by the existing loader (workflow/spec.py:143,load_spec+validate_references); the projection's node-mapping is contract-tested against both the child spec's node ids and the source enums, so drift fails CI. Still zero write-path machinery. - L2 — executed child runs. Child instances become real
RunStaterows driven by the engine'ssubworkflownode; transitions are effected by the child engine rather than inferred. This is where runtime machinery enters, and it is gated (§5).
3. Runtime options analysis
Four candidate runtimes for L2 (and implicitly, for how much of L0/L1 they'd let you skip). Scores are 1–5, higher = better for Bloom specifically, judged from the code as it stands.
(a) LangGraph subgraphs
Compile the coordination loop as a LangGraph subgraph per the existing sketch
(docs/design/langgraph-sketch.md §3), and model each ticket/PR as a subgraph invocation or
Send-based fan-out; checkpointer for durable suspend/resume.
- Fit 3/5. The rework loop (
review → changes_requested → rework → re-review → escalate, today spread across_apply_review_to_github,_flag_ticket_for_changes,_rework_with_engine,_escalate_ticket) genuinely is a graph and would gain legibility. But LangGraph's subgraph model shares/checkpoints state along one thread; N long-lived, independently-suspending children per project pushes toward N LangGraph threads — at which point you've rebuilt the supervisor/child split yourself, on a heavier substrate. The spike's own conclusion stands: the graph models control flow, labels still hold the truth. - Migration cost/risk 2/5. Real rewrite of
engine.py+ spec loader + orchestrator dispatch (sketch §7); theWorkflowContextseam helps but HITL (interrupt()) and checkpointer adoption touch every suspend point. The existing spike (LangGraphCoordinator, parity-tested) de-risks exactly one deterministic pass — the easiest slice. - Operational burden 3/5. In-process library, Postgres checkpointer can reuse the pool; but a
second persistence format (checkpoints) beside
bloom_runs+ the event store. - New dependencies 3/5. Already a dependency (spike), but promoted from optional flag to load-bearing.
- Testability 4/5. Graphs are unit-testable; the spike proved parity testing works.
- Honest-concurrent-view payoff 2/5. Almost none by itself: the view still has to be built
from state, because ticket truth stays in labels/
engine_runs. You pay for a runtime and still need this design's projection.
(b) Temporal parent/child workflows + signals
One parent workflow per project; child workflow per ticket/PR; webhooks become signals; retries and timeouts become Temporal policies.
- Fit 4/5. Conceptually the cleanest match — parent/child, signals, per-child retry policies, infinite-duration workflows are exactly Temporal's shape.
- Migration cost/risk 1/5. Total replatform. Everything Bloom just built is a hand-rolled
equivalent of what Temporal provides: the durable job queue + atomic claim
(
persistence/jobs.py), theprocessed_keys/event-key idempotency fences, the scheduler heartbeat (services/scheduler.py), suspend/resume viawaiting_on. Migrating means rewriting the orchestrator's ~5,200 lines of effectful paths into activities, re-solving determinism constraints, and dual-running during cutover. - Operational burden 1/5. A Temporal server cluster (or paid cloud) beside a currently single-Postgres deployment; new failure domain; new on-call surface.
- New dependencies 1/5. The heaviest possible.
- Testability 3/5. Good harnesses exist, but the test suite (unit + integration + E2E,
including
test_coordination_flow.py,test_plan_evolution.py) would largely need re-founding. - Honest-concurrent-view payoff 3/5. Temporal's UI shows child workflows — for operators. The product's Workflow tab still needs its own projection.
(c) Actor model (the "Collaborator Actor Model" direction)
Extend the M7 direction (docs/milestones/m7-specialist-review-agents.md, "M7-1 — Specialist
reviewer actor model and policy") to full actors: each collaborator (or each work item) an actor
with a mailbox and supervision tree.
- Fit 2/5. Actors model who acts (roster
Collaborators,domain/models.py:88) — and M7 uses the term as a policy/roster model, not a runtime. The problem here is representing work items' lifecycles, which are state machines, not mailboxes. Actor supervision/restart semantics duplicate what the job queue +EngineRunnerretry policy + escalation path already provide (_escalate_engine_failure,orchestrator.py:2930; retry-then-escalate, #455/c1e094e). - Migration cost/risk 2/5. No mature asyncio-native actor framework to adopt (Pykka is thread-based, Ray is a cluster runtime); this means hand-rolling mailboxes/supervision — new bespoke infrastructure precisely where the engineering standards say "search before you build."
- Operational burden 2/5, New dependencies 2/5 (either a poor-fit framework or bespoke code), Testability 3/5 (actor tests are famously order-sensitive).
- Honest-concurrent-view payoff 2/5. Mailboxes are even less inspectable than the current dicts; the view would still be a projection.
(d) Data-driven swimlane (pragmatic middle path — no new runtime)
Exactly §2's L0/L1: declare child specs as data, project instances from persisted state at read time, render supervisor + swimlanes. Write path untouched.
- Fit 4/5. Matches how the system already establishes truth: #464 derives node status from
state; overview/digests derive from state + snapshot (
_build_overview,_specialist_review_summaries,orchestrator.py:5071,5136); the reconciler derives from labels. One more derivation, at the right granularity. What it does not buy: the rework loop's control flow stays imperative in the orchestrator (that's the L2 gate's job). - Migration cost/risk 5/5. Additive read model + additive API field; no state migration; no behavior change; feature-flag-free rollout is plausible.
- Operational burden 5/5, New dependencies 5/5 (none).
- Testability 5/5. Pure functions from
(run.state, snapshot)→ lanes, testable in the exact style oftest_graph_status.py; enum-exhaustive contract tests make drift a CI failure. - Honest-concurrent-view payoff 5/5. This is the honest concurrent view: every persisted work item, with identity, position, attempt/round, and failure kind.
Scorecard
| Fit | Migration cost/risk | Ops burden | New deps | Testability | Honest view | |
|---|---|---|---|---|---|---|
| (a) LangGraph subgraphs | 3 | 2 | 3 | 3 | 4 | 2 |
| (b) Temporal | 4 | 1 | 1 | 1 | 3 | 3 |
| (c) Actor model | 2 | 2 | 2 | 2 | 3 | 2 |
| (d) Swimlane projection | 4 | 5 | 5 | 5 | 5 | 5 |
A deliberate dissent from the expected framing: the obvious reading of "hierarchical-supervisor workflow model" is "build a supervisor runtime that spawns child executions." I score that reading as premature on this codebase. The system's genuinely scarce property is not durable concurrent execution (the job queue + fences already provide it) — it is an honest, entity-granular representation of that execution. Options (a)–(c) each buy runtime semantics Bloom largely has, at high cost, and still leave the representation to be built. Option (d) buys the representation at near-zero cost and leaves the runtime question to be decided with evidence. And when L2 is warranted, there is a fifth option hiding in the codebase's own architecture — see §5.
4. Recommendation
Adopt the hierarchical-supervisor model as: (d) now, native subworkflow later, behind an ADR
gate. Phases:
Phase 0 — the shippable slice (design-only + read model; no runtime machinery)
- Author child specs as data (L1 from the start — declaring them costs the same as
documenting them):
apps/server/workflows/bloom-ticket.workflow.json— nodes transcribingEngineRunState(§2.2), edges transcribing the documented transitions, terminalsdone/escalated.bloom-pr-review.workflow.json— opened → specialist_round → verdict → merged / changes_requested⟲ / escalated (cap =review_max_rounds+ minor-only extra rounds,orchestrator.py:2427-2429).bloom-plan-change.workflow.json,bloom-decision.workflow.json,bloom-client-approval.workflow.json— the small pending→resolved gates. All validated by the existingload_specat app startup, exactly likebloom-sdlc.
- Build the projection — a pure module (proposed
services/project_flows.py) with one function per kind:(run.state, raw: _StatusSnapshot | None) → list[FlowInstance]. It reads only what §2.2 lists. Degrades exactly like #464: withraw is None, state-derived lanes still render and snapshot-derived enrichments drop out (mirroringtest_missing_snapshot_degrades_to_state_signals_only,test_graph_status.py:107). - Serve it — add
flowstoProjectDetail(additive;orchestrator.project_detail,:4668). Frontend renders lanes under the existingSdlcGraph(the graph stays; it is the supervisor's view). - Unify with #464 rather than duplicating it: reimplement
_active_node_ids's four steady-state signals on top of the projection (open PR lane →review_pull_requests, live ticket lane →track_progress, plan/decision lanes →prd_authoring/request_prd_approval), keeping the existing tests green. One derivation, two renderings (supervisor node glow + lanes) — this removes the risk of the two views disagreeing. - Fix the drift while we're here: declare
pending_client_approvalsin the spec's state fields and route its writes throughapply_writeslike every other field.
Phase 0 is deliberately mostly code-shaped but changes no behavior; per this worktree's constraints it is specified here and implemented in follow-up tickets.
Phase 1 — ADR gate for runtime machinery (design-only spike)
Write an ADR (docs/adr/) deciding whether any child flow needs to be executed rather than
projected, with explicit criteria. Enter L2 only if at least one holds after Phase 0 has been
in production:
- G1 — inference is lying. The projection demonstrably mis-states child position (e.g. label
truth vs
engine_runstruth diverge in ways users see; cf. the staleeng:label class of bug, #457/#455) and reconciliation-by-reading cannot fix it. - G2 — control flow needs to move. A product requirement needs per-child suspend/resume that the orchestrator's imperative paths cannot express safely (e.g. pausing one ticket's rework loop while others proceed, owner-driven per-ticket gates).
- G3 — the imperative loop is a defect factory. The review→rework→escalate code paths keep producing regressions of the #288/#457/#455 shape, arguing for a declared, testable child graph driving them.
Phase 2 — native child runs (runtime machinery, only if gated in)
Implement the already-reserved subworkflow node in the built-in engine
(workflow/engine.py:164), not a new framework:
- A child run is an ordinary
RunStaterow keyed"{thread_id}:ticket:{n}"(etc.) inbloom_runs. Precedent exists: the chat project-index already rides in aRunStaterow with sentinelcurrent_node="_index"(orchestrator.py:649-655) — the store handles non-project rows today. - The supervisor's steady-state loop spawns child runs at delegation time
(
_implement_delegated,orchestrator.py:3124) and the existing job/webhook handlers advance them; the child'sprocessed_keysfence carries the same idempotency guarantees as the parent. - Start with one child kind — the ticket flow, because its enum is already the state machine —
and run it in shadow mode: the child run advances alongside the untouched
engine_runswrites, with a parity check between projection and execution (the same parity-testing pattern the LangGraph spike validated for the reconciler). Promote to authoritative only when parity holds in production. - LangGraph remains a candidate implementation of Phase 2 behind the same seam
(
BLOOM_COORDINATION_ENGINEdemonstrates the pattern), but the default is the ~100-line engine we own: zero lock-in, already interprets the same spec dialect the child specs are written in, and the sketch's impedance mismatch (labels-as-truth) applies to it less — a childRunStateis just another projection surface until shadow mode proves otherwise.
Explicitly design-only vs. machinery: Phase 0 = specs-as-data + read model (no machinery). Phase 1 = ADR (no machinery). Phase 2 = the first and only step that adds runtime machinery, and it is entered only through the gate.
5. Data model, API, and event schema
5.1 Read model types (Phase 0)
class FlowInstance(BaseModel):
id: str # "ticket:12" | "pr:34" | "plan:rev-5" | "decision:d7" | "approval:26"
kind: str # ticket | pr_review | plan_change | decision | client_approval | lifecycle
spec_id: str # "bloom-ticket" — the child workflow this instance instantiates
title: str # issue/PR title, proposal summary…
node: str # current node id IN THE CHILD SPEC
status: str # live | waiting_on_owner | done | escalated
attempt: int | None # engine attempt / review round
failure_kind: str | None # from EngineRun.failure_kind
links: dict[str, str] # issue/PR web URLs (reuse issue_web_url/pull_request_web_url)
updated_at: str | None # last_attempt_at etc.
class ProjectFlows(BaseModel):
supervisor: ProjectGraph # today's graph, unchanged
lanes: list[FlowInstance] # live + recently-terminal instances
ProjectDetail gains flows: ProjectFlows | None (additive — the web client's ProjectDetail
interface in apps/web/src/hooks/use-project-detail.ts extends compatibly).
Presentation guidance: lanes are bounded. Live instances always render; terminal instances
render for a bounded recency window and the rest collapse to counts — engine_runs is keyed by
issue and never pruned, so an old project can hold hundreds of terminal entries.
5.2 Event schema
The event vocabulary is already per-entity — ticket_delegated {number, engineer},
engine_run_blocked {number}, engine_run_escalated {number, kind}, pr_reviewed {number, approved, merged}, ticket_escalated, ticket_unblocked, plan_changed, decision_proposed
… (publishers throughout orchestrator.py; durable history in bloom_project_events,
persistence/project_events.py). Two additive changes:
- Correlation convention: publishers add
flow: "<kind>:<key>"to eventdatawhere the entity is known (mechanical: thenumber/titleis already in hand at every publish site). The Timeline can then group by lane, and SSE-driven refetch (/{thread_id}/events,api/routes/projects.py:223) needs no change — the existing "notable event → refetch detail" contract already covers lane updates. - No new event types in Phase 0. Lane lifecycle (
flow_started/flow_completed) is derivable from existing events plus state; minting new write-path events before the ADR gate would couple the write path to a model we intend to keep read-only for now. (Phase 2 may add them when children become real runs.)
5.3 Persistence
- Phase 0/1: none. No new tables, no migration of
bloom_runsrows. The projection reads the state Supabase already holds; the event store is already durable (#450). - Phase 2: child
RunStaterows inbloom_runsunder namespaced keys (§4). Queries that enumerate projects must exclude child rows — note_LIST_ACTIVEandfind_thread_by_repoalready filter on properties child rows won't carry, but this becomes an explicit invariant with a test, not an accident.
5.4 Failure/retry semantics for child flows
Stated as invariants (Phase 0 renders them; Phase 2 enforces them):
- Retries live inside the child. Automatic retry of
TRANSIENT/IMPLEMENTATION/UNKNOWNfailures (retry-then-escalate, #455) is a self-loop on the child'simplementing/reworknodes, bounded byEngineRunnerpolicy;retries/failure_kindare child-instance fields. - Escalation is a child terminal plus a supervisor notification, never a supervisor stall.
needs_attention(_escalate_engine_failure→STATUS_NEEDS_ATTENTION, unassigneng:label per #457, owner ping with concrete reason per #459) ends the child; other children are unaffected; the supervisor stays parked. - Review-round caps are child-spec data.
review_max_rounds(+ minor-only extra,orchestrator.py:2427) bounds thepr_reviewchild's rework cycle; the cap crossing is the edge toescalated. - Idempotency is per-child. The attempt-scoped event keys
(
engine_implement:{thread}:{issue}:{seq}) become, in Phase 2, the child run'sprocessed_keys— same fence, narrower scope, and redelivery can no longer interleave across siblings through the shared parent row. - Blocked returns to the pool.
blockedis a child terminal whose supervisor-side effect (unassign,status:blocked, PO re-route) is already implemented; a later re-delegation mints a new child instance (ticket:12#2), matching today's fresh event key per attempt-seq.
5.5 Backward compatibility
- API: additive field; old clients ignore it.
- Events: additive
data.flowkey; consumers that ignore unknown keys (the web app does) are unaffected. - State: one declared-field addition (
pending_client_approvals) whose values already exist in production rows; declaring it changes validation, not data. New projects and old rows project identically because every projection input predates this design.
6. Test strategy
- Projection unit tests (Phase 0, the bulk): same style as
test_graph_status.py— given anengine_runsentry /specialist_reviewsround / pending proposal and an optional snapshot, assert the lane, node, attempt, and status. Include the degradedraw=Nonecases. - Enum-exhaustive contract tests: iterate
EngineRunStatemembers and assert every one maps to a node that exists inbloom-ticket.workflow.json; dittoEngineOutcome, review verdicts, andFailureKind. A new enum member without a mapping fails CI — this is the mechanism that keeps specs-as-data honest. - Spec validation tests: child specs load via
load_spec(dangling-ref validation for free); supervisor invariant test: park node active ⇔ zero live lanes, extendingtest_graph_status.py's park-node cases. - #464 parity: after
_active_node_idsis re-derived from lanes (§4 step 4), the entire existingtest_graph_status.pysuite must pass unmodified. That suite is the regression contract for this refactor. - Integration: extend
test_coordination_flow.py-style scenarios — delegate two tickets, open a PR, propose a plan change; assertGET /api/projects/{id}returns three+ lanes with correct positions; assert timeline events carryflowcorrelation. - E2E (web): seeded project renders lanes; SSE event triggers refetch and lane movement
(reusing
subscriber_countsynchronization,services/events.py:81). - Phase 2 shadow-mode parity (if gated in): child run's
current_nodevs projected node compared on every advance; divergence is a logged metric and a test failure in CI scenarios — the LangGraph-spike parity pattern (test_langgraph_coordinator.py) reused for a different engine.
7. Interaction with #464 (e67bd03)
This design treats #464 as its first increment, not something to route around:
- Philosophical continuity. #464 chose derive from persisted truth over move the pointer. The lane projection is the same choice at entity granularity; Phase 2's gate exists precisely because moving truth into a runtime should require evidence the derivation can't keep up.
- Mechanical unification. Until §4 step 4 lands, signals and lanes are computed twice from
the same inputs — the one real drift risk this design introduces. Landing the re-derivation of
_active_node_idson top of the projection in the same release as the lanes is therefore part of Phase 0's definition of done, guarded by the untouched #464 test suite. - Snapshot economics. #464 threads the caller's already-fetched
_StatusSnapshotinto_build_graphto avoid a second provider round-trip (orchestrator.py:4705-4710); the projection takes the same parameter and the same "one GitHub round-trip per detail view" property holds (project_detail,:4674-4684). - Done-phase stability.
_node_status's phase-index "done never regresses" rule (:5058) stays supervisor-side and untouched; lanes carry the churn.
8. Risks and open questions
Risks
- R1 — two truths for tickets.
engine_runsvs live labels can disagree (humans edit labels; webhooks race reports, cf. #288's status-echo guard). Mitigation: the ticket projection reads both and renders disagreement as a visibleattentionbadge rather than silently preferring either — disagreement is information (it caught #457's stale-label class once already). - R2 — unbounded lanes.
engine_runs/specialist_reviewsgrow monotonically. Mitigation: bounded rendering (§5.1); open question O3 for actual retention. - R3 — projection drift as code evolves. New orchestrator paths that mutate work-item state
without updating the projection. Mitigation: the enum-exhaustive contract tests (§6.2) plus the
reducer-layer discipline (undeclared-field writes already
KeyError; closing thepending_client_approvalsbypass removes the known hole). - R4 — Phase 2 dual-write divergence. Shadow children vs dict writes disagreeing under redelivery. Mitigation: shadow mode is read-compared only (never authoritative until parity), attempt-scoped fences already dedupe the inputs.
- R5 — scope creep toward a framework. The swimlane ships and pressure builds to "finish" the runtime anyway. The ADR gate's criteria (G1–G3) exist to make that a decision with evidence, not momentum.
Open questions
- O1 — Should
plan_change/decisionlanes fold into one "governance" lane in the UI? (Data model keeps them distinct; presentation may merge.) - O2 — Does the Telegram surface get a lane digest (the
_send_status/digest paths already summarize some of this), or is lanes a dashboard-only concept initially? - O3 — Retention policy for terminal
engine_runs/specialist_reviewsentries — archive into the event store? Out of scope here but the lane view makes the growth visible. - O4 — In Phase 2, does the
pr_reviewchild own the merge action (today_apply_review_to_githubmerges inline), or does merging stay a supervisor-side effect? The client-approval park (_park_for_client) argues for supervisor-side: merges are gated by cross-child concerns (owner sign-off, credentials) a single child shouldn't own. - O5 — Multi-instance API deployment: the projection is stateless and safe, but Phase 2 child advancement must ride the existing job queue's atomic claim — confirm no new lock ordering between parent and child rows (child advance takes child lock only; parent effects go through the existing thread lock, in that order, never nested the other way).
Appendix A — code index (facts this design rests on)
| Fact | Where |
|---|---|
| Single pointer per project | workflow/engine.py:29 (RunState.current_node) |
| Park node + description | workflows/bloom-sdlc.workflow.json:359-366 |
| Park state = "active project" query | persistence/supabase_store.py _LIST_ACTIVE; persistence/store.py list_active_threads |
subworkflow reserved, unimplemented | workflows/schema/workflow.schema.json:355-365; workflow/engine.py:164-165 |
| Ticket state machine | engine/base.py:39-55 (EngineRunState), :163-213 (EngineRun, with_result) |
engine_runs decl + writes | bloom-sdlc.workflow.json:116; orchestrator.py:2924-2928 |
specialist_reviews decl + rounds | bloom-sdlc.workflow.json:115; orchestrator.py:2252-2340 |
pending_plan_change write/clear | orchestrator.py:3936 / :3962,3995 |
Undeclared pending_client_approvals | orchestrator.py:1363,1525 vs workflow/reducers.py:48-50 |
| Engine runs outside the thread lock, fenced | orchestrator.py:3156,3177-3219 |
| Review rounds, caps, escalation | orchestrator.py:2361-2438,2427; 2930-2969 |
| #464 status derivation | orchestrator.py:5016-5068; tests tests/unit/test_graph_status.py |
| Detail endpoint / one-snapshot rule | orchestrator.py:4668-4684; api/routes/projects.py:137,223 |
| Frontend renders backend status verbatim | apps/web/src/components/sdlc-graph.tsx; apps/web/src/hooks/use-project-detail.ts |
| Event bus + durable history | services/events.py; persistence/project_events.py |
Non-project rows in bloom_runs precedent | orchestrator.py:649-655 (current_node="_index") |
| LangGraph spike + honest scorecard | services/langgraph_coordinator.py; docs/design/langgraph-sketch.md §7-8 |