The previous two posts separately examined Pi Session Tree and DeepSeek Harness Session Event Log. Their persistence models differ, but both follow a shared principle: after a process exits, a new Runtime should reconstruct the Session from persisted state rather than rely on the old Runtime object remaining alive.
This post uses a small TypeScript demo to validate that principle.
The demo does not implement a real LLM or reproduce DSH's full SessionEvent protocol. It retains only four mechanisms:
append-only Event Log
continuous seq
Message Projection
Crash Repair
What we ultimately want to verify is:
Process A
执行到一半退出
↓
JSONL Session Log
↓
Process B
重新读取和修复
↓
恢复 Message / Model Context
↓
继续新的 Turn
If this chain holds together, the lifecycles of Session and Runtime are genuinely decoupled.
Define a Minimal Event Model First
The demo retains only a few event types:
Three kinds of information have deliberately been separated here.
The first is the history visible to the model:
user/message
assistant/message
tool/result
The second is execution facts:
turn/start
turn/end
tool/call
The third is the request environment:
request/header
This split resembles DSH's full model, but the demo omits advanced capabilities such as Step, Chunk, Surface Replace, and Request Context.
Every Event carries a contiguous seq:
The seq is not just a field for display. It defines Replay order and provides the most basic integrity check.
Durable Append Comes Before the In-Memory Projection
The append() implementation is short:
The write order here is deliberate:
append JSONL
↓ success
push in-memory events
Because the demo uses synchronous file writes, this boundary is easy to express.
Real systems typically use asynchronous batching and therefore need explicit flush / checkpoint semantics. DSH's Persistence Plugin copies session/event into a background buffer and drains it through session/flush whenever a durability checkpoint is needed.[1]
The demo uses synchronous writes solely to focus on validating the state model; this does not mean production systems should perform synchronous disk I/O for every Chunk in an Agent Loop.
Messages Are a Projection of the Event Log
The Session does not maintain a separate, persistable messages.json file.
deriveMessages() reconstructs the Messages from the Event Log on every call:
Therefore:
The turn/start and tool/call events have not disappeared. They remain Session Facts; they simply do not enter the Model Message Projection.
This avoids checkpoint consistency problems between two separate pieces of persistent state:
session.jsonl authoritative
messages derived
If the UI later needs to display Tool Calls, another Projection can be added without modifying the Model Message Projection.
The Request Header Keeps Context from Depending on Messages Alone
Restoring Messages alone is not enough to fully reconstruct the next model request.
So the demo saves a simplified request/header:
When restoring Context, it reads the latest Header:
This shows that the Model Context itself is still not a persisted object.
It is assembled from two kinds of Durable State:
request/header
+
derived messages
↓
Model Context
Real systems may also add:
Memory
Compaction
Current Agent Policy
Runtime Injection
But the construction principle remains unchanged.
Simulate an Unfinished Turn
Process A creates the Session:
It then appends the usual events:
0 request/header
1 turn/start
2 user/message
3 assistant/message
4 tool/call
5 tool/result
The code deliberately does not write:
turn/end
Phase 1 simply stops there to simulate a process exiting midway through a Turn.
At this point, the facts on disk are sufficient to express two things:
Turn 1 曾经开始
Tool Result 已经完成并落盘
It is also clear that:
Turn 1 没有正常结束
This provides more recovery information than a lone run.status = RUNNING, because the log still preserves which specific facts had been completed.
open() Validates seq First
After Process B starts, it reads the JSONL:
assertContiguousSeq() verifies:
If the log looks like this:
0
1
2
4
The system does not silently Replay it.
Because there is no way to tell whether seq=3 never occurred or occurred but was lost.
This strict validation is one prerequisite for an Event Log to serve as the source of truth. DSH likewise requires contiguous Session seq values and validates event structure during Surface Fold and at load boundaries.[2]
Crash Repair Appends an interrupted Event
During recovery, it scans Turn Boundaries:
So after it is reopened, the log goes 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
No old Event is deleted or falsely represented as completed.
The new recovery fact simply states:
当前进程接管时,发现上一 Turn 未闭合;
该 Turn 因中断结束。
This approach maps directly to DSH's crash-recovery strategy for cold Sessions.[1]
Recovered Messages Remain Deterministic
The turn/end:interrupted event is not part of the Message Projection.
So Process B runs:
and still gets:
User
Check UserService.
Assistant
I will inspect the file first.
Tool Result
class UserService { ... }
An already persisted Tool Result does not disappear because the Turn was interrupted.
This demonstrates a practical advantage of the Event Log: execution completeness and fact preservation can be handled independently.
A Turn may fail or be interrupted, but events that completed successfully beforehand remain in place.
Subsequent business logic can then decide, based on the event semantics, whether to:
保留 Tool Result
忽略半完成 Assistant Chunk
重新执行未完成 Tool
要求用户确认
自动继续下一 Turn
None of these strategies requires changing history at crash time.
A New Runtime Can Continue Working
Once recovery is complete, the demo appends a second Turn:
The complete history now looks like this:
Turn 1
start
messages
tool result
interrupted
Turn 2
start
messages
completed
Process B never receives any of Process A's in-memory objects.
It relies only on:
Session Event Log
This is the most basic recovery capability enabled by decoupling Session from Runtime.
What This Demo Is Still Missing
The current implementation is only a state-model experiment and is still a long way from a production-grade Session.
At a minimum, it still lacks:
The hardest problem usually lies not in append(), but in external side effects.
For example, suppose the log contains:
tool/call
The process then makes a payment, sends an email, or modifies a database, but crashes before the tool/result is written to disk.
After recovery, you cannot simply rerun the Tool, because that may produce duplicate side effects.
This calls for further design at the Tool layer:
Idempotency Key
Execution Record
External Transaction ID
Reconciliation
As a result, while an Event Log can accurately record the facts known to the Agent, it cannot automatically solve every distributed consistency problem.
This is an important boundary. Crash Recovery for an Agent Session and Exactly-once Execution in an external system are separate concerns.
Four Design Takeaways from the Demo
This small implementation validates four points.
First, a Session does not need to persist a Runtime object. Given enough durable facts, a new process can rebuild the Runtime State.
Second, Messages can serve as a Projection. This keeps execution Events, UI Events, and Model Messages from having to be forced into the same data structure.
Third, the Model Context can also be reconstructed. Beyond Message History, request state such as the System Prompt, Tools, and model configuration needs a clear source.
Fourth, Crash Recovery is best expressed as a new recovery fact rather than by rewriting history that has already been persisted. An unfinished Turn can be marked as interrupted, while completed Tool Results remain intact.
Viewing this model alongside the previous two posts reveals three levels of complexity:
简单 Chat
messages[]
Pi
JSONL Entry Tree
→ current path
→ buildSessionContext()
DSH
SessionEvent Log
→ Surface / Projection
→ Model / UI / Replay / Recovery
A system should choose the least complex model that meets its product requirements.
If all it needs to store is chat content, Messages are sufficient. If it needs a long-running, branchable Coding Session, Pi's Tree is a focused fit. If Replay, recovery, multiple Projections, and request reconstruction all become core capabilities, an Event-sourced Session such as DSH would be more valuable.
Unit 2 has now completed a full loop: it began with “what a Session should persist,” moved through two real-world framework designs, and culminated in a minimal working implementation.
The next unit will tackle another question: as capabilities such as Tool, Memory, Sandbox, MCP, SubAgent, and UI continually enter Agent systems, how can the Runtime Core avoid growing without bound? Pi Extension, Cordis, and DeepSeek Harness's Everything is a Plugin all address 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