* docs: design note for flow steps + Checkpoint durable execution
* docs: fold in durable-execution decisions (State struct, single Step, run retention, retry)
* docs: rename State.Payload to State.Data
* feat(flow): ordered steps + Checkpoint durable execution
A flow can now be an ordered list of steps (a task with stages) instead
of a single LLM turn. State carries typed Data plus a Stage marker; each
step is checkpointed before and after via a pluggable Checkpoint
(store-backed by default), so a run survives a crash and resumes where it
stopped without re-running completed steps. Flow-level Retry with a
per-step override; runs retained for audit unless DeleteOnSuccess.
Step actions: Call (RPC), LLM (augmented turn), Dispatch (to an agent),
or any StepFunc. Single-step and agent-dispatch flows are unchanged.
* feat(flow): top-level re-exports + durable flow example
Expose the step/checkpoint API from the micro package (FlowSteps,
FlowStep, FlowState, FlowRetry, FlowWithCheckpoint, FlowCall/LLM/Dispatch,
Checkpoint, StoreCheckpoint) and add a runnable, key-free example
demonstrating crash + resume.
* docs: document durable flow steps (guide, README, CLI help)
* docs: blog post + changelog for durable workflows
* fix(flow): scope checkpoint keys by flow name (flow/{name}/runs/{id})
Run keys were flow/runs/{id} — a single global keyspace shared by every
flow on the default store. Namespace them by flow name so each flow's
state is kept apart. StoreCheckpoint now takes a scope argument (the flow
passes its name by default).
* feat(store): Scope handle; scope agent and flow state by name
Add store.Scope(s, database, table) — a store handle that confines every
operation to a database/table without mutating the shared store, so
co-located components don't clobber each other's table (the failure mode
of the global Init(Table(...)) approach).
Use it to keep each agent's memory and plan in its own table
(agent/{name}) and each flow's runs in its own (flow/{name}), instead of
one global table partitioned only by key prefix. Services already scope
by service name.
* feat: consistent state model — service store scoping, flow registry, list/history CLI
- service: scope store via store.Scope (database service / table name),
retiring the Init(store.Table(name)) global-mutation hack; bridge the
default store so handlers using store.DefaultStore stay isolated.
- flow: register in the registry as type=flow while running (with trigger
and step count), deregister on Stop. Live discovery, like agents.
- cli: micro flow list (registry), micro flow runs <name> (durable store),
micro agent history <name> (durable store). list = running, runs/history
= durable, mirroring the service model.
* test: mini-universe end-to-end harness + scheduled GitHub Action
internal/harness/universe boots a small but real go-micro world — four
services, a durable checkout flow that crashes at payment and resumes,
and a guardrailed agent with a tool wrapper reached over RPC — drives the
scenario, asserts the end state (10 checks), and shuts down. Everything
is real except the LLM (mocked), so it's deterministic and needs no key;
-provider anthropic runs it live. Exits non-zero on failure, so it's an
end-to-end test, not just a demo.
Adds .github/workflows/universe.yml (push/PR/daily/dispatch) running the
universe + existing harnesses on the mock provider, plus an opt-in job
that runs live when ANTHROPIC_API_KEY is set. 'make harness' runs them
locally.
* ci: run the live universe job against AtlasCloud (ATLASCLOUD_API_KEY)
* ci: run the live universe job only on schedule or manual dispatch
The deterministic mock job still runs on push/PR/daily; the live
(AtlasCloud) job runs daily and on manual workflow_dispatch only, so
changes don't burn API credits on every PR but can still be checked
against a real model on demand.
---------
Co-authored-by: Claude <noreply@anthropic.com>
11 KiB
Durable Execution: Flow Steps & Checkpoint
Status: Design proposal — not yet implemented.
This note sketches two related changes:
- Give flow a real step model — a flow is a task made of ordered steps — so it becomes the deterministic-workflow engine it has always claimed to be (today it runs a single LLM step per event).
- Introduce
Checkpoint, a pluggable durability primitive that persists run progress and resumes after a crash. Store-backed by default; both flow and agent use it.
The two are designed together because a step boundary is the natural place to checkpoint.
Motivation
A flow or agent run is long, expensive, and has side effects partway through (it sent an email at step 2, charged via x402 at step 4). Today all in-flight state lives in process memory: a crash loses the run, and re-running from the top repeats the side effects.
Durable execution means the run survives a crash and continues from where it stopped, without re-doing completed steps.
This is squarely a distributed-systems concern — checkpoint state, replay
on restart, pluggable backend — i.e. go-micro's kind of problem, built on
primitives it already has (store, WrapTool, call.ID).
What flow is today (for contrast)
flow is a concrete *Flow struct. Per broker event, Execute runs
one augmented-LLM turn (a single Generate with services as tools)
or dispatches the event to an agent, records one Result, and returns.
There is no notion of a task with ordered steps, no carried state, no
checkpoint. The step model below generalizes today's behavior: a flow
with one step == current flow.
Core concepts
State
What carries across steps. A struct, not a map — a typed Data
plus a Stage marker so you can always tell where a run is.
type State struct {
Stage string // name of the step the run is at — where it is
Data []byte // carried data, serialized; use Set / Scan
}
// Set replaces the data with the JSON encoding of v.
func (s *State) Set(v any) error
// Scan decodes the data into v (a pointer to the caller's struct).
func (s State) Scan(v any) error
The developer defines their own data struct and threads it through
with Set/Scan — type-safe at the edges, serializable in the middle
(which is what makes checkpointing possible). Stage is the readable
"where am I"; the engine also uses it as the resume point.
The trigger event seeds the first State.
Step
The unit of a flow. One kind — a struct with a name, the action to run, and an optional retry override. No per-kind constructors.
type StepFunc func(ctx context.Context, in State) (State, error)
type Step struct {
Name string
Run StepFunc
Retry int // optional per-step override of the flow's retry (0 = use flow default)
}
Common actions are helpers that return a StepFunc, dropped into
Step.Run — so there is still one Step type, and the actions compose:
flow.Call(service, endpoint) StepFunc // one RPC to a service
flow.LLM(opts...) StepFunc // one augmented-LLM turn
flow.Agent(name) StepFunc // dispatch to a registered agent
// …or write your own StepFunc.
Steps are authored by the developer and run in order. That ordering is the defining difference from an agent, where the model chooses the steps.
Run
The persisted record of one execution — what Checkpoint saves and
loads. Retained for success and failure (see retention below).
type Run struct {
ID string // durable run id (idempotency root)
Flow string // flow name
State State // carried data + Stage (where it is)
Steps []StepRecord // per-step status + outcome (history/audit)
Status string // running | done | failed
Started time.Time
Updated time.Time
}
type StepRecord struct {
Name string
Status string // pending | in_progress | done | failed
Attempts int // how many tries this step took
Result string // short serialized outcome / summary
Error string
}
The resume point is State.Stage — there is no separate numeric cursor,
so there is one source of truth for "where it is."
Checkpoint
The pluggable durability primitive. Persists and restores a Run.
type Checkpoint interface {
Save(ctx context.Context, run Run) error
Load(ctx context.Context, runID string) (Run, bool, error)
Delete(ctx context.Context, runID string) error
}
The built-in implementation is store-backed and on by default, keyed in the store:
database "flow", table "{name}", key {runID} → JSON(Run)
Runs are confined to their own store table — database flow, one
table per flow name — via store.Scope, not a single shared table keyed
by prefix. StoreCheckpoint(s, scope) takes that scope; the flow passes
its name by default. store.Scope injects the database/table per
operation, so it doesn't mutate or race on the shared store (the way
Init(Table(...)) would). Because it rides on store.Store, the storage
is also pluggable (Postgres, NATS KV, file) with no extra interface.
Retention: completed runs (success and failure) are kept by
default, so you have a durable history of what ran. Delete is only
called when the flow opts in with flow.DeleteOnSuccess() (failures are
always kept).
The run loop
run := load(runID) or new Run{State: {Stage: steps[0].Name, ...}}
start := index of step named run.State.Stage
for i := start; i < len(steps); i++ {
step := steps[i]
run.Steps[i].Status = "in_progress"; checkpoint.Save(run)
out, err := runWithRetry(ctx, step, run.State, retriesFor(step))
run.Steps[i].Attempts = attemptsTaken
if err != nil {
run.Steps[i].Status = "failed"; run.Steps[i].Error = err
run.Status = "failed"; checkpoint.Save(run) // kept for audit
return err // resumable: retry resumes here
}
run.State = out
run.Steps[i].Status = "done"
if i+1 < len(steps) {
run.State.Stage = steps[i+1].Name // <-- checkpoint boundary
} else {
run.State.Stage = "" // finished
}
checkpoint.Save(run)
}
run.Status = "done"; checkpoint.Save(run)
// Delete only if flow.DeleteOnSuccess() was set.
On restart, Load returns the Run; the loop resumes at the step named
run.State.Stage, so completed steps are skipped — their effects already
happened and their output is already in run.State.Data.
Retry
Flow-level by default, per-step override when needed (e.g. a tool that times out):
flow.Retry(2) // flow-level default for every step
flow.Step{Name: "charge", Run: …, Retry: 0} // override: never retry this one
retriesFor(step) uses step.Retry if set, else the flow default.
Idempotency (the honest part)
True exactly-once is impossible if a crash lands inside a step. What we
provide is at-least-once + a stable idempotency key per step:
runID + stepName. That key is passed to the tool as call.ID, so a
replayed call is recognized downstream and de-duplicated. Side-effecting
steps must cooperate (honor the key). The framework makes this
consistent; it cannot make it free.
Retry uses the same key, so a retried step is de-duplicated the same way.
This is where the existing WrapTool seam pays off: a durable wrapper
checks the checkpoint — if this call.ID already has a recorded result,
return it without re-calling.
Agent reuse
The agent loop is the self-directed analogue and uses the same
Checkpoint. The difference is who authors the steps:
| Steps authored by | Steps known | Durability | |
|---|---|---|---|
| flow | developer | up front (ordered list) | checkpoint between steps |
| agent | the model | discovered at runtime | checkpoint each LLM turn + its tool calls |
For the agent, Run.Steps grows as the model acts, instead of being
predefined. One requirement: the agent must own its loop (today the
provider drives it), so it can Save between turns. That is the one
structural change on the agent side.
Pluggability — two levels
- Storage (free today). Built-in
Checkpointoverstore.Store; swap the store backend. Covers "checkpoint to my DB instead." - Engine (future). Because steps are now explicit and named, a flow
can be mapped onto an external durable-execution engine — each
Stepbecomes a Temporal activity / Restate handler — by providing an alternative runner. Most users only need level 1; level 2 exists so teams already running Temporal aren't forced off it.
The explicit step model is what makes level 2 possible later; we don't build it now.
Proposed API
type Onboarding struct {
Email string `json:"email"`
WorkspaceID string `json:"workspace_id"`
}
f := flow.New("onboard-user",
flow.Trigger("events.user.created"),
flow.Retry(2), // flow-level retry default
flow.Steps(
flow.Step{Name: "plan", Run: flow.LLM(flow.Prompt("Plan onboarding for {{.Email}}"))},
flow.Step{Name: "workspace", Run: flow.Call("workspace", "Workspace.Create")},
flow.Step{Name: "welcome", Run: flow.Agent("comms")},
),
// Durable by default (store-backed); runs are retained for audit.
flow.WithCheckpoint(flow.StoreCheckpoint(service.Options().Store, "onboard-user")),
)
f.Register(reg, broker, client)
A single-step flow keeps today's behavior, so this is additive.
Decisions (resolved)
- State is a struct, not a map — typed
Data+Stage. The developer defines the data struct;Stagedoubles as the resume point, so there is one source of truth for position. - One
Stepkind — a struct withName,Run, and an optionalRetry. Common actions areStepFunchelpers (Call,LLM,Agent), not separate step constructors. - Runs are retained for success and failure by default;
flow.DeleteOnSuccess()opts into cleanup (failures always kept). - Retry is a flow-level option (
flow.Retry(n)), with a per-stepRetryfield as a fine-grained override.
Scope & phasing
- Step model in flow (no durability yet):
State,Step, orderedSteps, the run loop, retry. Single-step flows unchanged. Checkpoint+ store-backed default: persist/resume flow runs, retention.- Agent durability: move the agent loop in-package, reuse
Checkpoint. Opt-in (AgentDurable()), default off — overkill for short interactive chats, essential for long unattended runs. - Engine-level pluggability (Temporal/Restate): only if demand.
Each phase is independently useful and shippable.