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. Its err reports a payload that cannot be marshaled, resume data that cannot be unmarshaled into resume, or a second park in the same step, so return it like any other error.
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, err := flow.Interrupt(map[string]any{"request": "confirm"}, &resumeData)
if err != nil {
return false, errors.Trace(err)
}
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.