You're reading the 0.10.0 archive.

State machine

Every codon in a run moves through a small, deterministic state machine: it is set up, its agent process is spawned, a session is established, work proceeds, any background sentinels are drained, and the codon reaches a terminal outcome. The server records each step as a typed event, validates it against a fixed transition map, persists the result, and broadcasts it to connected clients.

This page is the reference for that machinery. It covers the statuses a codon can hold, the fields each status carries, the events that change state, the rules that validate those changes, and how state is saved, recovered, and published. It assumes you know what a codon is; if not, start with codons and return here when you need the exact contracts.

Understanding the codon state lifecycle#

Eight statuses cover a codon's lifetime. They separate rig setup, process startup, session initialization, agent work, sentinel draining (background observation), and the terminal outcomes. The normal forward path is:

preparingstartinginitializingrunningcompleting-sentinelscompleted

The transition map permits two direct completions. initializing can go directly to completed when the process exits cleanly but no valid session ID is produced. A codon in running can also reach completed without entering completing-sentinels. From any non-terminal status, the codon can instead go to failed or skipped. The three outcomes completed, failed, and skipped are terminal and have no outgoing transitions.

FIG. 1 The forward lifecycle and all permitted terminal exits. Failed and skipped have no outgoing transitions.
Read the diagram as text
Output
Non-terminal statuses
  preparing -> starting -> initializing -> running -> completing-sentinels
                               |              |              |
                               +--------------+--------------+-> completed

  any non-terminal status -----+-> failed
                              +-> skipped

All three outcomes are terminal: no outgoing transitions.
initializing -> completed: clean exit without a valid session ID.

The graph is deterministic; the model output produced while a codon runs is not. A few terms appear throughout the state definitions and are worth fixing before reading the tables:

  • A sentinel is a background observer attached to runtime or codon events, configured per codon. It can update its state while the codon is in progress, which is why completing-sentinels exists as a distinct phase.
  • A harness is the in-process runner or adapter that executes the codon's agent and reports events. Live execution selects the Claude Agent SDK harness for Anthropic and Anthropic-on-Bedrock models, or the embedded Pi harness for other models. Replay uses ReplayProcessManager as a separate path, and the live factory does not select ShimProcessManager. The per-codon --shim-idle-timeout belongs to the harness; the WebSocket/proxy --idle-timeout is a different timeout.

The claudePid, claudeLogPath, and claudeSessionId names you will see below are internal field names shared by both harnesses; their spelling does not make the state machine Claude-specific.

The status union itself is the complete set of states:

TYPESCRIPT
export type CodonStatus =
  | "preparing" // Rig setup running (copy files, run commands)
  | "starting" // Spawning Claude process
  | "initializing" // Process started, waiting for session ID
  | "running" // Claude is working (have session ID)
  | "completing-sentinels" // Completing sentinel work (draining queues)
  | "completed" // Success - terminal state
  | "failed" // Failed - terminal state
  | "skipped"; // User skipped - terminal state

Which states can a codon be in?#

Use the status literal union above for the complete set. The forward path is the useful default; the transition map in the next section is authoritative when a process exits early or a user skips a codon. See codons for the surrounding codon configuration and checkpoints for the checkpoint values carried during execution.

Which codon transitions are valid#

Knowing the statuses is not enough to know which moves between them are legal. CodonTransitions is the single map used to validate edges. An attempted edge not in this map produces InvalidTransitionError; when the state manager processes the event, it logs that error and continues with the next queued event. Terminal states have empty target lists, while every non-terminal state includes failed and skipped as recovery exits.

The source record and the equivalent lookup table:

TYPESCRIPT
export const CodonTransitions: Record<CodonStatus, CodonStatus[]> = {
  preparing: ["starting", "failed", "skipped"],
  starting: ["initializing", "failed", "skipped"],
  initializing: ["running", "completed", "failed", "skipped"],
  running: ["completing-sentinels", "completed", "failed", "skipped"],
  "completing-sentinels": ["completed", "failed", "skipped"],
  completed: [], // Terminal - no transitions
  failed: [], // Terminal - no transitions
  skipped: [], // Terminal - no transitions
};
Scroll to explore the table →
Current statusValid next statuses
preparingstarting, failed, skipped
startinginitializing, failed, skipped
initializingrunning, completed, failed, skipped
runningcompleting-sentinels, completed, failed, skipped
completing-sentinelscompleted, failed, skipped
completed
failed
skipped

