今天一开始,我只是发现了一个非常具体的问题:LMN516 的“我的”页面没有底部导航。

这件事从使用上看很直接。首页、记录、收藏、搜索都有底部那五个入口,点进“我的”以后却断了。没有导航,人进去以后就像被关在一个单独的页面里,还得想办法退回去。我当时的反应也很简单:这不就是加一个底部导航吗,为什么会改这么久?

真正把问题翻出来的,是后面这一句。我没有继续问按钮该放哪儿,而是问:到底是哪一层有问题?是 Control Tower 太重了,是流程设计不对,还是代码本身写得有问题,导致一个很小的修改被拖成了一个很长的任务?

我们后来把整个过程重新拆了一遍,才发现答案不是其中一个,而是几层问题同时叠在一起。

先说最表面的代码问题

原来的移动端底部导航,界面上其实一直有五个入口:今日、记录、收藏、我的、搜索。但代码里的 MobileAppTab 当时只正式定义了四个:today、record、collection、search。“我的”虽然显示在导航上,却是单独塞进去的一个 Link,没有被当成一个正式的 Tab。

与此同时,底部导航并不是由一个覆盖所有主页面的统一 Layout 自动提供,而是每个页面自己决定要不要套 MobileAppShell。首页自己套,记录自己套,收藏自己套,搜索自己套,/account 只要有一次忘了套,底部导航就直接消失。

所以这次 bug 的直接原因其实很普通:一个全局规则,被拆成了很多页面各自“记得做”。当五个入口里有一个在类型系统里都不是正式成员时,漏接并不奇怪。

这个问题后来修了:account 被补进正式 Tab,/account 也接进了统一的 MobileAppShell。但这只能解释“为什么没有导航”,解释不了“为什么修一个导航会搞这么久”。真正拖慢事情的是后面的流程。

一个小改动为什么会一路碰到 Git、Control Tower 和 Vercel

我们之前给 LMN516 建了一套越来越完整的协作体系:Q、RUN、Issue、Patch、Control Tower、Change Manager、Git、CI、Vercel、Release Candidate、Change Log……这些东西单独看都有作用,尤其是网站越来越大以后,我确实需要知道一件事是谁在做、做到哪儿、有没有落地、能不能回滚。

问题在于,这些治理能力后来有一部分进入了每一个修改的关键路径。

当时实际发生的链路,大概是这样:

一个很小的页面修改
        ↓
Q / RUN / Issue / Patch 进入执行链
        ↓
Worker 改代码
        ↓
main 同时还在被其他 Worker 推进
   ↙                 ↘
重新对齐            编号碰撞 / 重算
   \                 /
        ↓
      合入 main
        ↓
Vercel 自动触发 Production
        ↓
Release Guard 再检查 RC / Batch / Change Log
        ↓
不满足发布条件 → 构建被拦 → 再回头处理

问题不是哪一层单独多余,而是这些原本解决不同问题的机制,在一次普通小修里被串成了同一条必经路线。

这次只是给“我的”补导航,执行过程中 main 还在被别的 Worker 推进。我们刚准备落一次,主线又往前走了一截;重新对齐以后,另一个 Run 又占用了临时准备使用的编号;Patch 编号也出现了同样的问题。于是本来三处左右的前端改动,开始不断做 rebase、重新核对、重新编号、重新确认有没有覆盖别人刚合进去的代码。

最开始我们甚至以为 Control Tower 的 Q 编号机制本身就是“扫描最大编号再 +1”,所以天然会撞号。但继续查下去以后发现,这个判断并不完整。网站端 canonical 的 Q 分配其实早就用了 PostgreSQL advisory transaction lock,本身已经是原子操作。真正的问题是:有些 Worker 没有走这条 canonical API/MCP 链,而是在 Git 文件层自己扫编号。

这件事很有代表性。不是“Control Tower 设计错了”这么简单,而是系统里已经有正确入口,同时又保留了一条旧入口。只要不同 Worker 分别走两套规则,最终还是会撞。

