sessionruntime

package
v0.20.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 15, 2026 License: AGPL-3.0 Imports: 27 Imported by: 0

Documentation

Index

Constants

View Source
const (
	EventRuntimeSnapshot = "runtime_snapshot"
	EventRuntimeDelta    = "runtime_delta"
	EventRuntimeDropped  = "runtime_dropped"
	// EventDecisionOutput wakes a channel continuation reader. It carries no
	// payload: the reader always resumes from its cursor in the output log.
	EventDecisionOutput = "decision_output"

	RunStatusRunning   = "running"
	RunStatusAdmitting = "admitting"
	// RunStatusWaitingDecision keeps the admitted run active while its native
	// execution is parked on a durable approval or ask_user decision.
	RunStatusWaitingDecision = "waiting_decision"
	RunStatusAborting        = "aborting"
	RunStatusFinishing       = "finishing"
	RunStatusCompleted       = "completed"
	RunStatusAborted         = "aborted"
	RunStatusErrored         = "errored"
	RunStatusLost            = "lost"

	RunOperationRetry = "retry"
	RunOperationEdit  = "edit"

	CommandAbort                = "abort"
	CommandSteerWake            = "steer_wake"
	CommandToolApprovalResponse = "tool_approval_response"
	CommandUserInputResponse    = "user_input_response"
	CommandHistoryReset         = "history_reset"
	CommandResult               = "command_result"

	ResetScopeSession = "session"
	ResetScopeBot     = "bot"
)
View Source
const (
	// MaxPendingQueueItems bounds accepted items per queue and session. Queue
	// state is one serialized document per queue, so an unbounded pending set
	// would grow every mutation's read and write linearly.
	MaxPendingQueueItems = 64
)
View Source
const (
	// QueueErrorTargetRunNotActive marks a steer whose target run reached a
	// terminal state before the steer entered a model step.
	QueueErrorTargetRunNotActive = "queue_target_run_not_active"
)

Stable error codes recorded on rejected queue items. They are runtime vocabulary, not transport codes: the item stays readable through the queue API until compaction drops it.

Variables

View Source
var (
	// ErrSessionBusy means the session already has an active run and this is a
	// different invocation. SR-OWN-001 offers two ways to honour single-session
	// execution — durable queueing or a stable retryable busy answer — and this
	// runtime answers busy. It is an alias rather than a second sentinel so that
	// one errors.Is works whether the caller holds the ledger error or this one.
	//
	// The consequence is deliberate: the session runtime is not a general task
	// queue. Channel delivery and schedules already own a retry
	// mechanism, and reusing those is strictly better than teaching the runtime
	// to hold work for them, which would cost a queued state, a queue index,
	// promotion on every terminal transition, queued aborts, and a queue
	// projection on the client.
	ErrSessionBusy = ledger.ErrSessionBusy

	// ErrInvocationConflict means one invocation_id was submitted twice with
	// different canonical input. Per SR-ADM-001 that is a stable, identifiable
	// conflict rather than a new run: silently admitting it would give the
	// caller two runs for one retry.
	//
	// Both sentinels reach clients as apperror catalog codes — session_busy is
	// retryable, invocation_conflict is not. Domain code keeps the sentinel;
	// the HTTP boundary owns that mapping.
	ErrInvocationConflict = errors.New("runtime invocation conflicts with an earlier submission")

	// ErrLedgerUnavailable means durable admission was requested on a manager
	// built without a ledger. Admission is durable-first by definition, so
	// there is no in-memory fallback to degrade to.
	ErrLedgerUnavailable = errors.New("session runtime ledger is not configured")

	// ErrFenceUnavailable means a ledger was configured without the persistence
	// fence that claims depend on. Claiming without it would hand a run
	// ownership that PostgreSQL does not enforce, so the superseded owner would
	// keep writing history — the failure SR-OWN-002 exists to rule out.
	ErrFenceUnavailable = errors.New("session runtime persistence fence is not configured")

	// ErrSessionRuntimeSplit means the deployment declared more than one
	// instance but the live backend cannot arbitrate ownership between them.
	ErrSessionRuntimeSplit = errors.New("session runtime cluster mode requires a distributed backend")
)
View Source
var (
	ErrQueueSteerUnsupported    = errors.New("queue: active run has no steer consumer")
	ErrQueueNoActiveRun         = errors.New("queue: no active run")
	ErrQueueInvalidReference    = errors.New("queue: invalid claim reference")
	ErrQueueNotPending          = errors.New("queue: item is not an accepted pending item")
	ErrQueueInvocationConflict  = errors.New("queue: invocation payload conflicts with an existing item")
	ErrQueueCapacityExceeded    = errors.New("queue: pending capacity exceeded")
	ErrLiveQueueUnavailable     = errors.New("session runtime queue is unavailable")
	ErrQueueAdmissionOverloaded = errors.New("queue: admission overloaded")
)
View Source
var (
	ErrCommandOwnerUnavailable = errors.New("runtime command owner is unavailable")
	ErrCommandTargetNotActive  = errors.New("runtime command target is not active")
	ErrCommandTargetMismatch   = errors.New("run does not belong to this session")
	ErrCommandExpired          = errors.New("runtime command expired before acknowledgement")
	ErrCommandBusy             = errors.New("runtime command executor is busy")
	ErrCommandPayloadConflict  = errors.New("runtime command payload conflicts with an earlier request")
	ErrDecisionNotFound        = errors.New("runtime decision was not found")
	ErrManagerClosed           = errors.New("session runtime manager is closed")
	ErrRunOwnershipLost        = errors.New("runtime run ownership was lost")
	ErrHistoryResetInProgress  = errors.New("session history reset is in progress")
	ErrHistoryResetUnavailable = errors.New("session history reset coordination is unavailable")
	ErrHistoryResetLeaseLost   = runtimefence.ErrResetLeaseLost
)
View Source
var ErrDecisionOutputSequenceGap = errors.New("decision output sequence gap")

ErrDecisionOutputSequenceGap reports an append whose seq skips uncommitted entries. The producer treats it as a failed checkpoint write.

Functions

func IsActiveRunStatus added in v0.20.0

func IsActiveRunStatus(status string) bool

IsActiveRunStatus reports whether a projected run status still occupies the session: accepted, running, or waiting for a decision.

func SteerRunAvailable added in v0.20.0

func SteerRunAvailable(run *CurrentRunView) bool

SteerRunAvailable reports whether the current executor accepts new step inputs.

Types

type ActiveRunUpdate

type ActiveRunUpdate func(snapshot Snapshot, now time.Time) (Snapshot, bool, error)

type Admission

type Admission struct {
	RunID string
	// TurnID and TurnPosition are decided at admission so the eventual terminal
	// write targets a turn that already exists on paper.
	TurnID       string
	TurnPosition int64
	State        ledger.State
	// Replay is true when this invocation was already admitted. The rest of the
	// struct then describes the original run, whatever state it has reached.
	Replay bool
	// Started is true when this call took ownership and began execution. It is
	// false for a replay of a run someone else owns and for a replay of a
	// finished run.
	Started bool
	// Cursor is the position at which this run became observable. A caller that
	// both starts runs and subscribes to the session uses it to place the run in
	// the stream it is already reading, rather than guessing whether a snapshot
	// it holds predates its own submission (SR-OBS-002).
	//
	// It is set only when Started. A replay reserved nothing, so this call
	// produced no position; where the session stands now is a different fact,
	// and the subscription's snapshot is the authoritative answer to it.
	Cursor Cursor
	// Handle names the run this process now owns, including the fencing token
	// its durable writes must carry. It is meaningful only when Started.
	Handle RunHandle
}

Admission is the durable answer to one submission. It is safe to return to a caller as confirmation to stop retrying: everything in it is committed before the call returns.

type AdmitInput

type AdmitInput struct {
	BotID     string
	SessionID string
	// InvocationID is the caller's retry identity. Callers that cannot mint one
	// must derive it deterministically from their source (for example a channel
	// message id), because a fresh id per attempt turns a retry into a second
	// run.
	InvocationID string
	// Payload is the canonically encoded submission. Its fingerprint decides
	// whether a repeated invocation_id is the same submit or a conflict, so the
	// encoding must be stable for equal inputs — same key order, no timestamps,
	// no request ids.
	Payload []byte
	// Execution is the owner-side plumbing used only if this call wins the
	// claim. A replay that finds the run already owned never touches it.
	Execution Execution
}

AdmitInput is one submission from a public entry point: HTTP, a channel adapter or a schedule. Every caller supplies the same three identities, which is what lets one admission path serve all of them.

type Backend

type Backend interface {
	Now(ctx context.Context) (time.Time, error)
	// Load returns a snapshot the caller owns and may freely mutate.
	Load(ctx context.Context, key Key) (Snapshot, bool, error)
	Update(ctx context.Context, key Key, update SnapshotUpdate) (Snapshot, bool, error)
	Publish(ctx context.Context, event Event) error
	Subscribe(ctx context.Context, key Key) (Subscription, error)
	DecisionOutputStore
	Close() error
}

type Command

