Session State Machine

A session without a formal lifecycle model is a session governed by accident. Simple status strings — "idle", "running" — offer no algebraic guarantees: there is no compile-time proof that a crashed session will not silently resume, no contract preventing a client from displaying a state the server has already abandoned, no deterministic path from failure back to operational readiness. When an agent’s upstream connection collapses mid-turn, a naive status field offers no vocabulary for describing what happened, let alone for prescribing what must happen next. Diminuendo addresses this with a formal finite-state machine: seven named states, an explicit transition guard map encoded as a Record<SessionState, ReadonlySet<SessionState>>, and a family of pure functions that compute the next state from the current state and an incoming agent signal. The model was ported from the Crescendo desktop client’s connection-state.ts and elevated to server-side enforcement — a migration from local observation to authoritative arbitration.

The Seven States

          +--------------------------------------------------+
          |                                                  |
          v                                                  |
    +-----------+         +------------+         +---------+ |
    | inactive  | ------> | activating | ------> |  ready  | |
    +-----------+         +------------+         +---------+ |
          ^                  |    |                |   |   |  |
          |                  |    |                |   |   |  |
          |                  v    |                v   |   |  |
          |              +-------+|          +---------+  |  |
          |              | error  |          | running |  |  |
          |              +-------+           +---------+  |  |
          |                  |                 |   |       |  |
          |                  |                 |   |       |  |
          |                  v                 v   |       |  |
          |              +-----------+    +---------+     |  |
          +--------------| deactiv.  |    | waiting |     |  |
                         +-----------+    +---------+     |  |
                              ^               |           |  |
                              +---------------+-----------+--+
The topology of this graph is deliberate. Every edge represents a transition that the gateway will permit; every absent edge represents a transition that the gateway will reject, log, and discard. There are no implicit paths, no “should not happen” annotations — only the algebra of reachable states.
1

inactive

The ground state. No Podium connection exists. The session is metadata alone — a row in the tenant’s SQLite registry, inert and weightless. This is the resting state for sessions that have been created but never activated, sessions whose agents have been gracefully torn down, and sessions recovered from stale state after a gateway restart. It is both genesis and terminus — the only state from which activation can begin, and the state to which all paths ultimately return.
2

activating

The gateway is materializing an agent. A Podium instance is being created, a WebSocket connection is being established, and the system is in a liminal state between potential and readiness. This is inherently transient — it resolves to ready on success, error on failure, or back to inactive if the activation is cancelled before the handshake completes. Like Schrodinger’s connection, it exists in superposition until the upstream settles.
3

ready

The Podium connection is established and the agent stands idle, awaiting instruction. This is the quiescent state of a live session — the system has committed resources (a WebSocket, a Podium instance, a credit reservation pipeline) and is prepared to execute. From here, the session may begin processing a turn (running), be torn down (deactivating), return to inactive, or encounter failure (error). It is the fulcrum on which all productive work pivots.
4

running

The agent is actively processing a turn — streaming text fragments, invoking tools, executing shell commands, performing multi-step reasoning within its context window. This is the state of maximum activity and maximum exposure: tokens are being consumed, credits are being drawn, and the Podium connection is carrying live traffic. The state persists until the turn completes (ready), the agent requests human intervention (waiting), a failure occurs (error), or a tear-down is initiated (deactivating).
5

waiting

The agent has yielded control to the user. A question_requested or permission_requested event has been emitted, and execution is suspended until the human responds. The session holds its breath — the Podium connection remains open, the credit reservation remains active, but no tokens are flowing. The wait resolves when the user answers (transitioning back to running), when a failure occurs (error), or when the session is torn down (deactivating).
6

deactivating

Dissolution is underway. The Podium instance is being stopped, the WebSocket connection is being closed, and the session is transitioning from a live entity back to inert metadata. This resolves to inactive on success or error if the tear-down itself encounters a failure — a rare but possible condition when the upstream refuses to release cleanly.
7

error

An unrecoverable failure has occurred. The Podium connection may be in an unknown state; the agent may be unreachable; the credit reservation may be orphaned. The critical invariant is that there is no direct path from error back to ready or running. Recovery always requires passing through inactive (a full reset) or activating (a fresh connection attempt). This prevents the gateway from silently resuming a session whose underlying substrate may be corrupted.