RUN 后来单独补了 canonical reserve_run。Worker 不再自己猜 RUN-xxx,而是把已有 Q 交给数据库事务去分配唯一 RUN,并且一次把 Q 和 RUN 的关系登记进去。LMN Issue 和 Patch 这种仍然以 Git 文件为身份记录的编号,则改成远端 claim:Worker 在远端占住编号以后,其他 Worker 才会跳到下一个。

而且这里我们又踩了一次坑。第一版远端 claim 直接把当前 HEAD 推到编号对应的 ref。表面测试没问题,但继续想才发现,如果两个 Worker 恰好基于同一个 commit,同时抢同一个编号,两个 HEAD 是同一个 SHA,Git 可能会把第二次 push 当成“已经是这个值”,两边都以为自己抢号成功。后来才改成每个 Worker 先生成独立的 owner object,再去抢同一个 ref。之后做的测试也不再是 A 跑完再跑 B,而是两个进程同时启动,最后分别拿到不同编号。

这才算真的解决了并发问题,而不是“顺序跑一次看起来没事”。

更大的矛盾其实在 main 和 Production

我们之前一直有一条工作规则:修改完成以后可以合进 main,但“合 main”不等于“上线”,除非我明确要求部署。

规则是这样写的,基础设施却不是这样工作的。

当时 Vercel 的 Git 集成仍然是 main 一更新就自动触发 Production build。所以嘴上说的是“先合 main,不部署”,真实世界里却是“main 一动,Vercel 马上开始 Production”。这次导航修完以后就是这样:代码进入 main,Vercel 自动启动,然后 Release Guard 又因为 commit message 里没有当前 Release Candidate 和 Batch ID 把构建拦了下来。

Release Guard 本身没有错。它就是为了阻止未经发布流程确认的 Production。真正的问题是,我们先让每一个 main commit 都去撞一次 Production,再让 Release Guard 把不该发布的东西挡回来。

这等于拿发布系统当刹车,而不是从入口上把开发和发布分开。

所以这次最先改的不是导航,而是 vercel.json:关闭 Git 自动部署。现在 main 可以重新回到它本来应该承担的角色——代码集成和版本事实;Production 则成为一个明确的发布动作。以后 Worker 合 main,不会再自动消耗一次 Vercel Production 尝试。

这一步做完以后,后面的流程修改才真正轻下来。因为我们终于不用一边改“怎么避免小修触发发布”,一边每提交一次流程修复就真的再触发一次发布。

普通 build 也不该背着整套发布治理

第二个被拆开的东西是 build

之前 npm run build 前面的 prebuild 不只是做应用构建必须的准备,还顺带跑 Safari package、Control Tower contract、Control Room scan、全站 Audit、Theme Audit、Release Candidate history、Release validate 等一整套治理检查。

这些检查并不是没用。问题是它们不应该每次都出现。

改一个导航,需要确认导航没有退化、TypeScript 没问题、应用能编译,这是正常验证;但它并不需要每次都重新证明当前 Release Candidate、Change Log gate 和整套 Control Tower 治理都可以发布。

所以后来把它们拆成了两层。普通修改走 verify:针对性的契约测试、typecheck、build。真正要发布时,再进入 release:preflight,把 Control Tower、Audit、Release Candidate、Change Log 等治理重新全部拉起来。

治理没有被删除,只是从“每个小修都必须背着走”移动到了“真正发布的时候必须过”。

拆开以后,普通开发和发布变成了两条不同的路径:

普通修改
  ↓
复用 / 建立 Q
  ↓
reserve_run
  ↓
Worker 分支改代码
  ↓
针对性契约 + typecheck + build
  ↓
landing lock
  ↓
main
  ↓
结束

只有明确要求上线
  ↓
Change Log
  ↓
Release Candidate / Batch
  ↓
release:preflight
  ↓
Production

main 重新只是集成点,不再同时扮演发布按钮。

main 也不能让所有 Worker 一起抢

这次还有一个很直观的现象:我们处理中途,main 一直在变。不是有人做错了,而是多个 Worker 都有权限直接推进 main。

并行工作本身没问题,问题是最终落 main 也并行。这样每个 Worker 在准备提交的那一刻,都必须重新确认:刚才看的 main 还是不是现在的 main?有没有同文件冲突?有没有别人刚改过同一个控制记录?