type Command struct {
	Type         string `json:"type"`
	ID           string `json:"id,omitempty"`
	ReplyOwnerID string `json:"reply_owner_id,omitempty"`
	BotID        string `json:"bot_id"`
	SessionID    string `json:"session_id"`
	RunID        string `json:"run_id"`
	Generation   string `json:"generation"`
	FencingToken int64  `json:"fencing_token,omitempty"`
	TargetID     string `json:"target_id,omitempty"`
	// DecisionResolved means the command target was resolved from PostgreSQL
	// before routing. Owner-side execution must not consult the live UI
	// projection again: it is derived state and may lag the durable decision.
	DecisionResolved bool            `json:"decision_resolved,omitempty"`
	Payload          json.RawMessage `json:"payload,omitempty"`
	PayloadHash      string          `json:"payload_hash,omitempty"`
	ErrorCode        string          `json:"error_code,omitempty"`
	Error            string          `json:"error,omitempty"`
	CreatedAt        time.Time       `json:"created_at"`
	ExpiresAt        time.Time       `json:"expires_at,omitempty"`

	// StreamOutput is fixed at admission and travels to the owner with the command.
	// It must not depend on subscriber liveness: disconnecting cannot change a run.
	StreamOutput bool `json:"stream_output,omitempty"`
}

type CommandSubscription

type CommandSubscription struct {
	C     <-chan Command
	Close func()
}

type CurrentRunPatch

type CurrentRunPatch struct {
	RunID               string     `json:"run_id"`
	Status              *string    `json:"status,omitempty"`
	ErrorCode           *string    `json:"error_code,omitempty"`
	Error               *string    `json:"error,omitempty"`
	UpdatedAt           *time.Time `json:"updated_at,omitempty"`
	OwnerLeaseExpiresAt *time.Time `json:"owner_lease_expires_at,omitempty"`
}

type CurrentRunView

type CurrentRunView struct {
	// ConfigurationOnly holds the session lock but does not generate a reply.
	ConfigurationOnly bool   `json:"configuration_only,omitempty"`
	RunID             string `json:"run_id" validate:"required" format:"uuid"`
	// TurnID is the durable turn this run writes into, allocated at admission.
	// It is part of the observable view because SR-OBS-003 requires every
	// subscriber to agree on the run's turn, and a subscriber that only learns
	// the run id cannot line the run up against persisted history.
	TurnID string `json:"turn_id" validate:"required" format:"uuid"`
	// TurnPosition is the immutable turn-level sequence admission drew for
	// TurnID from bot_sessions.next_turn_position. It rides the live view for
	// the same reason TurnID does: a subscriber that learns only the turn's name
	// still cannot order it against persisted history, and SR-TURN-001 forbids
	// falling back to timestamps to decide where a turn belongs.
	TurnPosition int64 `json:"turn_position,omitempty"`
	// InvocationID is the caller-supplied intent identity recorded at admission
	// (session_runs.invocation_id). It rides the live view so the client that
	// originated the send can match projection frames to its optimistic turn by
	// reading the frame, instead of inferring the pairing from arrival timing
	// while waiting for the acceptance that names the two. Subscribers that did
	// not originate the run see an id unknown to them and treat the turn as
	// foreign — which is the correct standalone rendering for cross-device runs.
	InvocationID        string               `json:"invocation_id,omitempty"`
	Generation          string               `json:"generation"`
	Status              string               `json:"status"`
	OwnerID             string               `json:"owner_id,omitempty"`
	OwnerLeaseExpiresAt *time.Time           `json:"owner_lease_expires_at,omitempty"`
	StartedAt           time.Time            `json:"started_at"`
	UpdatedAt           time.Time            `json:"updated_at"`
	Messages            []chatview.UIMessage `json:"messages"`
	// UserTurns is the authoritative ordered set of user inputs already
	// admitted into this run, including the original input and applied steers.
	// The legacy request_user_turn is derived only at the JSON boundary.
	UserTurns []chatview.UITurn `json:"user_turns,omitempty"`
	// SteerSupported is published only by an installed step-boundary consumer.
	// Missing on old owners and on runtimes without that execution capability.
	SteerSupported bool `json:"steer_supported,omitempty"`
	// The snapshot outlives the lease key and retains the exact persistence
	// fence needed to reconcile a durable terminal after owner expiry.
	FencingToken int64 `json:"fencing_token,omitempty"`
	// SteerTurns locates live queue inputs inside the run's assistant message
	// stream. Claimed entries are provisional runtime state; applied entries
	// point at the history turn written by the application.
	SteerTurns             []SteerTurnView   `json:"steer_turns,omitempty"`
	ErrorCode              string            `json:"error_code,omitempty"`
	Error                  string            `json:"error,omitempty"`
	ProposedTerminalStatus string            `json:"proposed_terminal_status,omitempty"`
	FinishProposedAt       *time.Time        `json:"finish_proposed_at,omitempty"`
	Operation              *RunOperationView `json:"operation,omitempty"`
}

func (CurrentRunView) MarshalJSON added in v0.20.0

func (run CurrentRunView) MarshalJSON() ([]byte, error)

func (*CurrentRunView) UnmarshalJSON added in v0.20.0

func (run *CurrentRunView) UnmarshalJSON(data []byte) error

type Cursor

type Cursor struct {
	Epoch string `json:"epoch,omitempty"`
	Seq   int64  `json:"seq"`
}

Cursor is a position in one session's observable event stream. Epoch and Seq travel as a pair because Seq restarts whenever the epoch does: a live backend that loses its state hands the session a new epoch, so comparing sequence numbers across epochs would order two unrelated streams against each other.

This is the position subscribers dedupe and recover on (SR-OBS-002). It is a different thing from ledger.Cursor, which is a keyset position in the reaper's sweep over durable rows.

type DecisionFenceActivator

type DecisionFenceActivator interface {
	ReclaimWaitingDecision(
		ctx context.Context,
		botID, sessionID, runID, ownerID, liveGeneration string,
		previousToken, newToken int64,
		decisions []runtimefence.PreservedDecision,
	) error
}

DecisionFenceActivator advances a parked run's persistence fence while preserving every pending decision that can still resume it — a turn may park on several approvals and user inputs at once. Implementations must update the decision rows to the new token in the same transaction that activates the session fence.

type DecisionOutputLimits added in v0.20.0

type DecisionOutputLimits struct {
	MaxBytes  int
	MaxEvents int
}

DecisionOutputLimits bounds one log. Exceeding them marks the log failed rather than silently truncating it; the producer reports the overflow.

type DecisionOutputPage added in v0.20.0

type DecisionOutputPage struct {
	DecisionOutputState
	Events []json.RawMessage
}

DecisionOutputPage is a read from a cursor to the current end of the log.

type DecisionOutputRef added in v0.20.0

type DecisionOutputRef struct {
	BotID     string
	CommandID string
}

DecisionOutputRef identifies the raw output log of one accepted decision command. Logs are keyed per command, not per session: one run can park on a second question without ending, and successive answers must not share a cursor.

func (DecisionOutputRef) String added in v0.20.0

func (r DecisionOutputRef) String() string

type DecisionOutputState added in v0.20.0

type DecisionOutputState struct {
	Exists  bool
	Length  int
	Bytes   int
	Done    bool
	Failed  bool
	Claimed bool
	// Applied reports whether this append changed the log. Replays of an
	// already-committed seq and writes after a terminal marker are no-ops.
	Applied bool
	// Exceeded reports that this append tripped the limits and failed the log.
	Exceeded bool
}

DecisionOutputState is the log's committed position after an append or read.

type DecisionOutputStore added in v0.20.0

type DecisionOutputStore interface {
	AppendDecisionOutput(ctx context.Context, ref DecisionOutputRef, seq int64, payload json.RawMessage, limits DecisionOutputLimits) (DecisionOutputState, error)
	ReadDecisionOutput(ctx context.Context, ref DecisionOutputRef, from int) (DecisionOutputPage, error)
	ClaimDecisionOutput(ctx context.Context, ref DecisionOutputRef) (bool, error)
	ReleaseDecisionOutput(ctx context.Context, ref DecisionOutputRef) error
}

DecisionOutputStore is an append-only raw event log with the same lifetime/TTL as live state. It is separate from Snapshot so session state keeps one meaning and each append writes one entry, not the whole log.

Append is idempotent by seq: seq must be Length+1 to apply; seq <= Length is a replay and returns the current state; a larger seq is a gap and an error. A nil payload closes the log (Done). Claim hands exclusive forwarding rights to one caller across processes. Release drops the stored entries once they are delivered but keeps the Done/Failed/Claimed markers until the TTL: a retry that arrives after delivery must still lose the claim, never replay the run's output to the channel a second time.

type DecisionResponse

type DecisionResponse struct {
	ControlID  string
	Type       string
	DecisionID string
	BotID      string
	SessionID  string
	RunID      string
	Payload    json.RawMessage
	// contains filtered or unexported fields
}

DecisionResponse is one transport-neutral answer. ControlID is minted by the caller and remains the command identity even after the addressed run leaves live state.

type DecisionResponseResult

type DecisionResponseResult struct {
	SessionID  string
	Generation string
	RunID      string
	Handled    bool
	Applied    bool

	// Replayed acknowledges an earlier submission without rerunning its output.
	Replayed bool
}

