Yield and Re-Enter
A task can’t always finish in a single pass. It may need to wait for a human approval, run a child workflow to completion, or poll an external job that isn’t done yet. Rather than block a worker for minutes or days, the task yields: it hands control back to the Foreman, which durably parks the step and later re-executes the same task from the top once the awaited result is available.
This is one mechanic with three surface APIs — Interrupt, Subgraph, and Retry. Understanding the yield-and-re-enter shape once explains all three.
The Lifecycle
- The task runs and calls a yielding signal:
flow.Interrupt,flow.Subgraph, orflow.Retry. - The signal reports that the task should yield. The task returns immediately, without an error.
- The Foreman persists the parked step to SQL and frees the worker — nothing is held open. The flow survives a process crash or a mid-execution deploy in this parked state.
- When the awaited condition is met — resume data arrives, the child workflow finishes, or the backoff delay elapses — the Foreman dispatches the same task again, from the top.
- On this pass the result is available. The task recognizes the re-entry, adopts the result, and continues.
Because the flow’s state is persisted at the park point, re-entry can happen seconds or days later, on a different worker, after a redeploy. The task body is the same code both times; what changes is whether the result is ready.
The Idiom
Interrupt and Subgraph both return (yield, err) and unmarshal their result into an out-pointer you pass in. The canonical task shape branches on yield:
var out otherapi.SomeWorkflowOut
yield, err := flow.Subgraph(otherapi.SomeWorkflow.URL(), otherapi.SomeWorkflowIn{Field: value}, &out)
if yield {
return nil // first pass: parked, awaiting the child
}
if err != nil {
return errors.Trace(err) // re-entry with a failure: retry, route, or propagate
}
result := out.ResultField // re-entry with a result: adopt and continueyield == trueon the first pass — the task has just parked. Return without an error.yield == falseon re-entry —outnow holds the payload, orerrcarries a failure.
Interrupt
flow.Interrupt(payload, &resume) parks the flow for external input — a human approval, a third-party callback, any event that can’t be awaited synchronously. It returns (yield, err) and unmarshals whatever an external actor passes to foremanapi.Resume into resume when you resume the parked flow; err is always nil.
Subgraph
flow.Subgraph(url, in, &out) launches a child workflow from the task body — the way one workflow invokes another. It returns (yield, err) and unmarshals the child’s final state into out; err is a child-workflow failure the parent can recover from. Only the explicit in map crosses into the child and only out crosses back; nothing is auto-inherited in either direction, because the subgraph boundary is a function call rather than a shared state scope.
A subgraph invokes a whole workflow, never a bare task — a task is only ever a node in a graph, and the workflow is the independently-invocable unit. Prefer the generated typed client over the raw call: otherapi.NewSubgraph(flow).SomeWorkflow(ctx, args...) wraps flow.Subgraph with typed In/Out.
Retry
flow.Retry(initialDelay, multiplier, maxIntervalDelay, giveUpAfter) expresses the same lifecycle with a boolean instead of a tuple. It returns true while the step is within its retry horizon — the task then returns to park until the backoff elapses and re-enters from the top — and false once the horizon (giveUpAfter of wall-clock since the step was first created) is spent. It carries no condition of its own, so the task writes the retryable predicate in the surrounding if:
if status == "running" {
// Poll on a constant 30s interval, giving up 1h after the step first ran.
if !flow.Retry(30*time.Second, 1, 30*time.Second, time.Hour) {
return "", errors.New("job did not complete within poll budget")
}
return "", nil // parked; the Foreman re-runs this task after the delay
}Re-Entry Runs the Whole Task Again
The most important consequence of this pattern: re-entry re-executes the task body from the top. Everything before the yielding call runs on every pass. The yield guard only protects the code after the call.
func (svc *Service) ChargeAndConfirm(ctx context.Context, flow *workflow.Flow, amount int) (confirmed bool, err error) {
svc.chargeCard(ctx, amount) // ⚠️ runs again on every re-entry — double charge
var resumeData map[string]any
yield, _ := flow.Interrupt(map[string]any{"request": "confirm"}, &resumeData)
if yield {
return false, nil
}
confirmed, _ = resumeData["confirmed"].(bool)
return confirmed, nil
}To keep tasks safe to re-run:
- Keep the work above the yielding call cheap and idempotent — reading state, validating inputs, building the
inmap. - Never place an un-guarded non-idempotent side effect (charge a card, send an email, insert a row) above a yielding call; it fires once per re-entry.
- If a task must both cause a side effect and yield, do the side effect on re-entry (guarded by
!yield), or split it into its own task.
This is one reason tasks are written to read inputs from state, do their work, and write outputs back: a task built that way is naturally safe to re-run.
Sleep Parks but Does Not Re-Enter
flow.Sleep(d) also parks the flow, but it delays the next step rather than re-executing the current one — there is no re-entry and no yield return. Reach for Sleep to pace what comes after this task; reach for Retry when this task needs to run again after a delay.
One Park per Dispatch
A step parks at most once per dispatch (interrupt XOR subgraph), and a task must set at most one of Goto, Retry, Interrupt, or Subgraph. Setting two control signals fails the step. If a child workflow interrupts, the interrupt propagates up the parent chain to the root flow — each parked level re-enters in turn as the resume travels back down.
See Also
- Control Signals — the build guide’s per-signal walkthrough with resume calls.
- State — what crosses the subgraph
in/outboundary. workflowpackage reference — the*workflow.Flowcarrier and signal signatures.