逐日AI
第 2 周 · D14约 4 小时

checkpoint 与 rewind:文件快照、对话回退,以及两者为什么必须独立

改坏了要能退回来:实现基于内容哈希的文件快照,把它与会话事件日志组成两条独立的时间线,支持只回滚文件、只回滚对话或两者一起,并处理仓库里本来就有未提交改动的情况,第二周复盘。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能实现一套内容寻址的文件快照,并说清它与 git 提交的分工
  2. 能把文件回滚与对话回退做成两条独立可选的恢复路径
  3. 能处理未提交改动、外部改动与快照体积三类真实问题

昨天做的是动手之前的那道闸,今天做动手之后的那条退路。读完回到页面顶部把三条目标勾掉。

小白版讲解

存档与回滚:改坏了能退回昨天那一版

昨天那道闸挡住的是「方向错了」;今天要处理的是方向对了、计划批了、他也照着做了,但结果不对。这时候你要的不是解释,是一句「退回去」。

规矩很简单:动手之前先存一版,为的是让「重来一次」的成本降到一次按键。

而这里最容易走偏的一步是:别用 git 提交去存这一版。 替用户 git commit 一次「改动前的状态」看起来很省事,代价是他的提交历史里从此躺着一堆不是他写的提交,而那些是他要花时间 rebase 掉的。Agent 的存档是 Agent 自己的账本,不该混进用户的历史。 两者的分工一句话说清:

谁的账本记什么谁来决定
git 提交用户的「这是我认可的一个版本」用户
快照mca 自己的「这是我动手前后的样子」程序,自动

于是今天的三段:怎么存(只存该存的,而且别存重)、怎么退(文件和对话是两条线,回哪条你得说出来)、怎么别把用户的东西弄丢(回滚是唯一会主动覆盖用户文件的动作,所以它前面必须有一道检查)。

快照存什么:只存被工具改过的文件,按内容哈希去重

先划范围:只存被工具改过的文件。

一次任务里 Agent 会读十几个文件、跑几次测试,真正动过的可能只有一个。整个仓库拍一遍既慢又没用——没被动过的文件,磁盘上现在这一份就是最好的备份。

拍的时机是每次写工具执行的前后各一次。少任何一张都会缺一个状态:只拍「改之后」,你回不到第一次改之前——而那恰恰是「撤销这次任务」最常要的那一个;只拍「改之前」,最后一次改完的样子没人记,回滚会莫名其妙丢掉最新一次改动。

再补一张基线:会话开始时把整个仓库拍一遍,让「回到什么都还没发生的时候」变成真实可达的状态。少了它,最早能回到的只是「第一次写入之前的那一个文件」,而一次任务往往动了好几个。基线只拍一次——每轮重拍会把 Agent 自己的改动记成新基线,那么本章最后那条检查就永远查不出东西。

前后都拍,量立刻上来了:一次任务连改同一个文件两次就是四张,而这四张里有两张内容一模一样——第一次的「改之后」就是第二次的「改之前」

去重的做法叫内容寻址:一份内容存在哪儿,由它自己的哈希决定。

src/snapshot/store.ts
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 } // 已经有了,这就是去重
  } catch {
    await fs.mkdir(OBJECTS_DIR, { recursive: true })
    // 先写临时文件再改名:写到一半崩掉时,对象库里不能留下一个哈希对不上内容的文件
    const temp = `${target}.tmp-${process.pid}`
    await fs.writeFile(temp, content, 'utf8')
    await fs.rename(temp, target)
    return { hash, fresh: true }
  }
}

去重是白拿的:没有任何一张「谁和谁一样」的表,因为哈希本身就是那张表。本实验的自检把这条钉成一行:

TextText
✔ 两次 edit_file 记了 4 条(before,after,before,after),但只有 3 份不同内容——第一次的「改之后」就是第二次的「改之前」

两条口径顺手记住。一、哈希只认内容,不认文件名,所以对象库自己不知道哪个哈希属于哪个文件,那件事记在另一份索引里——存储层去重,索引层记语义。二、先写临时文件再原子改名:崩在半路时,对象库里绝不能留下一个哈希对不上内容的文件——那种文件比没有更糟,因为回滚会拿它去覆盖。