Transition Guard Map

The VALID_TRANSITIONS constant encodes the complete set of legal state transitions as a Record<SessionState, ReadonlySet<SessionState>>. It is the single source of truth for what the state machine will accept. Any transition not present in this map is rejected — silently from the perspective of the upstream agent, but loudly in the gateway’s structured logs.
export const VALID_TRANSITIONS: Record<SessionState, ReadonlySet<SessionState>> = {
  inactive:     new Set(["activating"]),
  activating:   new Set(["ready", "error", "inactive"]),
  ready:        new Set(["running", "deactivating", "inactive", "error"]),
  running:      new Set(["ready", "waiting", "error", "deactivating"]),
  waiting:      new Set(["running", "error", "deactivating"]),
  deactivating: new Set(["inactive", "error"]),
  error:        new Set(["inactive", "activating"]),
}
Rendered as an adjacency table for quick reference:
FromAllowed Targets
inactiveactivating
activatingready, error, inactive
readyrunning, deactivating, inactive, error
runningready, waiting, error, deactivating
waitingrunning, error, deactivating
deactivatinginactive, error
errorinactive, activating
There is no transition from error to ready or running. This is not an oversight — it is the central safety invariant of the state machine. Recovery from error always requires passing through inactive or activating, forcing a clean reconnection rather than allowing the system to resume on a potentially corrupted foundation. The absent edge is as load-bearing as the present ones.

Agent Status Mapping

The Podium agent speaks a different vocabulary than the session state machine. Where the state machine traffics in seven states, the agent reports ten distinct status values. The applySessionTransition function bridges these two worlds: a pure function that accepts the current session state and an agent-reported status, and returns either the next valid state or null if the transition would violate the guard map.
export function applySessionTransition(
  current: SessionState,
  agentStatus: AgentStatus,
): SessionState | null {
  const next = agentStatusToState(current, agentStatus)
  if (next === null) return null
  if (!VALID_TRANSITIONS[current].has(next)) return null
  return next
}
The ten recognized agent status values and their mappings:
Agent StatusTarget StateNotes
createdactivatingPodium instance materialized, WebSocket connecting
connectedreadyHandshake complete, agent idle
turn_startedrunningAgent has begun processing
turn_completereadyTurn concluded successfully
turn_errorready or errorContext-dependent: ready if currently running or waiting (recoverable); error otherwise (unrecoverable)
question_requestedwaitingAgent yields control, awaiting human input
approval_resolvedrunningUser responded to interactive prompt
terminatingdeactivatingGraceful shutdown initiated
terminatedinactiveShutdown complete, resources released
errorerrorUnrecoverable upstream failure
The turn_error status exhibits context-dependent polymorphism — a deliberate design choice. When a turn fails while the session is running or waiting, the failure is scoped to the turn itself: the Podium connection is still viable, and the session can return to ready to accept another turn. In any other state, a turn error signals a deeper structural problem, and the session must transition to error for full recovery.

Enforcement: transitionSessionState

The transitionSessionState helper in MessageRouterLive.ts is the chokepoint through which all state transitions must pass. It is the enforcer — the function that stands between the agent’s reported status and the gateway’s authoritative state. It validates the proposed transition against the guard map, updates the session’s ConnectionState ref, persists the new status to the tenant’s SQLite registry, and broadcasts the change to all subscribers.
const transitionSessionState = (
  tenantId: string,
  sessionId: string,
  cs: ConnectionState,
  newState: SessionState,
) =>
  Effect.gen(function* () {
    const current = yield* Ref.get(cs.sessionState)
    if (current !== newState && !VALID_TRANSITIONS[current].has(newState)) {
      yield* Effect.logWarning(
        `Invalid state transition for session ${sessionId}: ${current} -> ${newState} (rejected)`
      )
      return
    }
    yield* Ref.set(cs.sessionState, newState)
    yield* registry.updateStatus(tenantId, sessionId, newState).pipe(Effect.ignore)
    yield* broadcaster.tenantEvent(tenantId, {
      type: "session_updated",
      session: { id: sessionId, status: newState },
    })
  })