DecisionResponseResult separates "this is a runtime decision" from "the answer changed it". A resolved terminal decision is handled but not applied; an unfenced ACP/MCP request is not handled and follows its existing path.

type DecisionStore

type DecisionStore interface {
	ResolveRuntimeDecision(ctx context.Context, commandType, decisionID string) (DecisionTarget, error)
	PendingRuntimeDecisions(ctx context.Context, runID string) ([]DecisionTarget, error)
}

DecisionStore is implemented by the application layer over the PostgreSQL decision tables. RouteDecisionResponse uses ResolveRuntimeDecision for every transport; recovery uses PendingRuntimeDecisions to preserve every decision that parked a run while advancing its fencing token.

type DecisionTarget

type DecisionTarget struct {
	Type         string
	ID           string
	BotID        string
	SessionID    string
	RunID        string
	TurnID       string
	Status       string
	FencingToken int64
	ControlID    string
	PayloadHash  string
	// SessionRuntime is the session's runtime type. Recovery needs it to
	// tell a native parked run (resumable: the decision continuation is
	// rebuilt from the database) from an inline waiter run (codex, claude,
	// ACP), whose blocked turn died with its owner and cannot be resumed.
	SessionRuntime string
}

DecisionTarget is the durable identity of one approval or user-input request. It is resolved from PostgreSQL and is the correctness boundary for decision routing; CurrentRunView.Messages is only a subscriber projection.

type DistributedBackend

type DistributedBackend interface {
	Backend
	UpdateActiveRun(ctx context.Context, key Key, runID, generation string, update ActiveRunUpdate) (Snapshot, bool, error)
	StartRun(ctx context.Context, key Key, ref RunRef, update SnapshotUpdate) (Snapshot, bool, error)
	ReleaseRun(ctx context.Context, key Key, ref RunRef, update ActiveRunUpdate) (Snapshot, bool, error)
	// ReconcileTerminalRun applies an authoritative durable terminal outcome to
	// the matching live reservation even after its lease expired. The fencing
	// token is mandatory so a stale reaper cannot release a successor.
	ReconcileTerminalRun(ctx context.Context, key Key, ref RunRef, update ActiveRunUpdate) (Snapshot, bool, error)
	RenewLease(ctx context.Context, key Key, runID, ownerID, generation string, renewedAt, expiresAt time.Time) error
	ValidateRunOwnership(ctx context.Context, key Key, ref RunRef) error
	LoadRunRef(ctx context.Context, key Key, runID string) (RunRef, bool, error)
	DeleteRunRef(ctx context.Context, ref RunRef) (bool, error)
	PublishCommand(ctx context.Context, ownerID string, command Command) error
	SubscribeCommands(ctx context.Context, ownerID string) (CommandSubscription, error)
	StoreCommandResult(ctx context.Context, result Command, ttl time.Duration) error
	LoadCommandResult(ctx context.Context, commandID string) (Command, bool, error)
}

DistributedBackend adds cross-process run ownership and command routing. MemoryBackend intentionally does not implement this interface.

type Event

type Event struct {
	Type      string        `json:"type"`
	BotID     string        `json:"bot_id"`
	SessionID string        `json:"session_id"`
	Epoch     string        `json:"epoch,omitempty"`
	RunID     string        `json:"run_id,omitempty"`
	Seq       int64         `json:"seq"`
	UpdatedAt *time.Time    `json:"updated_at,omitempty"`
	Snapshot  *Snapshot     `json:"snapshot,omitempty"`
	Delta     *RuntimeDelta `json:"delta,omitempty"`
	Message   string        `json:"message,omitempty"`
}

type Execution

type Execution struct {
	// ConfigurationOnly keeps the execution lock without presenting an assistant turn.
	ConfigurationOnly bool
	// Admission persists the run's user turn and any replacement operation, and
	// returns the view subscribers should see when the run leaves admitting. It
	// runs after ownership is established, so its writes can be fenced with
	// RunHandle.FencingToken.
	Admission func(ctx context.Context, handle RunHandle) (RunAdmissionView, error)
	// AbortCh and Cancel are how a routed abort reaches this run. A run without
	// them is durably abortable but cannot be interrupted mid-turn.
	AbortCh chan<- struct{}
	Cancel  context.CancelFunc
	// InjectCh receives steering messages; nil means this caller does not
	// support steering. The caller owns channel closure; the runtime only stops
	// sending during teardown.
	InjectCh chan<- turn.InjectMessage
	// OwnershipCancel revokes the caller's execution context when the lease is
	// lost, so a superseded owner stops producing output rather than racing the
	// fence it can no longer pass.
	OwnershipCancel context.CancelCauseFunc
}

Execution is what the winner of a claim needs in order to actually run the turn. It is part of the admit call rather than a second step because the two cannot be separated safely: live state reserved without an executor is a run holding a session's only slot with nothing driving it to a terminal state.

type FenceActivator

type FenceActivator interface {
	Activate(ctx context.Context, botID, sessionID string, token int64) error
}

FenceActivator hands durable persistence ownership to one run's fencing token. It is the second half of a claim: the ledger decides who owns the run, and this decides whose writes PostgreSQL will still accept (SR-OWN-002).

The token is the same one the claim wrote to session_runs, which is the whole point of drawing it from a shared monotonic sequence. Two independent orderings — one for run ownership, one for persistence — could disagree, and the disagreement would surface as a superseded owner still writing history.

It is expressed over (bot, session, token) rather than over a store handle so the runtime never learns that PostgreSQL exists; runtimefence.NewActivator satisfies it.

type FollowUpClaimRef added in v0.20.0

type FollowUpClaimRef struct {
	ItemID       FollowUpItemID
	TriggerRunID string
	ClaimToken   string
}

type FollowUpItem added in v0.20.0

type FollowUpItem struct {
	ID                  FollowUpItemID
	BotID, SessionID    string
	EnqueuedDuringRunID string
	InvocationID        string
	Payload             []byte
	Status              QueueStatus
	Position            int64
	Claim               *FollowUpClaimRef
	// ErrorCode is set when Status is rejected.
	ErrorCode string
	CreatedAt time.Time
}

type FollowUpItemID added in v0.20.0

type FollowUpItemID string

type FollowUpPendingRef added in v0.20.0

type FollowUpPendingRef struct {
	ItemID FollowUpItemID `json:"item_id"`
}

type HistoryResetBackend

type HistoryResetBackend interface {
	AcquireHistoryReset(ctx context.Context, scope ResetScope, token string, ttl time.Duration) (ResetLease, bool, error)
	RenewHistoryReset(ctx context.Context, lease ResetLease, ttl time.Duration) (ResetLease, bool, error)
	ReleaseHistoryReset(ctx context.Context, lease ResetLease) (bool, error)
	EffectiveHistoryReset(ctx context.Context, scope ResetScope) (ResetLease, bool, error)
}

HistoryResetBackend provides a tokenized, expiring live gate. Redis uses key TTLs; MemoryBackend uses the same contract under its process mutex.

type HistoryResetHandler

type HistoryResetHandler func(context.Context, ResetScope) error

HistoryResetHandler performs owner-local runtime teardown. Returning is the acknowledgement boundary: the ACP pool must not return until Session.Close and the owned process Close operation have completed.

type Key

type Key struct {
	BotID     string `json:"bot_id"`
	SessionID string `json:"session_id"`
}

func (Key) String

func (k Key) String() string

String returns the canonical "botID:sessionID" composite used to key per-session state across backends and subscription registries.

type LeaseCandidate

type LeaseCandidate struct {
	Key          Key
	RunID        string
	FencingToken int64
	// ExpiresAt is the deadline recorded in the index, sampled on the backend's
	// clock rather than the reaper's.
	ExpiresAt time.Time
}

LeaseCandidate is one entry of the live backend's lease index whose deadline has passed. It carries the fencing token that was current when the lease was written, which is what lets the reaper apply a fenced transition without trusting anything else about the vanished owner.

type LiveQueueBackend added in v0.20.0

type LiveQueueBackend interface {
	EnqueueSteer(context.Context, Key, string, string, []byte) (SteerItem, error)
	EnqueueFollowUp(context.Context, Key, string, string, []byte) (FollowUpItem, error)
	PendingQueues(context.Context, Key, int) ([]SteerItem, []FollowUpItem, error)
	ReorderSteer(context.Context, Key, SteerPendingRef, SteerPendingRef) ([]SteerItem, error)
	ReorderFollowUp(context.Context, Key, FollowUpPendingRef, FollowUpPendingRef) ([]FollowUpItem, error)
	UpdateSteer(context.Context, Key, SteerItemID, []byte) (SteerItem, error)
	UpdateFollowUp(context.Context, Key, FollowUpItemID, []byte) (FollowUpItem, error)
	CancelSteer(context.Context, Key, SteerItemID) error
	CancelFollowUp(context.Context, Key, FollowUpItemID) error
	PromoteFollowUpToSteer(context.Context, Key, FollowUpPendingRef) (PromoteFollowUpResult, error)
	ClaimNextSteer(context.Context, RunHandle, bool) (SteerItem, SteerClaimRef, bool, error)
	ApplySteer(context.Context, Key, SteerClaimRef) error
	ReleaseSteer(context.Context, Key, SteerClaimRef) error
	// CloseSteerRun rejects every accepted or claimed steer that targets the
	// given run and seals the run against later steer admission. It is called
	// once the run is durably terminal; a steer is bound to its run and has no
	// meaning for any later run of the session.
	CloseSteerRun(context.Context, Key, string) error
	ClaimNextFollowUp(context.Context, Key, string) (FollowUpItem, FollowUpClaimRef, bool, error)
	ApplyFollowUp(context.Context, Key, FollowUpClaimRef) error
	ReleaseFollowUp(context.Context, Key, FollowUpClaimRef) error
}

