# 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](/concepts/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: `preparing` → `starting` → `initializing` → `running` → `completing-sentinels` → `completed` 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. ![The forward lifecycle and all permitted terminal exits. Failed and skipped have no outgoing transitions.](/content-assets/cf45dff5691c48c0/diagrams/contribute-state-machine/1.svg) The forward lifecycle and all permitted terminal exits. Failed and skipped have no outgoing transitions.
Diagram as text ```text 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](/concepts/codons) for the surrounding codon configuration and [checkpoints](/concepts/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 = { 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 }; ``` | Current status | Valid next statuses | | ---------------------- | -------------------------------------------------------- | | `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` | – | | `failed` | – | | `skipped` | – | ### Which transitions are legal? 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](#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: | Variant | Fields that become available in that state | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PreparingCodon` | No process or cost fields. | | `StartingCodon` | Optional `rigSetupCheckpoint`; optional `sentinels.loaded` and sentinel `totalCost`. | | `InitializingCodon` | `claudePid`, `claudeLogPath`; optional previous session, rig checkpoint, and `sentinels.loaded`. | | `RunningCodon` | `claudePid`, `claudeSessionId`, `claudeLogPath`, `currentCost`, `currentTokens`, `assistantMessageCount`, `extensionCount`, and optional `sentinels.loaded`. | | `CompletingSentinelsCodon` | The running-state process and cost fields while sentinel work drains; `sentinels.loaded` remains available. | | `CompletedCodon` | `endTime`, `claudeSessionId`, `claudeLogPath`, `exitCode: 0`, `finalCost`, `finalTokens`, `resultMessageReceived`, `completionCheckpoint`, and optional `sentinels.executed` and `budgetExceeded`. | | `FailedCodon` | `endTime`, `failedDuring`, `exitCode`, `failureReason`, `partialCost`, `partialTokens`, and optional process, checkpoint, extension, and `sentinels.executed` fields. | | `SkippedCodon` | `endTime`, `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`. > **VersionNote:** `ExtensionCountUpdated` makes the current union 14 events; older state-machine references may list 13. ## 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 { 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 { 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: ```text . ├── .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 │   │   └── │   │   └── write-line-claude.log │   ├── sentinels │   │   └── history │   ├── state.json │   └── state.json.bak ├── agentRoot │   ├── out.txt │   └── read_only_data_source -> /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](/reference/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](/contribute/execution-thread) 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](/concepts/sentinels) and [sentinel configuration](/reference/sentinel-config). 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. ```text {"id": "", "timestamp": "", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "", "llmCallCount": 0, "failedLLMCalls": 0, "totalTriggers": 0, "totalCost":"", "status": "active"}], "totalCost":""}}, "resultingState": {"currentRunId": "", "runCount": 1, "totalCost":"", "currentRunCost":""}}} ... {"id": "", "timestamp": "", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "", "llmCallCount": 0, "failedLLMCalls": 0, "totalTriggers": 0, "totalCost":"", "status": "active"}], "totalCost":""}}, "resultingState": {"currentRunId": "", "runCount": 1, "totalCost":"", "currentRunCost":""}}} ... {"id": "", "timestamp": "", "type": "state.transition", "data": {"transitionType": "SentinelStatesUpdated", "runId": "", "codonId": "validate-and-repair", "transition": {"type": "SentinelStatesUpdated", "data": {"runId": "", "codonId": "validate-and-repair", "sentinelStates": [{"id": "quality-observer", "model": "anthropic/claude-haiku-4-5", "loadedAt": "", "llmCallCount": 1, "failedLLMCalls": 0, "lastLlmCallAt": "", "totalTriggers": 1, "totalCost":"", "status": "active"}], "totalCost":""}}, "resultingState": {"currentRunId": "", "runCount": 1, "totalCost":"", "currentRunCost":""}}} ``` 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](/reference/errors-and-exit-codes); this page only identifies `failureReason` as required metadata for a `failed` transition. > **DeepDive:** `SentinelStatesUpdated` can fire while a codon is `starting`, `initializing`, `running`, or `completing-sentinels`, so sentinel tracking spans the codon's active lifetime rather than only the running phase.