Treat the map–not an assumed linear workflow–as the contract. In particular, initializing → completed is legal, and a terminal status cannot be advanced again. For failure diagnostics, the target state also determines which transition metadata is required; see metadata validation.

Which fields each state carries#

Each status exposes a different slice of data, and the type system encodes that directly. CodonExecution is a discriminated union keyed by status, so a variant exposes only fields valid for its phase: RunningCodon has currentCost, while PreparingCodon has no cost fields. This keeps state queries from treating not-yet-available process or result data as present.

TYPESCRIPT
export type CodonExecution =
  | PreparingCodon
  | StartingCodon
  | InitializingCodon
  | RunningCodon
  | CompletingSentinelsCodon
  | CompletedCodon
  | FailedCodon
  | SkippedCodon;

All variants also carry codonId, startTime, and optional loopContext. The state-specific fields, variant by variant:

Scroll to explore the table →
VariantFields that become available in that state
PreparingCodonNo process or cost fields.
StartingCodonOptional rigSetupCheckpoint; optional sentinels.loaded and sentinel totalCost.
InitializingCodonclaudePid, claudeLogPath; optional previous session, rig checkpoint, and sentinels.loaded.
RunningCodonclaudePid, claudeSessionId, claudeLogPath, currentCost, currentTokens, assistantMessageCount, extensionCount, and optional sentinels.loaded.
CompletingSentinelsCodonThe running-state process and cost fields while sentinel work drains; sentinels.loaded remains available.
CompletedCodonendTime, claudeSessionId, claudeLogPath, exitCode: 0, finalCost, finalTokens, resultMessageReceived, completionCheckpoint, and optional sentinels.executed and budgetExceeded.
FailedCodonendTime, failedDuring, exitCode, failureReason, partialCost, partialTokens, and optional process, checkpoint, extension, and sentinels.executed fields.
SkippedCodonendTime, skippedDuring, partialCost, partialTokens, and optional process, assistant-count, checkpoint, and sentinels.executed fields.

Two helpers operate over this union. isTerminalCodonStatus recognizes exactly completed, failed, and skipped. getCodonCost computes the value from the variant: final cost for a completed codon, partial cost for a failed codon, zero for a skipped codon, and current cost while running or draining sentinels. Preparing and starting states contribute zero.

Which events change state#

State never changes by direct mutation. Callers emit events from the StateTransition union, and the state manager applies them. The union contains 14 event types: RunStarted, RunCompleted, RunFailed, RunCrashed, CodonStarted, CodonTransitioned, CostsUpdated, CostsIncremented, AssistantMessageCountUpdated, ExtensionCountUpdated, CheckpointCreated, InitialCheckpointSet, CodonFinalCostSet, and SentinelStatesUpdated. The full union, with each event's trigger and data shape:

