Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Terminal Outcomes

Describe the exact TurnDone, TurnFailed, and TurnInterrupted outcomes for conceptual Turns.

developer

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

This page reserves the approved Harness navigation structure. The exact durable terminal events are TurnDone, TurnFailed, and TurnInterrupted.

← back to documentation