Beadsbd dolt pull卡死修复全解:config 表上的"两全其美"碰撞与commitBeforePull方案
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
导读
bd dolt pull在存在任何持久记忆(bd remember写入的kv.memory.*行)后必然失败,报Error 1105 (HY000): cannot merge with uncommitted changes,而bd doctor则持续报告Dolt Locks: config: modified——这就是本仓库中编号为"pull config wedge"(拉取配置卡死)的经典故障。本文以 PROPOSAL-pull-config-wedge.md 为骨架,结合仓库当前已落地的源码实现,完整还原该 bug 的根因(两个各自正确的 PR 在config表上发生碰撞)、端到端实证过程、最终修复设计(commitBeforePull与三档configCommitMode提交策略)以及配套回归测试,读者可据此理解 Beads 的 Dolt 提交语义边界,并掌握同类"merge refuses to start"问题的排查与修复方法。
说明:该提案最初以 "for review" 状态提交(Wyvern 库上实时复现,2026-06-14),其提出的修复方向在当前仓库代码中已经落地并进一步演进——从单纯的"预拉取提交必须包含 config"升级为带
kv.*白名单过滤的三档提交模式。文中源码引用一律以当前仓库实际实现为准。
症状:bd dolt pull恒定失败,且任何命令都无法自愈
故障的直观表现非常稳定:
bd dolt pull总是失败,报错与 Dolt 的合并前置校验直接相关:Error 1105 (HY000): cannot merge with uncommitted changesbd doctor每次命令后都报告Dolt Locks: config: modified——即使刚刚执行过bd dolt commit,即使开启了--dolt-auto-commit on也无法消除。该现象在内部被跟踪为 Wyvern wy-71t。push 完全不受影响,只有 pull(跨 clone 的 receive 路径)被卡死。这说明问题不在网络、认证或协议层,而在于合并发生前的工作集(working set)状态校验——Dolt 的
DOLT_MERGE拒绝在"有未提交变更"的工作集上启动。
值得强调的是,这个"每次命令后都重新变脏"的症状具有很强的迷惑性:它看起来像是某条命令每跑一次都会写入 config,导致永远无法收敛。后文(实证验证一节)将证明真实机制恰恰相反。
根因:两个各自正确的 PR 在config表上碰撞
提案给出的根因链条由三个事实环环相扣构成,其中前两个事实在当前源码中都能找到明确注释佐证:
事实 1:持久记忆存放在"已同步"的config表中
bd remember写入的每一条持久记忆,都是一行形如kv.memory.<slug>的记录,落在synced(参与 Dolt 同步)的config表中。提案在 Wyvern 线上库通过工作集实测确认了这一点——当时唯一变脏的行恰好就是 5 条记忆:
SELECT to_key, diff_type FROM dolt_diff('main','WORKING','config'); kv.memory.beads-jsonl-sync-procedure added kv.memory.bitbucket-altssh-dead added kv.memory.local-dev-ssl-posture added kv.memory.react-client-e2e-against-the-local-game-server added kv.memory.react-e2e-same-character-parallel-logins addedkv.memory.*作为记忆行的命名空间,在仓库中也有成体系的约束与测试:见 backend/conformance/memories_contract.go,其中明确列出了"记忆行kv.memory.*"、记忆与同名配置项互相遮蔽(shadow)的陷阱类、以及针对kv.memory.前缀裁剪的 DELETE 类攻击等契约行为。
事实 2:Commit刻意排除config表(GH#2455)
Beads 的常规提交入口 DoltStore.Commit 在注释中完整记录了这段历史:GH#2455 之后,Commit只暂存除config外的所有脏表。原因是最早的DOLT_COMMIT('-Am', …)会把并发操作遗留的半成品issue_prefix变更"顺手"扫进无关提交,造成污染。因此Commit现在的语义是:自动提交绝不触碰config,只有明确意图修改 config 的调用方(如CommitWithConfig)才允许提交它。
事实 3:预拉取自动提交恰好使用了Commit(GH#2474)
GH#2474 为了让合并能启动,引入了"拉取前自动提交工作集"的保护逻辑:两条 pull 路径(默认 remote 的pullFromRemote、命名 peer 的PullFrom)在 merge 前都会先调用s.Commit清理工作集。但正如事实 2 所述,Commit跳过config——于是这条保护对config表形同虚设。
净效应:只要存在任何一条记忆,kv.memory.*行就永远处于未提交状态(没有任何代码会去提交config),预拉取清理步骤执行完毕后config依然脏,DOLT_MERGE拒绝启动。这正是abortMerge注释中早已点名的场景——"a merge that REFUSED TO START on a dirty working set"(一个因工作集脏而拒绝启动的合并)。GH#2474 的意图被 GH#2455 悄然击穿,而只要执行过一次bd remember,config就必然脏——所以这个 wedge 是必然发生而非偶发。
提案还特别澄清了一个容易混淆的独立故障:同一数据库上出现的 v42→v51 schema 迁移分叉(#4259)与本问题无关,迁移后强制发布(force-publish)可以修复分叉并恢复 push,但 pull 仍会因 config 问题继续卡死。
实证验证:一个无需改源码的端到端证明
提案在 Wyvern 线上库完成了完整的机制闭环验证(未做任何源码修改):
唯一脏表就是
config,唯一脏行就是那 5 条kv.memory.*(即上文dolt_diff查询结果)。显式提交 config 后工作集立即变干净:
CALL DOLT_ADD('config'); CALL DOLT_COMMIT('-m', …)随后
dolt_diff('HEAD','WORKING','config')返回 0 行。后续普通命令(
bd list、bd show)不再让它变脏(仍为 0 行)。这证明 Beads 每条命令的"记忆再同步"操作是幂等的:一旦行内容与 HEAD 一致,重复写入相同值不会产生 diff。"每次命令后都重新变脏"的表象,唯一成因就是这些行从未被提交(Commit跳过 config),因此永远显示为未提交。把 config 提交一次即可打破循环。提交 config 后,
bd dolt pull可重复成功("Pull complete."),bd dolt push依然正常,bd doctor也不再报告config: modified。
结论非常干净:干净的 config 工作集是合并唯一需要的前置条件;因此"合并前把 config 提交掉"是一次性根治,而不是逐命令的临时补丁。同时提案也预告了反例:不打补丁的话,下一次bd remember产生新的未提交kv.memory.*行,wedge 立即复发。
修复方案:预拉取提交必须包含config
提案的修复方向是复用已有的CommitWithConfig(当时的store.go:1780,现位于 store.go#L3226,内部执行DOLT_COMMIT('-Am', …)),把两条预拉取提交点从Commit换成 config 包含式提交。提案原文给出了两份 diff:
--- a/internal/storage/dolt/store.go +++ b/internal/storage/dolt/store.go @@ pullFromRemote (GH#2474 pre-pull auto-commit) - if err := s.Commit(ctx, "auto-commit before pull"); err != nil { + // Include config: persistent memories live in config as kv.memory.* rows, + // and Commit() excludes config (GH#2455), so they never commit and leave + // the working set permanently dirty — DOLT_MERGE then refuses to start. + if err := s.CommitWithConfig(ctx, "auto-commit before pull"); err != nil { if !isDoltNothingToCommit(err) { return fmt.Errorf("failed to commit pending changes before pull: %w", err) } }--- a/internal/storage/dolt/federation.go +++ b/internal/storage/dolt/federation.go @@ PullFrom (GH#2474 pre-pull auto-commit) - if err := s.Commit(ctx, "auto-commit before pull"); err != nil { + if err := s.CommitWithConfig(ctx, "auto-commit before pull"); err != nil { if !isDoltNothingToCommit(err) { return nil, fmt.Errorf("failed to commit pending changes before pull: %w", err) } }当前仓库中的演进实现:commitBeforePull与三档configCommitMode
当前代码已把提案的方向落地并做了关键加固——没有直接照搬CommitWithConfig,而是新增了专用入口 DoltStore.commitBeforePull,并围绕config表定义了三种提交模式枚举(store.go#L2962-L2975):
configExclude:常规Commit使用的模式,跳过config(GH#2455 的原始语义);configIncludeUserKVOnly:预拉取自动提交专用——只暂存属于本 clone 自己的用户 KV 数据(kv.*命名空间,含kv.memory.*记忆行);一旦检测到任何非kv.的内部键(如issue_prefix)变脏,就拒绝提交并给出操作指引,确保 pull 永远不会自动提交不安全的 config(GH#2455 + GH#2474 双约束);configIncludeAll:仅用于显式合并收尾(bd federation sync --strategy/bd vc merge --strategy),操作者已明确选定冲突解决策略,因此所有被触及的 config 行(包括issue_prefix)都要如实提交,不允许丢弃解决结果。该模式还负责"干净工作集也要收尾"的边界情况(即--ours场景,详见commitWorkingSet中 wy-36ilm 的注释)。
底层的 commitWorkingSet 先查询dolt_status枚举脏表,再用反连接(anti-join)排除dolt_ignore表(wisps、wisp_%、leases 属于常态脏表且不可暂存),遇到config则按模式分流;对configIncludeUserKVOnly会额外调用assertDirtyConfigUserKVOnly校验脏键范围。config 采用显式DOLT_ADD暂存(而非依赖-Am),因为该路径必须在暂存前筛查脏键、只放行用户kv.*数据;源码注释同时记录了 GH#4412 的历史教训——旧版 server 模式下DOLT_COMMIT('-Am')曾出现不暂存 config 的现象(对应提案中"待验证的 wrinkle"),而CommitAll的 pinned-server 容器测试现已证明受支持路径上-Am能正确暂存 config。
两条拉取链路都已切换到该入口:
- 默认 remote 拉取:pullFromRemoteUnchecked(
Pull/PullRemote的公共内部实现),在 merge 前调用commitBeforePull(ctx, "auto-commit before pull"); - 命名 peer 拉取:pullFromPeer(
PullFrom的内部实现),同样先commitBeforePull,且 Sync(bd federation sync)的预合并提交也使用同一入口。
两条路径均以isDoltNothingToCommit容忍"无事可提交",其余错误包装为failed to commit pending changes before pull返回。
为什么这不会重开 GH#2455
提案专门论证了安全性:GH#2455 防范的是-Am把并发写者的半成品issue_prefix变更扫进无关提交。而预拉取提交在性质上完全不同——它是用户显式发起的操作(bd dolt pull),本身就要求干净工作集,且提交的是本 clone 自己的config 状态作为合并基准,这与CommitPending(bd dolt commit)做的事情完全一致。GH#2455 担心的竞态窗口(另一个 bd 进程在同一瞬间改写issue_prefix)并不会因为在这里提交 config 而有意义地扩大——反正用户下一次bd dolt commit也会提交它。当前实现的configIncludeUserKVOnly更进一步,用kv.*白名单把这种安全性从"论证"变成了"代码强制"。
可选加固:自动解决"收敛型"config合并冲突
主修复生效后,config(含kv.memory.*)变成了已提交、已同步的表——副作用是两个 clone 编辑同一条记忆时会触发config合并冲突。仓库中的自动解决器 TryAutoResolveMergeConflicts 目前只处理metadata、dependencies、schema_migrations,不含config,因此这类冲突会中断 pull 等待操作者介入。
提案建议的后续加固方向是:让解决器对机器可收敛的kv.memory.*键采用--theirs策略自动解决,与metadata表既有的收敛策略保持一致。当前代码已出现对应的收敛判定(configConflictsAreMemoryConvergent,见 store.go#L3005 附近注释)与测试覆盖(下文测试一节),说明该加固方向已在落地中。提案明确划界:这是 wedge 修复之外的增强,主修复已能解决报告的 bug;在那之前,同记忆的分歧编辑属于罕见的操作者可见事件,而非静默卡死。
被否决的替代方案:把记忆迁到dolt_ignore表
提案考虑过"把记忆挪进dolt_ignore忽略的表(就像 wisps 那样)",并明确否决:记忆的设计意图就是跨 clone 同步(它们经由bd export | bd import往返,属于共享状态的一部分),而dolt_ignore会让它们变成机器本地数据、彻底停止经 Dolt 同步——这是行为变更,不是修复。
测试计划与当前回归测试
提案给出的回归测试场景(建议放在pull_conflict_test.go/federation_pull_settle_test.go旁):
- 两个 tiny remote-backed DB 的 clone;
- clone A 上执行
bd remember --key t "x"(以kv.memory.*行弄脏 config,常规路径不提交任何东西); - clone B 上创建并 push 一个 issue;
- clone A 执行
Pull——必须成功(修复前返回cannot merge with uncommitted changes),且 clone A 最终同时拥有 B 的 issue 与自己的记忆。
该计划在仓库中已形成完整的测试族,集中存放于 internal/storage/dolt/pull_config_wedge_test.go:
TestCommitBeforePullIncludesConfig:直接复现 wedge 前置条件——插入kv.memory.*行后,普通Commit()确实不提交 config(确认 GH#2455 排除逻辑仍在),而commitBeforePull必须暂存 config 并让dolt_status干净;TestFederationSyncCommitsConfigBeforeFetch:bd federation sync的对应物——预合并提交必须包含 config,即使 sync 随后因 remote 不存在而失败(失败点已足够证明 config 已被暂存);TestPullAutoResolveMemoryConfigConflicts:验证仅涉及kv.memory.*行的合并冲突按theirs策略自动解决,这正是上文"可选加固"的测试落点;- 另有针对不安全 config 键的守卫测试(
kv.*白名单之外):commitBeforePull必须拒绝、点名违规键、保持 config 未提交且 HEAD 不动(防止 GH#2455 的陈旧 config 清扫问题以 pull 为入口复活); - 还有覆盖通用
kv.*行(bd kv set场景)的测试,证明白名单外推后普通 KV 写入也不再 wedge pull/sync。
测试辅助函数configDirty用SELECT COUNT(*) FROM dolt_status WHERE table_name = 'config'精确判定"config 工作集脏"这一 DOLT_MERGE 拒绝启动的确切条件,断言风格与提案建议的"预拉取提交后dolt_status为空"完全一致。
涉及文件清单
- PROPOSAL-pull-config-wedge.md——本提案原始文档(症状、根因、实证、修复、测试计划)
- internal/storage/dolt/store.go——
Commit(L2986,排除 config)、commitBeforePull(L3017,主修复入口)、CommitMergeResolution(L3032)、CommitWithConfig(L3226)、CommitAll(L3266)、commitWorkingSet(L3045)与configCommitMode三档枚举(L2962)、pullFromRemoteUnchecked(L3979,预拉取提交点) - internal/storage/dolt/federation.go——
PullFrom/pullFromPeer(L69/L79,命名 peer 拉取的预拉取提交点)与Sync(L355)共用commitBeforePull - internal/storage/dolt/pull_config_wedge_test.go——wedge 回归测试族
- internal/storage/versioncontrolops/mergesettle.go——
TryAutoResolveMergeConflicts(可选加固落点) - backend/conformance/memories_contract.go——
kv.memory.*记忆行命名空间与遮蔽陷阱的契约测试
结语
"pull config wedge"是一起教科书级的子系统交互事故:提交排除逻辑(GH#2455)与预拉取清理逻辑(GH#2474)各自单独看都正确,却在"记忆存于 synced config 表"这一事实之上组合出了必然失败。它留下的方法论启示对 Beads 乃至任何基于 Dolt/类 Git 存储的 agent 状态库同样适用:当"清理工作集"的提交路径与"某张表永不参与自动提交"的排除规则重叠时,必须先确认被排除的表里是否有必须同步的用户数据,再谈合并前置条件。当前仓库以commitBeforePull+kv.*白名单 + 三档提交模式给出了兼顾修复与防回归的最终答案,并以一整套回归测试固化了这条边界。
【免费下载链接】beadsBeads - A memory upgrade for your coding agent项目地址: https://gitcode.com/GitHub_Trending/beads1/beads
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考