ADR-0001: One workflow renderer¶
- Status: accepted
- Date: 2026-08-03
- Implementation: migration steps 1–2 complete; native-card retirement and the compatibility cleanup in steps 3–4 remain.
- Supersedes: nothing
- Applies to:
crates/cli/src/block.rs,crates/cli/src/runtime.rs,crates/cli/src/workflow.rs,crates/workflow
Context¶
Iteron ships two subsystems that share the word workflow and nothing else.
Native ultracode orchestration. The kernel decomposes a task, fans out
read-only investigators, reduces their evidence, and hands one writer the
result. It emits WorkflowUiEvent — a fixed, id-correlated vocabulary
(RunStarted, PlanReady, PhaseChanged, AgentStarted, AgentActivity,
AgentFinished, RunFinished) — and the TUI projects those into a
WorkflowCard: a flat connector tree with a done/total header. That
vocabulary is also a published machine-stream contract: every variant is a
cli.machine-stream.workflow-* surface in
governance/schema-compatibility.json, frozen at schema_version 5 with
golden fixtures under crates/cli/tests/golden/.
The script engine. crates/workflow embeds QuickJS and runs .js workflow
scripts with agent() / parallel() / pipeline() / phase() / log(). It
emits iteron_workflow::events::ProgressEvent — an unfrozen, in-process
vocabulary carrying free-form phase titles, narrator log lines, and per-agent
metrics — and the TUI projects those into a WorkflowRunCard: bordered phase
boxes, branch rows, collapsed finished agents.
The two renderers do not share a row, a glyph, a header, or a state model. The code says so out loud:
block.rslabels the phase-box tree "a SEPARATE projection from the native-ultracodeWorkflowCardabove".BlockKind::WorkflowRun,App::workflow_run_event, andApp::workflow_run_finishedall carry#[allow(dead_code)]with the note "live at M9" — the phase-tree renderer has no non-test caller from the interactive TUI. It is reachable only from the one-shotiteron workflow runlive loop.runtime.rs::launch_workflow— the in-turnWorkflowtool — passesiteron_workflow::NullSinkand then blocks onjoin. Every phase, log, and agent event of an in-turn run is discarded.
The result is one product with two half-built progress surfaces, and no principled answer to "which one gets the next fix". This ADR gives that answer so no further feature work lands in either renderer blind.
Decision¶
The phase-tree renderer survives. The native ultracode card is retired.
Concretely:
WorkflowRunCard/render_workflow_run(block.rs) is the one workflow renderer. New progress affordances — headers, run totals, per-row clocks, queued rows, phase layout — land there and only there.- Ultracode's Fan → Reduce is migrated onto the script engine as a built-in decomposition script, rather than the reverse. The engine already owns the parts that would otherwise have to be rebuilt inside the native path: a permit-bounded fan, a content-addressed resume journal, schema-forced structured output, background launch with cancellation, and a declarative phase header.
WorkflowCard/render_workflowretires when, and only when, ultracode runs as a script. Until then it stays live and correct — it is the only thing rendering ultracode today.WorkflowUiEventis not deleted. It is a published compatibility surface:iteron --output-format stream-jsonconsumers readworkflow_start/workflow_plan/workflow_phase/workflow_agent_*/workflow_end. It keeps being emitted for as long as the deprecation runway ingovernance/schema-compatibility.jsonrequires, independently of which renderer draws the TTY.
Why this direction and not the other¶
Routing the script engine's ProgressEvents into WorkflowUiEvent was the
cheaper-looking option, and it is not viable:
WorkflowUiEvent::PhaseChangedcarriesWorkflowPhaseUi, a closed enum (Planning/Exploring/Synthesizing/Writing/Direct). A script'sphase('build the index')has nowhere to go.- There is no
Logvariant, so the narrator line is unrepresentable. - The native card matches agents against a
PlanReadytask list known up front. A script's agent set is discovered as the script runs.
Widening WorkflowUiEvent to fit is not a refactor: each variant is a frozen
governed surface, so it costs a shared CLI stream schema-version bump. Paying
that to make the retiring renderer more expressive is the wrong direction.
Migration path for the retiring renderer¶
Ordered, each step independently shippable:
- Carry script progress to the interactive TUI.
launch_workflowmust forward itsProgressSinkto the UI channel the way the investigator child forwarder already does, behind one new UI event that reachesApp::workflow_run_event/App::workflow_run_finished, and their#[allow(dead_code)]attributes must go so the compiler holds the seam. This step requires a CLI stream schema-version bump (5 → 6): a newUiEventvariant becomes a newcli.machine-stream.*record type, andxtaskrefuses a new stream surface at the current version (new CLI stream surface ... requires a shared CLI schema version bump). It therefore cannot ride along with a renderer change; it is its own release-contract PR. - Move ultracode's decomposition into a built-in script driven by
KernelSpawner, keeping the existing authority, budget, and read-only investigator guarantees exactly as they are. Investigators becomeagent()calls inside aphase(); the writer stays on the kernel path. - Delete
WorkflowCard,render_workflow,App::workflow_event, andworkflow_indexonce nothing constructs them, keepingWorkflowUiEventand itsstream_eventproducer for the machine surface. - Retire
WorkflowExecutionModeUi::Sequentialon the same schema-version bump that step 1 pays for. It is never emitted by the kernel, but it is serialized as"execution_mode":"sequential"inside the frozenmachine_stream_all_v4/v5goldens and mirrorsiteron_protocol::WorkflowExecutionMode::SequentialFanin the frozen ABI, so removing it is a contract change, not a cleanup.
Consequences¶
- Every downstream workflow issue references this ADR. Work on
render_workflowis limited to correctness of what is already there (for example: rendering one row per concurrent investigator); new capability goes torender_workflow_run. - Until step 1 lands, an in-turn
Workflowtool call renders no live progress. That is a known, named gap with a named cost, not an oversight. - The
stream-jsoncontract is unaffected by steps 2 and 3.