Documentation / guides
Turn Lifecycle
Describe the conceptual lifecycle from TurnStarted to TurnDone, TurnFailed, or TurnInterrupted.
The Turn lifecycle has one opening durable event and one exact durable terminal. The loop actor owns the opening and terminal state transitions. A Turn goroutine owns model streaming and tool continuation, but it sends each completed Step back to the actor for the durable commit.
Opening boundary
For an admitted input, the actor mints a TurnID, builds event.TurnStarted, and publishes it through the checked event boundary before installing the active Turn. The event contains the exact initial *content.UserMessage, the loop-local TurnIndex, and the submit command ID in Header.Cause.CommandID. Only after the opening event commits does the actor append the user message to live loop history and launch Turn execution.
// The public event shape is value-based. Runtime publications use event values,
// so consumers should type-switch on event.TurnStarted, not an invented pointer
// event or a separate Start reply.
type TurnStarted struct {
enduring
loopScoped
Header
TurnIndex TurnIndex `json:"turn_index,omitzero"`
Message *content.UserMessage `json:"message,omitzero"`
}
TurnStarted is both an enduring event and a event.Reply. Its ReplyTo() is the submit command ID. It is not a separate transport acknowledgement.
Running boundary
The Turn builds one request per conceptual Step. The current model configuration and tool set are captured at Turn start. Each successful Step commits a StepDone; a tool-using Step may then fold queued input and continue. A text-only Step commits and proceeds directly to the Turn terminal.
%%{init: {"theme":"dark"}}%%
sequenceDiagram
participant C as caller
participant A as loop actor
participant T as Turn goroutine
participant H as hooks
participant E as event boundary
C->>A: admitted UserInput
A->>H: Begin OperationTurn
A->>E: TurnStarted
E-->>A: durable commit
A->>T: launch with Turn context
loop one or more Steps
T->>H: Begin OperationStep
T->>T: stream, execute tools, stage group
T->>A: Step commit handshake
A->>E: StepDone
E-->>A: commit acknowledgement
end
T-->>A: TurnDone, TurnFailed, or TurnInterrupted
A->>E: terminal event
E-->>A: durable commit
A->>H: Finish OperationTurn
Terminal and idle ordering
The actor keeps the Turn’s coordinates while committing the terminal, then clears the active Turn state. On a normal terminal it resolves the next queued input or emits LoopIdle; the terminal is therefore observed before the loop’s idle edge. A chained Turn does not emit LoopIdle between the prior TurnDone and the next TurnStarted.
The terminal set is closed:
| Terminal | Trigger | Current incomplete Step |
|---|---|---|
event.TurnDone | final assistant message committed | none |
event.TurnFailed | provider, output, tool, hook, or durable failure | discarded |
event.TurnInterrupted | Turn context canceled | discarded |
Observe with a Turn hook
package example
import (
"context"
"fmt"
"github.com/looprig/harness/pkg/hook"
)
func compileTurnTimingHook() (*hook.Runner, error) {
return hook.Compile(hook.Set{Around: []hook.Around{{
Operation: hook.OperationTurn,
Begin: func(ctx context.Context, call hook.Call) (context.Context, hook.FinishFunc) {
fmt.Printf("turn index=%d id=%v input=%v\n",
call.Turn.Index, call.TurnID, call.Turn.Input != nil)
return ctx, func(result hook.Result) {
fmt.Printf("turn outcome=%v error=%v\n", result.Outcome, result.Err)
}
},
}}})
}
The Turn hook is for in-process observation and policy. The durable lifecycle is the event stream. A hook finish callback may report a failure caused by the terminal commit path, but it does not replace the terminal event.