# The Mental Model — Three Answers to Process Death

> The simplest accurate model of what duet-agent is and why its three subsystems (relay state machine, observational memory, TurnState snapshot) are one coherent answer to the same problem: work that must survive a dead process.

- 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

- `README.md`
- `src/index.ts`
- `src/turn-runner/turn-runner.ts`
- `src/types/protocol.ts`
- `src/session/session.ts`

---

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

- [README.md](README.md)
- [src/index.ts](src/index.ts)
- [src/turn-runner/turn-runner.ts](src/turn-runner/turn-runner.ts)
- [src/types/protocol.ts](src/types/protocol.ts)
- [src/session/session.ts](src/session/session.ts)
- [src/types/state-machine.ts](src/types/state-machine.ts)
- [src/memory/observational.ts](src/memory/observational.ts)
</details>

# The Mental Model — Three Answers to Process Death

duet-agent is built around one central problem: how do you keep agent work alive when processes don't? Most harnesses offer no answer — when the chat session ends, the process dies, and the context and progress go with it. duet-agent answers that problem three times over, with three distinct subsystems that are engineered to solve the same threat from different angles. Understanding why those three subsystems exist — and why each one alone is not enough — is the fastest path to a reliable mental model of the whole framework.

This page explains what each subsystem is, what process-death scenario it defends against, and how the three fit together into one coherent architecture. Every claim is traced to a specific file and line in the repository.

---

## The One Problem, Stated Plainly

A long-running job — prospect an outbound contact, wait two weeks for a reply, book a meeting — requires at least three things to survive across process restarts:

1. **The business-process position**: which step of the workflow was active, what was decided, and where to pick up.
2. **The conversation memory**: what the agent has learned, tried, and been told across many turns and sessions.
3. **The in-process runtime state**: what model, tools, prompt shape, todos, follow-up queue, and session options the agent currently holds.

Lose (1) and the agent restarts the workflow from scratch. Lose (2) and the agent repeats work and forgets instructions. Lose (3) and a resumed process cannot reconstruct its live runtime without a snapshot.

duet-agent maps each of these three threats to one subsystem.

---

## Subsystem One — The Relay State Machine

### What It Is

A relay (internally `StateMachineSession` + `StateMachineDefinition`) is an agent-routed state machine over five possible state kinds: `agent`, `script`, `poll`, `timer`, and `terminal`. The available states are defined in a `StateMachineDefinition`; which one runs next is always a live agent decision, not a hard-coded graph.

```typescript
// src/types/state-machine.ts:153-163
export interface StateMachineDefinition {
  name: string;
  prompt: string;
  states: StateMachineState[];
}

export type StateMachineState =
  | StateMachineAgentState
  | StateMachineScriptState
  | StateMachinePollState
  | StateMachineTimerState
  | StateMachineTerminalState;
```

### What Death Scenario It Solves

The relay owns **business-process position**. Because `StateMachineSession` is serialized into `TurnState` and flushed to disk after every state transition, a process can die at any point — between states, during a two-week `poll` wait, mid-script — and resume exactly where it left off. The entire audit log (`history`), current state name, transition input, and progress counters all survive.

```typescript
// src/types/state-machine.ts:170-217
export interface StateMachineSession {
  definition: StateMachineDefinition;
  prompt: string;
  currentState?: string;
  currentInput?: Record<string, unknown>;
  progress?: StateMachineProgress;
  history: StateMachineSessionEvent[];
  terminal?: StateMachineTerminalResult;
  terminalAcknowledged?: boolean;
  createdAt: number;
  updatedAt: number;
}
```

The `sleep` terminal event is the relay's answer to long waits. When a `poll` or `timer` state has nothing to do yet, the runner emits `sleep` with a `wakeAt` timestamp. The outer layer (`Session`) persists the state, schedules a polling interval, and dispatches a `wake` command when the deadline arrives. No process needs to stay alive in between.

```typescript
// src/session/session.ts:549-567
private scheduleWake(terminal: Extract<TurnTerminalEvent, { type: "sleep" }>): void {
  this.cancelWake();
  const fire = (): void => {
    if (Date.now() < terminal.wakeAt) return;
    this.cancelWake();
    const state = this.runner.getState();
    if (!state || state.status !== "sleeping") return;
    this.dispatchTurn({ type: "wake" });
  };
  this.wakeTimer = setInterval(fire, WAKE_POLL_INTERVAL_MS);
  ...
}
```

### The Five State Kinds

| Kind | What It Does | Process-death behavior |
|---|---|---|
| `agent` | Runs a sub-agent with a prompt and optional skills | Output saved to history; agent re-runs if interrupted |
| `script` | Shells out to bash, curl, or any CLI | stdout/stderr captured; retries on resume |
| `poll` | Runs a command on an interval, sleeps between attempts | Emits `sleep`; outer layer owns the timer |
| `timer` | Waits for an absolute wall-clock time | Emits `sleep` with `wakeAt`; process can die |
| `terminal` | Finalizes the session with a named outcome | Written to `StateMachineSession.terminal`; persisted |