LiveQueueBackend is transient session coordination. Implementations must serialize each operation with the live run state for the same session.

type LivenessBackend

type LivenessBackend interface {
	// LivenessGeneration identifies this incarnation of the live backend. It is
	// created once and read by everyone, so a value different from the one
	// stamped on a run means the backend that claimed that run is gone and its
	// in-flight state cannot be recovered.
	LivenessGeneration(ctx context.Context) (string, error)

	// ExpiredLeaseCandidates returns up to limit entries whose lease deadline
	// has passed. The backend samples its own clock: a reaper with a skewed
	// clock must not be able to declare a healthy owner dead. Candidates are
	// returned, not claimed — the leader is single, so a visibility timeout
	// would add a second failure mode without removing one.
	ExpiredLeaseCandidates(ctx context.Context, limit int64) ([]LeaseCandidate, error)

	// ReleaseLeaseCandidate removes an index entry, but only while the entry
	// still carries the token the candidate was read with. A run reclaimed
	// between read and release keeps its new entry.
	ReleaseLeaseCandidate(ctx context.Context, candidate LeaseCandidate) (bool, error)

	// AcquireLeaderLease elects the single cluster-wide reaper. Renewal is the
	// same call: an existing holder re-acquires its own lease. Failover is safe
	// because every reaper duty is an idempotent fenced transition, so a new
	// leader repeating work the old one had started changes nothing.
	AcquireLeaderLease(ctx context.Context, ownerID string, ttl time.Duration) (bool, error)

	// ReleaseLeaderLease hands leadership back on graceful shutdown so a peer
	// takes over in one tick instead of one lease period.
	ReleaseLeaderLease(ctx context.Context, ownerID string) error
}

LivenessBackend is the half of the runtime backend that answers "is an owner still alive". It is separate from Backend and DistributedBackend because liveness is the one thing PostgreSQL deliberately does not know: the ledger records ownership changes, never lease ticks, so only this interface can tell the reaper which runs to give up on.

Both backends implement it. On a memory backend there is a single process and therefore no lease to expire, but there is still an incarnation to compare against and orphans to repair, which is exactly how a single-instance install recovers after a crash.

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

func NewManager

func NewManager(backend Backend, opts Options) *Manager

func NewManagerFromConfig

func NewManagerFromConfig(log *slog.Logger, cfg config.SessionRuntimeConfig, runs ledger.Store, fence FenceActivator) (*Manager, error)

NewManagerFromConfig is the single construction site where the two configured values turn into the full set of derived timings. runs is the durable ledger and fence is the persistence-ownership cutover that claims apply; they go together, and both may be nil only in tests that never admit a run.

func (*Manager) Abort

func (m *Manager) Abort(ctx context.Context, botID, sessionID, runID string) (bool, error)

func (*Manager) AbortControl

func (m *Manager) AbortControl(ctx context.Context, botID, sessionID, runID, controlID string) (bool, error)

AbortControl aborts a run on behalf of a client that named the request with its own control id. Two things follow from that id and neither is available to plain Abort:

The abort becomes idempotent across instances. A client that retries — or that reconnects to a different server and retries there — gets the answer the first attempt produced rather than a second execution, because the control id keys the shared command result. Without it a retry after the run terminalized would report "not active" and contradict the ack the client already holds.

The intent is recorded durably before anything is routed, so a run that is aborted survives as aborted-on-purpose rather than as an unexplained cancellation, even if the owner never answers (SR-CTL-001).

func (*Manager) AbortRun

func (m *Manager) AbortRun(ctx context.Context, handle RunHandle) (bool, error)

func (*Manager) Admit

func (m *Manager) Admit(ctx context.Context, in AdmitInput) (Admission, error)

Admit is the one durable entry point for starting a session run. It commits the admission before returning, so a caller that receives an Admission can stop retrying even if this server dies immediately afterwards (SR-ADM-001, SR-DUR-001).

The order is deliberate and not reorderable: persist, then claim, then execute. Reserving live state first would create a run that exists only in a process, which is the failure the ledger exists to prevent.

A caller that gets Started sees a run that is owned, fenced and reserved, and receives the handle its own durable writes must be fenced with. A caller that gets an Admission without Started is looking at a run someone else owns or one that already finished, and must not execute anything.

A session runs one turn at a time, so a different invocation arriving while one is active gets ErrSessionBusy and is expected to retry (SR-OWN-001). The runtime holds nothing on the caller's behalf.

func (*Manager) ApplyFollowUp added in v0.20.0

func (m *Manager) ApplyFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*Manager) ApplySteer added in v0.20.0

func (m *Manager) ApplySteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*Manager) BeginBotHistoryReset

func (m *Manager) BeginBotHistoryReset(ctx context.Context, botID string) (context.Context, func(), error)

func (*Manager) BeginSessionHistoryReset

func (m *Manager) BeginSessionHistoryReset(ctx context.Context, botID, sessionID string) (context.Context, func(), error)

func (*Manager) CancelFollowUp added in v0.20.0

func (m *Manager) CancelFollowUp(ctx context.Context, key Key, itemID FollowUpItemID) error

func (*Manager) CancelSteer added in v0.20.0

func (m *Manager) CancelSteer(ctx context.Context, key Key, itemID SteerItemID) error

func (*Manager) ClaimNextFollowUp added in v0.20.0

func (m *Manager) ClaimNextFollowUp(ctx context.Context, key Key, triggerRunID string) (FollowUpItem, FollowUpClaimRef, bool, error)

func (*Manager) ClaimNextSteer added in v0.20.0

func (m *Manager) ClaimNextSteer(ctx context.Context, handle RunHandle, sealIfEmpty bool) (SteerItem, SteerClaimRef, bool, error)

func (*Manager) Close

func (m *Manager) Close() error

func (*Manager) CloseContext

func (m *Manager) CloseContext(ctx context.Context) error

func (*Manager) CloseSteerRun added in v0.20.0

func (m *Manager) CloseSteerRun(ctx context.Context, key Key, runID string) error

func (*Manager) ContinuationStepIndex added in v0.20.0

func (m *Manager) ContinuationStepIndex(handle RunHandle) (int, error)

ContinuationStepIndex resumes the owner-local step cursor after a parked decision. A recovered owner starts a new generation/cursor; it is not a durable replay offset and must never be inferred from timestamps or messages.

func (*Manager) DecisionContinuationContext

func (m *Manager) DecisionContinuationContext(cmd Command) (context.Context, context.CancelFunc, RunHandle, error)

DecisionContinuationContext detaches the model continuation from the short command acknowledgement deadline while keeping it tied to the run owner's lifecycle and persistence fence.

func (*Manager) DisableSteer added in v0.20.0

func (m *Manager) DisableSteer(ctx context.Context, handle RunHandle) error

func (*Manager) EnableSteer added in v0.20.0

func (m *Manager) EnableSteer(ctx context.Context, handle RunHandle) error

EnableSteer advertises an actual execution consumer, not just an allocated channel. Other instances therefore reject queues aimed at old/unsupported owners.

func (*Manager) EnqueueFollowUp added in v0.20.0

