Skip to main content

Design sketch: Bloom's SDLC + coordination as a LangGraph StateGraph

Exploratory design. A concrete comparison against the current JSON spec (apps/server/workflows/bloom-sdlc.workflow.json + apps/api/src/bloom/workflow/engine.py) to judge whether LangGraph earns its place. Bare module paths below (e.g. workflow/engine.py, services/coordinator.py) live under apps/api/src/bloom/ in the monorepo.

Status: the suggested first step (section 8) is now implemented as a spike. The reconcile slice of the coordination loop runs as a real LangGraph StateGraph in services/langgraph_coordinator.py, behind the BLOOM_COORDINATION_ENGINE flag (default builtin). It is a drop-in equivalent of Coordinator, parity-tested against it. The rest of this document (the full main graph, interrupt()-based HITL, the checkpointer) remains a not-yet-wired sketch.

What this changes vs. today

Today Bloom's flow is split in two:

  • Declarative (the JSON spec, run by the ~100-line interpreter): discovery -> PRD -> approval -> planning -> issue sync -> park at await_project_event, and the PR-review branch.
  • Imperative (orchestrator methods, outside any spec): _run_coordination -> Coordinator.reconcile (delegation, status labels, milestone rollup), _apply_review_to_github (post review, merge, status:changes-requested), _escalate_ticket, lifecycle, project-chat.

The LangGraph sketch's headline: fold the imperative half into the graph as a subgraph, so the whole SDLC - including the monitoring/coordination loop - is one inspectable state machine with checkpointed history.


1. State schema (replaces state.fields + reducers in the JSON)

Bloom already has typed state with replace/merge/append/union reducers. In LangGraph that becomes a TypedDict with Annotated reducers - a near 1:1 translation.

from typing import Annotated, TypedDict
from operator import add

def merge_dict(a: dict, b: dict) -> dict: return {**a, **b} # 'merge' reducer
def union(a: list, b: list) -> list: return list({*a, *b}) # 'union' reducer

class BloomState(TypedDict):
thread_id: str
conversation: Annotated[list[dict], add] # 'append'
requirements: dict # 'replace' (last write wins - default)
prd: dict
prd_revision: int
tickets: list[dict]
repo_target: dict
github_sync: Annotated[dict, merge_dict] # 'merge'
reviews: Annotated[list[dict], add]
processed_keys: Annotated[list[str], add] # the idempotency fence we just built
lifecycle: str # active | archived | deleted
# inbound event, injected on resume:
pending_event: dict # {kind, payload}

Note processed_keys and github_sync carry straight over - the reducer model is the same idea LangGraph's Annotated uses, which is why this maps cleanly.


2. The main graph (mirrors the current spec)

from langgraph.graph import StateGraph, START, END
from langgraph.types import interrupt, Send

g = StateGraph(BloomState)

# --- Discovery -> PRD -> approval (today: await_message .. request_prd_approval) ---
g.add_node("discovery", discovery) # agent: ProductOwnerAgent
g.add_node("prd_authoring", prd_authoring) # agent
g.add_node("freeze_prd", freeze_prd) # passthrough + commit PRD.md (side effect)
g.add_node("planning", planning) # milestone_planning + ticket_generation
g.add_node("sync_github", sync_github) # sync_milestones + sync_tickets (fan-out below)
g.add_node("coordination", coordination_subgraph) # <-- the imperative half, now a subgraph
g.add_node("project_chat", project_chat) # monitoring-phase free-text reply

g.add_edge(START, "discovery")

# Clarify-or-advance: today this is the ask_clarification `human` node looping back to discovery.
def after_discovery(s: BloomState):
return "prd_authoring" if s["requirements"].get("enough") else "ask_clarification"
g.add_conditional_edges("discovery", after_discovery,
{"prd_authoring": "prd_authoring", "ask_clarification": "discovery"})

# PRD approval is a real interrupt (see section 4), inside prd_authoring's exit.
g.add_conditional_edges("prd_authoring", prd_decision,
{"approved": "freeze_prd", "revise": "prd_authoring"})
g.add_edge("freeze_prd", "planning")
g.add_edge("planning", "sync_github")
g.add_edge("sync_github", "coordination") # enter the monitoring/coordination loop