The key design choice: the runner agent, not a config graph, picks the next state on every step. This means a relay can start in the middle ("I already sent the email, just wait for a reply") without any workaround — the agent reads context and history and selects the appropriate state directly.

Sources: [src/types/state-machine.ts:219-347](src/types/state-machine.ts)

---

## Subsystem Two — Observational Memory

### What It Is

Observational memory is a PGlite database of derived text observations, extracted by a background observer/reflector pipeline from each turn's raw transcript. It is **not** a raw chat-log dump. After each agent run, the runner observes the latest unobserved transcript suffix and writes compact, text-only observations to persistent storage. When the observation log grows large, a reflector compresses groups into summaries.

The in-process budget math is explicit:

```typescript
// src/memory/observational.ts:73-80
export const MEMORY_BUDGET_RATIOS = {
  messageTokens: 0.6,       // raw-message tail
  observationTokens: 0.325, // local-memory pack in prefix
  globalContextTokenBudget: 0.075, // cross-session pack
} as const;
```

### What Death Scenario It Solves

Observational memory owns **cross-session knowledge**. The raw `TurnState.agent.messages` array is the in-process conversation transcript. But raw messages are not the same as memory: they are too large to fit in the full context window across many sessions, and they describe tool invocations and intermediate reasoning rather than durable facts.

The observational memory pipeline converts raw transcript activity into a frozen, two-layer prefix: a global pack of cross-session observations (ranked within a 7.5% budget of `effectiveContext`) and a local pack of this session's compaction summary (within 32.5%). Both layers are rebuilt only at compaction events, so the provider's prompt cache survives turn-over-turn.

This means a session resumed six months later on a different machine automatically gets both long-term cross-session knowledge and the current session's summary injected into the agent's prefix — without replaying raw messages.

The observer and reflector are intentionally background workers: they never block a foreground turn. The runner waits for durable writes only at observation boundaries, after a pi-agent run completes.

A critical consequence: without a `memoryDbPath`, the observational pipeline is entirely disabled. The README is explicit that this means no compaction — the raw transcript grows until a provider context-length error terminates the session.

Sources: [src/memory/observational.ts:55-80](src/memory/observational.ts), [src/turn-runner/turn-runner.ts:218-260](src/turn-runner/turn-runner.ts)

---

## Subsystem Three — TurnState Snapshot

### What It Is

`TurnState` is a serializable snapshot of everything the runner needs to reconstruct a live turn. It holds the agent's full message history, the state machine session if one is active, the current todo list, the follow-up queue, any queued but not yet executed commands, the active mode, runtime options (model, memory model, thinking level), and the overall lifecycle status.

```typescript
// src/types/protocol.ts:169-208
export interface TurnState {
  status: TurnStateStatus;
  mode: TurnMode;
  options?: TurnOptions;
  agent: AgentSession;
  stateMachine?: StateMachineSession;
  todos?: TurnTodo[];
  followUpQueue?: TurnFollowUpQueueEntry[];
  queuedCommands?: TurnCommand[];
}
```

### What Death Scenario It Solves

`TurnState` owns **in-process runtime state**. The relay knows which business state is active. Observational memory knows what the agent has learned across sessions. But the running process also holds transient state — which todos were in progress, which follow-up messages were queued, which model the user had switched to, whether a `wake` command was queued but not yet dispatched — that would be silently lost without an explicit snapshot.

`TurnState` captures all of it. The `Session` layer writes the snapshot to `state.json` on every terminal event (and on every `usage` tick for the context bar):

```typescript
// src/session/session.ts:652-663
private async writeStoredEnvelope(state: TurnState): Promise<void> {
  const payload: StoredSessionFile = {
    sessionId: this.id,
    updatedAt: Date.now(),
    state,
    sessionCostUsd: this.sessionCostUsd,
  };
  ...
  await writeFile(this.sessionFilePath(), `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
}
```

And any fresh process passes that snapshot back via `runner.start({ state })`:

```typescript
// src/types/protocol.ts:316-340
export interface TurnStartCommand {
  type: "start";
  mode?: TurnMode;
  state?: TurnState;
  options?: TurnOptions;
  mcpServers?: Record<string, McpHttpServerConfig>;
}
```

There is one additional safety net: `TurnRunner` automatically compacts `TurnState` before it leaves the runner on every terminal event. Eviction drops the oldest agent messages (while preserving tool-call/result pairs) so `state.json` cannot grow unbounded even if observational memory compaction has already run many times. This `autoStateCompaction` behavior is on by default with a 100 MB ceiling.

Sources: [src/types/protocol.ts:169-208](src/types/protocol.ts), [src/session/session.ts:626-667](src/session/session.ts)

---

## How the Three Subsystems Compose

```text
┌─────────────────────────────────────────────────────────┐
│                    Incoming Turn                         │
│          runner.start({ state }) / runner.turn()         │
└───────────────────────┬─────────────────────────────────┘
                        │
          ┌─────────────▼──────────────┐
          │         TurnState          │  ← "where am I right now?"
          │  mode / options / todos /  │
          │  followUpQueue / messages  │
          └──────┬─────────────┬───────┘
                 │             │
   ┌─────────────▼──┐    ┌─────▼──────────────────────────┐
   │ StateMachine   │    │  Observational Memory           │
   │ Session        │    │  (PGlite — observer/reflector)  │
   │                │    │                                 │
   │ "which step?"  │    │ "what have I learned?"          │
   │ audit log,     │    │ frozen 2-layer prefix,          │
   │ currentState,  │    │ global + local packs,           │
   │ terminal       │    │ hybrid recall tool              │
   └────────┬───────┘    └──────────────┬──────────────────┘
            │                           │
            └──────────┬────────────────┘
                       │
          ┌────────────▼───────────────┐
          │   Disk / state.json        │
          │   (Session.writeStored     │
          │    Envelope on every       │
          │    terminal event)         │
          └────────────────────────────┘
