Template Runner
File:src/cofounder_agent/services/template_runner.py
Tested by: src/cofounder_agent/tests/unit/services/test_template_runner_postgres_checkpointer.py, tests/unit/services/test_template_runner_state_partition.py, tests/unit/services/test_checkpoint_resumable.py + integration fan-out tests
Last reviewed: 2026-06-13
What it does
TemplateRunner is the sole pipeline path as of 2026-05-16 (Lane C Stage 4). The legacy WorkflowExecutor + its custom_workflows_service / template_execution_service / workflow_validator / phase_mapper / phase_registry / workflow_progress_service / phases/ chain — plus the agents/ tree — were all deleted 2026-05-09 (~3,800 LOC). The chunked StageRunner flow in content_router_service.py and plugins/stage_runner.py itself followed on 2026-05-16. There is no remaining legacy orchestration engine to migrate off of.
The runner is template-agnostic in intent — it takes a StateGraph and a PipelineState (a TypedDict shaped by the registered template), drives the graph to completion or halt, and returns a TemplateRunSummary with per-node metrics. Today it drives the canonical_blog (prod default — default_template_slug='canonical_blog') and dev_diary templates; future architect-composed pipelines slot in via the same surface.
Three things make it useful beyond a vanilla LangGraph wrapper:
make_stage_node(stage)— adapts an existingStageinstance (the legacymodules/content/stages/*.pyshape) into a LangGraph-compatible async node. The Stage’sexecute(context)becomes a node that readsstate, runs the stage, returns the diff to merge back. Lets us migrate one stage at a time without rewriting them as atoms first._emit_progress— fans node start/completion/failure events out to Discord vianotify_operator(critical=False). Gated by thetemplate_runner_progress_streamingsetting (default ON; Discord is the spam-friendly channel). NEVER routes to Telegram — that channel is reserved for critical alerts perfeedback_telegram_vs_discord.PipelineState.qa_reviews: Annotated[list, operator.add]— the parallel-fan-out reducer. Critic atoms in an architect-composed graph (narrate → [critic_1, critic_2] → aggregate) all append toqa_reviewson the same step; withoutoperator.addLangGraph’s default last-value channel rejects concurrent writes withInvalidUpdateError. Each critic returns its review wrapped in a one-element list; the reducer concats._resolve_checkpointer()— gated by thetemplate_runner_use_postgres_checkpointersetting (default off). When on, builds anAsyncPostgresSaver.from_conn_string(dsn)perrun()invocation and passes it intocompile(checkpointer=...)so LangGraph state survives worker restarts and is resumable bythread_id. DSN comes from the constructor kwarg (tests) orbrain.bootstrap.resolve_database_url. Fall-back posture per #371: missing DSN / missing dep / connection failure → log warning, fall back toMemorySaver. Setup-time failure on a reachable Postgres → raise_CheckpointerSetupError(loud) so a half-broken schema doesn’t silently degrade durability.- State-vs-services partition (
_partition_state_and_services, poindexter#382) —run()splits the caller’sinitial_stateinto two channels before invoking the graph. Pure data (task_id,topic,tags, etc.) stays on the StateGraph and is checkpointed. Live service handles (database_service,image_service,settings_service,image_style_tracker,site_config, plus any other key whose value isn’tormsgpack-encodable) ride inRunnableConfig.configurable["__services__"], which LangGraph threads through node calls without serializing. Themake_stage_nodeadapter merges the services back into the legacycontextdict so wrapped stages still readcontext.get("database_service")unchanged. Pre-#382 the runner pushed everything onto state and every checkpoint write loggedTypeError: Type is not msgpack serializable: DatabaseService(non-fatal but noisy on every dev_diary run). Annotation gotcha: nodeconfigparameters MUST be annotated as bareRunnableConfig(orOptional[RunnableConfig], or unannotated) — theRunnableConfig | Nonepipe form becomes a string underfrom __future__ import annotationsand falls outside LangGraph’sKWARGS_CONFIG_KEYSallow-list, so config silently arrives asNone.
Key methods
run(state, *, graph, capability_outcomes_writer=None)— async. Compiles + invokes the graph from the entrypoint. ReturnsTemplateRunSummary(records, terminal_state). Eachrecordis aTemplateRunRecord(node_name, status, started_at, finished_at, metrics)so callers can inspect per-node timing + outputs.make_stage_node(stage, *, fallback_pool=None)— adapter from theStageinterface. Thefallback_poolkwarg is captured at registration time fromshared_context.get_database_serviceso virtual-stage atoms don’t crash whenstate['database_service']isn’t seeded outside worker context._emit_progress(pool, *, event_type, payload, notify_operator_message=None)— fire-and-forget Discord push.poolandevent_type/payloadparameters are kept on the signature for source-compat; their pipeline_events INSERT was dropped in poindexter#366 phase 4 (no consumer ever read those rows). Future Langfuse-trace wire-up will readevent_type+payloadto populate span attributes.
Capability outcomes feedback loop
After a run completes, the runner writes per-node training signal intocapability_outcomes (a baseline-schema table; originally migration 0147) when the caller passes a capability_outcomes_writer. The router’s next routing decision can read this — same atom + same input shape ought to produce similar quality, similar cost, similar latency. ML-first design per feedback_always_keep_ml_in_mind: every deterministic component pairs with a learned-successor sketch.
Reads from / writes to
- Reads:
config['configurable']['__services__']['database_service']→ asyncpg pool for the stage adapters (legacystate['database_service']also works inside wrapped stages because the adapter merges services into the context dict — see partition note above);site_configfor thetemplate_runner_progress_streamingsetting. - Writes:
audit_log(via stage adapters that callaudit_log_bg) — the canonical historical record.capability_outcomes— per-node metrics for the router’s training loop.- Discord (via
notify_operator) — operator-visible progress.
- External APIs: none directly. Stages own LLM/HTTP calls; the runner just orchestrates.
Failure modes
- Node raises — captured in the
record.status='failed'+record.errorfield; downstream nodes that depend on the failed node’s output trigger LangGraph’s default abort. The terminal_state still returns with whatever ran. - Concurrent fan-out without reducer —
InvalidUpdateError. State key needsAnnotated[T, reducer_fn]. Already handled forqa_reviews; new fan-out targets need their own annotation. - Halt before completion — gates (e.g.,
atoms.approval_gate) return_halt=True. The runner stops cleanly; the calling pipeline picks up where it left off on the next pass once the operator approves (gate state lives inpipeline_gate_historyper poindexter#366 phase 1). - Resume atomicity —
poindexter pipeline resumerecords the gate approval BEFORE re-invokingrun(resume=True). A resume that raises (the graph never advances past the gate — e.g. the checkpointer can’t be set up) leaves a dangling approval, so the CLI rolls it back (approval_service.rollback_resume_approval) and restores the pause. A resume that halts past the gate (a downstream node failed AFTER the gate passed) strands the taskin_progresswith an intact checkpoint andawaiting_gatealready cleared —has_resumable_checkpoint(pool, thread_id)(this module) lets the CLI offer a continue-resume from the checkpoint instead of refusing. Belt-and-suspenders: approvals are stamped with the task’sretry_count, andatoms.approval_gateonly honors an approval matching the currentretry_count, so a stale approval can never auto-pass a post-sweep fresh run even if a rollback is missed. Seepoindexter/cli/pipeline.py::resume_command. - Discord delivery fails — swallowed at debug level. The orchestrator continues; the operator just doesn’t get the progress ping for that node.
See also
plugins/atom.py—AtomMetashape (capability tier, cost class, retry policy) used by future architect-composed graphs.services/atom_registry.py— bridges legacy stages into the atom catalog so the architect-LLM can drop a stage at any point in a composed graph.