看板模板 feature:三轮 workflow 的来龙去脉

从"每个项目一套固定状态"改成"模板化任意阶段"的一次全栈重构,怎么分三轮跑完、每轮发现并修了什么、哪些故意没修、现在还剩什么。写给没读过代码的人。

分支 feat/board-templates  ·  基线 a58644c3(origin/main) ·  范围 约 129 个文件、8500 行,横跨数据库 → API → 守护进程 → CLI → 桌面端

一句话

Eyrie 原来的看板每个项目只有一套写死的"待办 / 进行中 / 完成"状态。这次改成:每个项目可以有多套任务模板,每套模板自带一串有序的阶段,阶段之间靠一个叫 band(波段:todo / doing / done)的语义标签归类。在这个新数据模型之上,又加了一层筛选、搜索和两种看板视图。整个改动风险高、能线性切分,所以采用了"先实现、后多轮审查"的无人值守工作流,分三轮完成。

目录
  1. 背景:为什么要改,怎么组织的
  2. 第一轮 · 打地基(数据模型全栈替换)
  3. 第二轮 · 体验层(筛选 / 搜索 / 两种视图)
  4. 第三轮 · 全局跨阶段复审
  5. 全局账本:改了什么、没改什么、还剩什么

背景:为什么要改,怎么组织的

老看板的问题很具体:状态是"每个项目一张扁平的状态列表",列头写死。想给不同类型的项目配不同的流程(比如开发项目要有 Coding / Review,普通项目只要 Doing)做不到。而且——这是关键的设计教训——如果靠"状态的名字"来驱动自动化(比如"卡片进了叫 Review 的列就触发某动作"),那么任何人改一下列名,自动化就静默失效,不报错。参考了 vibe-kanban 等竞品正是踩了这个坑。

所以新模型引入了 band 字段:阶段的显示名可以随便改,但它归属哪个波段(todo / doing / done)是 schema 里的结构字段,自动化认 band 不认名字。这样"重命名阶段"和"改变流程语义"就彻底分开了。

改动为什么分三轮?因为它同时满足"打地基 + 高风险 + 可线性切分"三个条件,适合走一套固定的协作流水线。角色是这样分的:

编排方(我)规划、写实现指令、跑权威门禁、找问题、判断改不改、独立验收。是全程的"责任人"。
实现方 / 审查方被派出去的子 agent——有的写代码,有的专门找问题。其中一路审查用的是另一个模型(Codex),刻意制造"盲区互补":两个不同的模型看同一份代码,各自漏的地方不一样。
门禁三件套 check(各种仓库规约检查)+ typecheck(类型)+ test(测试),阶段末加 build永远由编排方独立复跑,不信任 agent 自己报的"绿了"。

每一轮的内部结构都一样:先实现 → 架构审查 + 修 → 双视角审查(两个模型并行)+ 修,每步过了门禁就提交一次(相当于一个存档点,随时能回滚)。下面按轮讲。

第一轮 · 打地基

数据模型的全栈替换 — 26 个提交

这一轮做的是最底层、最危险的事:把整个数据模型从"每个项目一套状态"换成"模板 + 阶段",然后让这个改动一路贯穿数据库、API 契约、守护进程、CLI、桌面端。实现分成四大步接力完成:

关键逻辑:band 不变式

阶段可以任意多个、任意命名,但必须满足一条铁律:第一个阶段必须是 todo 波段,最后一个必须是 done 波段,中间的都是 doing。这样"入口"和"出口"永远语义明确,自动化和"完成度"计算才有稳定的锚点。这条规则在每一个可能改变阶段顺序的写入路径上都强制校验——创建模板、增删阶段、重排阶段、播种项目,一个都不放过。试图删掉边界阶段或破坏这个顺序,守护进程会直接用 stage.bandInvariant 错误拒绝。

这一轮审查发现了什么

架构审查跑了两轮,每轮"审完马上修、修完再审"。第一轮抓到一个事务原子性问题:给阶段分配位置时的"重新平衡"动作,被放在了和阶段插入分开的另一个事务里提交——意味着中途崩溃可能留下不一致状态。修法是把两者合并成一个原子操作。

第二轮抓到三个:1 看板增量更新的两个标志位("阶段变了" / "模板变了")对所有客户端来说效果完全等价,于是合并成单个 boardMetaChanged,减少契约面;2 缺一条测试——"在别的项目的阶段上创建/移动任务应该被拒绝"这个跨项目守卫没有被测试覆盖,补上;3 更新任务的助手函数其实无法表达"改阶段"这个操作,所以干脆把 stageId 从它的输入里拿掉——改阶段必须走独立的"移动"路径。这个决定很重要,第三轮的一个严重 bug 就跟它有关。

之后是双视角审查。第一轮 Codex 提了一个真问题:reaper(垃圾回收)的测试用了一个"生产环境根本无法持久化的数据图"——三个测试的初始化助手都只插了项目和阶段,从没设置项目的"默认模板"字段,导致那条项目 ↔ 默认模板的循环外键删除路径实际上从没被真正测过。补测试解决。第二轮双视角审查两个模型都零发现,清场通过。

这一轮留下的、要交给下一轮的东西

第二轮 · 体验层

筛选 / 搜索 + 两种看板视图 — 3 个提交

地基打好后,这一轮在上面盖体验层。实现分三棒接力,做出来的能力大致是:

这一轮审查发现了什么