Dynamic fan-out replaces today's sync_tickets (map) / review_pull_requests (map) with the Send API - real parallelism with reducer-based join:

def fan_out_issues(s: BloomState):
return [Send("create_issue", {"ticket": t}) for t in s["tickets"]] # N issues in parallel
g.add_conditional_edges("planning", fan_out_issues) # each Send runs create_issue concurrently

3. The coordination subgraph (today: _run_coordination + review + escalation)

This is the part that has no representation in the current spec - it lives in orchestrator methods. As a subgraph it becomes explicit nodes/edges:

c = StateGraph(BloomState)

c.add_node("await_event", await_event) # interrupt(): park until a GitHub webhook resumes us
c.add_node("reconcile", reconcile) # Coordinator.reconcile: ready/blocked, delegate, rollup
c.add_node("review_prs", review_prs) # fan-out review_one_pr via Send
c.add_node("apply_review", apply_review) # post review; merge on approve; else changes-requested
c.add_node("escalate", escalate) # after review_max_rounds -> status:needs-attention

c.add_edge(START, "await_event")

def route_event(s: BloomState): # branch on what woke us
kind = s["pending_event"]["kind"]
if kind == "pull_request": return "review_prs"
if kind in ("issue", "reconcile_tick"): return "reconcile"
if kind == "chat": return "project_chat_in_parent"
return "await_event"
c.add_conditional_edges("await_event", route_event, {...})

def review_fan_out(s): return [Send("apply_review", {"pr": pr}) for pr in s["pending_event"]["prs"]]
c.add_conditional_edges("review_prs", review_fan_out)

def after_review(s): # the review->fix->escalate loop, now declarative
return "escalate" if s["_rounds"] >= s["review_max_rounds"] else "reconcile"
c.add_conditional_edges("apply_review", after_review, {"escalate": "escalate", "reconcile": "reconcile"})

c.add_edge("reconcile", "await_event") # loop back and wait for the next event
c.add_edge("escalate", "await_event")

coordination_subgraph = c.compile() # embedded as a node in the main graph

The review -> changes-requested -> rework -> re-review -> escalate cycle - currently spread across _apply_review_to_github, _flag_ticket_for_changes, _escalate_ticket, and the swarm - becomes a visible loop of edges. That's the single biggest legibility win.


4. Human-in-the-loop: interrupt() (today: human/event_wait + waiting_on)

Today a human node sets waiting_on and the orchestrator resumes on the next message. LangGraph's interrupt() is a first-class pause that can carry a payload and resume with an injected value:

def prd_authoring(state: BloomState):
prd = author_prd(state) # LLM
decision = interrupt({ # <-- pause; persisted by the checkpointer
"type": "prd_approval",
"summary": prd["summary_for_user"],
"prompt": "Approve this PRD, or tell me what to change.",
})
return {"prd": prd, "prd_decision": decision} # resumes here with the user's reply

Resuming (from the Telegram webhook) is one call:

await graph.ainvoke(Command(resume=user_text), config={"configurable": {"thread_id": tid}})

The escalation prompt (status:needs-attention) and the archive/delete confirmation map onto the same primitive instead of the current hand-rolled pending_lifecycle / waiting_on bookkeeping.


5. Durable suspend/resume across webhooks (today: Postgres RunState + webhook resume)

Bloom already suspends durably. In LangGraph the checkpointer does this, and it also stores history:

from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver

saver = AsyncPostgresSaver(pool) # reuse the same asyncpg pool as the state store
graph = builder.compile(checkpointer=saver)

# webhook -> resume the parked run for that thread:
await graph.ainvoke(Command(resume={"kind": "pull_request", "prs": [pr]}),
{"configurable": {"thread_id": tid}})

# NEW capability, ~free: inspect / rewind
async for snap in graph.aget_state_history(config): # every checkpoint, newest first
...