TYPESCRIPT
export type StateTransition =
  // ===== Run Lifecycle =====

  /**
   * New run started (fresh or from continuation point).
   * Creates new Run entry with starting codon.
   *
   * Triggered by: Server startup
   * State changes:
   * - Adds new run to runs array
   * - Sets currentRunId
   * - Creates git branch
   */
  | {
      type: "RunStarted";
      data: {
        runId: RunId;
        runFolder: string;
        gitBranch: string;
        startingConditions: StartingConditions;
        serverPid: number;
      };
    }

  /**
   * Run completed successfully (all codons done).
   *
   * Triggered by: Last codon completing successfully
   * State changes:
   * - Sets run.status = "completed"
   * - Sets run.endTime
   * - Clears currentRunId
   */
  | {
      type: "RunCompleted";
      data: { runId: RunId };
    }

  /**
   * Run failed (codon failed and server shutting down).
   *
   * Triggered by: Codon failure, fatal error
   * State changes:
   * - Sets run.status = "failed"
   * - Sets run.endTime
   * - Clears currentRunId
   */
  | {
      type: "RunFailed";
      data: { runId: RunId };
    }

  /**
   * Run crashed (detected on recovery).
   *
   * Triggered by: Stale lock file detection
   * State changes:
   * - Sets run.status = "crashed"
   * - Sets run.endTime
   * - Marks running codons as failed
   */
  | {
      type: "RunCrashed";
      data: {
        runId: RunId;
        detectedAt: string;
        lastCodonStatus: CodonStatus;
      };
    }

  // ===== Codon Lifecycle =====

  /**
   * New codon starting in current run.
   *
   * Triggered by: User command or auto-advance
   * State changes:
   * - Adds new PreparingCodon to run.codons
   * Validation: No other codon currently running
   */
  | {
      type: "CodonStarted";
      data: {
        runId: RunId;
        codonId: CodonId;
        loopContext?: {
          loopId: CodonId;
          iteration: number;
          codonIndexInLoop: number;
        };
      };
    }

  /**
   * Codon status changed (main state machine).
   *
   * Triggered by: Various codon lifecycle events
   * State changes:
   * - Updates codon status
   * - Sets relevant fields based on transition
   * Validation: Transition must be in CodonTransitions map
   */
  | {
      type: "CodonTransitioned";
      data: {
        runId: RunId;
        codonId: CodonId;
        from: CodonStatus;
        to: CodonStatus;
        metadata?: {
          // For starting → initializing
          claudePid?: number;
          claudeLogPath?: string;
          previousSessionId?: SessionId;

          // For initializing → running
          claudeSessionId?: SessionId;

          // For any → failed
          exitCode?: number;
          failureReason?: FailureReason;
          failedDuring?: CodonStatus;

          // For any → skipped
          skippedDuring?: CodonStatus;

          // For completing → completed
          resultMessageReceived?: boolean;

          // For running → completing-sentinels
          sentinelCount?: number;
          sentinelIds?: string[];

          // Checkpoint info
          checkpointSha?: string;
          checkpointBranch?: string;

          // For marking context exceeded
          contextExceeded?: boolean;

          // For extensions
          extensionCount?: number;

          // For budget exceeded (force completion)
          budgetExceeded?: BudgetExceededData;
        };
      };
    }

  // ===== Cost Updates =====

  /**
   * Token usage update from Claude.
   * Can happen frequently during execution.
   *
   * Triggered by: Assistant messages with usage
   * State changes:
   * - Updates currentCost/currentTokens (if running)
   * - Updates finalCost/finalTokens (if completing)
   */
  | {
      type: "CostsUpdated";
      data: {
        runId: RunId;
        codonId: CodonId;
        cost: number; // New total cost
        tokens: TokenUsage; // New total tokens
      };
    }

  /**
   * Incremental token usage update from Claude.
   * More resilient to race conditions than CostsUpdated.
   *
   * Triggered by: Assistant messages with usage (incremental approach)
   * State changes:
   * - Adds costDelta to currentCost (if running)
   * - Adds tokensDelta to currentTokens (if running)
   */
  | {
      type: "CostsIncremented";
      data: {
        runId: RunId;
        codonId: CodonId;
        costDelta: number; // The amount to add to the cost
        tokensDelta: TokenUsage; // The tokens to add to the totals
      };
    }

  // ===== Assistant Message Tracking =====

  /**
   * Assistant message count update.
   * Incremented when Claude sends a message.
   *
   * Triggered by: Assistant messages in Claude logs
   * State changes:
   * - Increments assistantMessageCount (if running/completing)
   */
  | {
      type: "AssistantMessageCountUpdated";
      data: {
        runId: RunId;
        codonId: CodonId;
        newCount: number; // New total count
      };
    }

  // ===== Extension Tracking =====

  /**
   * Extension count update.
   * Incremented when a codon extends.
   *
   * Triggered by: Extension trigger in hankweave-runtime
   * State changes:
   * - Updates extensionCount on RunningCodon
   */
  | {
      type: "ExtensionCountUpdated";
      data: {
        runId: RunId;
        codonId: CodonId;
        extensionCount: number; // New extension count
      };
    }

  // ===== Checkpoint Events =====

  /**
   * Git checkpoint created.
   *
   * Triggered by: Rig setup, completion, error, skip
   * State changes:
   * - Sets relevant checkpoint field in codon
   */
  | {
      type: "CheckpointCreated";
      data: {
        runId: RunId;
        codonId: CodonId;
        checkpointType: "rig-setup" | "completed" | "error" | "skipped";
        sha: string;
        branch: string;
      };
    }

  /**
   * Initial checkpoint set for the project.
   *
   * Triggered by: Git repository initialization
   * State changes:
   * - Sets state.initialCheckpoint
   */
  | {
      type: "InitialCheckpointSet";
      data: {
        sha: string;
      };
    }

  /**
   * Codon final cost set from Claude's result message.
   * This ensures the authoritative cost from Claude's result message
   * is stored before the codon completes.
   *
   * Triggered by: Claude result message with final cost
   * State changes:
   * - Updates currentCost and currentTokens in running codon
   */
  | {
      type: "CodonFinalCostSet";
      data: {
        runId: RunId;
        codonId: CodonId;
        finalCost: number;
        finalTokens: TokenUsage;
      };
    }

  /**
   * Sentinel states updated/initialized for a codon.
   * Sets the initial sentinel state when sentinels load,
   * or updates states before codon completion.
   *
   * Triggered by: After sentinels load, before completing-sentinels transition
   * State changes:
   * - Sets/updates RunningCodon.sentinels field
   * - Updates CompletingSentinelsCodon.sentinels field
   */
  | {
      type: "SentinelStatesUpdated";
      data: {
        runId: RunId;
        codonId: CodonId;
        sentinelStates: SentinelState[];
        totalCost: number;
      };
    };

