Skip to documentation
Documentation navigation

Documentation navigation

Documentation / guides

Interrupt

Interrupt active work while preserving already committed Steps.

developer

Interrupt is a cancellation request, not a rollback. A running loop stops at a safe boundary, publishes its terminal interruption event, and retains the Steps that were already committed. The public session method fans the request out to every live loop; a loop controller can scope interruption to one loop and its delegate subtree.

Public contracts

type Session interface {
	Interrupt(context.Context) (bool, error)
}

type Controller interface {
	Handle
	SetMode(context.Context, ModeName) error
	Change(context.Context, ...Change) error
	Interrupt(context.Context) error
}

Session.Interrupt stamps user agency and returns true if at least one live loop reported that it cancelled a running turn. It returns false for an idle session or a session already shutting down. A context or durable-session fault is returned as a typed *session.SessionError; a failed interrupt does not claim that work was stopped.

The low-level command is intentionally session-wide and carries no coordinates:

const (
	CommandInterrupt CommandName  = "Interrupt"
	InterruptAck     CommandField = "Ack"
)

type Interrupt struct {
	Header
	Ack chan<- bool `json:"-"`
}

func (c Interrupt) Validate() error

The actor sends one boolean to the required live channel. The channel must be non-nil. It is not a durable reply path and is omitted from JSON. A session runtime keeps the dispatch target alongside the command when it writes an audit intent record, because the command itself cannot address one loop.

%%{init: {"theme":"dark"}}%%
sequenceDiagram
    participant App as Application
    participant S as Session
    participant L1 as Primary loop
    participant L2 as Delegate loop
    participant E as Event fan-in

    App->>S: Interrupt(ctx)
    S->>S: mark every live loop interrupt-pending
    par concurrent fan-out
        S->>L1: Interrupt{Ack}
        S->>L2: Interrupt{Ack}
    end
    L1-->>S: true or false
    L2-->>S: true or false
    L1->>E: TurnInterrupted (if active)
    L2->>E: TurnInterrupted (if active)
    S-->>App: anyCancelled, error

What interruption preserves

The turn actor owns the commit handshake. If interruption arrives before a Step commit, that in-flight step is discarded and the terminal event is event.TurnInterrupted. Previously committed event.StepDone records remain in the loop’s history. The loop’s context cancellation also returns queued input as event.InputCancelled with CancelTurnInterrupted or CancelTurnFailed, as appropriate. A no-op interrupt on an idle loop produces no terminal turn event.

The subtree-scoped controller method also prevents a parent delegate wait from opening a fresh child step while the interrupted subtree drains. This is why a controller interrupt is not just a convenience wrapper around a single command.

Error handling

func stopAll(ctx context.Context, s session.Session) error {
	stopped, err := s.Interrupt(ctx)
	if err != nil {
		var se *session.SessionError
		if errors.As(err, &se) {
			log.Printf("interrupt failed at session boundary: %s", se.Kind)
		}
		return err
	}
	if !stopped {
		// The session was idle or already closing. There is no active turn to
		// await, so do not manufacture a success event.
		return nil
	}
	return nil
}

command.InvalidCommandError identifies a missing low-level Ack. It is an internal construction error, not a normal user cancellation result:

cmd := command.Interrupt{Ack: make(chan bool, 1)}
if err := cmd.Validate(); err != nil {
	var invalid *command.InvalidCommandError
	if errors.As(err, &invalid) {
		// invalid.Field is command.InterruptAck when Ack is nil
	}
}

Durability, shutdown, and proof

Interrupt intent may be appended before best-effort dispatch for audit and restore classification, but the live Ack is never serialized. Shutdown has different ownership and cleanup guarantees; it closes admission and joins all loops. Use Shutdown when the session itself must end.

The public behavior is exercised by internal/sessionruntime/session_test.go, which checks session-wide and exact-loop fan-out. The actor’s commit-cancellation contract is proved by pkg/loop/errors.go and the turn tests that assert committed Steps survive interruption. The command channel and serialization contract is covered by pkg/command/interrupt_test.go and pkg/command/marshal_test.go.

← back to documentation