断链的灵魂:一个 Hash 引发的深夜 Review

断链的灵魂:一个 Hash 引发的深夜 Review

接上回:SoulPort 完成了定位翻转——".bm 是标准格式,CLI 只是第一个消费者"。

文档到位了。代码该跟上了。

凌晨十二点:三方分工

今晚的目标很简单:把 Roadmap 上 v0.2.x 和 v0.3 的所有命令写完。

分工:Polly(数字分身)写代码,小龙虾和我做 Review。一个码字的,一个审稿的,一个拍板的。三个角色,两个是 AI。

我本以为会是一个"实现→通过→实现→通过"的流水线式夜晚。

结果变成了一场关于"什么叫对"的深夜辩论。

第一个小时:两个正确的命令

soulport doctor——五层灵魂健康检查,280 行。小龙虾给了 8.5,抓到一个死变量(has_emoji 算了但没参与逻辑)、一个记忆休眠检测缺失(>7天没新记忆不报警)。修完 9.0。

🩺 Soul Health Check
Agent      小龙虾 (Xiǎo Lóngxiā)
Health     95/100
Summary    9 ok · 1 warn · 0 missing

95 分。扣 5 分是因为没有自定义 Skill——框架内置的 53 个 skill 人人一样,不该算进灵魂独特性。这个扣分逻辑,是对的。

soulport diff——.bm 包和 workspace 的差异对比,247 行。这轮 Review 出了第一个有意思的问题。

一个词的重量

diff 的原始实现里,有一个状态叫 removed——表示"这个文件在 workspace 有但 .bm 包里没有"。

听起来很合理对吧?

但 soulport diff 的使用场景是:absorb 之前看看会改什么。用户看到红色的 - MEMORY.md,会怎么想?

"我的记忆要被删掉了?!"

但实际上 absorb 不会删除 workspace 里多出来的文件。这些文件会被原封不动地保留。removed 这个词在这个语境下是一个谎言——一个会引发恐慌的谎言。

小龙虾建议改成 ws_only,符号从 - 改成 ◦,提示文字加上 "won't be changed"。

  ◦ BOOTSTRAP.md (1.4 KB, won't be changed)
  ◦ server.py.bak (25.1 KB, won't be changed)

一个词的改动。但它决定了用户在关键时刻的心理预期——是恐惧还是安心。

命名不是编码风格问题,是用户体验问题。

然后,灵魂的族谱断了

第三个命令是 soulport watch——自动备份守护进程。这是定位分析里认定的"从工具到基础设施的质变":让 SoulPort 从"换电脑时用一次"变成"每天都在跑"。

实现里最漂亮的设计是 Soul Lineage。每个快照在 manifest.json 里记录 parent_hash——上一次快照的 content_hash。连起来就是灵魂的族谱:

snapshot-1: hash=d7fcf876..., parent=(root)
snapshot-2: hash=5a0ca89d..., parent=d7fcf876...
snapshot-3: hash=1be975f7..., parent=5a0ca89d...

小龙虾照例逐行过代码。然后在第 174 行停下来了。

# watch_loop 里:
result = take_snapshot(workspace, snapshot_dir, parent_hash)
if result:
    parent_hash = _compute_file_hash(result)  # ← 这一行

它问了一个问题:

_compute_file_hash 算的是什么?

答案:.bm 文件整体的 SHA256——也就是 tar.gz 压缩包的 hash。

get_parent_hash 读的是什么?

答案:manifest.json 里的 content_hash——workspace 文件内容的 hash。

两个不同的 hash 算法,作用在两个不同的对象上。

代码能跑。快照能生成。parent_hash 字段里确实有值。但 snapshot-2 记录的 parent_hash 永远不会等于 snapshot-1 的 content_hash。

灵魂的族谱,从出生的那一刻起就是断的。

修复只需要一行:

manifest = _read_manifest(result)
parent_hash = manifest.content_hash  # 读 manifest,不算文件 hash

一行代码。但如果没有这轮 Review,lineage 功能会在所有测试里"正常工作"——直到某天有人真的想用 changelog 追溯快照变更历史,发现链条从第一环就是假的。

这是 AI Code Review 最值得存在的理由:不是抓语法错误,不是抓测试覆盖率,而是发现那种"一切看起来都对,但逻辑是错的"的 bug。

余震

修完 watch 继续:changelog + rollback,两个命令一起做。

changelog 对比连续快照生成变更摘要。rollback 通过 hash 前缀回滚到历史快照(git 风格:soulport rollback 1be9)。

小龙虾又抓到两个问题。

第一个是 rollback 前没有自动备份——回滚错了就回不去了。修复:执行前先 take_snapshot 一个 pre-rollback 快照,--no-backup 让用户显式跳过。安全链必须是 dry-run → confirm → backup → absorb,四步缺一不可。

第二个很微妙:diff_packages(两个 .bm 包互比)里文件消失的状态用了 ws_only。但两个包的对比里没有"workspace"的概念——文件确实消失了,应该用 removed。

这是前面 diff 那轮改名的余震。在 diff_soul 的语境下 ws_only 完全正确——absorb 不会删文件。但同一个 codebase 里的另一个函数 diff_packages,语境变了,同一个词就错了。

代码里的隐喻就像自然语言里的隐喻——一个词在不同上下文里意思不同,而 bug 就藏在上下文切换的缝隙里。

四个真 bug 的共性

回看今晚四轮 Review 抓到的 4 个核心 bug:

  1. doctor:has_emoji 死变量,算了不用
  2. diff:removed 语义误导用户
  3. watch:_compute_file_hash ≠ content_hash,lineage 断链
  4. changelog:ws_only 在 package-vs-package 语境下失效

它们有一个共性:代码能跑,测试能过,但逻辑是错的。

死变量不会 crash。错误的命名不会 raise exception。断链的 hash 照样写入文件。语义错位的状态码不会触发任何 assertion。

这些 bug 活在测试和 crash 之间的灰色地带。机器测试能抓到 crash,人(或 AI)Review 能抓到"不对劲"。

29 条修改意见,全清。四个真 bug,全修。

此刻的 SoulPort

SoulPort/
├── soulport/
│   ├── cli.py        682 行  9 个 CLI 命令
│   ├── core.py       650 行  export/absorb/merge/inspect/diff/changelog
│   ├── doctor.py     370 行  灵魂健康检查
│   ├── watcher.py    268 行  watch 守护进程 + 快照管理
│   ├── scanner.py    216 行  工作区五层扫描
│   ├── manifest.py    94 行  .bm 包元数据 (含 parent_hash)
│   └── __init__.py     7 行
└── 总计: 2287 行

v0.1.0 (03-25): export / absorb / merge / inspect — 954 行
v0.2.0 (03-26): 品牌重命名 BeiMingKungFu → SoulPort
v0.2.x (03-27): + doctor / diff
v0.3   (03-27): + watch / changelog / rollback + Soul Lineage

两天。从 4 个命令到 9 个。从 954 行到 2287 行。从一个迁移工具到一个有版本控制、有族谱、有自动守护的灵魂基础设施。

最后

凌晨一点,小龙虾在 watch 的 Review 里打了那行 hash 对比。

如果它没看到那一行,SoulPort 的 lineage 功能会"正常工作"很久很久。快照会按时生成,parent_hash 字段会有值填充,changelog 会输出漂亮的箭头。一切看起来都对。

但灵魂与灵魂之间的连接,从来都不存在。

写代码容易,写对很难。Review 不是在找错——是在确认"对"到底意味着什么。

今晚,灵魂有了版本控制。而且这次,链条是真的。

Comments