Most of the lifecycle flows through one event: CodonTransitioned carries from and to plus optional metadata, and the required metadata depends on the target state. The guard interfaces define those requirements:

TYPESCRIPT
// Metadata for transitioning to initializing
export interface InitializingMetadata {
  claudePid: number;
  claudeLogPath: string;
}

// Metadata for transitioning to running
export interface RunningMetadata {
  claudeSessionId: SessionId;
}

// Metadata for transitioning to completed
export interface CompletedMetadata {
  checkpointSha: string;
  resultMessageReceived?: boolean;
  budgetExceeded?: BudgetExceededData;
}

// Metadata for transitioning to failed
export interface FailedMetadata {
  exitCode: number;
  failureReason: FailureReason;
  failedDuring: CodonStatus;
  checkpointSha?: string;
}

// Metadata for transitioning to skipped
export interface SkippedMetadata {
  skippedDuring: CodonStatus;
  checkpointSha?: string;
}

// Type guards
export function hasInitializingMetadata(metadata: unknown): metadata is InitializingMetadata {
  return (
    typeof metadata === "object" &&
    metadata !== null &&

Reading the guards as a table: the required keys are claudePid and claudeLogPath for initializing; claudeSessionId for running; checkpointSha for completed; exitCode, failureReason, and failedDuring for failed; and skippedDuring for skipped. The other three targets–preparing, starting, and completing-sentinels–do not require metadata. A missing or incorrectly shaped requirement raises MetadataValidationError.

Applying transitions in order#

Emitting an event does not apply it. stateManager.transition(event) is fire-and-forget: it appends the event to an in-memory queue and returns without waiting. A serial processQueue() loop removes one event at a time, and isProcessing prevents concurrent queue drains. The caller must therefore treat getState() as eventually consistent rather than assuming it includes an event that was queued moments earlier.

The queue loop, including its error handling:

TYPESCRIPT
  transition(event: ST.StateTransition): void {
    this.transitionQueue.push(event);
    this.processQueue(); // Don't await - let it run
  }

  private async processQueue(): Promise<void> {
    if (this.isProcessing) return;

    this.isProcessing = true;

    while (this.transitionQueue.length > 0) {
      const event = this.transitionQueue.shift();
      if (!event) break; // Should never happen, but satisfies linter

      try {
        this.validateTransition(event);
        const newState = this.applyTransition(this.state, event);
        this.state = newState;

        // Update cost cache if needed
        this.updateCostCache(event);

        // Update execution plan if needed
        this.updateExecutionPlan(event);

        await this.save();

        this.emit("stateChanged", event);
        this.logger.log(`State transition: ${event.type}`);

        // Emit specific events for important transitions
        if (event.type === "CodonTransitioned" && event.data.to === "running") {
          this.emit("codonRunning", {
            runId: event.data.runId,
            codonId: event.data.codonId,
            from: event.data.from,
            to: "running" as const,
            metadata: event.data.metadata,
          });
        }
      } catch (error) {
        this.logger.log(`State transition failed: ${error}`, "error");
        this.emit("transitionError", { event, error: error as Error });

        if (error instanceof InvalidTransitionError) {
        } else {
          break; // Fatal error
        }
      }
    }

    this.isProcessing = false;

Each cycle validates the event, applies it, updates derived caches and plans as needed, saves state, and emits stateChanged. An InvalidTransitionError is logged and processing continues with the next queued event; another error breaks the queue. Because the queue is in memory, a server restart can lose transitions that have not yet been processed. The queue's value is ordered, non-blocking state updates–not durability before save() completes.

Recovering persisted state#

Durability comes from the save path, not the queue. A save preserves the previous file as state.json.bak, writes the new state to state.json.tmp, and atomically renames the temporary file to state.json. This leaves the previous state available if the write fails; a leftover .tmp indicates an incomplete write rather than a state version to read.

TYPESCRIPT
  async save(): Promise<void> {
    try {
      // Create backup of current state
      if (fs.existsSync(this.statePath)) {
        await fs.promises.copyFile(this.statePath, this.stateBackupPath);
      }

      // Write to temp file first
      const tempPath = `${this.statePath}.tmp`;
      await fs.promises.writeFile(tempPath, JSON.stringify(this.state, null, 2), "utf-8");
      await renameWithRetry(tempPath, this.statePath, { logger: this.logger });
    } catch (error) {
      throw new PersistenceError("save", error as Error);
    }
  }

On startup, initialize() loads and validates state.json. If loading fails, it tries state.json.bak; if neither can be used, initialization starts fresh. Validation reports corrupted_data for invalid structure, missing_run when currentRunId has no corresponding run, and orphaned_folder as a warning when a run directory has no state entry.

After loading, crash detection checks runs still marked running. If their serverPid no longer exists, the manager queues RunCrashed, marks the run crashed, and marks its in-flight codon failed with exit code -1 and failure reason Server crashed. Completed runs and their backups remain in the execution directory, which is why a typical directory contains both state.json and state.json.bak alongside per-run logs and checkpoints:

Output
.
├── .gitignore
├── .hankweave
│   ├── archive-manifest.json
│   ├── checkpoints
│   │   ├── .gitconfig
│   │   └── .hankweavecheckpoints
│   │       ├── COMMIT_EDITMSG
│   │       ├── config
│   │       ├── description
│   │       ├── HEAD
│   │       ├── hooks
│   │       ├── index
│   │       ├── info
│   │       ├── logs
│   │       ├── objects
│   │       ├── ORIG_HEAD
│   │       └── refs
│   ├── events
│   │   └── events.jsonl
│   ├── execution-meta.json
│   ├── logs
│   │   └── server.log
│   ├── runs
│   │   └── <id>
│   │       └── write-line-claude.log
│   ├── sentinels
│   │   └── history
│   ├── state.json
│   └── state.json.bak
├── agentRoot
│   ├── out.txt
│   └── read_only_data_source -> <workspace>/fixtures/scenarios/execution-directory/data
├── model-validation.log
└── rigArchive

Tracking cost without root totals#

The root HankweaveState keeps the newest run first in runs, identifies the active run with currentRunId, optionally stores initialCheckpoint, and persists the flattened executionPlan. Costs are not denormalized into the root object; they are derived from codon data.

Cost is computed on demand with getCodonCost from each codon's status-specific fields, as described in the variant section above. An in-memory costCache accelerates total and current-run queries; it is rebuilt after CostsUpdated, CostsIncremented, and CodonFinalCostSet transitions. CostsUpdated replaces a codon's accumulated values, while CostsIncremented adds deltas, which is useful when usage messages can arrive close together.

What clients hear when state changes#

Persisted state and what clients observe are delivered through different channels. The state.transition WebSocket event carries the transition type, optional runId and codonId, the complete transition payload, and a resultingState summary containing currentRunId, runCount, totalCost, and currentRunCost. The schema:

TYPESCRIPT
export const stateTransitionEventDataSchema = z.object({
  transitionType: z.enum([
    "RunStarted",
    "RunCompleted",
    "RunFailed",
    "RunCrashed",
    "CodonStarted",
    "CodonTransitioned",
    "CostsUpdated",
    "CostsIncremented",
    "AssistantMessageCountUpdated",
    "ExtensionCountUpdated",
    "CheckpointCreated",
    "InitialCheckpointSet",
    "CodonFinalCostSet",
    "SentinelStatesUpdated",
  ]),
  runId: z.string().optional(),
  codonId: z.string().optional(),
  transition: z.object({
    type: z.string(),
    data: z.record(z.unknown()),
  }),
  resultingState: z.object({
    currentRunId: z.string().nullable(),
    runCount: z.number(),
    totalCost: z.number(),
    currentRunCost: z.number(),
  }),

This event is one slice of a larger catalog. The server event catalog has 36 events in four categories: 20 server-state events, 7 agentic-backbone events, 5 sentinel events, and 4 connection-state events. Connection-state events–server.ready, pong, history.batch, and incomplete.codon–are sent to clients but are not journaled; the other categories are persisted to the event journal and broadcast to connected clients. Here, journaling means persisting the event for the event-history stream. The complete catalog belongs to Events; this page focuses on the state-transition slice.

When a codon is force-completed by a budget cap, codon.completed includes budgetExceeded with {currency, limit, used}. That is the same data shape stored on a completed codon.

Connecting state to execution threads#

The state machine's outputs feed two neighboring surfaces. First, the state manager's flattened execution plan–the persisted execution sequence–and codon history feed the execution-thread view. In the kill-and-resume capture, the resumed runs[0] is completed, while the earlier runs[1] is crashed and its interrupted write-line codon is failed with exit code -1. See execution threads for thread construction and continuation chains rather than duplicating that model here. Internal source examples use repo-relative paths such as server/types/state-types.js, server/state-transition-guards.js, and server/state-manager.js, not package exports.

Second, sentinel fields change shape as a codon advances: a loaded sentinel is represented under sentinels.loaded during the active phases, and the terminal representation uses sentinels.executed. At runtime, SentinelStatesUpdated is accepted while a codon is starting, initializing, running, or completing-sentinels, and stores the incoming states under sentinels.loaded. The sentinel interface includes active counters such as llmCallCount, failedLLMCalls, lastLlmCallAt, totalTriggers, and totalCost. Sentinel configuration and the complete lifecycle belong to sentinel concepts and sentinel configuration.

The journaled excerpt below shows three consecutive SentinelStatesUpdated events for one sentinel. Watch the counters: llmCallCount and totalTriggers read 0, 0, and then 1, and lastLlmCallAt first appears in the final update.

Output
{"id": "<id>", "timestamp": "<ts>", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "<id>", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "<id>", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "<ts>", "llmCallCount": 0, "failedLLMCalls": 0, "totalTriggers": 0, "totalCost":"<n>", "status": "active"}], "totalCost":"<n>"}}, "resultingState": {"currentRunId": "<id>", "runCount": 1, "totalCost":"<n>", "currentRunCost":"<n>"}}}
...
{"id": "<id>", "timestamp": "<ts>", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "<id>", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "<id>", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "<ts>", "llmCallCount": 0, "failedLLMCalls": 0, "totalTriggers": 0, "totalCost":"<n>", "status": "active"}], "totalCost":"<n>"}}, "resultingState": {"currentRunId": "<id>", "runCount": 1, "totalCost":"<n>", "currentRunCost":"<n>"}}}
...
{"id": "<id>", "timestamp": "<ts>", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "<id>", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "<id>", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "<ts>", "llmCallCount": 1, "failedLLMCalls": 0, "lastLlmCallAt": "<ts>", "totalTriggers": 1, "totalCost":"<n>", "status": "active"}], "totalCost":"<n>"}}, "resultingState": {"currentRunId": "<id>", "runCount": 1, "totalCost":"<n>", "currentRunCost":"<n>"}}}

The capture confirms that SentinelStatesUpdated preserves the active-lifecycle fields and updates counters in place rather than replacing the sentinel record. Failure classification is owned by errors and exit codes; this page only identifies failureReason as required metadata for a failed transition.