落地通道的部署判断从“文件在哪个目录”换成“主进程到底加载了什么”,顺便把常驻作业从主进程的发布节奏里解了出来。

1. 上一版留下的粗糙边⌗

上一篇讲过,落地通道有两张表:验证计划决定合并候选要跑哪些检查,部署计划决定运行中的系统怎样才能用上它。但在这次改动之前,第二张表还是从第一张表派生出来的:

// 改动前的 deploymentPlan
const plan = verificationPlan(paths)
if (plan.level === 1) return { restart: false, ... }   // 热层/文档
if (plan.level === 2) return { restart: true,  ... }   // UI
return { restart: true, reason: plan.reason }          // 其余一律重启

第三级的意思是“有不属于热层、也不属于 UI 的路径”,所以它其实等于“仓库里任何别的文件”。验证这么分没有问题,未知路径就该从严检查。部署这么分就太宽了:仓库里不是每段代码都跑在主进程里。

举个虚构但很典型的例子:仓库里有一个独立运行的辅助客户端,它的代码放在 src/ 下的一个子目录,另外还有它自己的构建脚本和分发清单。改这几个文件,验证上理应走第三级,类型检查和受影响测试都要跑。可主进程从来不 import 这些文件,为它们发一次 release、重启主进程,结果只是多一次预演、多一次排空在飞的工作,再多一段试用期,运行中的代码一行都没变。

所以要回答的问题得换一个:重启是为了让主进程加载新代码,那就该看主进程会加载什么,文件放在哪个目录说明不了这一点。

2. 让打包器回答“主进程加载了什么”⌗

新增的 runtime-files.ts 只做一件事:拿到一份部署提交的检出,从主进程入口 src/start.ts 出发,用 Bun.build 带 metafile: true 解析一遍,取 metafile 里的 inputs 当作模块集合。

const result = await Bun.build({
  entrypoints: [join(root, MAIN_RUNTIME_ENTRY)], root, target: 'bun',
  packages: 'external', metafile: true,
  plugins: [{ name: 'runtime-repository-modules', setup(build) {
    build.onResolve({ filter: /^[^./]/ }, ({ path }) => {
      const workspace = workspaces.get(packageName(path))
      // 已安装的包由锁文件定版本;workspace 代码必须来自这个提交
      return workspace ? { path: Bun.resolveSync(path, workspace) } : undefined
    })
  }}],
})
const modules = new Set(Object.keys(result.metafile.inputs).map(toRepoRelative))

里面有三个刻意的选择:

选择原因
用打包器的解析结果,不自己写 import 扫描静态 import、字面量动态 import('./x')、package.json 的 exports 都按运行时同一套规则解析,不用另外维护一个“差不多像”的解析器
packages: 'external'第三方依赖不进图。它们的版本由 bun.lock 决定,锁文件本身放在启动输入里(见下节)
workspace 包用插件拉回图内workspace 包虽然以包名引用,代码却在这个仓库里、跟着这个提交变化。把它当外部包,改动就会漏掉

整个过程只解析,不执行:不安装依赖,也不运行入口。注释里写的是 “without installing or executing its code”。

测试夹具把这些边界都钉住了:入口静态引用一个 workspace 包,动态 import('./core'),再引用一个未安装的第三方包;旁边放一个主进程不引用的 src/temp-host/main.ts。断言模块集合恰好是:

src/core.ts
src/start.ts
vendor/shared/index.ts

第三方包不在图里,独立客户端的文件也不在。workspace 包的 package.json 进了启动输入。

3. 图看不见的东西:启动输入清单⌗

模块图只覆盖“被 import 的源码”。主进程启动还依赖另外几类文件,它们不会出现在 metafile 里,所以单列一张清单:

输入为什么图里没有为什么必须重启
package.json、bun.lock、bun.lockb、bunfig.toml、.npmrc不是模块决定装哪些依赖、以什么方式运行
workspace 各包的 package.json只是解析依据exports 一变,解析结果跟着变
tsconfig*.json编译配置影响路径别名和转译行为
patches/依赖补丁改的是已安装包的内容
src/storage/pg-migrations/迁移器用计算出来的路径动态 import,打包器追不到schema 迁移在主进程启动时执行
src/testing/remote/runner.sh是脚本,不是模块某个模块在初始化时就把它读进内存

迁移那一行最能说明为什么需要这张清单。仓库里的迁移器是这样加载迁移文件的:

await import(join(PG_MIGRATIONS_DIR, migration.file))

路径在运行时才拼出来,任何静态分析都看不到。如果只看模块图,改一个迁移文件会被判成“与主进程无关”,而这恰恰是最不该漏掉的一类改动。所以这里用了前缀规则,没有去追求“图是完备的”这种幻想。

判断顺序于是成了一条短链,测试 names the first module graph hit 断言 reason 报出的是第一个命中的文件:

改动路径
  │
  ├─ 命中模块图        → 重启  "<path>: main runtime module graph changed"
  ├─ 命中启动输入      → 重启  "<path>: main runtime startup/release input changed"
  ├─ 有 ui/ 路径       → 重启  (静态文件来自运行中的只读 release)
  └─ 以上都不是        → 不重启,源码树更新即可

UI 那一支保留了上一篇的结论:WebUI 的静态产物由运行中的不可变 release 提供,验证再轻,它也得跟着 release 走。

4. 验证级别和部署决定从此互不推导⌗

这次最干净的一处变化是 deploymentPlan 的签名:

deploymentPlan(paths, runtime: MainRuntimeFiles)

它不再调用 verificationPlan。测试里有一组四个路径(独立客户端的两个源文件、它的构建脚本、一份分发清单),同时断言了两件事:

