Checkpoints and Rewind: File Snapshots, Conversation Rollback, and Why the Two Must Stay Independent
Make bad edits undoable: implement content-hash-based file snapshots, form two independent timelines with the session event log, support rolling back files only, the conversation only, or both together, and handle a repo that already had uncommitted changes — week two retrospective.
Today's Goals
- Implement a set of content-addressed file snapshots, and explain how it divides labor with a git commit
- Turn file rollback and conversation rollback into two independent, selectable recovery paths
- Handle three real problems: uncommitted changes, external changes, and snapshot size
Yesterday built the gate before acting; today builds the way back afterwards. When you finish, scroll back up and tick the three goals off.
Plain-Language Walkthrough
Save points and rollback: bad edits can go back to yesterday's version
Yesterday's gate blocked wrong directions; today handles the case where the direction was right, the plan was approved, they followed it, and the result is still wrong. What you want then is not an explanation but "put it back."
The rule is simple: save a version before acting, so that "try again" costs one keypress.
And the easiest misstep here is: do not use a git commit for that version. Running git commit on the user's behalf for "the state before changes" looks convenient, and the cost is a pile of commits in their history that they did not write and will spend time rebasing away. An Agent's save points are the Agent's own ledger and must not mix into the user's history. The division in one sentence:
| Whose ledger | Records | Who decides | |
|---|---|---|---|
| git commit | the user's | "this is a version I endorse" | the user |
| Snapshot | mca's own | "this is what it looked like before and after I acted" | the program, automatically |
So today has three parts: how to save (only what should be saved, and not twice), how to go back (files and conversation are two lines, and you must say which), and how not to lose the user's work (rollback is the only action that actively overwrites user files, so a check must precede it).
What a snapshot stores: only files a tool changed, deduplicated by content hash
Scope first: only files a tool changed.
In one task the Agent reads a dozen files and runs tests several times, while it may have touched only one. Snapshotting the whole repository is slow and useless — for an untouched file, the copy on disk is the best backup there is.
The timing is once before and once after each write-tool execution. Missing either loses a state: with only "after," you cannot return to before the first change — exactly what "undo this task" most often wants; with only "before," nobody records what the last change produced, and rollback inexplicably discards the newest edit.
Add a baseline: snapshot the whole repository when the session starts, making "back to before anything happened" a reachable state. Without it, the earliest reachable point is "before the first write to one file," while a task usually touched several. The baseline is taken once — re-taking it each turn records the Agent's own changes as a new baseline, and this chapter's final check would then never find anything.
Snapshotting before and after raises the volume immediately: changing one file twice in a task is four snapshots, and two of those four are identical — the first change's "after" is the second change's "before."
The deduplication approach is content addressing: where a piece of content is stored is decided by its own hash.
export async function putBlob(content: string): Promise<{ hash: string; fresh: boolean }> {
const hash = hashOf(content)
const target = path.join(OBJECTS_DIR, hash)
try {
await fs.access(target)
return { hash, fresh: false } // already there: that is the deduplication
} catch {
await fs.mkdir(OBJECTS_DIR, { recursive: true })
// Write a temp file then rename: a crash mid-write must never leave an object
// whose hash does not match its content
const temp = `${target}.tmp-${process.pid}`
await fs.writeFile(temp, content, 'utf8')
await fs.rename(temp, target)
return { hash, fresh: true }
}
}def put_blob(content: str) -> tuple[str, bool]:
digest = hash_of(content)
target = OBJECTS_DIR / digest
if target.exists():
return digest, False # already there: that is the deduplication
OBJECTS_DIR.mkdir(parents=True, exist_ok=True)
# Temp file then atomic rename, for the same reason
temp = target.with_suffix(f".tmp-{os.getpid()}")
temp.write_text(content, encoding="utf-8")
temp.replace(target)
return digest, TrueDeduplication is free: there is no "which equals which" table, because the hash is that table. The lab's self-test pins it in one line:
v two edit_file calls recorded 4 entries (before, after, before, after) but only 3 distinct contents - the first change's "after" is the second change's "before"Two rules to take away. One: a hash knows content, not filenames, so the object store itself does not know which hash belongs to which file; that lives in a separate index — the storage layer deduplicates, the index layer records meaning. Two: write a temp file and rename atomically — a crash mid-write must never leave an object whose hash does not match its content, which is worse than nothing because rollback would use it to overwrite.
The index is a JSONL, formatted exactly like day seven's session log: append-only, never rewritten, with an incrementing seq, stopping before a bad line. Two things in each record deserve a mention: the hash answers "what is the content," the mtime answers "who touched it last" — both are needed, because when content is changed and changed back the hash matches and only the mtime reveals that the file was touched. Another unobtrusive value is an empty hash, meaning the file did not exist at that moment (create_file's "before"). It is not a default but information to be used: returning to that moment means deleting that file.
Two timelines: the files' and the conversation's
Now today's crux.
Day seven built the session event log; today built the file snapshot log. They look like the same kind of thing: append-only JSONL, with a seq, both able to "return to a moment." So the natural thought is to align them — one turn maps to a batch of snapshots, and rolling back rolls both.
That thought is wrong, not on aesthetic grounds but because two of the four use cases become impossible:
| Combination | When you want it |
|---|---|
| Files only | it edited wrongly, but the investigation was useful — keep the conversation and let it retry with context |
| Conversation only | the files are right but the conversation drifted (ten turns of detour) — keep the work and pull the thread back |
| Both | this whole route was wrong; restart from the fork |
| Neither | see what a rollback would do before deciding |
The second is the most used and the most overlooked. In a long task the edits are often right within the first few turns, and the next ten are the model second-guessing itself and re-reading the same file. There you want the conversation truncated back while not one byte on disk moves.
The only way to support all four is for the two lines to hold no reference to each other. So each records only its own thing: the session log records what was said, the snapshot log records what the files are.
Returning the files to a moment is a pure function, and the piece of the day most worth pinning with a test:
/** The moment of entry seq: for each file, take the last record with seq at or below it */
export function stateAt(checkpoints: Checkpoint[], seq: number): Map<string, Checkpoint> {
const state = new Map<string, Checkpoint>()
for (const entry of checkpoints) {
if (entry.seq > seq) break
state.set(entry.path, entry)
}
return state
}def state_at(checkpoints: list[Checkpoint], seq: int) -> dict[str, Checkpoint]:
"""The moment of entry seq: for each file, the last record with seq at or below it"""
state: dict[str, Checkpoint] = {}
for entry in checkpoints:
if entry.seq > seq:
break
state[entry.path] = entry
return stateA one-line algorithm whose failure is the most insidious: forget that seq check and "back to entry N" always equals "keep things as they are" — no error, no exception, simply nothing happening while the user believes they rolled back.
Comparing that moment's state with the current one yields what each file needs: restore those whose content differs, delete those that did not exist then. The third is the most often missed — a file created during the task should disappear when returning to before its creation. A rollback that only restores content leaves a pile of files that appeared from nowhere, and that residue is the hardest to diagnose because it looks like something you created.
The conversation side needs no new code: rolling back the conversation is day seven's fork — copy to a new file, truncate at a seq, record a fork event for lineage, with the parent session untouched. Even the "only clean boundaries" rule is reused directly. Today only gives it a different motive.
Before rolling back: refuse first, then warn
The last part, and the day's riskiest.
Rollback is the only action in this program that actively overwrites user files. edit_file has the guard that old_string must match verbatim, failing otherwise; rollback overwrites whole files and cannot fail — it will successfully wipe out the paragraph you typed by hand, with no notice.
So before overwriting, one question must be answered: are the files on disk what I recorded?
Comparing snapshot records with disk yields two kinds of mismatch, one refused and one warned, differing only in whether rollback would cause unrecoverable loss:
One: content mismatch, refuse the whole rollback. Someone edited that file outside mca, and that content was never in any snapshot. Rollback would erase it permanently with no recovery for anyone. So refuse first, then tell the user which files and what they can do:
x README.md: content differs from the last snapshot (#1 2fd7e6ca91735f4b), now 5bc79da1b9d83707 - this change is in no snapshot
x nothing was rolled back: 1 file has changes that are in no snapshot.
Save or commit them, or confirm they can be discarded, then retry with --force.Those three lines come from INJECT=dirty: it plants a piece of "hand-written, uncommitted" content into the repository after the baseline. The timing matters more than the content — it must come after the baseline, or the change becomes part of the baseline and this check never finds anything.
One boundary: check only recorded files. Including the whole repository drowns a first-time reader in unrelated warnings, and they learn to ignore warnings — exactly the outcome to avoid.
Two: content matches but the mtime is newer, warn and require confirmation. Someone touched the file, and the content matches the record (most commonly changed and changed back). Rollback is harmless here, so do not block the user, but do tell them:
! src/calc.js: content matches the snapshot, but the modification time is newer - this file was touchedThat is the entire purpose of the mtimeMs field. Comparing only hashes leaves it invisible.
One combination remains: neither. It is not a no-op, it is a dry run — print everything a rollback would do without touching one byte:
Dry run: going back to snapshot #4 would -
<- src/calc.js b674bb76041d3d54 -> b1b6e60880468d4b
Nothing was rolled back (that is all /rewind none does)Since rollback is the only action that actively overwrites user files, it deserves an entry point for looking before deciding.
Week two retrospective: it can now work for a long time
At the end of day seven, mca could turn a failing test green in a real repository, interruptibly and recoverably. It was a tool that could do one thing. These seven days were all about letting it do many things in a row — the difference is not capability but seven layers, each governing one thing:
| Day | The layer added | The problem it solves |
|---|---|---|
| D8 | @ references | do not make it search the whole repository for material |
| D9 | project instruction files | this repository's rules, not restated every time |
| D10 | task checklist | "how far along" visible at a glance in long tasks |
| D11 | memory | remembering this repository's traps across sessions |
| D12 | context compaction | a full context is not the end, it can be freed |
| D13 | questions and plan approval | spell things out before acting |
| D14 | snapshots and rewind | go back afterwards |
Those seven fall into three groups, and the grouping is itself the answer:
One: keep only what belongs in the context (D8, D9, D11, D12). References inject material precisely, instruction files keep rules resident, memory preserves cross-session facts, compaction frees a full desk. The shared benefit: for the same task, it takes fewer detours.
Two: put state in the open (D10, D13). The checklist makes "how far along" visible, the plan makes "how I intend to do it" visible. The value is not the feature but that a human can still follow what it is doing during a long task — an Agent that runs twenty turns you cannot follow will eventually get Ctrl+C'd.
Three: make errors undoable (D14). The first six layers lower the probability of error; today's layer admits it will still err and reduces an error's cost from "start over" to one keypress.
And one engineering thread this week is worth remembering more than any feature: five mechanisms were added over seven days, and kernel/ changed exactly twice.
D8 through D12 all added things to the context pipeline; D13's plan approval slotted into day five's gate (one non-read-only tool plus one rule); today's snapshot collection is that transparent wrapper again (day seven's persistence, day twelve's usage sampling and day thirteen's change checking are all it). Not one protocol field was added, and not one AgentEvent type.
That is not luck but the compound interest of day two's decision: StreamDelta and AgentEvent split into two layers, with the render layer subscribing only to semantic events. With that boundary, "add a layer of observation" and "add a tool" became two cheap forms of extension, and they carried the whole of week two.
Source Reading
Hands-On Lab
Today leaves five exercises, all five "looks simpler, is worse" traps: numbering snapshots sequentially (storing the same content many times), snapshotting only "after," forgetting the seq check in "return to a moment" (turning rollback into a silent no-op), a rollback that does not delete files, and a workspace check comparing only modification times and not content. The starter passes four of twelve unmodified.
- Make the object store content-addressed: the filename is the content hash, check existence before writing, and confirm the self-test line "4 entries, only 3 distinct contents."
- Add the "before" snapshot, noting it must hang on
tool_start— bytool_endthe file has already changed. - Add the seq check to
stateAt, then watch files-only rollback change from a no-op to a real rollback. - Make rollback delete files: a file created during the task should disappear when returning to before its creation.
- Complete the two-level workspace check (refuse on content mismatch, warn when only the time is newer), then run
MOCK=1 SELFTEST=1 pnpm startto see 12/12 passed, and use the README's pipe commands for the four combinations andINJECT=dirty.
Acceptance is six ticks: the self-test prints 12/12 passed; four records correspond to three contents; after one task only the changed file has new snapshots; all four combinations hold (files-only leaves the message count unchanged, conversation-only leaves file content unchanged, both changes both, and neither changes nothing while the dry run still computes what would move); INJECT=dirty refuses the rollback with the hand-written content intact; and changed-and-changed-back produces a warning without blocking.
Interview Questions
Today's three questions test storage design and recovery semantics, not "should an Agent support undo":
- How do you design an Agent's file snapshots? Why not just use git commits?
- Why must file rollback and conversation rollback be separate? What happens with only one side?
- Before rolling back you find uncommitted or externally changed files in the working tree. How do you handle it?
Full prompts, analyses and key points are in this course's day-fourteen question bank. Question two discriminates most — most answer "rolling back both is simplest," and few can say that conversation-only is the most used case in long tasks and explain why.
Checklist and Tomorrow
- I can state the division between snapshots and git commits, and why not to commit on the user's behalf
- I can explain why content addressing makes deduplication free, and why full contents beat diffs
- I can say why write tools snapshot before and after, and why "before" must hang on
tool_start - I can state the baseline snapshot's purpose, and why it is taken only once
- I can say what
hashandmtimeMseach answer, and what a nullhashmeans - I can name the four rollback combinations' uses, especially why conversation-only is the most common
- I can explain why the two timelines must not reference each other, and why
bothtakes two arguments - I can state the difference between the two pre-rollback checks: refuse on content mismatch, warn when only the time is newer
- I can group week two's seven layers into three, and say why not one protocol field was added
Tomorrow starts week three with D15, "The MCP Client: Hand-Writing JSON-RPC over stdio and Streamable HTTP." The first two weeks built a self-sufficient Agent, with every tool written by us; week three connects outside things: other people's tools (MCP), other people's experience (Skills), and more of itself (subagents). And all of them travel the two roads week two paved — add a tool, or add a layer of observation.
Interview questions
How would you design an agent's file snapshots, and why not just make git commits?Agent 的文件快照怎么设计?为什么不直接用 git 提交?
Common in ChinaCommon overseasBasic#snapshots#content-addressingHow to reason about it · think before answering
- This tests whether you separate two different ledgers. Anyone answering just use git has not considered that the user's commit history is theirs, and inserting commits they did not write is work they must rebase away.
- Start with the division of labor. A git commit is the user's ledger, recording this is a version I endorse, decided by the user. A snapshot is the agent's own ledger, recording what things looked like before and after I acted, decided automatically by the program. Timing, granularity and lifetime all differ: one task may write files ten times, producing a dozen snapshots, while the user wants a single commit. Mixing them pollutes history, and it also makes snapshots hostage to git state — a populated index, a rebase in progress, or a directory that is not a repo at all would each break snapshotting.
- Then what to store: only files a tool actually modified, and one snapshot before and one after each write. Miss either and a state is unreachable — with only after you cannot get back to before the first edit, which is exactly what undo this task needs; with only before you lose the most recent change. You also want a baseline taken when the session opens, or the earliest reachable state is just before the first write to one file, while a task usually touched several.
- How to avoid storing duplicates: content addressing — where a blob lives is determined by its own hash. Deduplication then comes for free with no table of what equals what, because the hash is that table. The saving is real: the after of one edit is the before of the next, so four records often map to three distinct contents.
- Two details that show you built it. First, store whole files, not diffs. Increments save space but a diff needs a base, bases form a chain, and a broken link ruins the rest — precisely wrong for something whose job is recovery after things break; source files are kilobytes, so trading disk for an entire failure class is worth it. Second, write a temp file and rename atomically: after a crash mid-write the store must never hold a file whose content does not match its hash, which is worse than nothing because a rewind would copy it over your work.
- Likely follow-up: how is the index kept? A hash identifies content, not a name, so the object store cannot know which hash belongs to which file; that lives in an append-only index recording file, time, hash, size and mtime. Storage deduplicates, the index carries meaning. The index must also allow a null hash, meaning the file did not exist at that moment — so rewinding there means deleting it.
分析过程 · 先想清楚再作答
- 这题在考「你能不能分清两种账本」。答「用 git 最省事」的人没想过一件事:用户的提交历史是他的东西,往里塞不是他写的提交,是要他花时间 rebase 掉的。
- 先说分工。**git 提交是用户的账本,记的是「这是我认可的一个版本」,由用户决定;快照是 Agent 自己的账本,记的是「我动手前后的样子」,由程序自动决定。** 两者的时机、粒度、生命周期都不一样:Agent 一次任务可能写十次文件,对应十几张快照,而用户可能只想提交一次。混在一起的直接后果是历史被污染,间接后果是快照受 git 状态摆布(暂存区里有东西、处于 rebase 中间态、仓库根本没初始化 git,快照就都拍不了了)。
- 再说存什么。**只存被工具改过的文件**,而且**写工具执行前后各拍一次**。少任何一张都会缺一个状态:只拍「改之后」就回不到第一次改之前(而那恰恰是「撤销这次任务」最常要的那一个);只拍「改之前」就丢掉最新一次改动。另外要有一张**基线**(会话开始时把仓库拍一遍),否则最早能回到的只是「第一次写入之前的那一个文件」,而任务往往动了好几个。
- 怎么存不重复:**内容寻址**——一份内容存在哪儿由它自己的哈希决定。去重于是是白拿的,不需要任何一张「谁和谁一样」的表,因为哈希本身就是那张表。而它省的量很实在:一次 edit 的「改之后」就是下一次的「改之前」,四条记录常常只对应三份内容。
- 两个实现细节能显出你写过:一、**存全文不存 diff**。增量最省空间,但 diff 要有基准、基准要有链,链断了整串都恢复不了——而快照的用途恰恰是「出事要能恢复」,那是最不该有链式依赖的时候;源码文件只有几 KB,磁盘换掉一整类失败模式很值。二、**先写临时文件再原子改名**:崩在半路时,对象库里绝不能留下一个哈希对不上内容的文件,那种文件比没有更糟,因为回滚会拿它去覆盖。
- 可预期的追问:索引怎么记?哈希只认内容不认文件名,所以对象库自己不知道哪个哈希属于哪个文件——那件事记在一份只追加的索引里(哪个文件、什么时候、哪个哈希、多大、mtime 多少)。**存储层负责去重,索引层负责语义。** 索引里还要允许哈希为空,它表示「那一刻这个文件不存在」,回到那一刻就意味着把文件删掉。
Key points
- A git commit is the user's ledger decided by the user; a snapshot is the agent's own, produced automatically — mixing them pollutes history
- Store only files a tool modified, one snapshot before and one after each write, plus a baseline at session start
- Content addressing makes deduplication free: the hash is the table of what equals what
- Store whole files, not diffs — recovery is the worst place for chained dependencies, and source files cost almost nothing
- Write to a temp file and rename atomically; hashes identify content, so the file-to-hash mapping lives in an append-only index
答题要点
- git 提交是用户的账本、由用户决定;快照是 Agent 自己的账本、自动产生,混在一起会污染历史
- 只存被工具改过的文件,写工具执行前后各拍一次,再加一张会话开始时的基线
- 内容寻址让去重白拿:哈希本身就是「谁和谁一样」那张表
- 存全文不存 diff:恢复场景最不该有链式依赖,源码文件的磁盘代价不值一提
- 先写临时文件再原子改名;哈希只认内容,文件与哈希的对应记在只追加的索引里
Why must file rollback and conversation rewind be separate? What happens when you rewind only one of them?文件回滚和对话回退为什么要分开?只回一边会发生什么?
Common in ChinaCommon overseasIntermediate#rewind#two-timelinesHow to reason about it · think before answering
- This carries the most signal, because most people answer rewinding both together is simplest and safest. But together is not a default — it deletes two of the four use cases.
- How to break it down: lay out the four combinations and what each is for. Files only — it edited wrongly but the investigation was useful, so keep the conversation and let it retry with that context. Conversation only — the edits are right but the dialogue has drifted through ten wasted turns, so keep the result and pull the thread back. Both — this whole path was wrong, restart from the fork. Neither — look first at what a rewind would do, which is a dry run rather than a no-op.
- The second is the most common and most overlooked case in long tasks. Edits are often correct within the first few turns, while the following ten are the model second-guessing itself, re-reading the same file and bloating the context. What you want is to truncate the conversation while the change on disk stays byte-identical. An implementation that only rewinds both forces you to throw away work that was already correct.
- Then the implementation requirement: the two timelines must not reference each other. The session log records what was said, the snapshot log records what the files are, each with its own sequence. Put a session sequence into snapshot records and rewinding one side has no representation left in the data model. Guessing by timestamp — the snapshot nearest this message — is worse: rewinding becomes nondeterministic, and rewinding is where determinism matters most.
- Acknowledge the cost: decoupled, rewinding both takes two arguments, a checkpoint id and a message sequence, which reads worse than one. But a turn may have touched five files or none, so the lines were never one-to-one. Awkward beats uncertain.
- Worth a sentence on each side's mechanics: for files, take the last record at or before the target for each path, then restore what differs and delete what did not exist at that moment — deletion is the commonly missed third case, since files created during the task must disappear or the leftovers look like something you made. For the conversation it is exactly the event log's fork: copy to a new file, truncate at a sequence, record a lineage event, leave the parent untouched, and reuse the existing rule that only clean boundaries are forkable.
- Likely follow-up: what if the compute-state-at-a-moment function is wrong? Forget the sequence comparison and rewinding to record N always equals keep everything — no error, no exception, nothing happens, while the user believes they rewound. That is the nastiest failure mode here, which is why that function should be pure and pinned by tests.
分析过程 · 先想清楚再作答
- 这题最有区分度,因为多数人会答「一起回最省事、也最不容易乱」。而「一起回」不是一个默认值,它是**把四种用法砍掉两种**。
- 怎么拆:先把四种组合和各自的用途摆出来。只回文件——它改错了,但那段排查过程有用,留着对话让它接着上文重试;只回对话——文件改对了,但对话已经跑偏(绕了十轮弯路),留着成果把话头拉回去;都回——这条路整个走错了,从岔路口重新开始;都不回——先看一眼「如果回滚会发生什么」再决定(这一支是**预演**,不是空操作)。
- **第二种是长任务里最常用也最容易被忽略的那一种。** 一次长任务里改动往往在前几轮就对了,后面十轮全是模型在自我怀疑、反复读同一个文件、把上下文撑大。这时候你要的是把对话截回去,而磁盘上那份改动一个字节都别动。只支持「一起回」的实现,在这个最常见的场景里只能让你把已经改对的东西也一起丢掉。
- 再说实现上的要求:**两条时间线不能互相引用。** 会话日志记「说过什么」,快照日志记「文件是什么」,各有自己的 seq。只要在快照记录里塞一个 sessionSeq 把两者绑起来,「只回一边」在数据结构层面就没有表达方式了。替代方案是按时间戳猜「离这条消息最近的那张快照」——那更糟,回滚从此是一件不确定的事,而回滚恰恰最需要确定。
- 代价要承认:解绑之后「都回」需要两个参数(一个快照编号、一个消息 seq),界面上不如一个参数漂亮。但一轮对话里可能改了五个文件也可能一个都没改,两条线本来就不一一对应——**不好用胜过不确定。**
- 两边各自怎么实现也值得说一句:文件那边是「对每个文件取 seq 小于等于目标的最后一条记录」,然后内容不同的还原、那一刻不存在的**删掉**(第三种最容易漏,任务里新建的文件必须消失,否则残留看起来像是你自己建的);对话那边直接就是事件日志的分叉——复制到新文件、截断到某个 seq、记一条血缘事件,母会话一个字节不动,连「只有干净的边界才能分」那条规则都是现成的。
- 可预期的追问:回到某一刻这个计算错了会怎样?如果忘了比较 seq,「回到第 N 条」就永远等于「保持现状」——**不报错、不抛异常,什么都没发生,而用户以为自己已经回退了。** 这是这一类功能最阴的失效方式,所以那个函数应该是纯函数并被测试钉死。
Key points
- Each of the four combinations has a real use, so rewinding both together removes two of them
- Conversation-only is the most common case in long tasks: the edits were right early, the last ten turns were detours
- The two timelines must not reference each other; binding them with a session sequence makes one-sided rewind inexpressible
- Matching them by timestamp is worse — it makes rewinding nondeterministic, exactly where determinism matters
- File rollback must delete files that did not exist at that moment; conversation rewind is just the event log's fork
答题要点
- 四种组合各有真实用途,「一起回」等于砍掉其中两种
- 只回对话是长任务里最常用的一种:改动早就对了,后面十轮全是绕弯路
- 两条时间线不能互相引用;塞一个 sessionSeq 绑死之后「只回一边」就没法表达了
- 按时间戳猜对应关系更糟:回滚从此不确定,而回滚最需要确定
- 回滚文件要包含「删掉那一刻不存在的文件」;回退对话直接复用事件日志的分叉
Before a rewind you find uncommitted changes in the workspace, or a file that was modified externally. How do you handle it?回滚前发现工作区有未提交改动,或者文件被外部改过,你怎么处理?
Common in ChinaCommon overseasDeep dive#workspace-safety#dirty-stateHow to reason about it · think before answering
- This tests whether you have thought about what makes rewinding special. Answering warn and continue misses the key point: a rewind is the only action in the whole program that actively overwrites the user's files.
- State that specialness first, because every later conclusion rests on it. String-replacement write tools have a natural guardrail — the old content must match verbatim or the call fails. A rewind is a whole-file overwrite: it does not fail, it succeeds at erasing the paragraph the user typed, silently. So a rewind needs a check in front of it, answering one question: are the files on disk still the way I recorded them?
- The check has two levels, separated by whether a rewind would cause unrecoverable loss. Content differs from the last snapshot: refuse the whole rewind. Someone edited it outside the agent, that content was never in any snapshot, and after overwriting nobody can recover it. So refuse first, list the files, then give the next step — commit or save, or explicitly pass a force flag after accepting the loss. Content matches but mtime is newer: warn only. The file was touched but matches the record, most often edited and edited back, so the rewind is harmless and blocking the user is unjustified.
- Those two levels also explain why a snapshot record needs both hash and mtime: the hash answers what the content is, the mtime answers who last touched it. With only hashes the second check is invisible.
- Scope it too: check only recorded files. Files the agent never touched are out of scope because the rewind will not touch them either. Include the whole repository and the first run drowns the user in irrelevant warnings, after which they learn to ignore warnings — exactly the outcome to avoid.
- On git: in a real repository uncommitted changes are usually detected with git status, but that is one special case of the broader criterion, which is the workspace contains changes the agent never recorded. A robust implementation checks both: git status catches editor changes, snapshot hashes catch what git does not know about, such as untracked or ignored files. Either way the handling is the same — refuse first, never overwrite quietly.
- Likely follow-up: does the user get stuck? No. Offer three ways out, all named in the refusal message: save or commit the work; pass an explicit force flag meaning I accept the loss; or run a dry run first that prints exactly what a rewind would do while touching nothing. The third is the one that should always exist: since a rewind is the only action that overwrites the user's files, it deserves a look-before-you-leap entry point.
分析过程 · 先想清楚再作答
- 这题在考「你有没有想过回滚这个动作的特殊性」。答「提示一下然后继续」的人漏了最关键的一点:**回滚是整个程序里唯一一个会主动覆盖用户文件的动作。**
- 先把这个特殊性说清楚,它是后面所有结论的前提。精确替换那类写工具有一条天然护栏——旧内容必须逐字匹配,匹配不上就失败;而回滚是整文件覆写,它**不会失败**,它会成功地把用户手写的那一段冲掉,而且没有任何提示。所以回滚前面必须有一道检查,而它要回答的问题是:**磁盘上现在这些文件,是不是我记录过的样子?**
- 检查分两级,判据是「回滚会不会造成不可恢复的损失」。**内容与最后一次快照不一致 → 拒绝整次回滚**:有人在 Agent 之外改过它,而那份内容从没进过任何快照,覆盖之后谁都恢复不了。所以先拒绝、再列出是哪几个文件、再给出下一步(先提交或保存,或者确认可以丢之后显式加 force)。**内容一致但修改时间更新 → 只警告**:文件被人碰过,但内容和记录一样(最常见的是改了又改回来),回滚是无害的,拦住用户没道理。
- 这两级也解释了为什么快照记录里**哈希与 mtime 两个都要**:哈希回答「内容是什么」,mtime 回答「谁最后碰过它」。只比哈希,第二级检查是隐形的。
- 范围也要划:**只检查记录过的文件。** Agent 从没碰过的文件不在检查范围里,因为回滚也不会去动它们。把整个仓库都纳进来,第一次跑就会被一堆无关警告淹没,然后人学会忽略警告——而那正是要避免的结果。
- 关于 git:真实仓库里「未提交改动」通常用 git status 判定,但那只是这条判据的一个特例——更普适的说法是「工作区里存在 Agent 没有记录过的改动」。所以健壮的实现**两条都查**:git status 抓编辑器里的改动,快照哈希抓 git 也不知道的那些(未跟踪文件、被 ignore 的文件)。而且不管哪一条命中,处理方式都一样:先拒绝,别悄悄覆盖。
- 可预期的追问:那用户就被卡住了?不。给三条出路,而且都要写在拒绝的那句话里:让他自己保存或提交;显式加一个 force 表示「我知道会丢」;或者先跑一次**预演**——把「如果回滚会发生什么」全打出来而一个字节都不动。第三条是最该有的那一条:回滚既然是唯一会主动覆盖用户文件的动作,就值得给它一个「先看看再决定」的入口。
Key points
- A rewind is the only action that actively overwrites user files, and it cannot fail, so it needs a pre-check
- Refuse the whole rewind when content differs from the snapshot — that content is in no snapshot and cannot be recovered; refuse first, then explain
- Warn only when content matches but mtime is newer, since the rewind is harmless and blocking is unjustified
- Hence record both hash and mtime; check only recorded files so users do not learn to ignore warnings
- git status is one special case of unrecorded changes — check both; and offer save, force, or dry-run as ways forward
答题要点
- 回滚是唯一会主动覆盖用户文件的动作,而且它不会失败,所以前面必须有检查
- 内容与快照不一致就拒绝整次回滚(那份内容不在任何快照里,冲掉无法恢复),先拒绝再提示
- 内容一致但修改时间更新只警告——回滚无害,没理由拦住用户
- 所以快照要同时记哈希与 mtime;只检查记录过的文件,避免用户学会忽略警告
- git status 只是「未记录的改动」的一个特例,健壮实现两条都查;拒绝时要给出保存 / force / 预演三条出路