Documentation / guides
Terminal Outcomes
Describe the exact TurnDone, TurnFailed, and TurnInterrupted outcomes for conceptual Turns.
Every conceptual Turn ends with exactly one of three public terminal event
types: event.TurnDone, event.TurnFailed, or event.TurnInterrupted. They
are enduring and terminal by construction. The terminal event closes the Turn’s
per-turn stream, but it does not erase StepDone records that committed before
the terminal boundary.
Terminal set
The public event shapes are exact:
// pkg/event/turn.go, with lifecycle/scope mixins omitted from this excerpt.
type TurnDone struct {
Header
TurnIndex event.TurnIndex
Message *content.AIMessage
Usage content.Usage
}
type TurnFailed struct {
Header
TurnIndex event.TurnIndex
Err error // json:"-"; typed in memory
}
type TurnInterrupted struct {
Header
TurnIndex event.TurnIndex
}
TurnDone is successful completion and carries the complete AI response plus
the checked sum of usage from completed requests in the Turn. TurnFailed
carries the trusted in-process error so a caller can use errors.As; it is for
provider, validation, hook, admission, and other non-cancellation errors.
TurnInterrupted carries no error because cancellation is a control outcome,
not a provider diagnosis.
All three embed the unexported terminal mixin. That mixin supplies
Class() == event.Enduring and EndsTurn() == true, so a terminal record can
never be treated as droppable. There is no separate public event for a generic
per-Turn completion or cancellation state.
Success payload
On a text-only final response, the runtime first commits the final StepDone
and then publishes TurnDone. Message is a cloned *content.AIMessage; it
is not a token stream and may include the final assistant blocks. Usage is
validated before publication:
func renderSuccess(ev event.Event) error {
done, ok := ev.(event.TurnDone)
if !ok {
return fmt.Errorf("want TurnDone, got %T", ev)
}
if done.Message == nil {
return errors.New("TurnDone has no complete AI message")
}
if err := done.Usage.Validate(); err != nil {
return fmt.Errorf("invalid TurnDone usage: %w", err)
}
return nil
}
The event validator also requires turn coordinates (SessionID, LoopID, and
TurnID) and a zero StepID. TurnIndex is loop-local; use the header IDs for
stable cross-loop correlation.
Failure payload
The runtime returns TurnFailed for a non-cancellation error. The failed
in-flight step has not committed, so no StepDone is emitted for that step. A
prior step remains durable and visible. Examples include an empty model
response, a malformed final structured output, a tool limit, a hook denial, and
a provider error.
var failed event.TurnFailed
for delivery := range subscription.Events() {
switch ev := delivery.Event.(type) {
case event.TurnFailed:
failed = ev
var empty *event.EmptyResponseError
if errors.As(ev.Err, &empty) {
// Ask the model or caller to retry with a non-empty response.
}
var limit *event.ToolLimitError
if errors.As(ev.Err, &limit) {
// Report a bounded tool-loop failure.
}
case event.TurnInterrupted:
// No provider error should be shown for cooperative cancellation.
}
}
Err is tagged json:"-" because arbitrary Go errors have no stable general
codec. event.MarshalEvent projects the live error to the stable wire form
used by the journal and reconstructs a typed restored error on decode. A
consumer must not expect the original error’s concrete identity after a
restart.
Interruption payload
When the Turn context is canceled, the runtime maps the cancellation to
TurnInterrupted, including cancellation while streaming, executing tools,
waiting for a gate, measuring a candidate request, draining queued input, or
waiting for a durable commit acknowledgement. The current incomplete step is
discarded. Previously committed steps and their StepDone events stay intact.
%%{init: {"theme":"dark"}}%%
stateDiagram-v2
[*] --> Running
Running --> CommittingStep: complete step
CommittingStep --> Running: StepDone durable
Running --> Done: final step committed
Running --> Failed: non-cancellation error
Running --> Interrupted: context cancelled
CommittingStep --> Interrupted: cancellation before commit ack
Done --> [*]
Failed --> [*]
Interrupted --> [*]
The terminal event is published before the loop’s idle transition. Queue
resolution follows the terminal: normal success can chain the next queued user
input, while failure and interruption return unresolved entries through
InputCancelled according to their agency and cancellation reason.
Durable projection
Use event.MarshalEvent only for enduring events:
wire, err := event.MarshalEvent(ev)
if err != nil {
var ephemeral *event.EphemeralNotPersistableError
if errors.As(err, &ephemeral) {
// TokenDelta and other live-only events are expected here.
}
return err
}
// Store wire in the journal; EventID and coordinates remain in the envelope.
_ = wire
Terminal events are marshalable. TurnDone.Message uses the content message
codec, TurnFailed.Err uses the error projection, and TurnInterrupted has no
interface-valued payload. TokenDelta is deliberately not part of durable
history, so replay reconstructs the committed message groups from StepDone
and the terminal record.
Source and proof
pkg/event/turn.godefines the three terminal event fields and comments their runtime meaning.pkg/event/event.godefines the terminal lifecycle mixin andEndsTurncontract.pkg/event/validate.govalidates coordinates andTurnDone.Usage.pkg/event/marshal.godefines durable encoding and theTurnFailed.Errprojection.internal/loopruntime/turn.gomaps stream, tool, commit, and context outcomes to the terminal set.internal/loopruntime/loop.goorders terminal publication, idle transition, and queue resolution.
This page reserves the approved Harness navigation structure. The exact durable terminal events are TurnDone, TurnFailed, and TurnInterrupted.