# State Machine Execution Flow — How the Runner Agent Drives Transitions

> The runner agent—not a config file—selects the next state every turn. This page traces how state-machine-controller.ts dispatches a state, records audit events in StateMachineSession.history, emits sleep for poll/timer states, handles interruptions, and runs a terminal acknowledgment turn. Includes the carry-forward invariant and the mid-session start rule.

- Repository: dzhng/duet-agent
- GitHub: https://github.com/dzhng/duet-agent
- Human wiki: https://grok-wiki.com/public/wiki/dzhng-duet-agent-82dbe2572d3a
- Complete Markdown: https://grok-wiki.com/public/wiki/dzhng-duet-agent-82dbe2572d3a/llms-full.txt

## Source Files

- `src/turn-runner/state-machine-controller.ts`
- `src/turn-runner/state-machine-session.ts`
- `src/turn-runner/tools.ts`
- `src/turn-runner/prompts.ts`
- `evals/state-machine-routing.eval.ts`
- `evals/state-machine-interrupt-resume.eval.ts`
- `test/turn-runner-state-machine-agent-events.test.ts`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [src/turn-runner/state-machine-controller.ts](src/turn-runner/state-machine-controller.ts)
- [src/turn-runner/state-machine-session.ts](src/turn-runner/state-machine-session.ts)
- [src/turn-runner/tools.ts](src/turn-runner/tools.ts)
- [src/turn-runner/prompts.ts](src/turn-runner/prompts.ts)
- [src/turn-runner/turn-runner.ts](src/turn-runner/turn-runner.ts)
- [evals/state-machine-routing.eval.ts](evals/state-machine-routing.eval.ts)
- [evals/state-machine-interrupt-resume.eval.ts](evals/state-machine-interrupt-resume.eval.ts)
- [test/turn-runner-state-machine-agent-events.test.ts](test/turn-runner-state-machine-agent-events.test.ts)
</details>

# State Machine Execution Flow — How the Runner Agent Drives Transitions

The duet-agent state machine is orchestrated entirely at runtime by a parent LLM agent — not by a static config file or a hard-coded transition table. Every state transition is a deliberate tool call made by the runner agent. This page traces that loop in full: how `StateMachineController` dispatches each state kind, how audit events accumulate in `StateMachineSession.history`, when `sleep` events are emitted for poll and timer states, how interruptions are absorbed without data loss, how the terminal acknowledgment turn works, and the two invariants every caller must honor — carry-forward and mid-session start.

Understanding this flow matters because the runner agent is the only entity that reads state output and selects the next state. Sub-agents and shell scripts only handle one state at a time; they have no view of prior states unless the runner explicitly carries that context forward.

---

## The Dispatch Loop

### Parent Agent Selects a State

After a state finishes, `TurnRunner.selectNextStateAfterCompletion` re-prompts the parent agent with the completed state's name and output. The parent must reply with a `select_state_machine_state` tool call (up to 3 retries before the machine is failed automatically). The tool is thin: it validates that the named state exists in the active definition, applies any inline override, and terminates the parent turn immediately with `terminate: true`.

```ts
// src/turn-runner/turn-runner.ts:862-918
private async selectNextStateAfterCompletion(
  stateName: string,
  output?: unknown,
): Promise<StateMachineExecutionResult> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const workerResult = await this.runAgentWorkerWithUsage({
      prompt: dedent`
        The state "${stateName}" finished.
        ...
        you must end this turn by calling the select_state_machine_state tool
      `,
      ...
    });
    const result = await this.controllerResultFromWorkerResult(...);
    if (!result) continue;  // retry
    return result;
  }
  return { type: "terminal", status: "failed", error: "..." };
}
```

Sources: [src/turn-runner/turn-runner.ts:862-918]()

The parent's `select_state_machine_state` tool call produces a `StateMachineRunnerDecision` — a plain object carrying `{ state, reason?, override?, input? }`. The decision is then handed to `StateMachineController.runDecision`.

### Controller Records the Decision and Dispatches