The critical behavior: invalid transitions are logged and discarded. They do not throw, they do not propagate, they do not corrupt the state machine. A misbehaving upstream agent cannot drive the gateway into an illegal configuration — the guard map is absolute. This is defensive programming in the Postel tradition: liberal in what is accepted from the network, strict in what is permitted to mutate internal state.

ConnectionState: Per-Connection Typed Refs

Each active session materializes a ConnectionState — a struct of Effect Ref values that collectively track the full in-flight state of a live session. Where less structured architectures accumulate scattered mutable variables across handler closures, ConnectionState consolidates everything into a single, typed, ref-counted structure. Every field is an atomic Ref, enabling concurrent reads and writes without locks.
export interface ConnectionState {
  // Turn tracking
  readonly turnId: Ref.Ref<string | null>
  readonly fullContent: Ref.Ref<string>
  readonly stopRequested: Ref.Ref<boolean>
  readonly turnStopped: Ref.Ref<boolean>

  // Tool call tracking
  readonly pendingToolCalls: Ref.Ref<Map<string, { toolName: string; startedAt: number }>>
  readonly completedToolIds: Ref.Ref<Set<string>>
  readonly persistedToolCallIds: Ref.Ref<Set<string>>

  // Thinking
  readonly isThinking: Ref.Ref<boolean>
  readonly thinkingContent: Ref.Ref<string>

  // Interactive
  readonly deferredInteractiveMessage: Ref.Ref<DeferredInteractiveMessage | null>
  readonly pendingApproval: Ref.Ref<boolean>

  // Billing
  readonly currentReservation: Ref.Ref<CreditReservation | null>
  readonly lastContextUsage: Ref.Ref<ContextUsage | null>

  // Sequencing
  readonly messageIdStack: Ref.Ref<string[]>
  readonly acked: Ref.Ref<boolean>

  // Session state
  readonly sessionState: Ref.Ref<SessionState>

  // Approval system (Phase 2)
  readonly sessionGrants: Ref.Ref<Map<string, RiskTier>>
  readonly autonomyProfile: Ref.Ref<AutonomyProfile>
  readonly pendingGitHubAction: Ref.Ref<{...} | null>

  // Agent mode (Phase 3)
  readonly agentMode: Ref.Ref<AgentMode>

  // Sandbox state (On-Demand)
  readonly sandboxState: Ref.Ref<"none" | "provisioning" | "ready" | "timed_out">
  readonly sandboxProvisionedAt: Ref.Ref<number | null>
  readonly lastSandboxActivity: Ref.Ref<number | null>
}
The structure spans seven functional domains — turn tracking, tool lifecycle, thinking state, interactive flow, billing, sequencing, and session lifecycle — each with its own cluster of refs. This is not incidental grouping; it reflects the natural grain of the data. Turn-scoped refs are reset together; billing refs are settled together; interactive refs are resolved together.

resetTurnState

At the boundary between turns, resetTurnState performs a surgical clearing of all turn-scoped refs. This is the function that enforces the invariant: no state leaks between turns. The previous turn’s accumulated text, pending tool calls, thinking content, billing reservation, and interactive state are all zeroed out, returning the ConnectionState to a clean slate while preserving session-level refs like sessionState and agentMode.
export const resetTurnState = (cs: ConnectionState): Effect.Effect<void> =>
  Effect.gen(function* () {
    yield* Ref.set(cs.turnId, null)
    yield* Ref.set(cs.fullContent, "")
    yield* Ref.set(cs.stopRequested, false)
    yield* Ref.set(cs.turnStopped, false)
    yield* Ref.set(cs.pendingToolCalls, new Map())
    yield* Ref.set(cs.completedToolIds, new Set())
    yield* Ref.set(cs.persistedToolCallIds, new Set())
    yield* Ref.set(cs.isThinking, false)
    yield* Ref.set(cs.thinkingContent, "")
    yield* Ref.set(cs.deferredInteractiveMessage, null)
    yield* Ref.set(cs.pendingApproval, false)
    yield* Ref.set(cs.currentReservation, null)
    yield* Ref.set(cs.lastContextUsage, null)
    yield* Ref.set(cs.acked, false)
    yield* Ref.set(cs.pendingGitHubAction, null)
  })

Stale Session Recovery

