Search⌘ K
AI Features

Make It Survive: Journals, Resume, and a UI That Only Watches

Explore how to design an AI agent harness that survives process interruptions by persisting events in an append-only journal. Understand managing uncertain states and session resumption with explicit refusal rules. Gain hands-on experience creating robust message handling, event ordering, and resumption logic that maintain consistency across crashes and restarts.

Wren can talk, act, and refuse. Terminate the process halfway through, and all of that is gone.

Worse than gone. The last thing the agent did was call write, and nothing on disk says whether that write finished. A second session that assumes the safe answer either redoes a completed change or skips one that never happened, and it cannot tell which case it is in.

This lesson adds two files and 108 lines. When you are done, you run the demo, stop it, run it again, and watch it refuse to guess.

What has to survive, exactly?

Enough to rebuild the conversation and an honest record of what is uncertain.

The temptation is to save the transcript because that is what the next run needs. It is also what goes stale first: a transcript is a summary of events, so saving it means maintaining two representations that can disagree, and the one you saved is the one that gets out of date.

Wren saves events instead. The kernel already emits everything needed to rebuild the transcript, so the journal is the source of truth, and the messages are derived:

TypeScript 5.8.3
import { appendFile, readFile, writeFile, rename } from 'node:fs/promises';
import type { Event, Message } from './types.ts';
// The journal is append-only and written before anything else changes, so a
// crash can lose the tail but can never leave a rewritten history.
export class Session {
path: string;
events: Event[] = [];
constructor(path: string) {
this.path = path;
}
static async open(path: string): Promise<Session> {
const session = new Session(path);
let raw = '';
try {
raw = await readFile(path, 'utf8');
} catch {
return session; // first run, no journal yet
}
// A half-written final line is the normal shape of a crash. Drop it.
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
session.events.push(JSON.parse(line));
} catch {
break;
}
}
return session;
}
async append(event: Event): Promise<void> {
this.events.push(event);
await appendFile(this.path, `${JSON.stringify(event)}\n`, 'utf8');
}
// The snapshot is a convenience, never the source of truth. Written to a
// temporary file and renamed, so a reader sees the old file or the new one.
async snapshot(path: string): Promise<void> {
await writeFile(`${path}.tmp`, JSON.stringify(this.events, null, 2), 'utf8');
await rename(`${path}.tmp`, path);
}
// Rebuild the conversation from events alone. No separate state to drift.
messages(): Message[] {
const messages: Message[] = [];
for (const event of this.events) {
if (event.kind === 'run_start') messages.push({ role: 'user', text: String(event.data.task) });
if (event.kind === 'turn_end' && event.data.text) messages.push({ role: 'assistant', text: String(event.data.text) });
if (event.kind === 'result') messages.push({ role: 'tool', text: String(event.data.output), callId: String(event.data.id) });
}
return messages;
}

Read it as four questions the journal answers, not as one persistence utility.

  • Lines 14 to 32 answer what happened. Line 18 reads the journal; lines 19 to 21 treat a missing file as an empty session, so the first run and a lost journal share one path. Lines 23 to 30 parse one line at a time and break on the first malformed one, turning a torn final line into a non-event.

  • Lines 34 to 37 answer how it grows. Line 35 updates memory, then line 36 appends one JSON line to disk. No rewrite means a crash can lose only the tail.

  • Lines 47 to 55 answer what the conversation was. run_start becomes the user message, turn_end the assistant's, and result the tool message. messages() rebuilds rather than trusting a second copy.

  • Lines 59 to 66 answer what remains uncertain. The next section makes that refusal rule explicit.

This is the checkpoint lesson's argument in code. A summary is an interpretation, and an interpretation of a run cannot be checked against the run. Events can be checked because they are what happened. The cost is that a journal is larger and less readable than a summary, and the benefit is that a fresh session never has to trust it.

Why append-only, and why before anything else?

Because the failure you are designing for happens between two writes.

Every event is appended to a JSON Lines file, one line per event, and ...