`runDecision` is the single dispatch point in `StateMachineController`. Every call starts by recording the runner's decision into `session.history`, then looking up the target state in the definition. For non-terminal states, `applyStateOverride` merges any inline override before `recordStateStarted` sets `currentState` and `currentInput` on the session snapshot and fires `onSessionChanged` — the callback the `TurnRunner` uses to emit a `state_machine` protocol event to connected UIs.

```ts
// src/turn-runner/state-machine-controller.ts:204-258
async runDecision(decision): Promise<StateMachineExecutionResult> {
  // 1. Interrupt any previously active work
  if (previous) {
    this.interrupt("Replaced by a newly selected state.");
    await previous.finished;  // await teardown before starting replacement
  }
  // 2. Record runner's choice in history
  this.session = recordRunnerDecision(stateMachine, decision);
  // 3. Look up state, fail if unknown
  const selectedState = findState(this.session, decision.state);
  // 4. Apply override, record state_started, notify UI
  this.session = recordStateStarted(this.session, effectiveState, decision.input);
  this.config.onSessionChanged?.(this.session);
  // 5. Dispatch by kind
  switch (effectiveState.kind) {
    case "agent":  return this.runAgentState(effectiveState);
    case "script": return this.runScriptState(effectiveState);
    case "poll":   return this.runPollState(effectiveState);
    case "timer":  return this.runTimerState(effectiveState);
    case "terminal": return this.runTerminalState(effectiveState, decision.reason);
  }
}
```

Sources: [src/turn-runner/state-machine-controller.ts:204-258]()

The five `kind` values map to five distinct execution paths described below.

---

## State Kinds and Their Execution Paths

```text
StateMachineRunnerDecision
        │
        ▼
StateMachineController.runDecision()
        │
        ├─ kind: "agent"    → runAgentState()   → StateAgentHandle.prompt()
        ├─ kind: "script"   → runScriptState()  → ShellStateHandle.run()
        ├─ kind: "poll"     → runPollState()    → ShellStateHandle.run() → sleep loop
        ├─ kind: "timer"    → runTimerState()   → immediate or deferred completion
        └─ kind: "terminal" → runTerminalState() → recordStateMachineCompleted()
```

### `agent` States

A fresh `StateAgentHandle` is created for each agent state execution — sub-agents get no view of the parent transcript or prior sub-agent work. The handle wraps a `pi-agent-core` `Agent` instance configured with mode `"agent"` tools (coding tools, `todo_write`, `ask_user_question`, `read_skill`; no state-machine control tools). When `agent.prompt()` resolves:

- `interrupted` → `recordInterruptedState`, return `{ type: "interrupted" }`.
- `ask` → `recordStateAskedUser`, return `{ type: "ask", questions }`.
- `failed` → `recordStateFailed`, return `{ type: "terminal", status: "failed" }`.
- `complete` → `recordStateCompleted`, return `{ type: "state_completed" }`.

State prompts may use `{{ input.field }}` templates; `renderTemplate` expands them from `session.currentInput` before the handle is built.

Sources: [src/turn-runner/state-machine-controller.ts:266-299](), [src/turn-runner/turn-runner.ts:920-990]()

### `script` States

`runScriptState` renders the command template, creates a `ShellStateHandle`, and awaits `shell.run()`. Success produces `{ type: "state_completed" }` with the shell output normalized: `stdout`/`stderr` are trimmed and `parsed` is populated by `parseStructuredOutput`. Failure checks `shell.interruptedReason()` to distinguish an interrupt (→ `{ type: "interrupted" }`) from a genuine error (→ `{ type: "terminal", status: "failed" }`).

Sources: [src/turn-runner/state-machine-controller.ts:301-336]()

### `poll` States and the Sleep Loop

`runPollState` first checks whether `timeoutMs` has elapsed using `elapsedSinceStateStarted`, which reads `session.progress.states[name].startedAt`. If the timeout is exceeded, the machine fails immediately. Otherwise, the shell command runs once. If the exit code is in `successCodes`, the poll completes normally. If not — the shell error path — the state sleeps:

```ts
// src/turn-runner/state-machine-controller.ts:371-375
// Exit code not in successCodes → keep polling.
const wakeAt = Date.now() + state.intervalMs;
this.session = recordStateSleep(this.requireSession(), state, wakeAt);
return { type: "sleep", wakeAt };
```

`recordStateSleep` increments `progress.states[name].sleeps` and writes `nextWakeAt`. The `TurnRunner` converts the `sleep` result into a `TurnTerminalEvent` of type `"sleep"`, which signals the caller to put the session to rest and call `wake()` at `wakeAt`.

Sources: [src/turn-runner/state-machine-controller.ts:338-379]()

### `timer` States

Timer states are the simplest: if `wakeAt > Date.now()` and the call did not come from `wake()`, emit sleep. When woken (or when `wakeAt` is already in the past), complete with `{ elapsedMs, timestamp }`.

Sources: [src/turn-runner/state-machine-controller.ts:381-393]()

### `terminal` States

`runTerminalState` calls `recordStateMachineCompleted`, which appends a `state_machine_completed` event to history and sets `session.terminal`. The caller-supplied `reason` wins over the state's static `reason` field — this lets the runner agent attach a specific failure message when selecting the auto-injected `failed` terminal.

Auto-injection: `assertValidDefinition` (called when the runner creates a definition) calls `injectMissingTerminalEscapeHatches`, ensuring `"failed"` and `"cancelled"` terminals exist in every definition so the runner can always abort without writing boilerplate.

Sources: [src/turn-runner/state-machine-controller.ts:395-407](), [src/turn-runner/tools.ts:1071-1079]()

---

## History Audit Trail

Every meaningful transition appends to `session.history` through one of the named recorder functions in `state-machine-session.ts`. The append helper enforces a hard cap of 100 entries (`STATE_MACHINE_HISTORY_LIMIT`), dropping the oldest entries when exceeded. The starting `state_machine_started` marker can fall off under this cap; consumers must not rely on its presence.

| Recorder | History event type | When called |
|---|---|---|
| `createStateMachineSession` | `state_machine_started` | Session creation |
| `recordRunnerDecision` | `runner_decided` | Every `runDecision` call |
| `recordStateStarted` | `state_started` | Before dispatching any state kind |
| `recordStateCompleted` | `state_completed` | Agent/script/poll/timer success |
| `recordStateSleep` | *(no history event; updates progress only)* | Poll/timer going to sleep |
| `recordStateFailed` | `state_failed` + `state_machine_completed` | State error or timeout |
| `recordStateInterrupted` | `state_interrupted` | Interrupt received |
| `recordStateAskedUser` | `state_asked_user` | Agent sub-agent asked user a question |
| `recordStateMachineCompleted` | `state_machine_completed` | Terminal state reached |

Sources: [src/turn-runner/state-machine-session.ts:76-257]()

`recordStateStarted` also clears `nextWakeAt` on all states (via `clearProgressWakeTimes`) when any new state begins, which prevents stale wake times from accumulating across transitions.

---

## Sleep / Wake Protocol

```mermaid
stateDiagram-v2
    [*] --> Running : runDecision()
    Running --> StateCompleted : state exits normally
    StateCompleted --> Running : selectNextStateAfterCompletion (parent picks next state)
    Running --> Sleeping : poll/timer emits {type:"sleep", wakeAt}
    Sleeping --> Running : wake() called at wakeAt → controller.wake()
    Running --> WaitingForHuman : agent state calls ask_user_question
    WaitingForHuman --> Running : TurnRunner.answer() → selectNextStateAfterCompletion
    Running --> Terminal : terminal state selected
    Terminal --> [*] : acknowledgment turn
    Running --> Interrupted : interrupt() called
    Interrupted --> Running : resume → selectNextStateAfterCompletion
```

When `driveStateMachineResult` receives `{ type: "sleep" }`, `TurnRunner.controllerResultToTerminal` converts it to a public `TurnTerminalEvent` of type `"sleep"`. The session status is set to `"sleeping"`. `TurnRunner.wake()` resumes by calling `stateMachineController.wake()`, which calls `currentScheduledState` to identify the current poll or timer state and re-dispatches it.