expect(verificationPlan(paths).level).toBe(3)
expect(deploymentPlan(paths, runtime)).toEqual({ restart: false, reason: /no main runtime file changed/ })

第三级验证,不重启。 这两件事以前绑在一起,原因只是它们恰好用了同一份路径分类。拆开以后各自表达各自的事实:要检查多少,取决于改动有多大可能出错;要不要重启,取决于运行中的进程会不会读到它。

反过来也一样。如果独立客户端里某个文件被主进程 import 了(夹具里的 src/temp-host/update-source.ts),它就在模块图里,照样触发重启,reason 会指出就是这个文件。“这个目录不重启”这种规则写不出来,也不需要写。

5. 用哪一份代码算图⌗

图必须从部署提交本身算出来,不能从源码检出算。源码检出是可变的,部署那一刻里面可能已经有别的提交,甚至有没提交的修改。

所以 Deployer 用 withWorktree 在部署目标上开一个临时 detached worktree,在里面跑 mainRuntimeFiles,跑完删掉。同一个目标的结果按 commit 缓存,一次部署里多次判断不会重复打包。

测试 uses the deployment commit graph rather than the mutable source checkout 专门模拟了这种污染:落地一个只改独立客户端的提交之后,往源码检出的 src/start.ts 里写一句 import './not-in-the-commit'。如果实现偷懒去读检出,要么解析失败,要么图被污染。实际结果是部署正常结束,没有重启,落地记录的 detail 写的是 “no main runtime file changed”。

6. 算不出图,不等于“保险起见重启”⌗

这里有个很诱人的默认:图算失败了,就当它改了主进程,重启一下总是安全的吧?

实现明确没这么做。mainRuntimeFiles 遇到无法解析的相对 import 会抛 main runtime module graph failed,部署随之中止。测试 a graph error aborts deployment without restarting or marking the landing running 断言了三件事:

断言含义
p.restarts 为空没有发起重启
落地记录的 deploy 状态仍是 pending没有假装部署完成
没有活跃部署记录不会留下悬空的半成品

而且图恢复正常后,再调用一次 deployLane 就能走到 finished。

我认为“出错就重启”在这里是错的默认,理由有两条:

  1. 它让分类器无法证伪。 只要失败路径的结果和“改了核心”一样,图分析坏掉也没人会发现。部署照常进行,只是悄悄退回到第一节那种按路径猜的行为。抛错会让这条退化变成一条看得见的失败。
  2. 图算不出来,往往说明这个提交本身有问题。 主进程入口出发有一个相对 import 解析不了,主进程多半也起不来。拿它去重启,是把一个能在部署前确定性发现的问题推到预演阶段才暴露,甚至更晚。

这和“未知路径升到第三级验证”并不矛盾。那里说的是“不认识的东西从严检查”,前提是检查能跑起来。这里说的是“检查跑不起来,就别假装有结论”。

7. 顺带解开的第二个耦合:常驻作业⌗

源码树里还有几个常驻作业。它们以 bun run <entry> 的方式从源码检出启动,启动时把代码加载进内存,之后就不再读文件。落地通道会记住每个作业上次是在哪个提交上重启的,等它的 import 闭包变了,再按精确的 label 把它踢一下。

以前的规则是:只要检出里有主进程还没部署、又落在作业闭包里的改动,作业就等。用意是对的,作业不能跑到主进程前面,因为 schema 迁移要等主进程启动时才执行。问题在于条件太宽:只改了作业自己的代码,也要等到下一次主进程 release。现在主进程的 release 本身就可能被省掉(第 4 节),这类作业就可能一直等下去。

新规则把“等”的条件收窄成三项的交集:

未部署的改动 path 会让作业等待,当且仅当:
  path ∈ 主进程运行时文件(模块图 ∪ 启动输入)
  且 ( path ∈ 作业闭包  或  path ∈ {package.json, bun.lock}  或  path 是迁移文件 )

测试给出的决策表:

未部署的改动作业自上次以来的改动决定
只有作业入口作业入口kick,不等主进程
作业与主进程共用的存储模块 + 作业入口—wait
一个迁移文件作业入口wait,reason 里点名该迁移
只有主进程用的路由模块 + 作业入口作业入口kick,路由不在作业闭包里
一份 skill 文档作业入口kick
主进程运行版本未知 / 无法 diff—wait

迁移那一行用的还是第 3 节那个道理:作业的闭包里没有一句 import 指向迁移文件,但它在 schema 上依赖迁移,这个依赖是隐式的。代码里的注释只写了这一点:“Migrations are an implicit schema dependency, not a literal import in a job’s closure.”

刷新作业时,主进程的模块图在检出 HEAD 上只算一次,所有作业共用;图算失败就整体抛错,不踢任何作业(测试 computes the main runtime graph once at checkout head; graph failure does not kick jobs)。失败语义和部署那边一致。

8. 代价和没解决的事⌗

项现状
每次部署多一次 worktree + 打包解析换来的是可能省掉一次完整重启(预演、排空、试用期)。我手头没有耗时数据,只能说按结构推断这笔交换合算
启动输入清单是手写的新出现一种“模块初始化时读文件”的写法,就得有人把它加进清单。runner.sh 那一项就是这样补上的,代码里留了一句 why 注释
计算路径的动态 import打包器追不到。目前已知的一处(迁移)靠前缀规则兜住,其他同类写法要靠清单来发现
UI 仍然要重启没变。要改,得先有独立于 release 的静态产物发布路径

整件事说穿了:“要不要重启”是一个关于运行中进程读了哪些字节的事实问题,以前拿路径分类去近似它,现在直接从部署提交里把答案算出来。算得出就照答案办,算不出就停下报错,不拿“重启总没错”当作答案。