索引那一份就是一个 JSONL,格式与第七天那条会话日志完全同源:只追加、永不改写、带自增的 seq,坏行停在前一行。每条记录里两样东西值得单独说:哈希回答「内容是什么」,mtime 回答「谁最后碰过它」——两个都要,因为内容改了又改回来时哈希一样,只有 mtime 会告诉你这个文件被动过。另一个不显眼的取值是哈希为空,它表示那一刻这个文件不存在create_file 的「改之前」)。它不是缺省值,是要被用到的信息:回到那一刻就意味着把这个文件删掉。

两条时间线:文件的与对话的

现在到今天的题眼。

第七天做了会话事件日志,今天做了文件快照日志。它们看起来是同一类东西:只追加的 JSONL、带 seq、都能「回到某一刻」。于是最自然的想法是把两者对齐——一轮对话对应一批快照,回退时一起回。

这个想法是错的,理由不是审美,是四种用法里有两种会做不出来:

组合什么时候要它
只回文件它改错了,但那段排查过程有用——留着对话,让它接着上文重试
只回对话文件改对了,但对话已经跑偏(绕了十轮弯路)——留着成果,把话头拉回去
都回这条路整个走错了,从岔路口重新开始
都不回先看一眼「如果回滚会发生什么」再决定

第二种最常用,也最容易被忽略。 一次长任务里改动往往在前几轮就对了,后面十轮全是模型在自我怀疑、反复读同一个文件。这时候你要的是把对话截回去,而磁盘上那份改动一个字节都别动。

要同时支持这四种,唯一的办法是两条线本来就没有互相引用。所以两边都只记自己那件事:会话日志记「说过什么」,快照日志记「文件是什么」。

文件那一边怎么回到某一刻,是一个纯函数,也是全天最该被测试钉住的一段:

src/snapshot/log.ts
/** 回到第 seq 条那一刻:对每个文件,取 seq 小于等于它的最后一条记录 */
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
}

一句话的算法,但它错了之后的表现最阴:忘了那一行 seq 判断,「回到第 N 条」就永远等于「保持现状」——不报错、不抛异常,只是什么都没发生,而用户以为自己已经回退了。

拿这一刻的状态和现在的状态一比,就得出每个文件要做什么:内容不同的还原,那一刻不存在的删掉。第三种最容易被漏——任务里新建的文件,回到新建之前就该消失。只做「还原内容」的回滚会留下一堆凭空多出来的文件,而那种残留最难查,它看起来像是你自己建的。

对话那一边一行新代码都不用写:回退对话就是第七天那个分叉——复制到新文件、截断到某个 seq、记一条 fork 事件留住血缘,母会话一个字节都不动。连「只有干净的边界才能分」那条规则都直接复用。今天只是给它换了个动机。

回滚之前:先拒绝,再提示

最后一段,也是全天风险最高的一段。

回滚是这个程序里唯一一个会主动覆盖用户文件的动作。 edit_file 有「old_string 必须逐字匹配」这条护栏,匹配不上就失败;而回滚是整文件覆写,它不会失败——它会成功地把你手写的那一段冲掉,而且没有任何提示。

所以覆盖之前必须回答一个问题:磁盘上现在这些文件,是不是我记录过的样子?

拿快照记录和磁盘对一遍,会遇到两种不一致,处理方式一个拒绝一个警告,区别只在「回滚会不会造成不可恢复的损失」:

一、内容不一致 → 拒绝整次回滚。 有人在 mca 之外改过这个文件,而那份内容从没进过任何一次快照。回滚会让它永久消失,谁都恢复不了。所以先拒绝,再告诉用户是哪几个文件、可以怎么办