A gateway restart is an extinction event for in-memory state. No Podium connections survive the process boundary; no ConnectionState refs persist beyond the runtime that created them. Any session that was in a non-idle state at the moment of shutdown is stale by definition — its metadata claims a status that its infrastructure can no longer support. The reconcileStaleSessions function runs on startup for each tenant, querying all sessions whose status is not "inactive" and resetting them to the ground state. For sessions that were running or waiting at crash time, it additionally attempts to recover the last full_content_snapshot from SQLite, so that reconnecting clients can display partial output rather than a blank slate.
export const reconcileStaleSessions = (tenantId: string) =>
  Effect.gen(function* () {
    const registry = yield* SessionRegistryService
    const staleSessions = yield* registry.listNonIdle(tenantId)

    if (staleSessions.length === 0) {
      yield* Effect.logDebug(`StaleRecovery: no stale sessions for tenant ${tenantId}`)
      return { recovered: 0, total: 0, restoredSnapshots: 0 }
    }

    yield* Effect.forEach(
      staleSessions,
      (session) =>
        Effect.gen(function* () {
          if (session.status === "running" || session.status === "waiting") {
            const recovered = yield* recoverInFlightState(session.id).pipe(
              Effect.catchAll(() => Effect.succeed(null))
            )
            if (recovered) {
              // Notify connected clients about the interrupted state
              yield* broadcaster.sessionEvent(session.id, {
                type: "turn_error",
                sessionId: session.id,
                message: "Session interrupted by server restart. Partial output recovered.",
                code: "SERVER_RESTART",
                seq: recovered.lastEventSeq + 1,
                ts: Date.now(),
              }).pipe(Effect.ignore)
            }
          }
          yield* registry.updateStatus(tenantId, session.id, "inactive").pipe(Effect.ignore)
        }),
      { concurrency: 5 },
    )
    // ...
  })
Stale session recovery runs with a concurrency of 5 to avoid saturating the SQLite writer on startup when many sessions require resetting. Each reset is idempotent — running it twice produces no additional effect. The concurrency bound is a pragmatic choice: high enough to recover quickly, low enough to leave headroom for the gateway’s other startup tasks.

Legacy State Migration

The current seven-state model superseded an earlier four-state model (idle, running, awaiting_question, error). Sessions persisted under the old schema require translation when read. The migrateLegacyStatus function provides this bridge — a pure mapping from the old vocabulary to the new.
export function migrateLegacyStatus(legacy: string): SessionState {
  switch (legacy) {
    case "idle":               return "inactive"
    case "running":            return "running"
    case "awaiting_question":  return "waiting"
    case "error":              return "error"
    default:                   return "inactive"
  }
}
The protocol schema also accepts legacy status values on read — "idle" and "awaiting_question" are recognized and silently converted — though new code never emits them. This is a one-way valve: the old world can be read, but the new world is the only world that can be written.

Comparison with Crescendo

The state machine was ported from the Crescendo desktop client’s connection management layer and substantially rearchitected for server-side enforcement. The differences are not incremental improvements but categorical changes in where authority resides.

Server-Side Enforcement

In Crescendo, the state machine runs client-side within a Tauri process. Invalid transitions are visible only in local logs — the server has no opinion on session state. In Diminuendo, the state machine is enforced at the gateway: all clients observe the same authoritative state, and invalid transitions are rejected before they can propagate. The server is the single source of truth; clients are projections.

Persistent State

Crescendo holds session state in memory alone — a process restart erases all knowledge of what sessions were doing. Diminuendo persists the current state to SQLite on every transition, enabling stale session recovery after restarts, consistent state across reconnections, and an audit trail of state changes that survives the process boundary.

Multi-Client Broadcast

Crescendo manages a single user’s view of a single session. Diminuendo broadcasts state transitions to all subscribers of a session via Bun pub/sub — dashboards, CLIs, web clients, and desktop applications all observe transitions in real time. The state machine is a shared oracle, not a local notebook.

Billing Integration

Diminuendo’s state transitions are coupled with the billing subsystem. A credit reservation is created when entering running and settled when transitioning to ready (on turn completion) or error (on failure). The state machine does not merely describe what the session is doing — it governs what the session is allowed to cost. Crescendo has no billing integration.