```

The three subsystems are layered, not redundant:

- **TurnState** is the envelope that any process can hold and hand back to a fresh runner. It is the cheapest unit of persistence — JSON on disk.
- **StateMachineSession** (embedded in TurnState) is the business-process ledger. It answers "what step are we on?" but says nothing about what the agent has learned.
- **Observational memory** (in PGlite, separate from TurnState) is the knowledge layer. It answers "what has the agent learned across all sessions?" but says nothing about workflow position or runtime options.

Each layer fails differently. Without observational memory, sessions resume with no long-term recall and no compaction — context overflows. Without the relay state machine, multi-step workflows restart from scratch on every process death. Without TurnState snapshots, the runner cannot reconstruct the exact model config, queued commands, or todo list a previous process was holding.

The protocol makes the composition explicit: every terminal event (`complete`, `ask`, `sleep`, `interrupted`) carries the latest `TurnState`. Callers that need process-level durability persist it; callers that do not can ignore it. Either way, the runner has already done the work of collecting the three answers into one serializable envelope.

Sources: [src/types/protocol.ts:643-692](src/types/protocol.ts), [src/turn-runner/turn-runner.ts:364-390](src/turn-runner/turn-runner.ts)

---

## Terminal Events as the Handoff Point

The protocol design expresses the mental model precisely. There are exactly four terminal event types, and every one of them carries a `TurnState`:

| Terminal event | When emitted | Process-death implication |
|---|---|---|
| `complete` | Agent or state machine finished | Safe to exit; resume with the same state if user follows up |
| `ask` | Agent needs structured human input | Resume with an `answer` command; state is preserved |
| `sleep` | Poll or timer state waiting on external signal | Outer layer schedules a wake; process can exit |
| `interrupted` | User or system aborted the turn | State at interruption point is persisted; next prompt re-starts |

The `sleep` event is where the three answers converge most visibly. When the relay enters a `poll` or `timer` state, it emits `sleep` with a `wakeAt` timestamp. The `Session` layer writes `state.json` (TurnState snapshot), arms a polling timer to fire a `wake` command when the wall clock reaches `wakeAt`, and any subscribing UI renders a sleeping banner. If the process dies before the timer fires, the persisted `TurnState` (with `status: "sleeping"`) is enough for the next process to synthesize a fresh `sleep` event and re-arm the timer.

```typescript
// src/session/session.ts:209-229
private async replaySleepFromResumedState(state: TurnState): Promise<void> {
  const scheduled = this.currentScheduledState(state);
  ...
  await this.handleTurnEvent({ type: "sleep", wakeAt, state });
}
```

This pattern — persist the snapshot, let the outer layer own the clock, make the runner stateless between turns — is why a serverless invocation, a container that didn't exist when the work started, or a different machine next month can all resume the same session by calling `runner.start({ state })` with the saved JSON.

Sources: [src/session/session.ts:192-229](src/session/session.ts), [src/types/protocol.ts:664-691](src/types/protocol.ts)

---

## Summary

duet-agent's three subsystems are not independent features bolted together. They are three answers to the same architectural threat — process death — each operating at a different layer of the stack. The relay state machine preserves business-process position as a serializable session object embedded in `TurnState`. Observational memory preserves cross-session knowledge as derived observations in a local PGlite database, injected as a frozen prefix so the provider's prompt cache survives turn boundaries. The `TurnState` snapshot preserves all live runtime state — options, todos, queued commands, conversation history — in a single JSON envelope that any fresh process can hand back to a runner. Remove any one of the three and a class of long-running jobs becomes unreliable; together they make the deployment model tractable: cron wakes a container, hands it the snapshot, runs one turn, persists, exits.

Sources: [src/turn-runner/turn-runner.ts:281-340](src/turn-runner/turn-runner.ts)