TextText
✘ README.md:内容与最后一次快照(#1 2fd7e6ca91735f4b)不一致,现在是 5bc79da1b9d83707——这份改动不在任何快照里
✘ 没有回滚任何东西:有 1 个文件的改动不在任何快照里。
先把它们保存或提交,或者确认真的可以丢,再加 --force 重来一次。

上面这三行是 INJECT=dirty 跑出来的:它在基线之后往仓库里塞了一段「用户手写、还没提交」的内容。注入的时机比内容更重要——必须在基线之后,否则那段改动就成了基线的一部分,这条检查永远查不出东西。

一条边界:只检查记录过的文件。 把整个仓库都纳进来,读者第一次跑就会被一堆无关警告淹没,然后学会忽略警告——而那正是要避免的结果。

二、内容一致但 mtime 更新 → 警告并要求确认。 文件被人碰过,但内容和记录一样(最常见的是改了又改回来)。回滚在这种情况下是无害的,所以不该拦住用户,但该让他知道:

TextText
⚠ src/calc.js:内容与快照一致,但修改时间比快照更新——这个文件被人碰过

这一条就是 mtimeMs 那个字段的全部用处。只比哈希的话它是隐形的。

还剩一种组合没说:都不回。它不是空操作,它是预演——把「如果回滚会发生什么」全打出来,一个字节都不动:

TextText
预演:回到快照 #4 的话——
  ← src/calc.js  b674bb76041d3d54 → b1b6e60880468d4b
什么都没有回滚(这是 /rewind none 的全部作用)

回滚既然是唯一会主动覆盖用户文件的动作,就值得给它一个「先看看再决定」的入口。

第二周复盘:它现在已经能长时间干活了

第七天结束时,mca 能在真实仓库里把一个失败的测试修绿,全程可中断、可恢复。那时它是一个能干一件事的工具。这七天全部是为了让它能连着干很多件事——两者之间差的不是能力,是七层各管一件的机制:

加的那一层它解决的问题
D8@ 引用别让它自己翻遍整个仓库找资料
D9项目指令文件这个仓库的规矩,不用每次重说一遍
D10任务清单长任务里「做到哪一步了」一眼可见
D11记忆跨会话记住这个仓库的坑
D12上下文压缩上下文满了不是终点,是可以腾的
D13提问与计划审批动手之前先把话说清楚
D14快照与回退动手之后还能退回来

这七层可以归成三组,而分组本身就是答案:

一、上下文里只留该留的(D8、D9、D11、D12)。 @ 引用让资料精确注入,指令文件让规矩常驻,记忆让跨会话的事实留下来,压缩让满了的桌子能腾。共同收益是:同样一个任务,它需要绕的弯路变少了。

二、把状态摆到明面上(D10、D13)。 清单让「做到哪一步」可见,计划让「打算怎么做」可见。价值不在功能,在于长任务里人还能看懂它在干什么——一个跑二十轮而你看不懂的 Agent,你迟早会按 Ctrl+C。

三、让错误可撤销(D14)。 前面六层都在降低出错的概率,只有今天这一层承认「它还是会错」,然后把错误的代价从「重新来一遍」压到「一次按键」。

而这一周有一条工程上的线索比任何一个功能都值得记住:七天里加了五层机制,kernel/ 一共只改了两次。

D8 到 D12 全部是往上下文流水线里加东西;D13 的计划审批塞进了第五天那道门(一个非只读工具加一条规则);今天的快照采集又是那层透明包装(第七天的落盘、第十二天的用量取样、第十三天的改动核对都是它)。协议层一个字段都没加,AgentEvent 一个事件类型都没加。

这不是运气,是第二天那个决定的复利:StreamDeltaAgentEvent 分成两层、渲染层只订阅语义事件。有了这条分界,「加一层观察」和「加一个工具」成了两种便宜的扩展方式,而它们撑下了整个第二周。

源码导读

动手实验

🧪 D14 实验:一套快照与回退机制,支持文件与对话分别或一起恢复

代码位置:labs/my-coding-agent-21days/day-14-checkpoint-rewind

今天挖了五个练习点,五个全是「看着更简单、实际更糟」的陷阱:按顺序编号存快照(于是同一段内容存很多遍)、只拍「改之后」、算「回到某一刻」时忘了看 seq(于是回滚变成安静的空操作)、回滚不删文件、工作区检查只比修改时间不比内容。起点代码原样跑是十二项里过四项。

  1. 把对象库改成内容寻址:文件名就是内容哈希,写之前先看在不在,并确认自检那一行「4 条记录只有 3 份不同内容」。
  2. 补上「改之前」那一张快照,注意它必须挂在 tool_start 上——tool_end 时文件已经改了。
  3. stateAt 补上那一行 seq 判断,然后看只回文件那一项从空操作变成真的回退。
  4. 让回滚会删文件:任务里新建的文件,回到新建之前就该消失。
  5. 把工作区检查补成两级(内容不一致拒绝、只有时间更新警告),然后跑 MOCK=1 SELFTEST=1 pnpm start 看到 12/12 通过,再用 README 里那几条管道命令看四种组合与 INJECT=dirty

验收看六条勾:自检 12/12 通过;四条记录只对应三份内容;一轮任务之后只有被改过的那个文件有新快照;四种组合各自成立(只回文件时消息条数不变、只回对话时文件内容不变、都回时两边都变、都不回时两边都不变但预演算得出要动什么);INJECT=dirty 时回滚被拒绝而手写内容还在;改了又改回来时只报警告不拦住。

面试题

今天三道题,考的是存储设计与恢复语义,不是「Agent 要不要支持撤销」:

  1. Agent 的文件快照怎么设计?为什么不直接用 git 提交?
  2. 文件回滚和对话回退为什么要分开?只回一边会发生什么?
  3. 回滚前发现工作区有未提交改动或被外部改过,你怎么处理?

完整题干、分析过程与答题要点见本课面试题库的第十四天。第二题最有区分度——多数人会答「一起回最省事」,能说出「只回对话是长任务里最常用的那一种」并解释原因的人很少。

检查清单与明日预告

  • 能说清快照与 git 提交的分工,以及为什么不能替用户提交
  • 能解释内容寻址为什么让去重变成白拿的,以及为什么存全文不存 diff
  • 能说出为什么写工具前后各拍一张,以及「改之前」为什么必须挂在 tool_start
  • 能说出基线快照的用处,以及为什么它只能拍一次
  • 能说清 hashmtimeMs 各回答什么问题,以及 hashnull 表示什么
  • 能说出四种回滚组合各自的用途,尤其「只回对话」为什么最常用
  • 能解释为什么两条时间线不能互相引用,以及 both 为什么要两个参数
  • 能说出回滚前那两级检查的区别:内容不一致拒绝、只有时间更新警告
  • 能把第二周那七层归成三组,并说出「协议层一个字段都没加」这件事的原因

明天进第三周,第一站是 D15《MCP 客户端:手写 JSON-RPC 接上 stdio 与 Streamable HTTP》。前两周做的是一个自给自足的 Agent——所有工具都是我们自己写的;第三周开始给它接外面的东西:别人的工具(MCP)、别人的经验(Skills)、更多的自己(子 Agent)。而它们全都会走第二周铺好的那两条路——加一个工具,或者加一层观察。

面试题库

  • Agent 的文件快照怎么设计?为什么不直接用 git 提交?How would you design an agent's file snapshots, and why not just make git commits?
    国内高频海外高频基础#snapshots#content-addressing

    分析过程 · 先想清楚再作答

    1. 这题在考「你能不能分清两种账本」。答「用 git 最省事」的人没想过一件事:用户的提交历史是他的东西,往里塞不是他写的提交,是要他花时间 rebase 掉的。
    2. 先说分工。**git 提交是用户的账本,记的是「这是我认可的一个版本」,由用户决定;快照是 Agent 自己的账本,记的是「我动手前后的样子」,由程序自动决定。** 两者的时机、粒度、生命周期都不一样:Agent 一次任务可能写十次文件,对应十几张快照,而用户可能只想提交一次。混在一起的直接后果是历史被污染,间接后果是快照受 git 状态摆布(暂存区里有东西、处于 rebase 中间态、仓库根本没初始化 git,快照就都拍不了了)。
    3. 再说存什么。**只存被工具改过的文件**,而且**写工具执行前后各拍一次**。少任何一张都会缺一个状态:只拍「改之后」就回不到第一次改之前(而那恰恰是「撤销这次任务」最常要的那一个);只拍「改之前」就丢掉最新一次改动。另外要有一张**基线**(会话开始时把仓库拍一遍),否则最早能回到的只是「第一次写入之前的那一个文件」,而任务往往动了好几个。
    4. 怎么存不重复:**内容寻址**——一份内容存在哪儿由它自己的哈希决定。去重于是是白拿的,不需要任何一张「谁和谁一样」的表,因为哈希本身就是那张表。而它省的量很实在:一次 edit 的「改之后」就是下一次的「改之前」,四条记录常常只对应三份内容。
    5. 两个实现细节能显出你写过:一、**存全文不存 diff**。增量最省空间,但 diff 要有基准、基准要有链,链断了整串都恢复不了——而快照的用途恰恰是「出事要能恢复」,那是最不该有链式依赖的时候;源码文件只有几 KB,磁盘换掉一整类失败模式很值。二、**先写临时文件再原子改名**:崩在半路时,对象库里绝不能留下一个哈希对不上内容的文件,那种文件比没有更糟,因为回滚会拿它去覆盖。
    6. 可预期的追问:索引怎么记?哈希只认内容不认文件名,所以对象库自己不知道哪个哈希属于哪个文件——那件事记在一份只追加的索引里(哪个文件、什么时候、哪个哈希、多大、mtime 多少)。**存储层负责去重,索引层负责语义。** 索引里还要允许哈希为空,它表示「那一刻这个文件不存在」,回到那一刻就意味着把文件删掉。

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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 提交是用户的账本、由用户决定;快照是 Agent 自己的账本、自动产生,混在一起会污染历史
    • 只存被工具改过的文件,写工具执行前后各拍一次,再加一张会话开始时的基线
    • 内容寻址让去重白拿:哈希本身就是「谁和谁一样」那张表
    • 存全文不存 diff:恢复场景最不该有链式依赖,源码文件的磁盘代价不值一提
    • 先写临时文件再原子改名;哈希只认内容,文件与哈希的对应记在只追加的索引里

    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
  • 文件回滚和对话回退为什么要分开?只回一边会发生什么?Why must file rollback and conversation rewind be separate? What happens when you rewind only one of them?
    国内高频海外高频进阶#rewind#two-timelines

    分析过程 · 先想清楚再作答

    1. 这题最有区分度,因为多数人会答「一起回最省事、也最不容易乱」。而「一起回」不是一个默认值,它是**把四种用法砍掉两种**。
    2. 怎么拆:先把四种组合和各自的用途摆出来。只回文件——它改错了,但那段排查过程有用,留着对话让它接着上文重试;只回对话——文件改对了,但对话已经跑偏(绕了十轮弯路),留着成果把话头拉回去;都回——这条路整个走错了,从岔路口重新开始;都不回——先看一眼「如果回滚会发生什么」再决定(这一支是**预演**,不是空操作)。
    3. **第二种是长任务里最常用也最容易被忽略的那一种。** 一次长任务里改动往往在前几轮就对了,后面十轮全是模型在自我怀疑、反复读同一个文件、把上下文撑大。这时候你要的是把对话截回去,而磁盘上那份改动一个字节都别动。只支持「一起回」的实现,在这个最常见的场景里只能让你把已经改对的东西也一起丢掉。
    4. 再说实现上的要求:**两条时间线不能互相引用。** 会话日志记「说过什么」,快照日志记「文件是什么」,各有自己的 seq。只要在快照记录里塞一个 sessionSeq 把两者绑起来,「只回一边」在数据结构层面就没有表达方式了。替代方案是按时间戳猜「离这条消息最近的那张快照」——那更糟,回滚从此是一件不确定的事,而回滚恰恰最需要确定。
    5. 代价要承认:解绑之后「都回」需要两个参数(一个快照编号、一个消息 seq),界面上不如一个参数漂亮。但一轮对话里可能改了五个文件也可能一个都没改,两条线本来就不一一对应——**不好用胜过不确定。**
    6. 两边各自怎么实现也值得说一句:文件那边是「对每个文件取 seq 小于等于目标的最后一条记录」,然后内容不同的还原、那一刻不存在的**删掉**(第三种最容易漏,任务里新建的文件必须消失,否则残留看起来像是你自己建的);对话那边直接就是事件日志的分叉——复制到新文件、截断到某个 seq、记一条血缘事件,母会话一个字节不动,连「只有干净的边界才能分」那条规则都是现成的。
    7. 可预期的追问:回到某一刻这个计算错了会怎样?如果忘了比较 seq,「回到第 N 条」就永远等于「保持现状」——**不报错、不抛异常,什么都没发生,而用户以为自己已经回退了。** 这是这一类功能最阴的失效方式,所以那个函数应该是纯函数并被测试钉死。

    How to reason about it · think before answering

    1. 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.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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.
    7. 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.

    答题要点

    • 四种组合各有真实用途,「一起回」等于砍掉其中两种
    • 只回对话是长任务里最常用的一种:改动早就对了,后面十轮全是绕弯路
    • 两条时间线不能互相引用;塞一个 sessionSeq 绑死之后「只回一边」就没法表达了
    • 按时间戳猜对应关系更糟:回滚从此不确定,而回滚最需要确定
    • 回滚文件要包含「删掉那一刻不存在的文件」;回退对话直接复用事件日志的分叉

    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
  • 回滚前发现工作区有未提交改动,或者文件被外部改过,你怎么处理?Before a rewind you find uncommitted changes in the workspace, or a file that was modified externally. How do you handle it?
    国内高频海外高频深入#workspace-safety#dirty-state

    分析过程 · 先想清楚再作答

    1. 这题在考「你有没有想过回滚这个动作的特殊性」。答「提示一下然后继续」的人漏了最关键的一点:**回滚是整个程序里唯一一个会主动覆盖用户文件的动作。**
    2. 先把这个特殊性说清楚,它是后面所有结论的前提。精确替换那类写工具有一条天然护栏——旧内容必须逐字匹配,匹配不上就失败;而回滚是整文件覆写,它**不会失败**,它会成功地把用户手写的那一段冲掉,而且没有任何提示。所以回滚前面必须有一道检查,而它要回答的问题是:**磁盘上现在这些文件,是不是我记录过的样子?**
    3. 检查分两级,判据是「回滚会不会造成不可恢复的损失」。**内容与最后一次快照不一致 → 拒绝整次回滚**:有人在 Agent 之外改过它,而那份内容从没进过任何快照,覆盖之后谁都恢复不了。所以先拒绝、再列出是哪几个文件、再给出下一步(先提交或保存,或者确认可以丢之后显式加 force)。**内容一致但修改时间更新 → 只警告**:文件被人碰过,但内容和记录一样(最常见的是改了又改回来),回滚是无害的,拦住用户没道理。
    4. 这两级也解释了为什么快照记录里**哈希与 mtime 两个都要**:哈希回答「内容是什么」,mtime 回答「谁最后碰过它」。只比哈希,第二级检查是隐形的。
    5. 范围也要划:**只检查记录过的文件。** Agent 从没碰过的文件不在检查范围里,因为回滚也不会去动它们。把整个仓库都纳进来,第一次跑就会被一堆无关警告淹没,然后人学会忽略警告——而那正是要避免的结果。
    6. 关于 git:真实仓库里「未提交改动」通常用 git status 判定,但那只是这条判据的一个特例——更普适的说法是「工作区里存在 Agent 没有记录过的改动」。所以健壮的实现**两条都查**:git status 抓编辑器里的改动,快照哈希抓 git 也不知道的那些(未跟踪文件、被 ignore 的文件)。而且不管哪一条命中,处理方式都一样:先拒绝,别悄悄覆盖。
    7. 可预期的追问:那用户就被卡住了?不。给三条出路,而且都要写在拒绝的那句话里:让他自己保存或提交;显式加一个 force 表示「我知道会丢」;或者先跑一次**预演**——把「如果回滚会发生什么」全打出来而一个字节都不动。第三条是最该有的那一条:回滚既然是唯一会主动覆盖用户文件的动作,就值得给它一个「先看看再决定」的入口。

    How to reason about it · think before answering

    1. 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.
    2. 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?
    3. 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.
    4. 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.
    5. 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.
    6. 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.
    7. 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.

    答题要点

    • 回滚是唯一会主动覆盖用户文件的动作,而且它不会失败,所以前面必须有检查
    • 内容与快照不一致就拒绝整次回滚(那份内容不在任何快照里,冲掉无法恢复),先拒绝再提示
    • 内容一致但修改时间更新只警告——回滚无害,没理由拦住用户
    • 所以快照要同时记哈希与 mtime;只检查记录过的文件,避免用户学会忽略警告
    • git status 只是「未记录的改动」的一个特例,健壮实现两条都查;拒绝时要给出保存 / 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

评论