The previous two articles analyzed the Pi Session Tree and DeepSeek Harness Session Event Log. Although their persistence models differ, they both follow a common principle: after a process exits, the new Runtime should reconstruct the session from persistent state, rather than relying on the old Runtime object continuing to exist.
This article verifies this principle with a minimal TypeScript Demo.
The Demo doesn't implement a real LLM or replicate the complete DSH SessionEvent protocol. It only preserves four mechanisms:
append-only Event Log
continuous seq
Message Projection
Crash Repair
What we're validating is:
Process A
crashes halfway through
↓
JSONL Session Log
↓
Process B
re-reads and repairs
↓
recover Message / Model Context
↓
continue with new Turn
If this chain holds, the lifecycle of Session and Runtime are truly decoupled.
Defining the Minimal Event Model First
The Demo only retains a few event types:
export type SessionEventData = {
'turn/start': { turn: number }
'turn/end': {
turn: number
reason: 'completed' | 'failed' | 'cancelled' | 'interrupted'
}
'user/message': { message: Message }
'assistant/message': { message: Message }
'tool/call': {
callId: string
name: string
args: unknown
}
'tool/result': { message: Message }
'request/header': {
model: string
systemPrompt: string
tools: string[]
}
}
Here we've deliberately distinguished three types of information.
The first type is the model's visible history:
user/message
assistant/message
tool/result
The second type is execution facts:
turn/start
turn/end
tool/call
The third type is request environment:
request/header
This division mirrors DSH's full model, but the Demo removes advanced capabilities like Step, Chunk, Surface Replace, and Request Context.
Each Event carries a continuous seq:
export type SessionEvent<T extends SessionEventType = SessionEventType> = {
[K in SessionEventType]: {
seq: number
time: number
type: K
data: SessionEventData[K]
}
}[T]
seq isn't just a display field. It defines Replay order and provides the most basic integrity check.
Durable Append Before Memory Projection
The append() implementation is brief:
append<T extends SessionEventType>(
type: T,
data: SessionEventData[T],
): SessionEvent<T> {
const event = {
seq: this.events.length,
time: Date.now(),
type,
data,
} as SessionEvent<T>
appendFileSync(this.file, `${JSON.stringify(event)}\n`, 'utf8')
this.events.push(event)
return event
}
There's a deliberately preserved write order here:
append JSONL
↓ success
push in-memory events
The Demo uses synchronous file writes, so this boundary is easier to express.
Real systems typically use async batching, requiring explicit flush / checkpoint semantics. DSH's Persistence Plugin copies session/event to a background buffer, draining it via session/flush when durability checkpoints are needed. [1]
The Demo uses synchronous writes only to focus on validating the state model—it doesn't imply production systems should perform synchronous disk IO for every Chunk in the Agent Loop.
Message as Projection of Event Log
Session doesn't independently maintain a persistable messages.json.
deriveMessages() constructs from Event Log each time:
deriveMessages(): Message[] {
const result: Message[] = []
for (const event of this.events) {
switch (event.type) {
case 'user/message':
case 'assistant/message':
case 'tool/result':
result.push(event.data.message)
break
}
}
return result
}
So:
Event Log
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
↓ deriveMessages()
Messages
user/message
assistant/message
tool/result
turn/start and tool/call don't disappear—they still belong to Session Fact, just not entering Model Message Projection.
This avoids checkpoint issues between two persistent states:
session.jsonl authoritative
messages derived
If a future UI needs to display Tool Calls, another Projection can be added without modifying Model Message Projection.
Request Header Makes Context Not Dependent on Message Alone
If we only restore Message, we can't fully construct the next model request.
So the Demo saves a simplified request/header:
session.append('request/header', {
model: 'demo-model',
systemPrompt: 'You are a coding agent.',
tools: ['readFile', 'runTest'],
})
When restoring Context, read the latest Header:
buildModelContext() {
const header = this.latestRequestHeader()
return {
systemPrompt: header.systemPrompt,
model: header.model,
tools: [...header.tools],
messages: this.deriveMessages(),
}
}
This shows that Model Context itself is still not a persistable object.
It's composed from two types of Durable State:
request/header
+
derived messages
↓
Model Context
Real systems might continue adding:
Memory
Compaction
Current Agent Policy
Runtime Injection
But the construction principle remains the same.
Simulating an Incomplete Turn
Process A creates a Session:
const sessionA = EventSession.create('demo', file)
Then appends normally:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
The code deliberately omits:
turn/end
And stops here to simulate the process crashing midway through a Turn.
At this point, the facts on disk are sufficient to express two things:
Turn 1 once started
Tool Result has completed and been persisted
We can also clearly see:
Turn 1 did not end normally
This carries more recovery information than just run.status = RUNNING, because exactly which facts completed remains in the log.
open() Validates seq First
After Process B starts, it reads JSONL:
session.events = readEvents(file)
session.assertContiguousSeq()
session.repairInterruptedTurn()assertContiguousSeq() validates:
for (const [index, event] of this.events.entries()) {
if (event.seq !== index) {
throw new Error(
`invalid session log: expected seq ${index}, got ${event.seq}`
)
}
}
If the log becomes:
0
1
2
4
The system won't silently Replay.
Because we can't tell if seq=3 never happened or happened but was lost.
This strict validation is one of the prerequisites for Event Log to serve as the source of truth. DSH also requires Session seq to be contiguous and validates event structure at Surface Fold and load boundaries. [2]
Crash Repair Appends an interrupted
When recovering, scan for Turn Boundaries:
private repairInterruptedTurn(): void {
let openTurn: number | undefined
for (const event of this.events) {
if (event.type === 'turn/start') {
openTurn = event.data.turn
}
if (event.type === 'turn/end' && event.data.turn === openTurn) {
openTurn = undefined
}
}
if (openTurn !== undefined) {
this.append('turn/end', {
turn: openTurn,
reason: 'interrupted',
})
}
}
So after reopening, the log transforms from:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
to:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
6 turn/end:interrupted
Old Events aren't deleted or伪造 (fabricated) as completed.
The new recovery fact simply states:
When this process took over, the previous Turn was not closed;
the Turn ended due to interruption.
This approach directly corresponds to DSH's crash repair strategy for cold Sessions. [1]
Recovered Messages Remain Deterministic
turn/end:interrupted doesn't belong to Message Projection.
So Process B executing:
sessionB.deriveMessages()
Still gets:
User
Check UserService.
Assistant
I will inspect the file first.
Tool Result
class UserService { ... }
Persisted Tool Results don't disappear due to Turn interruption.
This demonstrates a practical advantage of Event Log: execution integrity and fact retention can be handled separately.
Turns can fail or be interrupted, but events completed before that still exist.
Subsequent business logic can decide based on event semantics:
retain Tool Result
ignore incomplete Assistant Chunk
re-execute incomplete Tool
require user confirmation
automatically continue to next Turn
These strategies don't require modifying history at crash time.
New Runtime Can Continue Working
After recovery completes, the Demo continues appending a second Turn:
sessionB.append('turn/start', { turn: 2 })
sessionB.append('user/message', {
message: {
role: 'user',
content: 'Continue from the recovered state.'
}
})
sessionB.append('assistant/message', {
message: {
role: 'assistant',
content: 'The previous tool result is still available.'
}
})
sessionB.append('turn/end', {
turn: 2,
reason: 'completed'
})
The complete history is now:
Turn 1
start
messages
tool result
interrupted
Turn 2
start
messages
completed
Process B never received any memory objects from Process A.
It only depends on:
Session Event Log
This is the most basic recovery capability after Session and Runtime decoupling.
What's Still Missing From This Demo
The current implementation is only a state model experiment—it's clearly distant from production-level Sessions.
At minimum, it's missing:
async Persistence + flush checkpoint
fsync / database transaction
concurrent writer coordination
Event schema version
unknown Event compatibility strategy
Streaming chunk provenance
Step Boundary
Tool execution idempotency
Compaction / Surface Replace
Snapshot acceleration
Session Fork
large log pagination
The hardest problems usually aren't in append(), but in external side effects.
For example, if the log contains:
tool/call
The process then executes payment, sends email, or modifies the database, then crashes before tool/result is persisted.
After recovery, simply re-executing the Tool may produce duplicate side effects.
This requires further Tool layer design:
Idempotency Key
Execution Record
External Transaction ID
Reconciliation
Therefore, Event Log can accurately record facts the Agent knows, but can't automatically solve all distributed consistency problems.
This is an important boundary. Agent Session Crash Recovery and external system Exactly-once Execution are different problems.
Four Design Conclusions From the Demo
This minimal implementation validates four points.
First, Session doesn't need to save Runtime objects. As long as persistent facts are sufficient, new processes can rebuild Runtime State.
Second, Message can be a Projection. This way, execution Events, UI Events, and Model Messages don't need to be forced into the same data structure.
Third, Model Context can also be reconstructed. Beyond Message History, request states like System Prompt, Tools, and model configuration need explicit sources.
Fourth, Crash Recovery should express new recovery facts rather than篡改 (tampering with) already-persisted history. Incomplete Turns can be marked as interrupted, while completed Tool Results remain.
Putting this model together with the previous two articles shows three complexity levels:
Simple Chat
messages[]
Pi
JSONL Entry Tree
→ current path
→ buildSessionContext()
DSH
SessionEvent Log
→ Surface / Projection
→ Model / UI / Replay / Recovery
Systems should choose the lowest complexity model matching product requirements.
If only chat content needs saving, Message is sufficient; if long-running branchable Coding Sessions are needed, Pi's Tree is well-suited; if Replay, recovery, multiple Projections, and request reconstruction become core capabilities, Event-sourced Sessions like DSH are more valuable.
Unit Two completes a full cycle here: starting from "what should Session save," through two real framework designs, back to an executable minimal implementation.
The next unit enters another problem: as Tool, Memory, Sandbox, MCP, SubAgent, UI, and other capabilities continuously enter Agent systems, how does Runtime Core avoid continuous bloat? Pi Extension, Cordis, and DeepSeek Harness's "Everything is a Plugin" are all answering this question.
Demo
Directory structure:
demo/
├── package.json
├── README.md
└── src/
├── event-session.ts
└── demo.ts
Run:
cd demo
npm run demo
References
[1] DeepSeek Harness Persistence: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/persistence.zh.md
[2] DeepSeek Harness Session / Surface: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/session.md ; https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/core/session/src/surface.ts