Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Failure and Cancellation

Describe failure and cancellation around the conceptual Harness Step boundary.

developer

Step failure is reported by the enclosing Turn. Harness has no separate public per-Step lifecycle event. The current Step either reaches the StepDone commit boundary or is discarded; the Turn then emits the exact terminal event that explains why execution stopped.

Failure mapping

Cause in the current StepIn-flight StepTurn result
Provider Stream or Next returns a non-cancellation errorNo StepDoneevent.TurnFailed{Err: original typed error}
Stream reaches EOF with no usable text, thinking, or tool useNo assistant messageevent.TurnFailed with *event.EmptyResponseError
Tool iteration or call limit is exceededTool Step is not committedevent.TurnFailed with *event.ToolLimitError
A Step hook or inference hook guard refusesNo Step commitevent.TurnFailed with a safe hook error
Step context is canceled while streaming, executing, or committingCurrent Step is discardedevent.TurnInterrupted

TurnFailed.Err retains the typed in-memory cause for errors.As. Its durable codec projects an error-safe representation because arbitrary Go errors do not have a stable JSON shape. TurnInterrupted carries no error field; the context cancellation is represented by the event kind.

Cancellation and rollback

The submit call’s context controls command delivery only. Once a command is admitted, the loop derives the Turn context from its own loop lifetime. Session.Interrupt or shutdown cancels that Turn context. The commit handshake also selects on cancellation, so a goroutine parked waiting for StepDone publication is released.

Rollback stops at the current Step. Earlier StepDone records and their committed history remain. A tool batch that is canceled before its result group commits produces neither a partial StepDone nor partial tool-result history.

%%{init: {"theme":"dark"}}%%
stateDiagram-v2
    [*] --> Streaming
    Streaming --> ToolExecution: usable AI message has tool uses
    Streaming --> Commit: usable text-only AI message
    ToolExecution --> Commit: all normalized results appended
    Streaming --> Failed: provider error or empty output
    ToolExecution --> Interrupted: context canceled
    Commit --> Done: durable boundary accepts Step group
    Commit --> Interrupted: commit context canceled
    Done --> [*]
    Failed --> [*]
    Interrupted --> [*]

Classify the terminal

package example

import (
	"errors"
	"fmt"

	"github.com/looprig/harness/pkg/event"
)

func classifyTurnTerminal(ev event.Event) error {
	switch terminal := ev.(type) {
	case event.TurnFailed:
		var empty *event.EmptyResponseError
		if errors.As(terminal.Err, &empty) {
			return fmt.Errorf("model returned no usable output: %w", empty)
		}
		var limit *event.ToolLimitError
		if errors.As(terminal.Err, &limit) {
			return fmt.Errorf("tool budget exhausted: %w", limit)
		}
		return fmt.Errorf("turn failed: %w", terminal.Err)
	case event.TurnInterrupted:
		return fmt.Errorf("turn interrupted")
	default:
		return nil
	}
}

An in-process hook can distinguish hook.OutcomeDenied, hook.OutcomeFailed, and hook.OutcomeCanceled, but those are observation results, not durable Step outcomes. If a guard panics, hook.Runner fails closed with *hook.GuardError; the Turn still owns the public terminal event.

Queued inputs after failure

When a Turn ends abnormally, the actor resolves still-queued inputs with event.InputCancelled instead of silently dropping them. The reason is event.CancelTurnFailed for a failed Turn and event.CancelTurnInterrupted for an interrupted Turn. The returned input may be retried by the caller. See Queued Input and Folding for the queue boundary.

Source and proof

← back to documentation