Sources: [src/turn-runner/turn-runner.ts:685-703](), [src/turn-runner/state-machine-controller.ts:260-264]()

**Mid-session start rule:** when a user prompt arrives while the session is sleeping, `restoreSleepAfterPromptIfNeeded` returns a new `sleep` terminal after the parent-agent turn completes — preserving the `nextWakeAt` timestamp so the poll loop is not disrupted. Only follow-up commands are queued; steer commands fire an immediate parent prompt that can redirect the state machine.

Sources: [src/turn-runner/turn-runner.ts:705-735]()

---

## Interruption Handling

Interruptions can originate from two places:

1. **External interrupt** (`TurnRunner.interrupt()`): aborts the parent pi-agent and calls `stateMachineController.interrupt("Interrupted")`.
2. **State replacement** (`runDecision` with active work): the controller self-interrupts with `"Replaced by a newly selected state."` and **awaits `previous.finished`** before starting the replacement state. This teardown wait is critical — without it, the orphaned sub-agent or shell could keep emitting events into the new state's turn.

In both cases, `recordInterruptedState` sets `session.currentState` to the reserved sentinel `INTERRUPTED_STATE_MACHINE_STATE` (`"interrupted"`). The `state_interrupted` history event captures the reason and any partial output (assistant text for agent states, `{ stdout, stderr }` for shell states).

The dedup guard in `recordInterruptedState` prevents double-recording when the sub-agent's `finished` promise settles after the interrupt has already been written:

```ts
// src/turn-runner/state-machine-controller.ts:417-437
// If the last history event is already a state_interrupted for this state,
// update it in place rather than appending a second entry.
if (
  session.currentState === INTERRUPTED_STATE_MACHINE_STATE &&
  last?.type === "state_interrupted" &&
  last.state === stateName
) {
  this.session = { ...session, history: [...history.slice(0, -1), { ...last, reason, output }] };
  return;
}
```

Sources: [src/turn-runner/state-machine-controller.ts:409-437](), [src/turn-runner/state-machine-controller.ts:183-202]()

---

## Terminal Acknowledgment Turn

Every terminal — whether selected explicitly by the runner or produced by a runtime failure — triggers one additional parent-agent turn before the public `TurnTerminalEvent` is emitted. This is the **terminal acknowledgment turn**.

`TurnRunner.runStateMachineTerminalAcknowledgment` gates on `session.terminal` being set and `session.terminalAcknowledged` being false. It calls `markTerminalAcknowledged()` immediately (setting `session.terminalAcknowledged = true`) to ensure the same terminal cannot be acknowledged twice.

The acknowledgment prompt, built by `formatStateMachineTerminalAcknowledgmentPrompt`, is deliberately neutral about whether the terminal was chosen or a runtime failure:

```ts
// src/turn-runner/tools.ts:859-884
return dedent`
  The state machine "${session.definition.name}" has reached a terminal state and is no longer running.

  ${toXML({ state_machine_terminal: { state, status, reason } })}

  Respond now:
  - If you want to start follow-up work, call create_state_machine_definition.
  - Otherwise reply to the user in plain text...

  Do not call select_state_machine_state — there is no active state machine to advance.
`;
```

The parent's reply on this turn may:

- **Plain text only** → `runStateMachineTerminalAcknowledgment` returns `undefined`, and the caller emits the original terminal event. The natural-language summary lands as the final assistant message.
- **`create_state_machine_definition`** → a new session is created (`createStateMachineSession` returns a fresh object), so it has its own `terminalAcknowledged = false`, and the new machine will receive its own acknowledgment when it terminates.

Sources: [src/turn-runner/turn-runner.ts:832-860](), [src/turn-runner/tools.ts:859-884]()

---

## The Carry-Forward Invariant