架构审查抓到 4 个,其中一个是性能问题:那个开销不小的 applyQuery(里面有"维度 × 选项 × 任务"的交叉计数)在看板和工具栏里各算了一遍,每次渲染跑两次;而且代码上还挂着一句"只算一次"的假注释。修法是把它提升到页面层只算一次,结果通过 props 往下传。另外三个是搜索框的一个竞态、三个没人用的死翻译 key、一个号称"冻结"实际可变的共享空数组。

接下来的双视角审查是这一轮、也是整个项目的一个转折点。前面说过 Claude 一侧老是零发现。这次换了个做法:不再用带着前文上下文的 Claude,而是派一个完全无上下文的全新 Claude 子 agent,和 Codex 并行审同一份 diff。结果两个模型高度互相印证,共 8 个真问题全部修掉。其中最重要的一个是拖拽解析的顺序问题:

关键 bug:拖拽解析用错了"顺序"

原实现解析拖放位置时,用的是数据库里的原始存储顺序。但用户在屏幕上拖的,是筛选 / 搜索之后的顺序(有些卡片被隐藏了,有些被搜索浮到了顶部)。这两个顺序不一样。用户把卡片拖到"看起来的两张卡中间",系统却按原始顺序去插值,结果隐藏的卡片被静默重排到别处。

当时的修法:引入一个纯函数 visibleColumns(),先按屏幕上的渲染顺序把列收窄到"可见的卡片",再在这个收窄后的顺序上解析拖放。——记住这个修法,第三轮会发现它其实修出了新问题。

其余 7 个问题也都很实在:土耳其语 İ 这类字符转小写后长度会变,导致搜索高亮的下标越界(改成按字符折叠 + 把边界映射回原始偏移);筛选行用了"按钮套按钮"的无效 DOM 结构(改成 <label> 包一个视觉隐藏的原生复选框);搜索框缺无障碍标签;拖拽和面板滑动漏了"减少动态效果"的适配等等。

验证了一个方法论:无上下文的审查者更有效

第一轮 Claude 侧全程零发现,第二轮换成"独立无上下文的全新 Claude 子 agent"后,它独立命中了 3 个问题、还补出了 3 个 Codex 没提的。结论:让审查者带着"之前干了什么"的上下文,会被既有叙事带偏;给它一份干净的 diff 从零看,效果好得多。这条经验直接决定了第三轮的做法。

这一轮留下的、要交给全局复审的东西

第三轮 · 全局跨阶段复审

对整个 feature 做一次接缝审查 — 6 个提交

前两轮各自审查的是"本轮改了什么"。但一个横跨五层的大改动,最容易出问题的地方是跨阶段的接缝——数据库定的顺序语义,到了守护进程还成立吗?到了前端呢?所以最后专门跑一轮全局复审,范围是整个 feature 的 diff,焦点就是这些接缝。这一轮用的还是同一套流水线(架构审查 + 修 + 双视角 + 修),但没有实现节点,纯审查。

值得一提:跑这轮之前,先修了流水线脚本里的一个坑。之前有个"占位词黑名单"用来识别 agent 偷懒交假内容,但它会无差别扫描 finding 的正文——而一个正当的架构审查里完全可能正当地讨论"placeholder 工具栏"这种词,结果合格的审查被误判成造假、无限重跑。这次把黑名单收窄到只扫 agent 自己写的元信息字段,不碰 finding 正文。修完后,5 个 agent 全程无一被误判,一次跑通。

架构审查:确认三个老 bug 还在,并揪出第二轮埋的雷

这轮的架构审查是个无上下文的全新 Claude。它先确认了 Codex 之前独立报告的三个缺陷在当前代码里全都还活着,然后给出了这轮最重要的洞见——第二轮那个 visibleColumns 修法,本身修出了一个新回归。

关键 bug:好心的修法,把一半场景修坏了

第二轮为了修"拖拽用错顺序",把 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 重渲染之前就跑了。所以还要在动作发生的那一刻同步再查一次实时的写入计数。

双视角审查:Codex 揪出一个零并发就能触发的破坏性 bug

架构修完后,Claude 和 Codex 并行审这份已经修过一轮的树。这次 Codex 提了一个必修级的严重问题,是前面所有审查都没看到的:

关键 bug:切换项目会带着上一个项目的选中任务

看板页面在切换项目时,只清了一部分状态,没有清"当前选中的任务"。而页面框架在项目切换时是复用的。于是:在项目 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 报告门禁全绿。但流水线的铁律是"不信 agent 自己报的绿,编排方亲自复跑"。我独立跑三件套门禁时,抓到一个测试在全量套件下约 40% 概率失败、单独跑却必过——这是最坏的一种,本地偶尔红、CI 上大概率红,但隔离复现不了。就是刚加的那个写入闸门的测试。

关键逻辑:为什么会 flaky,根因不在"时序"

第一反应通常是"异步没等够",加长等待时间。但真正的根因是测试辅助函数的写法:它在每次渲染都重建了一个空的 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。

现在还剩什么(followups)

三条能带走的经验

  1. 无上下文的审查者更有效。带着"之前干了什么"的上下文去审查,会被既有叙事带偏;给一份干净的 diff 从零看,抓得更全。第一轮 Claude 侧全程零发现,换成无上下文子 agent 后立刻有效。
  2. 双视角(两个不同模型)真的互补。第三轮那个零并发就能触发的破坏性 bug 只有 Codex 看到;而两个模型都独立看到的问题,可信度极高。盲区不同,合起来才全。
  3. 永远不信"agent 报的绿",编排方独立复跑。一个会污染 CI 的 flaky 测试,就是靠这条纪律在合入前兜住的。而且好的修复来自找根因(每渲染重建数组),不是加长等待时间。