What does not change: the webhook ingestion layer, signature/secret checks, the durable job queue, and the idempotency fence all still sit in front of this - LangGraph resumes a run, it does not receive webhooks. The event_key de-dup we built still gates ainvoke.


6. Node-by-node: current spec -> LangGraph

Current JSON node (type)LangGraph equivalent
await_message (event_wait)interrupt() at graph start / resume via Command
requirement_discovery (agent)discovery node (same agent call)
ask_clarification (human)interrupt() + conditional edge back to discovery
prd_authoring (agent)prd_authoring node
request_prd_approval (human)interrupt({...}) inside/after prd_authoring
freeze_prd (passthrough)freeze_prd node (commits PRD.md)
milestone_planning,ticket_generation (agent)planning node(s)
sync_milestones (tool)sync_github node
sync_tickets (map) -> create_issue_for_ticketSend-based fan-out to create_issue
notify_planning_ready (tool)edge side effect / node
await_project_event (event_wait)await_event node (interrupt()) in the subgraph
review_pull_requests (map) -> review_one_pull_request (agent)Send fan-out to apply_review
notify_review (tool)tail of apply_review
track_progress,deploy,run_tests,gather_feedbacknodes (post-MVP, unchanged)
(none - imperative today) _run_coordination, _escalate_ticketcoordination subgraph

7. Honest scorecard

Net gains

  • The imperative coordination/review/escalation loop becomes an inspectable subgraph (Studio-visualizable, graph-testable) instead of scattered orchestrator branches. This is the main win.
  • State history / time-travel for free (aget_state_history, rewind-and-resume) - Bloom has none today.
  • Send-based parallel issue creation and PR review, with framework-managed join.
  • HITL (interrupt) unifies PRD approval, escalation, and lifecycle confirmation under one primitive.

Parity (already solved - no gain)

  • Typed state + reducers; durable suspend/resume; the webhook layer; the job queue + idempotency fence all remain and sit in front of LangGraph.

Costs / friction

  • A heavy dependency + its opinions replacing a ~100-line, zero-lock-in, fully-tested interpreter.
  • Label-driven coordination doesn't fully move into graph state: the source of truth for ticket status is GitHub issue labels, not BloomState. reconcile still reads/writes labels as a side effect; the graph models the control flow, not the coordination state itself. This is the biggest impedance mismatch to weigh.
  • Migration touches the WorkflowContext/Engine seam (designed for exactly this swap) but is a real rewrite of engine.py + the spec loader + orchestrator dispatch.

Suggested first step if pursued: port only the coordination subgraph (section 3) behind the existing seam, leaving discovery->planning on the current interpreter, and A/B it. It's the highest-value, most self-contained slice and needs no change to the discovery flow.


8. What was actually built (the spike)

The first step above is implemented, scoped down to the reconcile pass - the most self-contained, purely-deterministic node of the coordination subgraph (the event routing, await_event interrupt, and PR-review fan-out from section 3 are still just sketch).

LangGraphCoordinator(Coordinator) in services/langgraph_coordinator.py expresses one reconcile pass as a linear StateGraph:

START -> load_issues -> classify -> delegate -> rollup_milestones -> END

Design choices that keep it honest as a drop-in:

  • Subclasses Coordinator - it is a Coordinator (same type, same provision_labels), and reuses the inherited, production-tested _set_status / _rollup_milestones. No coordination logic is duplicated; only the driver differs.
  • Selected by config: BLOOM_COORDINATION_ENGINE=langgraph swaps it in at create_app; the built-in reconciler stays the default. Nothing else in the app changes.
  • Parity-tested: tests/unit/test_langgraph_coordinator.py runs both engines through the same scenarios (via the shared fake_github fixture) and asserts the label mutations, delegations, unblocks and milestone closures are indistinguishable, including a mixed scenario that exercises every branch (delegated / blocked / at-capacity / escalated / milestone-close).

What this validated, cheaply: the impedance mismatch from section 7 is real but survivable - the GitHub labels stay the source of truth, and the graph state (_ReconcileState) only carries the GitHub client + per-pass working values, not the coordination state. The graph models control flow; labels still hold the truth. That is exactly the trade the full migration would have to accept.