func (m *Manager) EnqueueFollowUp(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (FollowUpItem, error)

func (*Manager) EnqueueSteer added in v0.20.0

func (m *Manager) EnqueueSteer(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (SteerItem, error)

func (*Manager) FinishRun

func (m *Manager) FinishRun(ctx context.Context, handle RunHandle, status, message string) error

func (*Manager) FinishRunWithErrorCode

func (m *Manager) FinishRunWithErrorCode(ctx context.Context, handle RunHandle, status, errorCode string) error

FinishRunWithErrorCode records a stable public failure code without treating that code as a display message or persisting private provider diagnostics.

func (*Manager) HandleAgentEvent

func (m *Manager) HandleAgentEvent(ctx context.Context, handle RunHandle, event native.StreamEvent) ([]chatview.UIMessage, error)

func (*Manager) HandleAgentEventWithStatus

func (m *Manager) HandleAgentEventWithStatus(ctx context.Context, handle RunHandle, event native.StreamEvent) ([]chatview.UIMessage, string, error)

HandleAgentEventWithStatus applies an agent event and returns the run status from that same serialized mutation. Terminal callers use this instead of a second snapshot read so a routed control and terminal event have one winner even when a later backend read would fail.

func (*Manager) IsDistributed

func (m *Manager) IsDistributed() bool

IsDistributed reports whether this manager coordinates owners through a cross-process backend. Memory managers intentionally return false.

func (*Manager) LivenessGeneration added in v0.20.0

func (m *Manager) LivenessGeneration(ctx context.Context) (string, error)

LivenessGeneration returns the current live-backend incarnation for application-owned recovery code. It is read-only; ownership still changes only through the durable fenced claim.

func (*Manager) MarkInlineDecisionRun added in v0.20.0

func (m *Manager) MarkInlineDecisionRun(botID, sessionID, runID string)

MarkInlineDecisionRun declares, before the runtime starts prompting, that this run's runtime blocks inline on decisions: terminal decision statuses resume the run directly. Runs without the declaration keep the native park semantics (only EventAgentStart resumes), so a decision answered faster than the parking FinishRun cannot resume — and then complete — the run underneath the native re-entry. Scope-keyed (run IDs are unique); the caller runs before any decision event, so no generation check is needed.

func (*Manager) OwnerID added in v0.20.0

func (m *Manager) OwnerID() string

OwnerID returns this manager's stable execution-owner identity.

func (*Manager) PendingQueues added in v0.20.0

func (m *Manager) PendingQueues(ctx context.Context, key Key, limit int) ([]SteerItem, []FollowUpItem, error)

func (*Manager) PromoteFollowUpToSteer added in v0.20.0

func (m *Manager) PromoteFollowUpToSteer(ctx context.Context, key Key, ref FollowUpPendingRef) (PromoteFollowUpResult, error)

func (*Manager) PublishDecisionOutput added in v0.20.0

func (m *Manager) PublishDecisionOutput(ctx context.Context, command Command, seq int64, payload json.RawMessage) error

PublishDecisionOutput appends one raw event (or closes the log with a nil payload) and then wakes readers. Commit before notify, exactly as runtime UI deltas do; a lost wakeup costs latency, never data.

func (*Manager) PublishQueueUserTurns added in v0.20.0

func (m *Manager) PublishQueueUserTurns(ctx context.Context, handle RunHandle, update QueueUserTurnUpdate) error

PublishQueueUserTurns projects one committed queue step into the live run. The update is atomic so applying one steer and claiming the next cannot briefly render them out of order. A claimed steer is shown only after the coordinator committed its claim and execution accepted the resulting input.

func (*Manager) ReleaseFollowUp added in v0.20.0

func (m *Manager) ReleaseFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*Manager) ReleaseSteer added in v0.20.0

func (m *Manager) ReleaseSteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*Manager) ReorderFollowUp added in v0.20.0

func (m *Manager) ReorderFollowUp(ctx context.Context, key Key, item, before FollowUpPendingRef) ([]FollowUpItem, error)

func (*Manager) ReorderSteer added in v0.20.0

func (m *Manager) ReorderSteer(ctx context.Context, key Key, item, before SteerPendingRef) ([]SteerItem, error)

func (*Manager) RouteDecisionResponse

func (m *Manager) RouteDecisionResponse(ctx context.Context, response DecisionResponse) (DecisionResponseResult, error)

RouteDecisionResponse is the single decision entry point for WebSocket, HTTP, and gRPC callers. PostgreSQL resolves the decision before live ownership is consulted, while the client control id is checked first so a successful command remains replayable after the run terminalizes.

func (*Manager) RunRef

func (m *Manager) RunRef(ctx context.Context, botID, sessionID, runID string) (RunRef, bool, error)

func (*Manager) SetAdmissionObserver added in v0.20.0

func (m *Manager) SetAdmissionObserver(observer func(botID, sessionID string))

SetAdmissionObserver installs the lightweight activity invalidation sink. The composition root owns its transport; Runtime does not depend on the bot activity hub or carry conversation contents through that channel.

func (*Manager) SetCommandHandler

func (m *Manager) SetCommandHandler(handler func(context.Context, Command) error)

SetCommandHandler installs the owner-local executor for routed runtime commands whose domain behavior lives outside the sessionruntime package.

func (*Manager) SetDecisionFinalizer added in v0.20.0

func (m *Manager) SetDecisionFinalizer(finalizer func(context.Context, RunHandle) error)

SetDecisionFinalizer closes durable decisions before a run becomes terminal. A failure retains ownership so the normal finish retry can complete cleanup.

func (*Manager) SetDecisionStore

func (m *Manager) SetDecisionStore(store DecisionStore)

SetDecisionStore installs the PostgreSQL-backed decision authority used by every response transport and by waiting-decision recovery.

func (*Manager) SetHistoryResetHandler

func (m *Manager) SetHistoryResetHandler(handler HistoryResetHandler)

SetHistoryResetHandler installs the owner-local operation-boundary closer. SessionPool supplies it without sessionruntime depending on the ACP package.

func (*Manager) SetLostRunDecisionCanceller added in v0.20.0

func (m *Manager) SetLostRunDecisionCanceller(canceller func(context.Context, string, string, string, int64, string) error)

SetLostRunDecisionCanceller installs run-scoped cleanup for decisions parked by a run that the reaper has durably marked lost.

func (*Manager) SetTerminalObserver

func (m *Manager) SetTerminalObserver(observer func(context.Context, TerminalRun))

SetTerminalObserver installs the application-owned sink for authoritative durable run outcomes. The callback is invoked synchronously without holding the manager lock, and may be called more than once for the same run when a fenced terminal transition is replayed.

func (*Manager) SetTerminalReconciler

func (m *Manager) SetTerminalReconciler(reconciler func(context.Context) error)

SetTerminalReconciler installs a bounded application-owned repair pass for terminal runs whose observation was interrupted after the ledger commit. The elected reaper invokes it once per tick after its own terminal duties.

func (*Manager) Snapshot

func (m *Manager) Snapshot(ctx context.Context, botID, sessionID string) (Snapshot, error)

Snapshot returns the session's authoritative runtime view. The live backend answers first because it is the only place a run in flight exists; the ledger fallback below covers the case where it cannot answer at all.

func (*Manager) Start

func (m *Manager) Start(ctx context.Context) error

func (*Manager) SteerWake added in v0.20.0

func (m *Manager) SteerWake(handle RunHandle) <-chan struct{}

SteerWake is an owner-local, coalesced notification. The queue remains the source of truth; neither duplicate notifications nor a stale wake apply input.

func (*Manager) StreamDecisionResponse added in v0.20.0

func (m *Manager) StreamDecisionResponse(ctx context.Context, response DecisionResponse, output chan<- json.RawMessage) (DecisionResponseResult, error)

func (*Manager) Subscribe

func (m *Manager) Subscribe(ctx context.Context, botID, sessionID string) (Subscription, error)

func (*Manager) UpdateFollowUp added in v0.20.0

func (m *Manager) UpdateFollowUp(ctx context.Context, key Key, itemID FollowUpItemID, payload []byte) (FollowUpItem, error)

func (*Manager) UpdateSteer added in v0.20.0

func (m *Manager) UpdateSteer(ctx context.Context, key Key, itemID SteerItemID, payload []byte) (SteerItem, error)

func (*Manager) ValidateRunOwnership

func (m *Manager) ValidateRunOwnership(ctx context.Context, handle RunHandle) error

ValidateRunOwnership fails closed before durable side effects when this process no longer owns the active runtime run.

func (*Manager) WaitDecisionContinuationReady

func (m *Manager) WaitDecisionContinuationReady(ctx context.Context, cmd Command) error

WaitDecisionContinuationReady holds the resumed model call until the stream that produced the deferred decision has finished its terminal persistence. The decision itself is already committed and acknowledged; this barrier only prevents its tool result from racing the assistant tool-call write.

func (*Manager) WaitForHistoryReset

func (m *Manager) WaitForHistoryReset(ctx context.Context, botID, sessionID string) error

WaitForHistoryReset prevents ACP pre-session/cold-start paths that are not themselves durable run admissions from crossing the shared reset gate.

type MemoryBackend

type MemoryBackend struct {
	// contains filtered or unexported fields
}

func NewMemoryBackend

func NewMemoryBackend() *MemoryBackend

func NewMemoryBackendWithTTL

func NewMemoryBackendWithTTL(stateTTL time.Duration) *MemoryBackend

func (*MemoryBackend) AcquireHistoryReset

func (b *MemoryBackend) AcquireHistoryReset(ctx context.Context, scope ResetScope, token string, ttl time.Duration) (ResetLease, bool, error)

func (*MemoryBackend) AcquireLeaderLease

func (*MemoryBackend) AcquireLeaderLease(context.Context, string, time.Duration) (bool, error)

AcquireLeaderLease always succeeds: a single process is trivially the leader.

func (*MemoryBackend) AppendDecisionOutput added in v0.20.0

func (b *MemoryBackend) AppendDecisionOutput(ctx context.Context, ref DecisionOutputRef, seq int64, payload json.RawMessage, limits DecisionOutputLimits) (DecisionOutputState, error)

func (*MemoryBackend) ApplyFollowUp added in v0.20.0

func (b *MemoryBackend) ApplyFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*MemoryBackend) ApplySteer added in v0.20.0

func (b *MemoryBackend) ApplySteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*MemoryBackend) CancelFollowUp added in v0.20.0