Each agent state runs in a **fresh sub-agent context** with no view of prior agent transcripts, tool output, or state output. The only inputs are the rendered prompt (after template expansion) and the `input` object passed via `runDecision`. The system prompt layer (`createStateMachineSystemPromptLayer`) codifies this as a rule:

> "Treat every transition as a chance to update the next state's prompt or input with whatever the orchestrator now knows that the sub-agent will need." — [src/turn-runner/prompts.ts:94]()

Mechanically, the runner agent must either:

- Pass concrete facts as `input` (when the next state has a matching `inputSchema`), or
- Use `override.prompt` to inline findings from the prior state into the next state's prompt before selecting it.

A static prompt referring to "the findings from the previous step" without `input` or an `override` is a bug — the sub-agent has no channel to receive those findings. `applyStateOverride` in the controller merges the override fields shallowly (`{ ...state, ...override.state }`) before `runAgentState` renders templates.

Sources: [src/turn-runner/tools.ts:534-543](), [src/turn-runner/state-machine-controller.ts:237-248]()

---

## Definition Validation and Auto-Injection

When the runner agent calls `create_state_machine_definition`, `assertValidDefinition` runs before the tool returns:

1. **Reserved name check** — no state may be named `"interrupted"`.
2. **Schema validation** — each `inputSchema` must be a valid JSON Schema.
3. **Schedule validation** — poll states must have a positive `intervalMs`; timer states must have a finite `wakeAt`.
4. **Minimum cadence** — newly created poll states must have `intervalMs ≥ 15 minutes`; timer `wakeAt` must be ≥ 15 minutes in the future. This floor is only enforced at creation, not on externally supplied definitions passed via `mode:` config.
5. **Auto-injection** — `"failed"` and `"cancelled"` terminal escape hatches are added if missing.
6. **Completed terminal required** — at least one terminal with `status: "completed"` must exist.

Sources: [src/turn-runner/tools.ts:1055-1090](), [src/turn-runner/tools.ts:1049-1053]()

---

## Full Sequence: One State Completion Cycle

```mermaid
sequenceDiagram
    participant TR as TurnRunner
    participant SMC as StateMachineController
    participant SM as StateMachineSession (history)
    participant Parent as Parent Agent
    participant Sub as Sub-Agent / Shell

    TR->>SMC: runDecision({ state: "fetch-data", input: {url} })
    SMC->>SM: recordRunnerDecision()      [runner_decided]
    SMC->>SM: recordStateStarted()        [state_started]
    SMC->>TR: onSessionChanged(session)   [UI event: state_machine]
    SMC->>Sub: agent.prompt() / shell.run()
    Sub-->>SMC: { type: "complete", result }
    SMC->>SM: recordStateCompleted()      [state_completed]
    SMC-->>TR: { type: "state_completed", stateName, output }
    TR->>Parent: re-prompt with completed output
    Parent->>TR: select_state_machine_state({ state: "analyze-data" })
    TR->>SMC: runDecision({ state: "analyze-data", input: {...} })
    Note over SMC,SM: next cycle begins
```

Sources: [src/turn-runner/state-machine-controller.ts:204-258](), [src/turn-runner/turn-runner.ts:760-797]()

---

## Summary

The runner agent is the only entity that selects states. `StateMachineController.runDecision` is the single dispatch entry point: it records the decision, applies any override, records state start, fires a UI notification, and then dispatches to one of five `run*State` methods by state kind. Every transition appends audit events to `StateMachineSession.history` (capped at 100). Poll and timer states emit a `sleep` result that suspends the session until `wake()` restores it. Interruptions are safely absorbed through a teardown-wait (`previous.finished`) and a dedup guard on the history. Every terminal — chosen or runtime-failed — triggers one acknowledgment turn that gives the parent agent a chance to summarize or chain follow-up work. The carry-forward invariant is the key operational rule: because sub-agents start fresh, the runner must explicitly pass any facts the next state needs via `input` or `override.prompt` on every transition. These invariants are documented in the system prompt layer at [src/turn-runner/prompts.ts:88-94]() and enforced mechanically by the controller and tool validators.
