Session Persistence and Recovery: an Append-Only Event Log, Resume and Forking, Week One Retrospective
Stop a conversation from vanishing with the process: use an append-only JSONL event log to record every message, tool call, and usage figure, replay the log to restore state after a restart, and fork a new session from any event — then look back at how these seven layers stacked up over week one.
Today's Goals
- Design an append-only session event log, and explain why a snapshot-style session object won't work
- Restore a session by replaying events, and handle a corrupted log and version incompatibility
- Fork a new session from any event, and explain the difference between forking and rolling back
This is week one's last day. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
The shift handover: they clock off and someone else continues tomorrow
Yesterday's new hire can now handle failures on their own. But they leave at six, and someone else takes over the next day.
How well the handover goes depends on what they left behind. Leave "I looked at that bug" and the next person investigates from scratch; leave a record — what was done when, which command was run, what it output, what was concluded — and the next person picks it up in five minutes.
The Agent's situation is identical, except that "clocking off" happens far more often: the user pressed Ctrl+C, the terminal was closed, the machine rebooted, you changed a line of code and restarted. Every time, the in-memory message array is gone, including the facts it took three tool calls to establish.
One misconception to clear up: today's work is not giving the model memory. Day one already said the model has no memory; history is something we re-carry in every turn. Today only moves that carrying from memory to disk — recovery does not mean it remembered, it means the history was carried back in. The distinction is more than wording: precisely because we carry the history ourselves, we can choose how much to carry (day twelve's compaction) and up to which point (today's forking).
So the question becomes a plain engineering one: what shape should this record take? The first layer of the answer is the shape, the second is how to read it back, the third is what else you can do with it once read. Today covers all three.
Why an event log and not a session object: appendable, auditable, forkable
The first temptation is serializing the whole session into one JSON file: an object with a message array, usage statistics and current state, rewritten at the end of each turn. Five lines of code.
It has three problems, all of the "fine until it is not, and then it is ugly" kind:
- Every write reads and writes the whole thing. It slows as the session grows, and writing requires holding the entire content in memory.
- There are endlessly many failure modes. Crash halfway through a whole-file write and you get truncated JSON — not "the last message is missing" but a file that will not parse at all. More insidious is a half-successful overwrite: new content shorter than old leaves the tail of the old file behind.
- There is no history, only the present. You cannot answer "which turn made that edit" or "how many tokens did the previous turn cost." Those questions always come up in a post-mortem or a cost review.
An event log is the opposite: append only, never rewrite. Every time something happens, add a line at the end.
| Comparison | Session object | Event log |
|---|---|---|
| Writing | whole-file overwrite | append at the end only |
| Failure modes | many, and hard to diagnose | exactly one: the last line may be incomplete |
| History | none, only the present | naturally complete and auditable |
| Forking | deep-copy the whole object | copy the first N lines |
The second row is today's sentence worth remembering: appending has exactly one failure mode, so it is the only one you can think through in advance. Section five deals entirely with that one.
One event per line: sequence, timestamp, type — three fields suffice
Use JSONL: one JSON object per line, with no syntactic relationship between lines. The cost is assembling on read; the benefit is touching only the file's end on write.
Each line has three mandatory fields — seq, ts and type — followed by that type's own fields:
{"seq":1,"ts":"2026-09-07T10:00:00.000Z","type":"message","message":{"role":"user","content":"run the tests and see which case fails"}}
{"seq":4,"ts":"2026-09-07T10:00:01.200Z","type":"message","message":{"role":"assistant","content":"Let me run the tests first.","toolCalls":[{"id":"call_test_1","name":"run_command","args":"{\"command\":\"node --test\"}"}]}}
{"seq":5,"ts":"2026-09-07T10:00:01.900Z","type":"tool_end","id":"call_test_1","result":{"ok":false,"content":"exit code 1..."}}Each field is irreplaceable: seq locates and forks ("branch from event 8"), ts audits ("how long did this step take"), and type determines what other fields follow. The lab has five types: message, tool_end, usage, error and fork.
export type SessionEvent =
| { seq: number; ts: string; type: 'message'; message: Message }
| { seq: number; ts: string; type: 'tool_end'; id: string; result: ToolResult }
| { seq: number; ts: string; type: 'error'; message: string }
| { seq: number; ts: string; type: 'fork'; from: string; atSeq: number }
async append(event: NewEvent): Promise<SessionEvent> {
this.seq += 1
const full = { seq: this.seq, ts: new Date().toISOString(), ...event } as SessionEvent
// One appendFile is one O_APPEND write. This is the persistence layer's only write path:
// being the only one is what keeps the failure mode to "the last line is incomplete"
await fs.appendFile(sessionPath(this.id), `${JSON.stringify(full)}\n`, 'utf8')
return full
}@dataclass(frozen=True)
class SessionEvent:
seq: int
ts: str
type: Literal["message", "tool_end", "usage", "error", "fork"]
payload: dict[str, Any]
class SessionLog:
def append(self, kind: str, **payload: Any) -> SessionEvent:
self._seq += 1
event = SessionEvent(self._seq, datetime.now(timezone.utc).isoformat(), kind, payload)
line = json.dumps({"seq": event.seq, "ts": event.ts, "type": kind, **payload})
# "a" is O_APPEND: the only write path, so the only failure mode is a partial last line
with open(self.path, "a", encoding="utf-8") as handle:
handle.write(line + "\n")
return eventTwo small decisions worth stating:
- A session id looks like
s-20260907-100000-ab12(date, time, four random characters). The time makes "sort by filename" equal "sort by time" — which is how--resumewithout arguments picks the most recent one, with no separate "current session" pointer to maintain. The randomness is because two sessions can start in the same second. - Persistence happens when an event occurs, not at the end of a turn. Batching to the end of a turn loses everything if the process is killed mid-turn; and the log's order becomes "all events, then all messages," leaving the reader to reorder mentally. The lab's order is naturally correct: the assistant message, its
tool_end, then the tool result message.
Replay recovery: which events rebuild messages and which are only for display
Recovery is replay: read the events from the start and rebuild the message array. There is one rule, and it is counterintuitive —
Only message events participate in the rebuild; none of the other four do.
tool_end clearly holds the tool result, so why not rebuild the tool message from it? Because the tool result already landed in the log as a message with role tool, and that is what gets sent to the model. What tool_end additionally records is elapsed time, character count, whether it was truncated, and whether it was stopped by the approval gate or by loop detection — that is for humans: auditing, costing, post-mortems.
export function replay(events: SessionEvent[]): Replayed {
const messages: Message[] = []
let replayed = 0
let skipped = 0
for (const event of events) {
if (event.type === 'message') {
messages.push(event.message)
replayed += 1
} else {
// tool_end / usage / error / fork: for humans only, not part of the rebuild
skipped += 1
}
}
return { messages, replayed, skipped }
}def replay(events: Iterable[SessionEvent]) -> Replayed:
"""Only message rebuilds. Mixing other types in makes tool results appear twice."""
messages = [event.payload["message"] for event in events if event.type == "message"]
skipped = sum(1 for event in events if event.type != "message")
return Replayed(messages=messages, replayed=len(messages), skipped=skipped)Separating the two classes pays directly: the rebuild has one rule and never forks over "which field is authoritative"; and adding a statistics column to the terminal only adds another non-message event type, with the replay code unchanged. Conversely, mixing both into one event type bills you on day twelve's compaction — you would have to distinguish "this part goes to the model" from "this part is for humans" inside one object.
In the lab, one turn produces 8 events, 5 of them message; replay yields 5 messages with 3 not participating. Those numbers are reproducible under MOCK=1 (the script is fixed). The cumulative token count on that line is not — tool results carry the command's elapsed time ("exit code 1 (65ms)"), and a different millisecond count changes the estimated tokens. So the self-test asserts it is greater than zero, not a specific value.
On version compatibility in passing: logs live a long time and your event types will grow. So do not crash on an unknown type when reading; the lab treats it as one of the non-rebuilding kinds. Conversely, the meaning of a field already written must never change — that would be rewriting history.
What to do about a corrupted log: a truncated last line is normal, so stop at the previous one
Now for that single failure mode.
A process can be Ctrl+C'd, killed, or have its terminal closed at any time. If it stops midway through an appendFile, the file's last line is half a JSON object. That is normal, not exceptional — over a year of sessions it happens many times.
There is one way to handle it: stop at the line before the bad one; do not skip, do not repair.
export async function readEvents(id: string): Promise<ReadResult> {
const lines = (await fs.readFile(sessionPath(id), 'utf8')).split('\n')
if (lines.at(-1) === '') lines.pop() // a well-terminated file ends with an empty string
const events: SessionEvent[] = []
for (let i = 0; i < lines.length; i += 1) {
const parsed = parseLine(lines[i] as string, events.at(-1)?.seq ?? 0)
// Stop here rather than continue: skipping assumes the middle can be corrupt too,
// and appending never produces that
if (!parsed.ok) return { events, stoppedAtLine: i + 1, reason: parsed.reason }
events.push(parsed.event)
}
return { events, stoppedAtLine: null, reason: null }
}def read_events(path: Path) -> ReadResult:
events: list[SessionEvent] = []
with open(path, encoding="utf-8") as handle:
for lineno, line in enumerate(handle, start=1):
if not line.endswith("\n"):
# No trailing newline means the last line was not finished: stop, do not continue
return ReadResult(events, stopped_at=lineno, reason="the last line was cut short")
parsed = parse_line(line, events[-1].seq if events else 0)
if parsed is None:
return ReadResult(events, stopped_at=lineno, reason="this line cannot be read")
events.append(parsed)
return ReadResult(events, stopped_at=None, reason=None)Why not skip the bad line and keep reading? Because skipping assumes the middle can be corrupt. Appending never produces that — and if it truly happens, something else touched the file, which is even less reason to guess. Stop, report the line number and the reason, and let the user decide.
Do not validate only that the JSON parses, either. The lab checks two more things: seq must strictly increase (a non-increasing one means two processes are overwriting each other, or the file was concatenated), and type must be recognized. The Python version exploits one more signal — a line with no trailing newline is the unfinished one — which is earlier and more precise than waiting for a parse failure.
Forking: copy to a new file and truncate, far safer than rewriting in place
With sequence numbers comes a new capability: branch from event N into a new session.
Its practical uses are more numerous than they sound. At event eight it proposed a direction and you want to try another without losing the current one; or you want two different attempts from the same prelude for comparison — the embryo of day twenty's benchmark evaluation.
The implementation has two steps: copy the lines with seq at or below the fork point into a new file, then append a fork event recording the lineage (which session and which step it came from). Three rules:
- Copy to a new file; never truncate in place. In-place truncation is a rewrite, and crashing halfway loses both the parent session and the fork. The cost is one extra copy on disk.
- Do not renumber
seq. Keeping the original numbers is what lets a fork point map straight back to the parent session. - Not every line is a fork point; only clean boundaries are. You may branch only after a user message or an assistant message with no tool calls. Truncating after an assistant message that requested tools yields a message array where a tool was requested with no result, and the next request is immediately invalid — that is the invariant fixed on day three. So the terminal must list the available positions rather than making the user count lines:
you > /fork
You can fork from these positions (clean boundaries only):
/fork 2 user: run the tests and see which case fails
/fork 8 assistant: the failing case is divide by zero: div...
/fork 9 user: then fix it
/fork 19 assistant: all four cases green, the divide-by-zero case is fixed...One last distinction, revisited on day fourteen: forking is not rolling back. Forking grows a new branch from that step, leaving the old one untouched; rolling back moves the current branch back to that step. And today's log covers only the conversation, not files — forking a new session does not restore the already-edited src/calc.js on disk. File rollback needs snapshots, day fourteen's subject. The log restores the conversation, snapshots restore files, the two are independent, and you can roll back only one side.
Week one retrospective: with seven layers stacked, it already completes a real fix
Week one ends here. Looking back, each day added a layer, and the order was not arbitrary:
Mermaid source
flowchart TB
D1["D1 REPL and gateway abstraction<br/>put the model in the terminal"] --> D2["D2 streaming and event layering<br/>StreamDelta and AgentEvent"]
D2 --> D3["D3 read-only tools<br/>loop, fragment merging, truncation"]
D3 --> D4["D4 write tools and shell<br/>first real code change"]
D4 --> D5["D5 approval gate<br/>three states, a pause not an exception"]
D5 --> D6["D6 errors and self-correction<br/>feedback, backoff, cancel, limits"]
D6 --> D7["D7 session persistence<br/>append-only log, recovery, forking"]Each layer depends only on those below it, deliberately:
- D2's layering (what the gateway said versus what the loop did) let the next five days add capabilities without touching the render layer.
- D3's read-only flag became D5's "read-only tools pass by default," one field saving an allowlist.
- D5 made approval "return a result" rather than throw, so D6 needed no new code for refusals — a refused call looks like a tool failure.
- D6 cleaned up within-turn failures first, and only then did D7 discuss cross-process recovery. Reversed, "this turn errored" and "last time did not finish" tangle in the same code, and their handling is entirely different.
Today's mca completes a real job: run tests in a real repository, read code, edit files, run again to prove the fix, asking where it should ask, stopping where it should stop, getting up after falling, and continuing after the process exits. That is a Coding Agent's core loop — the remaining fourteen days all add "work longer, work smarter, distribute better"; the core will not change again.
Week two changes direction from tomorrow: the first seven days solved whether it can work, the next seven solve whether it can avoid detours. The first item is not making it search the whole repository itself.
Source Reading
Hands-On Lab
Today leaves five exercises, two of which are "looks tidier, is actually a rewrite" traps: skipping bad lines and continuing, and truncating the parent session while forking. The starter passes four of eleven unmodified.
Recovery must be verified across two processes, so the self-test's parent spawns two children: the first opens a new session and establishes a fact, the second continues with --resume. Replaying inside one process proves nothing — the message array was in memory anyway.
- Define the five event types and the sequence rule, writing messages, tool calls and usage as events; after one turn, use
/logto check that seq runs contiguously from 1 and thattool_endsits exactly between the assistant message and the tool result. - Implement appending and replay recovery; start a different process with
--resumeand see whether the model's first sentence cites the case name established last turn. - Manually cut the log's last line in half and confirm recovery stops at the previous complete event and reports the line number.
- Implement forking from a given sequence number, and have
/forkwith no argument list the available positions; after forking, confirm the parent session's file is byte-identical. - Run the self-test:
MOCK=1 SELFTEST=1 pnpm startshould print 11/11 passed. Event counts, seq values and the number of fork points are reproducible; the session id and each line'stsare not, so do not assert on them.
Acceptance is five ticks: the self-test prints 11/11 passed; the log persists line by line in the correct order after turn one; a different process with --resume cites last turn's finding; a truncated last line still recovers and reports the line number; and /fork lists no assistant message with tool calls, with the parent session untouched after forking.
Interview Questions
Today's three questions test persistence trade-offs, not "what is event sourcing":
- For session persistence, do you choose an event log or a session snapshot? What does each cost?
- How does an append-only log handle a last line cut short by a crash?
- What is the difference between forking a new session from a historical step and rolling back to it?
Full bilingual prompts, analyses and key points are in this course's day-seven question bank. Question two looks smallest and discriminates most — it asks whether you have thought about failure modes, not whether you can write try/catch.
Checklist and Tomorrow
- I can name the session object's three costs and why an event log has only one failure mode
- I know why each line needs seq, ts and type, and why a session id carries a timestamp
- I can explain why only message events rebuild, and the rest are for display and audit
- I know why a bad line means stopping at the previous one, and why "helpfully repairing" a log is forbidden
- I can explain why forking copies to a new file and why seq is not renumbered
- I can name the positions that are not clean fork points and what breaks if you truncate there
- I can state the difference between forking and rolling back, and what the log and snapshots each cover
Tomorrow is D8, "Reference Injection: Parsing @ for Files, Directories, URLs and Images, and Reporting How Much Was Injected," starting week two. Over these seven days, every time it wanted to see a file it had to glob, then grep, then read across three or four turns — while you often knew which file mattered all along. Tomorrow builds @ references: files, directories and web addresses resolved straight into injected context, with the terminal explicitly printing how many characters and roughly how many tokens were injected. It leads week two because the next few days (project instructions, memory, compaction) all push things into the context, and @ references are the simplest and most controllable kind, so use it to establish the discipline that injection must be accounted for.
Interview questions
For session persistence, would you use an append-only event log or store a session snapshot? What does each cost?会话持久化你选事件日志还是存会话快照?各自的代价是什么?
Common in ChinaCommon overseasBasic#persistence#event-logHow to reason about it · think before answering
- This checks whether you choose by failure mode. Saying event logs are more professional scores nothing, and neither does snapshots are simpler. The signal is making the two costs comparable.
- How to break it down: ask what happens if the write is interrupted halfway. A snapshot is a whole-file overwrite with unbounded failure modes: a truncated JSON does not parse at all, and worse, when the new content is shorter than the old, the tail of the previous file survives and you get a syntactically valid, semantically corrupt file. Append-only has exactly one failure mode: the last line may be incomplete. The single mode is the one you can actually design for.
- Second dimension: a snapshot holds the present, not the past. It cannot answer which round made that edit or how many tokens the last round cost, and postmortems and cost accounting always ask. A log carries history by construction, because it records what happened rather than what is.
- Third dimension: forking. Forking a log is copying the first N lines; forking a snapshot means deep-copying an object and deciding which fields should follow.
- State the log's costs too, or it sounds like a sales pitch: you must replay on read, the file only grows so long sessions need archiving, and you must decide which event types participate in reconstruction versus which are display only, or the replay logic forks. In my implementation only message events rebuild state; tool metadata, usage, errors, and lineage are for humans.
- Likely follow-up: event types will grow, so what about old logs? Two rules — never crash on an unknown type (treat it as display only), and never change the meaning of a field you already wrote. The second is rewriting history, which is worse than incompatibility.
分析过程 · 先想清楚再作答
- 这题在看你会不会按「失败模式」选方案。答「事件日志更专业」拿不到分,答「快照简单够用」也拿不到——区分度在于你能不能把两者的代价说成可比较的东西。
- 怎么拆:先问一句「这个文件写到一半崩掉会怎样」。快照是整体覆盖,失败模式无穷多:截断的 JSON 整个解析不了;更阴险的是新内容比旧内容短时,尾部还留着旧文件的残渣,于是你拿到一个语法合法、语义错乱的文件。只追加的失败模式只有一种——最后一行可能不完整。**唯一的那种,才是你能事先想清楚的那种。**
- 再看第二个维度:快照只有现状,没有历史。「那次编辑是第几轮做的」「上一轮花了多少 token」这类问题它答不了,而事后复盘与算成本一定会问。事件日志天然带历史,因为它记的是「发生了什么」而不是「现在是什么」。
- 第三个维度是分叉:日志分叉就是复制前 N 行,快照分叉要深拷贝整个对象并想清楚哪些字段该跟着走。
- 结论要给出日志的代价,否则听起来像在推销:读的时候要自己重放(多一层代码);文件只增不减,长会话要另配归档;而且要定清楚「哪些事件参与重建、哪些只用于展示」,否则重放逻辑会分叉。我的实现里只有 message 事件参与重建,工具调用的 meta、用量、错误、血缘都只给人看。
- 可预期的追问:事件类型以后会加,旧日志怎么办?两条纪律——读到不认识的类型不要崩(当成不参与重建的那一类),已经写出去的字段含义不许改。后者等于篡改历史,比不兼容更糟。
Key points
- Choose by failure mode: append-only has one, whole-file overwrite has unbounded ones
- The nastiest overwrite case is shorter new content leaving old bytes in the tail, giving a valid but corrupt file
- A snapshot holds only the present, so it cannot answer which round or how many tokens
- The log's costs: replay on read, a file that only grows, and a clear rule on which events rebuild state
- Evolution rules: never crash on unknown event types, never change the meaning of a field already written
答题要点
- 按失败模式选:追加写只有「最后一行不完整」一种,整体覆盖的失败模式无穷多
- 覆盖写最阴险的情况是新内容比旧内容短,尾部残留旧数据,文件语法合法语义错乱
- 快照只有现状没有历史,答不了「第几轮做的」「花了多少 token」这类复盘问题
- 日志的代价是要重放、文件只增不减、必须定清楚哪些事件参与重建
- 演进纪律:不认识的事件类型不要崩,已写出去的字段含义不许改
How does an append-only log handle a last line that was cut off mid-write?只追加的日志怎么处理写到一半崩掉的最后一行?
Common in ChinaCommon overseasIntermediate#durability#event-logHow to reason about it · think before answering
- This looks like the smallest question and filters the most. It is not about writing a try/catch, it is about whether you treat a torn last line as normal. Answers like add a checksum or use transactions try to eliminate it, and it cannot be eliminated.
- How to break it down: state the framing first — a torn last line is the normal case, not an exception. Processes get Ctrl+C'd, killed, and have their terminals closed; over a year of sessions this happens many times. Since it is normal, handling it belongs on the main path, not hidden in an error branch.
- The conclusion is one sentence: stop at the line before the bad one, do not skip it and do not repair it. Stop, because nothing exists after the bad line. Do not skip, because skipping assumes corruption can appear mid-file, which append-only writes do not produce — if it really did, the file was touched by something else and guessing is even worse.
- Then why not repair, the half people get wrong: the tempting move is to delete the bad line and rewrite the file. That is an overwrite, exactly what append-only exists to avoid, and if that rewrite is interrupted you lose the good lines too. The right behavior is to stop on read and to keep appending after the last complete event on write. The half line stays in the file forever; it is harmless and it is evidence of where the last crash happened.
- Do not validate only that the JSON parses. Check at least two more things: sequence numbers must strictly increase, since non-increasing means two processes overwrote each other or the file was concatenated, and the type must be recognized. When reading line by line there is an even earlier signal: the line without a trailing newline is the unfinished one, which beats waiting for a parse error.
- Likely follow-up: do you tell the user? Yes, with the line number and the reason. Recovering partially and staying silent makes the user think the model is guessing; saying it stopped at line N with the first M events intact lets them decide whether to keep using that session.
分析过程 · 先想清楚再作答
- 这题看着最小,实际最能筛人。它问的不是「你会不会写 try catch」,而是「你有没有把这件事当成常态」。答「加个校验和」「用事务」的人,都是在试图消灭它,而它消灭不掉。
- 怎么拆:先给出定性——**最后一行写坏是常态,不是异常。** 进程会被 Ctrl+C、被 kill、被关掉终端,一个会话跑一年,这种情况会发生很多次。既然是常态,处理它就该是主路径的一部分,不该藏在错误分支里。
- 结论只有一句:**读到坏行就停在前一行,不跳过、不修补。** 停在前一行是因为坏行之后不存在东西;不跳过是因为跳过它等于假设文件中间也会坏,而追加写不会产生那种情况——一旦真的产生,说明文件被别的东西动过,那时候更不该猜。
- 然后是「不修补」为什么重要,这是最容易答错的一半:常见的自作聪明是发现最后一行坏了就删掉它再写回去。**那是一次改写,而改写正是只追加想避免的事**——如果这次改写本身被中断,你会连前面那些好行一起弄坏。正确做法是读的时候停在前一行,写的时候从最后一条完整事件之后继续追加。那半行会一直留在文件里,它无害,而且它是「上次崩在这里」的证据。
- 校验别只校验 JSON 能不能解析。至少再查两件事:序号必须严格递增(不递增说明有两个进程在互相覆盖或者文件被拼过),类型必须认识。逐行读的时候还有一个更早的信号——**没有换行符结尾的那一行,就是没写完的那一行**,比等解析失败更准。
- 可预期的追问:那要不要告诉用户?要,而且要给行号与原因。恢复得不完整而不说,用户会以为模型在瞎猜;说清「停在第几行、前面 N 条是完整的」,他自己就能判断要不要接着用这条会话。
Key points
- Frame it first: a torn last line is normal, so handling it belongs on the main path
- Stop at the line before the bad one and never skip it, since skipping assumes mid-file corruption
- Never repair it: deleting and rewriting is an overwrite that can destroy the good lines if interrupted
- On write, keep appending after the last complete event and leave the half line as crash evidence
- Validate strictly increasing sequence numbers and known types; a missing trailing newline is the earliest signal
答题要点
- 定性先说:最后一行写坏是常态,处理它属于主路径而不是错误分支
- 读到坏行停在前一行,不跳过——跳过等于假设文件中间也会坏
- 绝不「顺手修好」:删掉坏行再写回去是一次改写,中断时会连好行一起弄坏
- 写入时从最后一条完整事件之后继续追加,那半行留着当崩溃证据
- 校验要加上序号严格递增与类型可识别;逐行读时「没有换行符结尾」是更早的信号
What is the difference between forking a new session at some point in history and rolling back to that point?从历史某一步分叉出一条新会话,和回滚到那一步有什么区别?
Common in ChinaCommon overseasDeep dive#fork#session-stateHow to reason about it · think before answering
- This tests semantic precision and whether you have realized there is more than one kind of state. Anyone who says they are basically the same will ship a feature that corrupts the user's files.
- How to break it down: name what each one touches. A fork grows a new branch from that point and leaves the original untouched, implemented by copying the first N events into a new file. A rollback moves the current branch back, implemented either by rewriting the log or by invalidating everything after that point. One is addition, the other subtraction, and subtraction forces you to ask who still references what you dropped.
- Then the real point of the question: session state is not one thing. The event log governs the conversation, not the files already modified on disk. So after forking, the edited source file does not travel back with you — you get an old conversation paired with new files, and if you do not say so, the model keeps reasoning from a false premise. Rolling files back needs file snapshots, a separate mechanism.
- Conclusion: the two mechanisms are independent, you can roll back only one of them, and the user must be told which one they rolled back. In my implementation the log restores the conversation, snapshots restore files, and forking touches only the former.
- Two implementation points worth volunteering: fork by copying into a new file rather than truncating in place, since truncation is an overwrite that can lose both branches if interrupted; and do not renumber sequence numbers in the fork, because keeping them lets you map the fork point straight back to the parent.
- Likely follow-up: can you fork at any event? No. Only after a user message or an assistant message without tool calls. Truncating right after an assistant message that requested tools yields a message array with a call and no result, which makes the next request invalid, since every call needs exactly one result. So list the valid points for the user instead of making them count sequence numbers.
分析过程 · 先想清楚再作答
- 这题在考语义精确度,也在考你有没有想过「状态不止一份」。答「差不多,都是回到某一步」的人,接下来一定会做出一个把用户文件搞坏的功能。
- 怎么拆:先分清两件事各自动了谁。分叉是「从那一步长出一条新的,旧的原封不动」,实现是复制前 N 条事件到新文件;回滚是「让当前这条退回到那一步」,实现要么是改写现有日志,要么是在语义上把后面的作废。前者是加法,后者是减法——**加法几乎不会出事,减法要考虑清楚被丢掉的东西还有没有人在引用。**
- 然后是这题真正的题眼:**会话状态不止一份。** 事件日志管的是对话,磁盘上被改过的文件不在它管辖范围内。所以分叉出一条新会话之后,那个已经被改过的源文件不会跟着回去——你得到的是「一段旧对话 + 一份新文件」,如果不说清楚,模型会基于错误的前提继续推理。文件的回滚要靠文件快照,那是另一套机制。
- 结论:两套机制互相独立,可以只回滚一边,而且要让用户知道自己回滚的是哪一边。我的实现里日志恢复对话、快照恢复文件,分叉只碰前者。
- 实现上还有两条值得主动讲:分叉要复制到新文件而不是原地截断(原地截断是改写,写到一半崩了会同时失去母会话和分叉);分叉的序号不重新编号(保留原编号才能拿分叉点直接对回母会话)。
- 可预期的追问:能从任意一条事件分叉吗?不能。只有用户消息与不带工具调用的助手消息之后才是干净边界。在一条带工具调用的助手消息之后截断,会得到「请求了工具却没有结果」的消息数组,下一轮请求直接不合法——每个调用必须有且只有一条结果消息。所以要把可选位置列给用户,别让他自己数序号。
Key points
- Forking is additive: copy the first N events into a new session and leave the parent untouched; rollback is subtractive and must handle what it drops
- The real point is that state is plural: the log governs the conversation, not the files already changed on disk
- So after a fork you have an old conversation with new files; rolling files back needs snapshots, an independent mechanism
- Fork by copying into a new file rather than truncating in place, and keep the original sequence numbers so the fork point maps back
- Only user messages and assistant messages without tool calls are clean fork points, so list the valid ones for the user
答题要点
- 分叉是加法:复制前 N 条事件长出新会话,母会话原封不动;回滚是减法,要处理被丢掉的东西
- 题眼是状态不止一份:日志管对话,磁盘上改过的文件不在它管辖范围内
- 所以分叉之后是「旧对话 + 新文件」,文件回滚要靠快照,两套机制互相独立
- 分叉要复制到新文件而不是原地截断;序号不重新编号,才能对回母会话
- 只有用户消息与不带工具调用的助手消息之后才是干净分叉点,要把可选位置列给用户