func (b *MemoryBackend) CancelFollowUp(ctx context.Context, key Key, itemID FollowUpItemID) error

func (*MemoryBackend) CancelSteer added in v0.20.0

func (b *MemoryBackend) CancelSteer(ctx context.Context, key Key, itemID SteerItemID) error

func (*MemoryBackend) ClaimDecisionOutput added in v0.20.0

func (b *MemoryBackend) ClaimDecisionOutput(ctx context.Context, ref DecisionOutputRef) (bool, error)

func (*MemoryBackend) ClaimNextFollowUp added in v0.20.0

func (b *MemoryBackend) ClaimNextFollowUp(ctx context.Context, key Key, triggerRunID string) (FollowUpItem, FollowUpClaimRef, bool, error)

func (*MemoryBackend) ClaimNextSteer added in v0.20.0

func (b *MemoryBackend) ClaimNextSteer(ctx context.Context, handle RunHandle, sealIfEmpty bool) (SteerItem, SteerClaimRef, bool, error)

func (*MemoryBackend) Close

func (b *MemoryBackend) Close() error

func (*MemoryBackend) CloseSteerRun added in v0.20.0

func (b *MemoryBackend) CloseSteerRun(ctx context.Context, key Key, runID string) error

func (*MemoryBackend) EffectiveHistoryReset

func (b *MemoryBackend) EffectiveHistoryReset(ctx context.Context, scope ResetScope) (ResetLease, bool, error)

func (*MemoryBackend) EnqueueFollowUp added in v0.20.0

func (b *MemoryBackend) EnqueueFollowUp(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (FollowUpItem, error)

func (*MemoryBackend) EnqueueSteer added in v0.20.0

func (b *MemoryBackend) EnqueueSteer(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (SteerItem, error)

func (*MemoryBackend) ExpiredLeaseCandidates

func (*MemoryBackend) ExpiredLeaseCandidates(context.Context, int64) ([]LeaseCandidate, error)

ExpiredLeaseCandidates is always empty for a memory backend: one process owns every run it admitted, so a lease would only ever expire together with the process that was watching it.

func (*MemoryBackend) LivenessGeneration

func (b *MemoryBackend) LivenessGeneration(context.Context) (string, error)

LivenessGeneration returns this process's incarnation. A memory backend keeps live state in the heap, so its incarnation ends with the process: after a restart every run the ledger still shows as active belongs to a generation that no longer exists, which is precisely the signal the recovery sweep needs.

func (*MemoryBackend) Load

func (b *MemoryBackend) Load(ctx context.Context, key Key) (Snapshot, bool, error)

func (*MemoryBackend) Now

func (*MemoryBackend) Now(ctx context.Context) (time.Time, error)

func (*MemoryBackend) PendingQueues added in v0.20.0

func (b *MemoryBackend) PendingQueues(ctx context.Context, key Key, limit int) ([]SteerItem, []FollowUpItem, error)

func (*MemoryBackend) PromoteFollowUpToSteer added in v0.20.0

func (b *MemoryBackend) PromoteFollowUpToSteer(ctx context.Context, key Key, ref FollowUpPendingRef) (PromoteFollowUpResult, error)

func (*MemoryBackend) Publish

func (b *MemoryBackend) Publish(ctx context.Context, event Event) error

func (*MemoryBackend) ReadDecisionOutput added in v0.20.0

func (b *MemoryBackend) ReadDecisionOutput(ctx context.Context, ref DecisionOutputRef, from int) (DecisionOutputPage, error)

func (*MemoryBackend) ReleaseDecisionOutput added in v0.20.0

func (b *MemoryBackend) ReleaseDecisionOutput(ctx context.Context, ref DecisionOutputRef) error

func (*MemoryBackend) ReleaseFollowUp added in v0.20.0

func (b *MemoryBackend) ReleaseFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*MemoryBackend) ReleaseHistoryReset

func (b *MemoryBackend) ReleaseHistoryReset(ctx context.Context, lease ResetLease) (bool, error)

func (*MemoryBackend) ReleaseLeaderLease

func (*MemoryBackend) ReleaseLeaderLease(context.Context, string) error

func (*MemoryBackend) ReleaseLeaseCandidate

func (*MemoryBackend) ReleaseLeaseCandidate(context.Context, LeaseCandidate) (bool, error)

func (*MemoryBackend) ReleaseSteer added in v0.20.0

func (b *MemoryBackend) ReleaseSteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*MemoryBackend) RenewHistoryReset

func (b *MemoryBackend) RenewHistoryReset(ctx context.Context, lease ResetLease, ttl time.Duration) (ResetLease, bool, error)

func (*MemoryBackend) ReorderFollowUp added in v0.20.0

func (b *MemoryBackend) ReorderFollowUp(ctx context.Context, key Key, item, before FollowUpPendingRef) ([]FollowUpItem, error)

func (*MemoryBackend) ReorderSteer added in v0.20.0

func (b *MemoryBackend) ReorderSteer(ctx context.Context, key Key, item, before SteerPendingRef) ([]SteerItem, error)

func (*MemoryBackend) StartRunIfNoHistoryReset

func (b *MemoryBackend) StartRunIfNoHistoryReset(ctx context.Context, key Key, update SnapshotUpdate) (Snapshot, bool, error)

func (*MemoryBackend) Subscribe

func (b *MemoryBackend) Subscribe(ctx context.Context, key Key) (Subscription, error)

func (*MemoryBackend) Update

func (b *MemoryBackend) Update(ctx context.Context, key Key, update SnapshotUpdate) (Snapshot, bool, error)

func (*MemoryBackend) UpdateFollowUp added in v0.20.0

func (b *MemoryBackend) UpdateFollowUp(ctx context.Context, key Key, itemID FollowUpItemID, payload []byte) (FollowUpItem, error)

func (*MemoryBackend) UpdateSteer added in v0.20.0

func (b *MemoryBackend) UpdateSteer(ctx context.Context, key Key, itemID SteerItemID, payload []byte) (SteerItem, error)

type Options

type Options struct {
	OwnerID       string
	StateTTL      time.Duration
	OwnerLeaseTTL time.Duration
	// BackendLossGrace comes from config because a Redis restart budget depends
	// on the deployment. Everything else below OwnerLeaseTTL is derived.
	BackendLossGrace time.Duration
	// Cluster mirrors session_runtime.cluster.
	Cluster bool
	// Ledger is the durable session run store.
	Ledger ledger.Store
	// Fence applies the persistence-ownership cutover for a claimed run. It is
	// required together with Ledger.
	Fence                  FenceActivator
	CommandAckTTL          time.Duration
	CommandWorkerLimit     int
	Logger                 *slog.Logger
	EpochGenerator         func() string
	RunGenerationGenerator func() string

	// ScanBatchSize and MaxScanBatchesPerTick exist so tests can watch recovery
	// page rather than to be tuned in production; both default to package
	// constants.
	ScanBatchSize         int32
	MaxScanBatchesPerTick int
	// contains filtered or unexported fields
}

type PromoteFollowUpResult added in v0.20.0

type PromoteFollowUpResult struct {
	FollowUp FollowUpPendingRef
	Steer    SteerItem
}

type QueueStatus added in v0.20.0

type QueueStatus string
const (
	QueueAccepted QueueStatus = "accepted"
	QueueClaimed  QueueStatus = "claimed"
	QueueApplied  QueueStatus = "applied"
	QueueRejected QueueStatus = "rejected"
	QueueExpired  QueueStatus = "expired"
	QueueCanceled QueueStatus = "canceled"
)

type QueueUserTurnUpdate added in v0.20.0

type QueueUserTurnUpdate struct {
	PersistedTurns        []chatview.UITurn
	AppliedSteerItemID    string
	AppliedSteerTurn      *chatview.UITurn
	ClaimedSteerItemID    string
	ClaimedSteerText      string
	ClaimedSteerTimestamp time.Time
	// ClaimedSteerTurn* is the turn slot drawn for this input at claim time. It
	// is the same identity the step commit files the user row under, so the live
	// bubble and the settled one are one turn from the start.
	ClaimedSteerTurnID       string
	ClaimedSteerTurnPosition int64
	// AfterStepIndex, when set, is the durable step whose output must already
	// be in the live projection before a claimed steer is anchored. The commit
	// barrier runs on the model loop while agent events are consumed on
	// another goroutine, so without this wait the anchor could be computed
	// from a projection still missing the tail of the step, and the steer
	// would render above output that preceded it.
	AfterStepIndex *int
}

type Reaper

type Reaper struct {
	// contains filtered or unexported fields
}

Reaper is the single cluster-wide janitor for the session run ledger. Exactly one instance acts at a time, elected through a TTL lease in the live backend.

One leader rather than many workers is a deliberate simplification. The three duties below are all idempotent fenced transitions, so the only thing extra reapers would add is duplicate PostgreSQL traffic and a second way for a candidate to be consumed and then dropped by a crash. Failover costs at most one lease period and repeats at most one transition, which changes nothing.

The reaper is the only component that can decide a run is unrecoverable, because it is the only one that reads liveness and durable state together.

func NewReaper

