从"每个项目一套固定状态"改成"模板化任意阶段"的一次全栈重构,怎么分三轮跑完、每轮发现并修了什么、哪些故意没修、现在还剩什么。写给没读过代码的人。
Eyrie 原来的看板每个项目只有一套写死的"待办 / 进行中 / 完成"状态。这次改成:每个项目可以有多套任务模板,每套模板自带一串有序的阶段,阶段之间靠一个叫 band(波段:todo / doing / done)的语义标签归类。在这个新数据模型之上,又加了一层筛选、搜索和两种看板视图。整个改动风险高、能线性切分,所以采用了"先实现、后多轮审查"的无人值守工作流,分三轮完成。
老看板的问题很具体:状态是"每个项目一张扁平的状态列表",列头写死。想给不同类型的项目配不同的流程(比如开发项目要有 Coding / Review,普通项目只要 Doing)做不到。而且——这是关键的设计教训——如果靠"状态的名字"来驱动自动化(比如"卡片进了叫 Review 的列就触发某动作"),那么任何人改一下列名,自动化就静默失效,不报错。参考了 vibe-kanban 等竞品正是踩了这个坑。
所以新模型引入了 band 字段:阶段的显示名可以随便改,但它归属哪个波段(todo / doing / done)是 schema 里的结构字段,自动化认 band 不认名字。这样"重命名阶段"和"改变流程语义"就彻底分开了。
改动为什么分三轮?因为它同时满足"打地基 + 高风险 + 可线性切分"三个条件,适合走一套固定的协作流水线。角色是这样分的:
| 编排方(我) | 规划、写实现指令、跑权威门禁、找问题、判断改不改、独立验收。是全程的"责任人"。 |
| 实现方 / 审查方 | 被派出去的子 agent——有的写代码,有的专门找问题。其中一路审查用的是另一个模型(Codex),刻意制造"盲区互补":两个不同的模型看同一份代码,各自漏的地方不一样。 |
| 门禁 | 三件套 check(各种仓库规约检查)+ typecheck(类型)+ test(测试),阶段末加 build。永远由编排方独立复跑,不信任 agent 自己报的"绿了"。 |
每一轮的内部结构都一样:先实现 → 架构审查 + 修 → 双视角审查(两个模型并行)+ 修,每步过了门禁就提交一次(相当于一个存档点,随时能回滚)。下面按轮讲。
数据模型的全栈替换 — 26 个提交
这一轮做的是最底层、最危险的事:把整个数据模型从"每个项目一套状态"换成"模板 + 阶段",然后让这个改动一路贯穿数据库、API 契约、守护进程、CLI、桌面端。实现分成四大步接力完成:
project_statuses 表,新建 task_templates(模板)和 template_stages(阶段)两张表;任务表的 status_id 字段改名成 stage_id;项目表加一个"默认模板"字段。因为还在开发期没有线上数据,直接重新生成了一份全新的数据库基线(单个 0000_init),开发者删库重建即可,不做增量迁移。status.* 错误命名空间拆成 template.* 和 stage.*,还新增了"跨项目 / 跨模板移动任务"的专门错误码。project status 子命令换成 project template / project stage。阶段可以任意多个、任意命名,但必须满足一条铁律:第一个阶段必须是 todo 波段,最后一个必须是 done 波段,中间的都是 doing。这样"入口"和"出口"永远语义明确,自动化和"完成度"计算才有稳定的锚点。这条规则在每一个可能改变阶段顺序的写入路径上都强制校验——创建模板、增删阶段、重排阶段、播种项目,一个都不放过。试图删掉边界阶段或破坏这个顺序,守护进程会直接用 stage.bandInvariant 错误拒绝。
架构审查跑了两轮,每轮"审完马上修、修完再审"。第一轮抓到一个事务原子性问题:给阶段分配位置时的"重新平衡"动作,被放在了和阶段插入分开的另一个事务里提交——意味着中途崩溃可能留下不一致状态。修法是把两者合并成一个原子操作。
第二轮抓到三个:1 看板增量更新的两个标志位("阶段变了" / "模板变了")对所有客户端来说效果完全等价,于是合并成单个 boardMetaChanged,减少契约面;2 缺一条测试——"在别的项目的阶段上创建/移动任务应该被拒绝"这个跨项目守卫没有被测试覆盖,补上;3 更新任务的助手函数其实无法表达"改阶段"这个操作,所以干脆把 stageId 从它的输入里拿掉——改阶段必须走独立的"移动"路径。这个决定很重要,第三轮的一个严重 bug 就跟它有关。
之后是双视角审查。第一轮 Codex 提了一个真问题:reaper(垃圾回收)的测试用了一个"生产环境根本无法持久化的数据图"——三个测试的初始化助手都只插了项目和阶段,从没设置项目的"默认模板"字段,导致那条项目 ↔ 默认模板的循环外键删除路径实际上从没被真正测过。补测试解决。第二轮双视角审查两个模型都零发现,清场通过。
stageId 从更新助手里拿掉了,所以体验层做拖拽时,"卡片跨列 = 改阶段"必须走独立的移动端点。这正好和"拖拽卡片"的直觉对齐。筛选 / 搜索 + 两种看板视图 — 3 个提交
地基打好后,这一轮在上面盖体验层。实现分三棒接力,做出来的能力大致是:
applyQuery(board, query),输入原始看板和一份查询条件,输出一个"视图模型"。查询条件包括三个维度的筛选(模板 / 标签 / 优先级)和搜索关键词。搜索的行为是:匹配的卡片浮到列首并高亮,不匹配的淡化但不隐藏;筛选则是真的隐藏,同时给出"显示 X / 共 Y"的双口径计数。架构审查抓到 4 个,其中一个是性能问题:那个开销不小的 applyQuery(里面有"维度 × 选项 × 任务"的交叉计数)在看板和工具栏里各算了一遍,每次渲染跑两次;而且代码上还挂着一句"只算一次"的假注释。修法是把它提升到页面层只算一次,结果通过 props 往下传。另外三个是搜索框的一个竞态、三个没人用的死翻译 key、一个号称"冻结"实际可变的共享空数组。
接下来的双视角审查是这一轮、也是整个项目的一个转折点。前面说过 Claude 一侧老是零发现。这次换了个做法:不再用带着前文上下文的 Claude,而是派一个完全无上下文的全新 Claude 子 agent,和 Codex 并行审同一份 diff。结果两个模型高度互相印证,共 8 个真问题全部修掉。其中最重要的一个是拖拽解析的顺序问题:
原实现解析拖放位置时,用的是数据库里的原始存储顺序。但用户在屏幕上拖的,是筛选 / 搜索之后的顺序(有些卡片被隐藏了,有些被搜索浮到了顶部)。这两个顺序不一样。用户把卡片拖到"看起来的两张卡中间",系统却按原始顺序去插值,结果隐藏的卡片被静默重排到别处。
当时的修法:引入一个纯函数 visibleColumns(),先按屏幕上的渲染顺序把列收窄到"可见的卡片",再在这个收窄后的顺序上解析拖放。——记住这个修法,第三轮会发现它其实修出了新问题。
其余 7 个问题也都很实在:土耳其语 İ 这类字符转小写后长度会变,导致搜索高亮的下标越界(改成按字符折叠 + 把边界映射回原始偏移);筛选行用了"按钮套按钮"的无效 DOM 结构(改成 <label> 包一个视觉隐藏的原生复选框);搜索框缺无障碍标签;拖拽和面板滑动漏了"减少动态效果"的适配等等。
第一轮 Claude 侧全程零发现,第二轮换成"独立无上下文的全新 Claude 子 agent"后,它独立命中了 3 个问题、还补出了 3 个 Codex 没提的。结论:让审查者带着"之前干了什么"的上下文,会被既有叙事带偏;给它一份干净的 diff 从零看,效果好得多。这条经验直接决定了第三轮的做法。
visibleColumns 有纯函数单测,但"筛选状态下真实拖拽不重排隐藏卡"的完整链路没测(拖拽库在测试环境里很难驱动)。对整个 feature 做一次接缝审查 — 6 个提交
前两轮各自审查的是"本轮改了什么"。但一个横跨五层的大改动,最容易出问题的地方是跨阶段的接缝——数据库定的顺序语义,到了守护进程还成立吗?到了前端呢?所以最后专门跑一轮全局复审,范围是整个 feature 的 diff,焦点就是这些接缝。这一轮用的还是同一套流水线(架构审查 + 修 + 双视角 + 修),但没有实现节点,纯审查。
值得一提:跑这轮之前,先修了流水线脚本里的一个坑。之前有个"占位词黑名单"用来识别 agent 偷懒交假内容,但它会无差别扫描 finding 的正文——而一个正当的架构审查里完全可能正当地讨论"placeholder 工具栏"这种词,结果合格的审查被误判成造假、无限重跑。这次把黑名单收窄到只扫 agent 自己写的元信息字段,不碰 finding 正文。修完后,5 个 agent 全程无一被误判,一次跑通。
这轮的架构审查是个无上下文的全新 Claude。它先确认了 Codex 之前独立报告的三个缺陷在当前代码里全都还活着,然后给出了这轮最重要的洞见——第二轮那个 visibleColumns 修法,本身修出了一个新回归。
第二轮为了修"拖拽用错顺序",把 visibleColumns(按可见顺序解析)用在了所有拖拽路径上。问题在于,聚焦视图里往列中间拖一张卡时,前端会算出"落在哪两张卡之间"的两个锚点——但它算的是可见子集里的相邻两张。而守护进程校验这两个锚点是否相邻时,用的是完整存储顺序。
举个具体场景:某列存储顺序是 A, X, B, C,其中 X 被筛选隐藏了。用户看到的是 A, B, C,把 C 拖到"看起来的 A 和 B 之间"。前端发出的锚点是"A 之后、B 之前":
存储顺序: A X B C
↑ 隐藏
用户看到: A B C
用户意图: 把 C 放到 A 和 B 之间
前端发出: afterTaskId=A, beforeTaskId=B
守护进程校验: 在完整顺序里 A 和 B 相邻吗?
A(下标0) 和 B(下标2) 之间隔着 X —— 不相邻!
结果: 抛 invalidSortAnchor,正常的拖拽手势被回滚
也就是说,只要有任何筛选隐藏了卡片、或搜索浮动了卡片,正常的拖放就会失败回滚。而冒烟测试只会"筛选完看看计数对不对",从不真的在被隔开的一对卡之间拖放,所以这个 bug 完美躲过了人工冒烟。
架构审查还发现,第二轮那条断言拖拽正确性的测试只测了前端这半、没有让锚点走完守护进程的校验,等于把错误行为当成了"正确"钉死在测试里——这正是它掩盖了上面这个 bug 的原因。
把两条拖拽路径都改回按原始存储顺序解析:聚合视图不发锚点(守护进程默认就理解成"追加到完整有序列的末尾"),聚焦视图用存储里的真实相邻卡当锚点。visibleColumns 整个删掉——一旦两条路径都不用它,它就没有正确的调用者了。同时把三个老 bug 一起修了,还引入了一个共享的"任务写入闸门"(下面详述),补上了走完守护进程校验的回归测试。这一步产出 2 个提交。
三个老 bug 里有一个是这样:编辑任务标题(走"更新"路径)和拖动改阶段(走"移动"路径)是两套独立的乐观更新,各有各的"进行中"判断,但它们改的是同一份看板缓存。于是"改标题"和"拖卡片"可以基于同一个版本号同时开跑,一个成功推进到下一版本,另一个因为版本过期失败——失败的那个回滚时,可能把另一个的乐观改动一起覆盖掉。
修法是加一道所有任务写入共用的闸门:只要有任何一个任务写入在进行中,就同时禁掉拖拽、阶段选择和字段编辑。这里有个细节——不能只靠"渲染时读到的进行中状态",因为"失焦提交编辑"和"点击"是同一次事件派发里的两个动作,点击的处理函数在 React 重渲染之前就跑了。所以还要在动作发生的那一刻同步再查一次实时的写入计数。
架构修完后,Claude 和 Codex 并行审这份已经修过一轮的树。这次 Codex 提了一个必修级的严重问题,是前面所有审查都没看到的:
看板页面在切换项目时,只清了一部分状态,没有清"当前选中的任务"。而页面框架在项目切换时是复用的。于是:在项目 A 打开了任务 A-42 的详情面板,导航到项目 B——面板还显示着 A-42。这时任务的写操作只认任务 id、不认当前在哪个项目,所以:
在项目 A 打开任务 A-42
→ 导航到项目 B(面板仍显示 A-42)
→ 改个标题失焦 → 对 A-42 发出更新,却乐观地改了项目 B 的看板缓存
→ 点删除并确认 → 把 A-42 及其子树从"显示着项目 B"的界面上硬删除
这不需要任何并发——单个用户的日常"导航 + 编辑"就能触发一次跨项目的破坏性写入。修法是项目变化时把选中任务和草稿状态一起重置。
此外 Codex 还提了 5 个"应修"问题,都很有价值:看板增量更新时没有让"已消失任务"的详情缓存失效(远端删了任务,本地还开着它的详情面板,可以继续对已删任务发编辑);删除操作绕过了刚加的写入闸门;一句注释谎称某个数值范围能保证浮点精度(实际算下来精度差了好几个数量级,大数值下位置会撞车);批量移动的最终顺序未定义;阶段重排时不更新版本号,导致基于旧版本的重排会被错误接受。Claude 一侧则独立印证了"删除绕过闸门"这条,还提了个命名漂移的小项。双视角的价值在这里体现得很清楚:一个必修 bug 只有 Codex 看到,而"删除绕过闸门"两个模型都独立看到了——高度可信。
双视角修复把两位审查者的 7 条全部采纳、无一跳过,产出 3 个提交。
流水线跑完,agent 报告门禁全绿。但流水线的铁律是"不信 agent 自己报的绿,编排方亲自复跑"。我独立跑三件套门禁时,抓到一个测试在全量套件下约 40% 概率失败、单独跑却必过——这是最坏的一种,本地偶尔红、CI 上大概率红,但隔离复现不了。就是刚加的那个写入闸门的测试。
第一反应通常是"异步没等够",加长等待时间。但真正的根因是测试辅助函数的写法:它在每次渲染都重建了一个空的 resolver 数组。
// 有问题的写法(简化)
function useControllablePendingMutation() {
const resolvers = [] // ← 每次渲染都是一个新的空数组
const mutation = useMutation({
mutationFn: () => new Promise(r => { resolvers.push(r) }),
})
return { mutation, settle: () => resolvers.forEach(r => r()) }
}
发起 mutation 后,"进入进行中状态"会触发一次重渲染。等测试调用 settle() 去结束它时,读到的已经是后一次渲染的那个空数组——什么都没结束,promise 永远挂着,最后"闸门应该重新打开"的断言在负载下超时失败。负载轻时重渲染时机赶巧就过了,所以偶发。
修法不是加等待,而是绕开这个每渲染重建的陷阱:直接在缓存上构建 mutation,settle 用一个稳定的闭包(参照同文件里本就稳定的另外两个测试的写法)。修完用测试框架的退出码(而不是靠抓取输出文本,那个我一度判断失误过)连跑 18 次全绿确认。
最后独立复跑了完整门禁(check / typecheck / test / build 三个应用全过)以及 6 个关键接缝测试套件(共 82 个测试),确认那些补上的回归测试确实在位、且断言的是对的东西——拖拽存储顺序锚点、批量移动的请求顺序、阶段重排的版本校验、大数值位置分配都覆盖到了。
如果直接信了 agent 报的"门禁绿",这个 flaky 测试就会带着进 CI,然后在某次无关的提交上莫名其妙地红,浪费一整天去查。"编排方独立复跑"这条纪律,直接兜住了一个会污染 CI 的缺陷。
| 第一轮 打地基 | 把数据模型从"每项目固定状态"换成"模板 + 阶段 + band 波段",贯穿五层。审查修掉:事务原子性、等价标志合并、跨项目守卫缺测、循环外键删除路径缺测、更新助手收窄(改阶段必须走移动路径)。 |
| 第二轮 体验层 | 筛选 / 搜索内核 + 聚合 / 聚焦两种视图 + 工具栏。审查修掉:查询函数重复计算、搜索竞态、Unicode 高亮越界、无效 DOM 结构、无障碍缺失、动画偏好。并在此验证了"无上下文审查者更有效"。 |
| 第三轮 全局复审 | 专审跨阶段接缝。修掉:必修 第二轮埋的拖拽回归、必修 切换项目的跨项目破坏性写;应修 缓存失效、删除绕过闸门、浮点精度、批量顺序、版本校验;外加编排方自己抓到并修掉的 flaky 测试。 |
整个过程里被"跳过"的问题其实极少——因为项目处在开发地基期,收改门槛定的是"只要有价值就改,不分大小",审查节点只负责剔除伪问题和过度设计,不做"知道就好"的搁置。三轮下来,架构修复和双视角修复都是 0 skipped(全部采纳)。唯一一次"先放过、后来改主意"是第二轮那个"URL 指向已删模板渲染空板"的问题:当时判断非崩溃、可手动恢复,作为小项放过;第三轮 Codex 论证了它其实是个跨阶段问题(别的客户端删模板时,缓存修好了但 URL 状态没人管),于是在第三轮补修了——现在看板加载后会校验 URL 里的聚焦模板还在不在,不在就回落到聚合视图并清掉 URL。
data-model.md 文档:新的模板 / 阶段数据模型(band 枚举、循环外键、单一基线)还没写进数据模型文档,老文档里"固定三栏"的假设也需要更新。这是当前最优先的收尾项。