最后加的是一个协作式 landing lock。Worker 仍然可以并行分析、并行改各自的代码,但真正准备把结果落进 main 时,需要先取得远端唯一锁。拿不到就说明此刻有人正在 landing,等对方释放以后再进。

后来我们把“并发”拆成三个不同的问题分别处理:

身份不能撞      → Q / RUN 原子分配
文件编号不能撞  → LMN / Patch 远端 claim
main 不能抢写   → landing lock 串行落地

这样以后再出现冲突时,也能直接知道是哪一层出了问题,而不是把所有并发问题都混成一句“Control Tower 又撞了”。

第一版 landing lock 也不是一次就对。开始用了 annotated tag,fresh clone 里没有配置 Git 用户名,测试直接失败。后来改成随机 blob 作为 owner,不依赖 Git 用户身份,再用两个 Worker 同时抢锁测试:A 成功以后,B 会明确得到 MAIN_LANDING_LOCKED;A 释放以后,B 才能进入。

这个改动的目标不是把 Git 变复杂,而是让并行只发生在适合并行的地方。工作可以并行,main 最后的写入必须串行。

我们还顺手发现了一条已经过期的 CI

检查构建链时,又发现 .github/workflows/release-f-fixed-ci.yml 还是一个旧发布阶段留下的工作流,里面写死的是 8 月 17 日的 Release Candidate 和 Batch,但它仍然会跟着很多 main 的 app、components、lib、scripts 改动自动触发。

当前 Release Candidate 已经换了,这条 CI 继续自动跑,只会制造额外消耗和噪音。

所以没有把它删掉,而是改成只允许手动 workflow_dispatch。历史发布需要复现时它还在,但普通小修不会再被一条过期发布流程拦住。

导航最后反而没有继续大重构

查到这里以后,其实很容易产生另一个冲动:既然发现 MobileAppNav 本身也有结构问题,那干脆趁这次把接近 500 行的导航、手势、侧边栏全部重新设计一遍。

但这又会回到原来的问题——为了修一个小问题,把范围越做越大。

所以最后没有继续强行重构导航核心文件,而是先加了一条防回归契约:底部导航必须始终存在五个主入口;account 必须是正式 MobileAppTab/account 必须挂共享 MobileAppShell;“我的”又不能污染之前用于记录四个 World 页面返回位置的 last-mobile-world

也就是说,这次先把已经发生过的错误变成以后会自动报错的东西。等真正有一个独立的导航重构任务时,再去做“单一配置源”这种结构优化,不把它偷偷塞进当前修复。

这次优化过程中,我们自己也犯了几次错

这一段我觉得应该留下,因为如果只写最后正确的架构,会让整个过程看起来太顺了。

我们最开始把 Q 撞号归因于“编号器没有原子锁”,后来才查到 canonical Q 其实已经有锁,问题是 Worker 绕过了 canonical 入口;改 package.json 拆 build 时,我又一度把几项依赖版本误带成旧值,好在最终 diff 自检抓到了,马上恢复,并补了一条“流程改动不得偷偷漂移依赖版本”的测试;landing lock 第一版依赖 annotated tag,也是在测试里暴露了 fresh clone 的 Git identity 问题;LMN/P 远端 claim 第一版虽然顺序测试通过,后来继续推演同 HEAD 并发时又发现仍可能重复成功,最后才把测试升级成真正的 race test。

所以这次真正有用的并不是“我们想出了一个更漂亮的流程”,而是每次出现一个解释以后,都继续问一句:这个解释在真实并发、真实 Git、真实 Production 行为里还成立吗?

最后留下来的流程反而比之前简单。

一个普通的小修改,现在应该是:先找到对应 Q,需要执行就由 canonical 入口分配 RUN;改代码;跑和这次修改真正相关的契约、typecheck 和 build;取得 landing lock;合入 main。到这里就结束。

只有我明确说“上线”,才开始进入 Change Log、Release Candidate、Batch、全套治理检查和 Production。

这才是这次我真正想要的结果:以后再看到一个页面少了底部导航,它就应该只是一个底部导航问题。控制系统继续存在,但它负责处理并发、身份、发布和风险,不再要求每一个很小的修改都背着整套发布流程走一遍。