e071084ebe
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
312 lines
11 KiB
Markdown
312 lines
11 KiB
Markdown
# Durable Execution: Flow Steps & Checkpoint
|
|
|
|
**Status:** Design proposal — not yet implemented.
|
|
|
|
This note sketches two related changes:
|
|
|
|
1. 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).
|
|
2. 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.
|
|
|
|
```go
|
|
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.
|
|
|
|
```go
|
|
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:
|
|
|
|
```go
|
|
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).
|
|
|
|
```go
|
|
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`.
|
|
|
|
```go
|
|
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):
|
|
|
|
```go
|
|
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
|
|
|
|
1. **Storage (free today).** Built-in `Checkpoint` over `store.Store`;
|
|
swap the store backend. Covers "checkpoint to my DB instead."
|
|
2. **Engine (future).** Because steps are now explicit and named, a flow
|
|
can be mapped onto an external durable-execution engine — each `Step`
|
|
becomes 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
|
|
|
|
```go
|
|
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; `Stage` doubles as the resume
|
|
point, so there is one source of truth for position.
|
|
- **One `Step` kind** — a struct with `Name`, `Run`, and an optional
|
|
`Retry`. Common actions are `StepFunc` helpers (`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-step
|
|
`Retry` field as a fine-grained override.
|
|
|
|
---
|
|
|
|
## Scope & phasing
|
|
|
|
1. **Step model in flow** (no durability yet): `State`, `Step`, ordered
|
|
`Steps`, the run loop, retry. Single-step flows unchanged.
|
|
2. **`Checkpoint` + store-backed default**: persist/resume flow runs,
|
|
retention.
|
|
3. **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.
|
|
4. **Engine-level pluggability** (Temporal/Restate): only if demand.
|
|
|
|
Each phase is independently useful and shippable.
|