func NewReaper(runs ledger.Store, liveness LivenessBackend, tune tuning, ownerID string, logger *slog.Logger) *Reaper

NewReaper builds a reaper for one manager. It shares the manager's derived tuning so the leader lease and the tick that renews it cannot drift apart.

func (*Reaper) Close

func (r *Reaper) Close(ctx context.Context) error

Close stops ticking and hands leadership back so a peer takes over in one tick instead of waiting out the lease.

func (*Reaper) SetLostRunDecisionCanceller added in v0.20.0

func (r *Reaper) SetLostRunDecisionCanceller(canceller func(context.Context, string, string, string, int64, string) error)

SetLostRunDecisionCanceller installs the application-owned cleanup for decisions created by a run that is durably marked lost. It is deliberately run-scoped: canceling a whole session could expire a newer run's prompt.

func (*Reaper) SetTerminalObserver

func (r *Reaper) SetTerminalObserver(observer func(context.Context, TerminalRun))

SetTerminalObserver installs the sink for authoritative durable outcomes.

func (*Reaper) SetTerminalReconciler

func (r *Reaper) SetTerminalReconciler(reconciler func(context.Context) error)

SetTerminalReconciler installs the bounded repair pass run by the elected reaper after its ordinary ledger duties.

func (*Reaper) SetWaitingDecisionRecoverer

func (r *Reaper) SetWaitingDecisionRecoverer(recoverer func(context.Context, LeaseCandidate) (bool, error))

SetWaitingDecisionRecoverer installs the owner-local half of parked-run recovery. It is set before Start, so the reaper never observes a partially configured callback.

func (*Reaper) Start

func (r *Reaper) Start(ctx context.Context) error

Start seeds the liveness generation and begins ticking. Reading it here also proves the backend is reachable before the reaper claims it can decide runs unrecoverable; the sweep re-reads it every pass from then on.

type RedisBackend

type RedisBackend struct {
	// contains filtered or unexported fields
}

func NewRedisBackend

func NewRedisBackend(ctx context.Context, opts RedisOptions) (*RedisBackend, error)

func (*RedisBackend) AcquireHistoryReset

func (b *RedisBackend) AcquireHistoryReset(ctx context.Context, scope ResetScope, token string, ttl time.Duration) (ResetLease, bool, error)

func (*RedisBackend) AcquireLeaderLease

func (b *RedisBackend) AcquireLeaderLease(ctx context.Context, ownerID string, ttl time.Duration) (bool, error)

func (*RedisBackend) AppendDecisionOutput added in v0.20.0

func (b *RedisBackend) AppendDecisionOutput(ctx context.Context, ref DecisionOutputRef, seq int64, payload json.RawMessage, limits DecisionOutputLimits) (DecisionOutputState, error)

func (*RedisBackend) ApplyFollowUp added in v0.20.0

func (b *RedisBackend) ApplyFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*RedisBackend) ApplySteer added in v0.20.0

func (b *RedisBackend) ApplySteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*RedisBackend) CancelFollowUp added in v0.20.0

func (b *RedisBackend) CancelFollowUp(ctx context.Context, key Key, itemID FollowUpItemID) error

func (*RedisBackend) CancelSteer added in v0.20.0

func (b *RedisBackend) CancelSteer(ctx context.Context, key Key, itemID SteerItemID) error

func (*RedisBackend) CheckHealth

func (b *RedisBackend) CheckHealth(ctx context.Context) error

func (*RedisBackend) ClaimDecisionOutput added in v0.20.0

func (b *RedisBackend) ClaimDecisionOutput(ctx context.Context, ref DecisionOutputRef) (bool, error)

func (*RedisBackend) ClaimNextFollowUp added in v0.20.0

func (b *RedisBackend) ClaimNextFollowUp(ctx context.Context, key Key, triggerRunID string) (FollowUpItem, FollowUpClaimRef, bool, error)

func (*RedisBackend) ClaimNextSteer added in v0.20.0

func (b *RedisBackend) ClaimNextSteer(ctx context.Context, handle RunHandle, sealIfEmpty bool) (SteerItem, SteerClaimRef, bool, error)

func (*RedisBackend) Close

func (b *RedisBackend) Close() error

func (*RedisBackend) CloseSteerRun added in v0.20.0

func (b *RedisBackend) CloseSteerRun(ctx context.Context, key Key, runID string) error

func (*RedisBackend) DeleteRunRef

func (b *RedisBackend) DeleteRunRef(ctx context.Context, ref RunRef) (bool, error)

func (*RedisBackend) EffectiveHistoryReset

func (b *RedisBackend) EffectiveHistoryReset(ctx context.Context, scope ResetScope) (ResetLease, bool, error)

func (*RedisBackend) EnqueueFollowUp added in v0.20.0

