ADR 0008: ChangeWorkflow, Work Graph persistence, and causal routing
Context
Section titled “Context”Rhumbatron compiles one Change into a Work Graph. Only causally affected Agents wake. Task state needs idempotent transitions and durable events. ProjectRootDO holds only cold project-wide state.
Decision
Section titled “Decision”POST /v1/projects/:id/changesstartsChangeWorkflowwith instance ID = Change ID. The workflow engine inpackages/platformruns it, and the integration code defines it. A request withstartPlanning: falsedoes not start it. Smoke checks use this option.- Steps (
workers/integration/src/change-workflow.ts):- Load the Change. Mark it
compiling. - Run the intake gate (ADR 0025). A question makes the Change
needs_input, and the workflow waits for the answer. - Admit the request on ProjectRootDO.
- Plan the Work Graph (Root Planner).
- Compare the plan with the other open requests of the Project (ADR 0023, ADR 0025).
- Persist the Work Graph. Record the events. Dispatch the ready Tasks.
- Load the Change. Mark it
- The Root Planner uses the opus tier on the Anthropic API. It makes one tool call that Zod and
an acyclicity check validate. It gets one repair turn and one step retry. The opus tier runs on
Sonnet while
ALLOW_OPUSis not"true"(ADR 0021). - Plan rules in code (
workers/integration/src/root-planner.ts):- For an empty repository, t1 is an implement bootstrap Task, and every other Task depends on it (ADR 0022). A plan that breaks this rule gets the repair turn.
- A UI Task and an API Task of the same area with no dependency path between them get the edge UI -> API. No model call adds it. The UI agent then reads the real API routes.
- Each step is idempotent. The steps use deterministic Task IDs,
INSERT OR IGNORE, conditional status updates, and stable event and command IDs. - Tasks and dependency edges are in the product database. The object store keeps the Work
Graph JSON under
work-graphs/<project>/<change>.json, with the planner model, tokens, and cost. - Change and Task events go to the outbox of the ResourceShardDO that owns
change:<id>(appendEvents, idempotent on event ID). Its alarm publishes them to the event log (NATS JetStream streamsEVENTS_00toEVENTS_07). - Dispatch marks the ready Tasks, records
task.ready, sendsagent.wakecommands on theagent-wakeupsqueue, and then setswake_sent_at(at least once). - The agents code consumes wakes. It drops a stale wake and writes a false-wakeup point to the
metrics table. For a valid wake, it assigns the Task, records
task.assignedandagent.woken, and starts a run on theAgentDOof the Task (ADR 0009). - The
causal-routersubscription of the event router runsrouteEvent(packages/causal-router). It sends these commands on theintegrationqueue:change.dispatch(Tasks that became ready),candidate.create(the Work Graph is complete), andcandidates.recompose(aftercanonical.advanced, ADR 0013). The router sends commands on a queue, so the coordinator, the router, and integration have no call cycle. - The Causal Router allows at most 1,000 wake, dispatch, and compose commands per Change per UTC
day (
CHANGE_DAILY_COMMAND_CAP). - Ambiguous resource overlaps go to Clef-flash (model ID
@cf/cloudflare/clef-flash). Claude Haiku answers it through a forced tool call. The router abstains below a probability of 0.7. When Clef-flash abstains or the cap is reached, the router wakes the Agent. Theclef-flashcounter inusage_countersallows 200 calls per UTC day. The intake gate uses the same counter.
Budget
Section titled “Budget”Per Change: about 11 workflow steps, plus 3 steps for each earlier open request of the Project.
Per woken Task: 1 to 4 model calls and about 3 queue operations. Code caps Clef-flash calls with
the daily clef-flash counter. Agent model calls count against the monthly Anthropic budget
(ADR 0021).
Diagram
Section titled “Diagram”sequenceDiagram participant API as API participant WF as ChangeWorkflow (integration) participant S as ResourceShardDO change:ID participant Q as Queues (JetStream) participant AG as Agents participant EL as Event log (JetStream) participant R as Event router (causal-router) API->>WF: CHANGE_WORKFLOW.create(id = Change ID) WF->>WF: load, mark compiling, intake gate, admit, plan (opus tier) WF->>WF: compare with other requests, persist Tasks and edges (database), Work Graph (object store) WF->>S: appendEvents (change.compiled, task.created) WF->>S: dispatch: appendEvents (task.ready) WF->>Q: agent.wake on agent-wakeups, then wake_sent_at Q->>AG: wake consumer: relevance check, task.assigned, agent.woken, AgentDO.start S->>EL: outbox alarm publishes EL->>R: routeEvent (rules first, Clef-flash for ambiguous overlaps) R->>Q: agent.wake for affected agents R->>Q: change.dispatch, candidate.create, candidates.recompose on integration