func (b *RedisBackend) EnqueueFollowUp(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (FollowUpItem, error)

func (*RedisBackend) EnqueueSteer added in v0.20.0

func (b *RedisBackend) EnqueueSteer(ctx context.Context, key Key, itemID, invocationID string, payload []byte) (SteerItem, error)

func (*RedisBackend) ExpiredLeaseCandidates

func (b *RedisBackend) ExpiredLeaseCandidates(ctx context.Context, limit int64) ([]LeaseCandidate, error)

func (*RedisBackend) LivenessGeneration

func (b *RedisBackend) LivenessGeneration(ctx context.Context) (string, error)

LivenessGeneration reads the shared incarnation marker, creating it with SET NX when absent so concurrently starting processes converge on one value instead of each minting its own. The marker lives and dies with the Redis dataset: a flushed or non-persistent Redis comes back without it, the next SET NX writes a new value, and every run stamped with the old one is unrecoverable by definition.

func (*RedisBackend) Load

func (b *RedisBackend) Load(ctx context.Context, key Key) (Snapshot, bool, error)

func (*RedisBackend) LoadCommandResult

func (b *RedisBackend) LoadCommandResult(ctx context.Context, commandID string) (Command, bool, error)

func (*RedisBackend) LoadRunRef

func (b *RedisBackend) LoadRunRef(ctx context.Context, key Key, runID string) (RunRef, bool, error)

func (*RedisBackend) Now

func (b *RedisBackend) Now(ctx context.Context) (time.Time, error)

func (*RedisBackend) PendingQueues added in v0.20.0

func (b *RedisBackend) PendingQueues(ctx context.Context, key Key, limit int) ([]SteerItem, []FollowUpItem, error)

func (*RedisBackend) PromoteFollowUpToSteer added in v0.20.0

func (b *RedisBackend) PromoteFollowUpToSteer(ctx context.Context, key Key, ref FollowUpPendingRef) (PromoteFollowUpResult, error)

func (*RedisBackend) Publish

func (b *RedisBackend) Publish(ctx context.Context, event Event) error

func (*RedisBackend) PublishCommand

func (b *RedisBackend) PublishCommand(ctx context.Context, ownerID string, command Command) error

func (*RedisBackend) ReadDecisionOutput added in v0.20.0

func (b *RedisBackend) ReadDecisionOutput(ctx context.Context, ref DecisionOutputRef, from int) (DecisionOutputPage, error)

func (*RedisBackend) ReconcileTerminalRun added in v0.20.0

func (b *RedisBackend) ReconcileTerminalRun(ctx context.Context, key Key, ref RunRef, update ActiveRunUpdate) (Snapshot, bool, error)

func (*RedisBackend) ReleaseDecisionOutput added in v0.20.0

func (b *RedisBackend) ReleaseDecisionOutput(ctx context.Context, ref DecisionOutputRef) error

func (*RedisBackend) ReleaseFollowUp added in v0.20.0

func (b *RedisBackend) ReleaseFollowUp(ctx context.Context, key Key, ref FollowUpClaimRef) error

func (*RedisBackend) ReleaseHistoryReset

func (b *RedisBackend) ReleaseHistoryReset(ctx context.Context, lease ResetLease) (bool, error)

func (*RedisBackend) ReleaseLeaderLease

func (b *RedisBackend) ReleaseLeaderLease(ctx context.Context, ownerID string) error

func (*RedisBackend) ReleaseLeaseCandidate

func (b *RedisBackend) ReleaseLeaseCandidate(ctx context.Context, candidate LeaseCandidate) (bool, error)

ReleaseLeaseCandidate drops the index member only while it still carries the token it was read with. Because the token is part of the member string, the compare is free: a run reclaimed between read and release has a different member and this ZREM removes nothing.

func (*RedisBackend) ReleaseRun

func (b *RedisBackend) ReleaseRun(ctx context.Context, key Key, ref RunRef, update ActiveRunUpdate) (Snapshot, bool, error)

func (*RedisBackend) ReleaseSteer added in v0.20.0

func (b *RedisBackend) ReleaseSteer(ctx context.Context, key Key, ref SteerClaimRef) error

func (*RedisBackend) RenewHistoryReset

func (b *RedisBackend) RenewHistoryReset(ctx context.Context, lease ResetLease, ttl time.Duration) (ResetLease, bool, error)

func (*RedisBackend) RenewLease

func (b *RedisBackend) RenewLease(ctx context.Context, key Key, runID, ownerID, generation string, renewedAt, expiresAt time.Time) error

func (*RedisBackend) ReorderFollowUp added in v0.20.0

func (b *RedisBackend) ReorderFollowUp(ctx context.Context, key Key, item, before FollowUpPendingRef) ([]FollowUpItem, error)

func (*RedisBackend) ReorderSteer added in v0.20.0

func (b *RedisBackend) ReorderSteer(ctx context.Context, key Key, item, before SteerPendingRef) ([]SteerItem, error)

func (*RedisBackend) StartRun

func (b *RedisBackend) StartRun(ctx context.Context, key Key, ref RunRef, update SnapshotUpdate) (Snapshot, bool, error)

func (*RedisBackend) StoreCommandResult

func (b *RedisBackend) StoreCommandResult(ctx context.Context, result Command, ttl time.Duration) error

func (*RedisBackend) Subscribe

func (b *RedisBackend) Subscribe(ctx context.Context, key Key) (Subscription, error)

func (*RedisBackend) SubscribeCommands

func (b *RedisBackend) SubscribeCommands(ctx context.Context, ownerID string) (CommandSubscription, error)

func (*RedisBackend) Update

func (b *RedisBackend) Update(ctx context.Context, key Key, update SnapshotUpdate) (Snapshot, bool, error)

func (*RedisBackend) UpdateActiveRun

func (b *RedisBackend) UpdateActiveRun(ctx context.Context, key Key, runID, generation string, update ActiveRunUpdate) (Snapshot, bool, error)

func (*RedisBackend) UpdateFollowUp added in v0.20.0

func (b *RedisBackend) UpdateFollowUp(ctx context.Context, key Key, itemID FollowUpItemID, payload []byte) (FollowUpItem, error)

func (*RedisBackend) UpdateSteer added in v0.20.0

func (b *RedisBackend) UpdateSteer(ctx context.Context, key Key, itemID SteerItemID, payload []byte) (SteerItem, error)

func (*RedisBackend) ValidateRunOwnership

func (b *RedisBackend) ValidateRunOwnership(ctx context.Context, key Key, ref RunRef) error

type RedisOptions

type RedisOptions struct {
	URL       string
	KeyPrefix string
	StateTTL  time.Duration
}

type ResetLease

type ResetLease struct {
	Scope     ResetScope `json:"scope"`
	Token     string     `json:"token"`
	ExpiresAt time.Time  `json:"expires_at"`
}

ResetLease is the live-backend half of the reset fence. The same token is used in PostgreSQL so renewal and release are successor-safe on both sides.

type ResetScope

type ResetScope struct {
	BotID     string `json:"bot_id"`
	SessionID string `json:"session_id,omitempty"`
}

ResetScope identifies the canonical history protected by a reset lease. SessionID is empty for a bot-wide reset.

type RunAdmissionView

type RunAdmissionView struct {
	RequestUserTurn *chatview.UITurn
	Operation       *RunOperationView
}

RunAdmissionView is the canonical state published when a reserved run becomes active. RequestUserTurn is intentionally runtime state rather than a durable history row; ordinary sends still persist user + assistant together when the run reaches a terminal result.

type RunHandle

type RunHandle struct {
	BotID     string
	SessionID string
	RunID     string
	// OwnerID identifies the live execution owner for queue claims. It is
	// intentionally carried with the handle so claim CAS checks use the same
	// owner identity as the session runtime.
	OwnerID    string
	TurnID     string
	Generation string
	// FencingToken is the ledger ownership token for this run. Callers need it
	// to fence their own durable writes, which is why it travels with the
	// handle rather than staying inside the runtime. It is zero for runs
	// created by backend-only reservation tests.
	FencingToken int64
}

RunHandle identifies one admitted run. A run id can be reused by a client that replays an old reservation, so owner-side mutations also carry the generation.

type RunOperationView

type RunOperationView struct {
	Kind                 string           `json:"kind" validate:"required" enums:"retry,edit"`
	ReplaceFromMessageID string           `json:"replace_from_message_id" validate:"required"`
	ReplacementUserTurn  *chatview.UITurn `json:"replacement_user_turn,omitempty"`
}

type RunRef

type RunRef struct {
	BotID      string `json:"bot_id"`
	SessionID  string `json:"session_id"`
	RunID      string `json:"run_id"`
	OwnerID    string `json:"owner_id"`
	Generation string `json:"generation"`
	// FencingToken is the durable ownership token this reservation was made
	// with. It is stored with the ref so the lease index entry can be written
	// and removed from the backend's own copy, without a caller having to
	// reconstruct a token it may no longer hold. Zero means the run has no
	// ledger identity and therefore nothing for the reaper to transition.
	FencingToken int64 `json:"fencing_token,omitempty"`
}

type RuntimeDelta

type RuntimeDelta struct {
	CurrentRunView    *CurrentRunView         `json:"current_run_view,omitempty"`
	Run               *CurrentRunPatch        `json:"run,omitempty"`
	UserTurnUpserts   []chatview.UITurn       `json:"user_turn_upserts,omitempty"`
	SteerTurnUpserts  []SteerTurnView         `json:"steer_turn_upserts,omitempty"`
	SteerTurnRemovals []string                `json:"steer_turn_removals,omitempty"`
	MessageAppends    []RuntimeMessageAppend  `json:"message_appends,omitempty"`
	ProgressAppends   []RuntimeProgressAppend `json:"progress_appends,omitempty"`
	MessageUpserts    []chatview.UIMessage    `json:"message_upserts,omitempty"`
	ResetMessages     bool                    `json:"reset_messages,omitempty"`
}

RuntimeDelta carries only the state changed by one committed runtime transition. Full snapshots are reserved for hydration and gap recovery.

type RuntimeMessageAppend

type RuntimeMessageAppend struct {
	ID      int                    `json:"id"`
	Type    chatview.UIMessageType `json:"type"`
	Content string                 `json:"content"`
}

type RuntimeProgressAppend

type RuntimeProgressAppend struct {
	ID       int `json:"id"`
	Progress any `json:"progress"`
	Input    any `json:"input,omitempty"`
}

type Snapshot

type Snapshot struct {
	BotID          string          `json:"bot_id"`
	SessionID      string          `json:"session_id"`
	Epoch          string          `json:"epoch"`
	Seq            int64           `json:"seq"`
	CurrentRunView *CurrentRunView `json:"current_run_view,omitempty"`
	UpdatedAt      time.Time       `json:"updated_at"`
}

Snapshot is the authoritative live view of one session. It holds at most one run: admission answers busy rather than queueing, so there is no pending list to project and a subscriber never has to reason about work it cannot see yet.

func EmptySnapshot

func EmptySnapshot(botID, sessionID string) Snapshot

EmptySnapshot returns the canonical empty runtime snapshot for a session.

type SnapshotUpdate

type SnapshotUpdate func(snapshot Snapshot, exists bool) (Snapshot, bool, error)

type SteerClaimRef added in v0.20.0

type SteerClaimRef struct {
	ItemID       SteerItemID
	RunID        string
	OwnerID      string
	Generation   string
	FencingToken int64
	ClaimToken   string
}

type SteerItem added in v0.20.0

type SteerItem struct {
	ID                            SteerItemID
	BotID, SessionID, TargetRunID string
	InvocationID                  string
	Payload                       []byte
	Status                        QueueStatus
	Position                      int64
	Claim                         *SteerClaimRef
	// ErrorCode is set when Status is rejected.
	ErrorCode string
	CreatedAt time.Time
}

type SteerItemID added in v0.20.0

type SteerItemID string

type SteerPendingRef added in v0.20.0

type SteerPendingRef struct {
	ItemID SteerItemID `json:"item_id"`
}

type SteerTurnView added in v0.20.0

type SteerTurnView struct {
	ItemID string `json:"item_id" validate:"required" format:"uuid"`
	Status string `json:"status" validate:"required" enums:"claimed,applied"`
	Text   string `json:"text" validate:"required"`
	// TurnID and TurnPosition are drawn when the steer is claimed, not when its
	// step commits: a subscriber that only learns the queue item id cannot line
	// the input up against history, which left the live bubble and the settled
	// one rendering side by side until the commit published the durable name.
	TurnID         string    `json:"turn_id,omitempty" format:"uuid"`
	TurnPosition   int64     `json:"turn_position,omitempty"`
	AfterMessageID int       `json:"after_message_id"`
	Timestamp      time.Time `json:"timestamp" validate:"required" format:"date-time"`
}

type Subscription

type Subscription struct {
	C     <-chan Event
	Close func()
}

type TerminalRun

type TerminalRun struct {
	RunID        string
	BotID        string
	SessionID    string
	FencingToken int64
	State        string
	ErrorCode    string
	ErrorMessage string
}

TerminalRun is the authoritative durable outcome of one admitted run. It is emitted only after the fenced session_runs transition has applied, or when a replay observes that the same run is already terminal. State uses the durable ledger vocabulary: completed, aborted, failed, or lost.

Directories

Path Synopsis
Package ledger is the durable half of the session runtime: the record of which runs were admitted, who owns them, and how they ended.
Package ledger is the durable half of the session runtime: the record of which runs were admitted, who owns them, and how they ended.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL