AI

1 号机和 3 号机的代理都生成了下一号迁移:两张 PR 各自全绿,合进 main 以后谁先跑?(中英切换)

周四上午九点,1 号机上的代理在做「金额统一用分」:把 Order.total 改名为 amount_cents,makemigrations 生成 0002_rename_total_order_amount_cents.py。同一时间,3 号机上的代理在做 VIP 标记:给 Customer 加 vip 字段,顺手写了一段回填,用 orders_order.total > 100000 判断谁是 VIP,生成的文件叫 0002_customer_vip.py。两个代理都是从同一个 main 拉的分支,那一刻 main 上最新的迁移是 0001_initial,所以它们都诚实地写下了「我依赖 0001」。

两张 PR 各自的 CI 都是绿的:新建数据库、跑迁移、跑测试,没有一处可疑。4 号机先审完 1 号机的 PR,09:40 合入。5 号机审 3 号机的 PR,页面上的绿勾还在,09:41 合入。git 没有报冲突——两个迁移是两个不同的新文件,models.py 里改的也是两个不同的类。十分钟后部署流水线在 migrate 上停住了。

这篇要回答的是:两台机器上的代理从同一个 main 各自生成了下一号迁移,每张 PR 单独都是绿的,合进去以后到底会发生什么、为什么分支上的 CI 拦不住、闸门该装在哪一步。 它和 /merge-train-on-machine-7/ 讲的「各自绿、合起来红」是同一家族,但迁移多了一层:决定结果的不只是代码,还有数据库已经走过的历史。

两台机器、同一个 main

实验室:三套迁移工具,同一个撞车

我在本机搭了三个玩具仓库,全部用真实命令跑,不是模拟:Django 6.1.3、Alembic 1.20.1(SQLAlchemy + SQLite),以及 Flyway 10.20.1 官方命令行(自带 JRE,连 SQLite)。每个仓库都有一个 M0,两个代理分支从 M0 分叉,各写一个迁移。

Django 是最响的那个。 两个分支各自 migrate 和 makemigrations --check 都通过;合进 main 后,migrate 直接报 CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph: (0002_customer_vip, 0002_rename_total_order_amount_cents in orders),并且贴心地附上一句 To fix them run 'python manage.py makemigrations --merge'。

这句提示对代理是一个陷阱。代理看到报错、看到修复命令,就会去执行它。makemigrations --merge 生成了一个空的 0003_merge_*.py,把两个叶子并成一个,图合法了。但 3 号机那段回填还在 SELECT ... WHERE total > 100000,而 total 已经被 1 号机改名了。结果:新库上 migrate 报 no such column: total;先上过 1 号机迁移的环境也报同样的错;只有先上过 3 号机迁移的那套环境能跑通。同样的代码,成败取决于这个库以前先跑了谁。我还试了把合并迁移里两条 dependencies 的顺序对调,migrate --plan 给出的顺序没有变——在本机这个版本上,你没法靠合并迁移的写法控制兄弟迁移谁先跑。Django 文档对 --merge 的描述只有一句「Enables fixing of migration conflicts」,它修的是图,不是语义。

Alembic 分成响和不响两种。 两个分支的 down_revision 都是 a0001,合进去以后 alembic heads 列出两个 head。alembic upgrade head 会拒绝:Multiple head revisions are present for given argument 'head',这是 Alembic 分支文档 里写明的行为。麻烦在于报错信息里同时给了出路:「或者用 heads 升级全部 head」。alembic upgrade heads 不报错,于是我专门构造了一个不报错但会算错的例子:1 号机的 b1111 把 2026 年之前的 closed 订单改成 archived;3 号机的 c3333 加 archived_at 列,并给所有 archived 订单回填日期。两个迁移都正确,只是互相依赖顺序,而这种依赖没有写进 down_revision。

我塞了 5 行种子数据,跑了三种历史。生产上 1 号机先上线,然后 upgrade heads 只补跑 c3333,订单 1、2 有 archived_at;另一套先上了 3 号机的环境,订单 1、2 是 NULL;全新的 CI 库执行 upgrade heads,本机这次的顺序是先 c3333 再 b1111,订单 1、2 也是 NULL。三个库,没有一步报错,CI 是绿的,数据已经分叉了。

三种历史,三个库

Flyway 把问题从 CI 挪到了生产。 用顺序号时,两个代理都写 V42__,合进去以后 Flyway 直接拒绝:Found more than one migration with version 42,很响,在 CI 就会挂。很多团队对这个痛点的标准回答是「改用时间戳版本号」。我照做了:1 号机的迁移是 V20261108093000,3 号机的是 V20261108091500,3 号机生成得早,但合得晚。生产上先部署了 09:30 那个;等 09:15 那个到达时,默认配置下 migrate 报 Validate failed ... Detected resolved migration not applied to database: 20261108091500,并提示「设 -outOfOrder=true 允许执行」。同一时刻,CI 用的新库从零跑三个迁移,按版本号排序,Successfully applied 3 migrations,全绿。打开 outOfOrder=true 之后生产也能跑,Flyway 自己打出一行警告(outOfOrder 设置文档):outOfOrder mode is active. Migration of schema "main" may not be reproducible.——生产的顺序是 09:30→09:15,新库是 09:15→09:30。时间戳消除了重名,没有消除顺序问题,反而把一个 CI 上就能看见的报错变成了只在生产出现的报错。

机制:分支上的 CI 检查的是一条生产永远不会走的路

迁移系统本质上是一张有向图,每个迁移声明自己依赖谁;数据库里记录的是「已经走过哪些节点」。代理在分支上生成迁移时,图里只有 main 当时的末端,所以它声明的父节点永远是 M0 的末端。两个代理并行,就是两条从同一个点长出来的边。

git 合并的是文本。两个新文件不会冲突,git 就认为合并成功;迁移图在文本层面看不出分叉。分支 CI 在新库上从零跑,验证的是「M0 + 我的迁移」这一条线;生产要走的是「M0 + 别人的迁移 + 我的迁移」,这条线在分支 CI 里从来没出现过。即使你像 7 号机合并员那样在合并结果上跑测试,如果测试只用新库,Flyway 时间戳和 Alembic upgrade heads 这两种情况在新库上依然是绿的——新库没有历史,而出事的恰恰是历史。

还有一层更隐蔽的:工具只知道声明出来的依赖。3 号机的回填读的是 1 号机要改名的列,1 号机的归档决定了 3 号机要回填哪些行,这些语义依赖不在 dependencies 或 down_revision 里。所以「把图修合法」(merge 迁移、merge heads、outOfOrder)永远只是让工具闭嘴,代码之间的先后关系还是没人定。真正的修法只有一种:让后来者真正排在先到者后面,然后重新读一遍自己的迁移。

解法:闸门检查合并结果,绿勾绑定 main 的 SHA

我写了一个 migration_gate.py,并在三个仓库上跑完了「撞车 → 拒绝 → 重新挂父 → 通过」的全程。它做五件事:

  1. 在合并结果上检查。 用 git worktree 检出当前 main,把 PR 头合进来,所有检查都在这个临时树里跑,不在分支上跑。
  2. 图检查。 Alembic 要求 alembic heads 恰好一个;Django 跑 makemigrations --check --dry-run,同时用 MigrationLoader 数每个 app 的叶子;Flyway 检查没有重复版本号,并且 PR 新增的版本号必须大于 main 上最大的版本——这一条专门拦时间戳倒序。
  3. 真跑迁移,跑两遍历史。 一遍是「先按 main 建库、灌种子、再跑 PR」,模拟生产;一遍是从零建库,模拟 CI。Alembic 上我还比较两份数据 dump,必须一致。
  4. 禁止项。 PR 新增合并迁移、修改 main 上已经存在的迁移文件,直接失败。
  5. 结果里写下 main 的 SHA。 合入时 merge_if_fresh.sh 比对这个 SHA 和当前 main,不一致就拒绝,要求重跑。

闸门放在合并结果上

第 5 条最容易被省掉,也最关键。实验里 09:20 两张 PR 都对 M0 跑了闸门,两张都是 PASS——那一刻合并结果确实是干净的。1 号机合入后 main 变成 M1,3 号机的 PASS 是对 M0 算的,已经作废,但页面上看不出来。merge_if_fresh.sh 在这里打出 REFUSE agent3/archived-at: gate ran on main=f66a7cb, main is now d154256 -> re-run gate。在一个副本上不经检查直接合入,alembic heads 立刻变成两个。重跑闸门,失败原因有两条:两个 head,以及 replay parity 的 diff,正好是订单 1、2 那两行。这就是 GitHub 里「Require branches to be up to date before merging」和 merge queue 做的事:不是多跑一次测试,而是让测试结果和某个具体的 main 绑在一起。

闸门失败后,3 号机的代理在自己机器上 rebase 到 main,跑 reparent_migration.py:Alembic 把 c3333 的 down_revision 从 a0001 改成 b1111;Django 把 0002_customer_vip 改名为 0003_customer_vip,依赖改成 0002_rename_total_order_amount_cents;Flyway 把 V42__add_region.sql 改成 V43__add_region.sql。之后闸门 PASS,合入,main 只剩一个 head。

这里有一个我自己踩到的坑。Django 那条线第一次跑时,闸门只做图检查,重新编号后的 0003_customer_vip 图是合法的,闸门放行了——可它的回填 SQL 还在读 total。我在闸门里加上「两遍历史都真跑 migrate」之后,这一步变成两条失败:main 先跑和从零跑都报 no such column: total。代理把回填改成 amount_cents,闸门才通过。重新挂父解决的是顺序,不是内容;它的价值是把「取决于历史」的错误变成每个环境都一样的确定性错误,然后逼你去改。

最后一个场景也是真实常见的:之后 2 号机的代理觉得归档的截止日期不对,直接修改了 main 上已有的 b1111。已经跑过 b1111 的库永远不会再跑它,新库会跑新版本。闸门给出两条失败:「PR edits migration(s) that already exist on main」,以及 parity diff 里订单 2 一边是 archived、一边还是 closed。

代价:严格到什么程度

「必须基于最新 main」的代价是重跑。我用一个粗糙的模拟估了一下(这部分是模拟,不是 GitHub 实测):三台开发机一天 18 张 PR,四分之一带迁移,CI 12 分钟。什么都不做,大约每周 7 次两张迁移 PR 的检查窗口重叠,每次都可能留下双 head 或重号;所有 PR 都要求最新 main,每天多出约 7 次 rebase+CI;只对带迁移的 PR 要求最新(path 过滤的必需检查),每天多出不到 0.5 次。对这个团队,我的建议是第三种:迁移 PR 走严格车道,其他 PR 照旧。

GitHub 的 merge queue 能自动完成这件事,但文档写明它只对组织下的公开仓库、或使用 GitHub Enterprise Cloud 的组织私有仓库开放;并且必需检查要额外监听 merge_group 事件,否则队列会一直等那个永远不会出现的检查。个人私有仓库用不上时,就让 7 号机合并员在合并前跑 migration_gate.py 并比对 SHA,效果等价,只是要自己维护。

关于命名:顺序号会撞,时间戳不撞。我的结论是保留顺序号。撞号在 CI 上是响的;时间戳倒序在 Flyway 上只在生产响;Alembic 的随机 ID 永远不会撞名,分叉只有在有人执行 upgrade head 时才响,换成 upgrade heads 就不响了。命名方案的目标不该是「永不冲突」,而是「冲突必须在合并前被看见」;配合闸门的「新版本必须大于 main 最大值」,两种命名都能管住,但顺序号让人一眼能看出顺序。

什么时候不适合

  • 故意的多分支。 Alembic 支持 branch label 和多个 base,有的团队就是要两条独立的迁移线。这时「heads 恰好一个」要换成「每个 branch label 恰好一个 head」。
  • Django 多个 app。 叶子是按 app 算的,两个代理改的是不同 app 时不会冲突,闸门也不会拦,这是对的。别把规则写成全局只能有一个新迁移。
  • parity 只和种子一样好。 我的种子只有 5 行,刚好覆盖了出问题的那两行。真实项目要用脱敏后的小样本或专门为迁移写的种子,否则 dump 一致不代表语义一致。
  • 大表数据迁移。 在 CI 里两遍真跑可能太慢,把数据回填拆成独立的后台任务,迁移本身只改结构,闸门只验结构。
  • 声明式 schema 工具。 如果你用 schema diff 类工具从目标状态生成变更,没有「下一号」这回事,本文的命名和挂父部分不适用,但「在合并结果上、对带历史的库验证」仍然适用。

给 1–3 号机的规则,和明天可以抄的步骤

我给开发机代理的 AGENTS.md 片段核心只有几句:生成迁移之前先 git fetch && git rebase origin/main,并且最后一步才生成;永远不改 main 上已有的迁移;永远不用 makemigrations --merge、alembic merge heads、-outOfOrder=true 去让 CI 变绿;闸门说多 head 或倒序时,跑 reparent_migration.py,然后重读自己的迁移,因为它现在排在别人的改动后面了;同一个 app 每台机器同时最多一张带迁移的 PR,并打上 has-migration 标签让 4、5 号机一眼看到;闸门失败不许 --admin 合入。

明天可以按这个顺序做:第一,把 migration_gate.py 放进 tools/,先在本地对现在打开的迁移 PR 跑一遍,看有没有已经撞上的;第二,把 kit 里的 migration-gate.yml 加进仓库,在规则集里设为必需检查,只对迁移路径生效;第三,能用 merge queue 就开 merge queue 并确认 merge_group 触发,不能用就让 7 号机合并前比对 base_sha;第四,给 parity 写一份覆盖关键状态的种子;第五,把上面的 AGENTS.md 规则发到 1–3 号机。

诚实说明:什么跑了,什么没有

本机真实跑过: Django 6.1.3、Alembic 1.20.1、Flyway 10.20.1 命令行(SQLite),所有报错信息都是实际输出;三条「撞车 → REFUSE → 闸门 FAIL → 重新挂父 → PASS → 合入」流程;Django 上 --merge 之后仍然 no such column;Alembic 三种历史的数据分叉和 parity diff;Flyway 时间戳倒序在「生产」库 Validate 失败、新库全绿;修改已有迁移被拦下;工作流 YAML 只做了语法解析。

没有跑: migration-gate.yml 没有在 GitHub Actions 上运行过,merge queue 和 merge_group 也没有实际触发;没有七台机器联调,「1 号机」「3 号机」是在同一台机器上用分支模拟的;每周撞车次数和额外 CI 次数来自粗糙模拟;没有用 PostgreSQL 或 MySQL,DDL 事务性不同的数据库上失败形态可能不一样(比如 MySQL 的 DDL 不能回滚,失败一半更难收拾)。

套件:parallel-agents-same-migration-number-kit.tar.gz,包含闸门、重新挂父脚本、merge_if_fresh.sh、工作流样例、AGENTS.md 片段、三套实验脚本和完整运行日志。

一句话的判断:迁移 PR 的绿勾只对生成它时的那个 main 有效。 分支上的 CI 证明的是一条生产不会走的路;闸门要放在合并结果上,要在带历史的库上真跑,绿勾要绑定 main 的 SHA。main 一动,就重跑。

Thursday, 09:00. The agent on machine-1 is doing "store money in cents": it renames Order.total to amount_cents, and makemigrations writes 0002_rename_total_order_amount_cents.py. At the same moment the agent on machine-3 is adding a VIP flag: a vip field on Customer, plus a backfill that marks VIPs with orders_order.total > 100000. Its file is 0002_customer_vip.py. Both agents branched from the same main, whose newest migration was 0001_initial, so both honestly wrote "I depend on 0001".

Each PR's CI is green: fresh database, run migrations, run tests, nothing odd. Machine-4 finishes reviewing machine-1's PR and merges at 09:40. Machine-5 reviews machine-3's PR; the green check is still on the page; it merges at 09:41. git reports no conflict — the two migrations are two different new files, and models.py changed in two different classes. Ten minutes later the deploy pipeline stops on migrate.

The question for this post: when agents on two machines each generate the next migration off the same main, and each PR is green alone, what actually happens after both merge, why branch CI cannot catch it, and where the gate belongs. It is the same family as /merge-train-on-machine-7/ ("green alone, red together"), with one extra layer: the outcome depends not only on the code but on the history the database has already walked.

Two machines, one main

Lab: three migration tools, one collision

I built three toy repos on this box and ran everything with real commands, not simulation: Django 6.1.3, Alembic 1.20.1 (SQLAlchemy + SQLite), and the official Flyway 10.20.1 command line (bundled JRE, SQLite). Each repo has an M0; two agent branches fork from M0 and each writes one migration.

Django is the loud one. Each branch passes migrate and makemigrations --check. After both merge, migrate fails with CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph: (0002_customer_vip, 0002_rename_total_order_amount_cents in orders), and helpfully adds To fix them run 'python manage.py makemigrations --merge'.

For an agent that hint is a trap. It sees an error and a fix command, so it runs the command. makemigrations --merge creates an empty 0003_merge_*.py that joins the two leaves; the graph is legal again. But machine-3's backfill still does SELECT ... WHERE total > 100000, and total has been renamed by machine-1. Result: a fresh DB fails with no such column: total; an environment that shipped machine-1's migration first fails the same way; only an environment that shipped machine-3's migration first succeeds. Same code; success depends on which migration that database ran first. I also swapped the order of the two dependencies inside the merge migration: migrate --plan did not change. On this version you cannot steer sibling order through the merge migration. The Django docs describe --merge in one line, "Enables fixing of migration conflicts". It fixes the graph, not the meaning.

Alembic comes in a loud and a quiet flavour. Both branches set down_revision = "a0001"; after merge, alembic heads lists two heads. alembic upgrade head refuses: Multiple head revisions are present for given argument 'head', as documented in the Alembic branches guide. The trouble is that the same message offers the way out — "or 'heads' for all heads". alembic upgrade heads does not error, so I built a case that does not error but computes the wrong thing: machine-1's b1111 flips closed orders created before 2026 to archived; machine-3's c3333 adds an archived_at column and backfills it for every archived order. Both migrations are correct; they just depend on each other's order, and that dependency is not in down_revision.

With five seed rows I ran three histories. Prod shipped machine-1 first, then upgrade heads only adds c3333: orders 1 and 2 get archived_at. Another environment shipped machine-3 first: orders 1 and 2 are NULL. A fresh CI database running upgrade heads applied c3333 before b1111 on this run: orders 1 and 2 are NULL. Three databases, not one error, green CI, and the data has already forked.

Three histories, three databases

Flyway moves the failure from CI to production. With sequential numbers, both agents write V42__; after merge Flyway refuses: Found more than one migration with version 42. Loud, fails in CI. The standard answer to that pain is "use timestamp versions". I did: machine-1's migration is V20261108093000, machine-3's is V20261108091500 — generated earlier, merged later. Prod deployed the 09:30 one first; when 09:15 arrives, the default config fails migrate with Validate failed ... Detected resolved migration not applied to database: 20261108091500 and suggests -outOfOrder=true. Meanwhile CI's fresh DB sorts by version and reports Successfully applied 3 migrations — green. With outOfOrder=true prod runs too, and Flyway prints its own warning (outOfOrder docs): outOfOrder mode is active. Migration of schema "main" may not be reproducible. Prod ran 09:30→09:15; the fresh DB ran 09:15→09:30. Timestamps removed the name clash but not the ordering problem, and turned an error you could see in CI into one you only see in production.

Mechanism: branch CI checks a path production will never walk

A migration system is a directed graph. Each migration declares its parent; the database records which nodes it has visited. When an agent generates a migration on a branch, the graph only contains main's tip at that moment, so the declared parent is always M0's tip. Two parallel agents are two edges growing from the same node.

git merges text. Two new files do not conflict, so git calls the merge clean; the fork in the migration graph is invisible at the text level. Branch CI runs on a fresh database and validates "M0 + my migration". Production must walk "M0 + their migration + my migration", a path branch CI never saw. Even if you run tests on the merge result like the machine-7 merge clerk does, if those tests only use a fresh database, the Flyway timestamp case and the Alembic upgrade heads case are still green — a fresh database has no history, and history is exactly what breaks.

There is a quieter layer. Tools only know declared dependencies. Machine-3's backfill reads the column machine-1 renames; machine-1's archiving decides which rows machine-3 backfills. Neither fact lives in dependencies or down_revision. So "making the graph legal" (merge migrations, merge heads, outOfOrder) only silences the tool; nobody has decided the order between the two changes. The only real fix: make the late arrival actually sit after the earlier one, then re-read your own migration.

The fix: gate the merge result, bind the green check to main's SHA

I wrote migration_gate.py and ran all three repos through collide → refuse → re-parent → pass. It does five things:

  1. Check the merge result. git worktree checks out current main, merges the PR head, and every check runs in that temporary tree — never on the branch alone.
  2. Graph check. Alembic: exactly one alembic heads. Django: makemigrations --check --dry-run, plus counting leaves per app with MigrationLoader. Flyway: no duplicate versions, and every version the PR adds must be greater than main's newest — that rule exists for timestamp inversions.
  3. Really migrate, along two histories. Once as "build the DB at main, seed it, then apply the PR" (prod-like) and once from scratch (CI-like). On Alembic I also compare the two data dumps; they must match.
  4. Forbidden moves. A PR that adds a merge migration or edits a migration file already on main fails outright.
  5. Record main's SHA in the result. At merge time merge_if_fresh.sh compares it with current main and refuses on mismatch.

Gate the merge result

Item 5 is the one people skip and the one that matters. In the lab both PRs ran the gate against M0 at 09:20 and both passed — at that moment the merge result really was clean. After machine-1 merged, main became M1; machine-3's PASS was computed on M0 and was void, but nothing on the page said so. merge_if_fresh.sh printed REFUSE agent3/archived-at: gate ran on main=f66a7cb, main is now d154256 -> re-run gate. Merging anyway on a scratch copy immediately produced two alembic heads. Re-running the gate gave two reasons: two heads, and a replay-parity diff showing exactly orders 1 and 2. That is what GitHub's "Require branches to be up to date before merging" and merge queue are for: not one more test run, but tying a test result to a specific main.

After the failure, machine-3's agent rebases on main and runs reparent_migration.py. Alembic: c3333's down_revision goes from a0001 to b1111. Django: 0002_customer_vip becomes 0003_customer_vip, depending on 0002_rename_total_order_amount_cents. Flyway: V42__add_region.sql becomes V43__add_region.sql. The gate passes, the PR merges, main has one head.

A pit I fell into myself: on the Django line, the first gate version only checked the graph. The renumbered 0003_customer_vip had a legal graph, so the gate let it through — while its backfill still read total. Once I added "really run migrate along both histories", that step failed twice: main-first and from-scratch both hit no such column: total. The agent changed the backfill to amount_cents, and only then did the gate pass. Re-parenting fixes order, not content. Its value is turning a history-dependent failure into the same deterministic failure everywhere, which forces the fix.

One last, very common scene: later, machine-2's agent decides the archive cutoff is wrong and edits b1111, already on main. Databases that ran b1111 will never run it again; fresh ones run the new version. The gate fails twice: "PR edits migration(s) that already exist on main", and a parity diff where order 2 is archived on one side and still closed on the other.

Cost: how strict to be

"Must be based on latest main" costs re-runs. I estimated it with a rough simulation (simulated, not measured on GitHub): three dev machines, 18 PRs a day, a quarter carrying migrations, 12-minute CI. Doing nothing yields about 7 overlapping gate windows between migration PRs per week, each a potential double head or duplicate number. Requiring latest main for every PR adds about 7 rebase+CI cycles a day. Requiring it only for PRs that touch migrations (a path-filtered required check) adds under 0.5 a day. For this team I recommend the third: a strict lane for migration PRs, everything else as before.

GitHub's merge queue automates this, but its documentation says it is only available for public repos owned by organizations, or private repos in organizations on GitHub Enterprise Cloud; and required checks must also listen to the merge_group event, otherwise the queue waits forever for a check that never reports. Where you cannot use it — a personal private repo, say — have the machine-7 merge clerk run migration_gate.py and compare SHAs before merging. Same effect, you maintain it yourself.

On naming: sequential numbers collide, timestamps do not. My conclusion is to keep sequential numbers. A collision is loud in CI. A timestamp inversion on Flyway is loud only in production. Alembic's random IDs never clash by name; the fork is loud only when someone runs upgrade head, and silent once they switch to upgrade heads. The goal of a naming scheme should not be "never conflict" but "conflicts must be visible before merge". With the gate's "new version must exceed main's newest" rule, either scheme is controllable, but sequential numbers let a human see the order at a glance.

When this does not fit

  • Intentional multiple branches. Alembic supports branch labels and multiple bases; some teams want independent migration lines. Then "exactly one head" becomes "exactly one head per branch label".
  • Several Django apps. Leaves are per app. Two agents touching different apps do not conflict and the gate will not block them, which is correct. Do not write a global "only one new migration" rule.
  • Parity is only as good as the seed. My seed had five rows, which happened to cover the two rows that broke. Real projects need a small anonymised sample or a seed written for migrations; otherwise matching dumps do not mean matching meaning.
  • Large data migrations. Running them twice in CI may be too slow. Split backfills into background jobs, keep migrations structural, and gate the structure.
  • Declarative schema tools. If a schema-diff tool generates changes from a target state, there is no "next number", so naming and re-parenting do not apply; "validate on the merge result against a database with history" still does.

Rules for machines 1–3, and steps to copy tomorrow

The core of the AGENTS.md snippet for the dev machines is short. Before generating a migration, git fetch && git rebase origin/main, and generate it as the last step. Never edit a migration that is already on main. Never use makemigrations --merge, alembic merge heads, or -outOfOrder=true to turn CI green. When the gate reports multiple heads or an inversion, run reparent_migration.py, then re-read your migration, because it now runs after someone else's change. At most one migration-carrying PR per app per machine, labelled has-migration so machines 4 and 5 see it. No --admin merge past a failing gate.

Tomorrow, in order. First, put migration_gate.py in tools/ and run it locally against the migration PRs open right now, to see whether any have already collided. Second, add the kit's migration-gate.yml and make it a required check scoped to migration paths. Third, if you can use merge queue, enable it and confirm the merge_group trigger; if not, have machine 7 compare base_sha before merging. Fourth, write a seed for parity that covers the states you care about. Fifth, push the AGENTS.md rules to machines 1–3.

Honesty: what ran, what did not

Really ran on this box: Django 6.1.3, Alembic 1.20.1, and the Flyway 10.20.1 CLI (SQLite); every error message quoted is real output. Three full runs of collide → REFUSE → gate FAIL → re-parent → PASS → merge. Django still hitting no such column after --merge. Alembic's three-history data fork and parity diff. Flyway's timestamp inversion failing Validate on the "prod" DB while the fresh DB stays green. Editing an existing migration being blocked. The workflow YAML was only parsed for syntax.

Did not run: migration-gate.yml has never run on GitHub Actions, and merge queue / merge_group were not exercised. No seven-machine setup — "machine-1" and "machine-3" are branches on one box. The weekly collision count and extra CI runs come from a rough simulation. No PostgreSQL or MySQL; databases with different DDL transactionality may fail differently (MySQL DDL cannot roll back, so half-applied failures are messier).

Kit: parallel-agents-same-migration-number-kit.tar.gz — gate, re-parent script, merge_if_fresh.sh, workflow sample, AGENTS.md snippet, the three lab scripts, and full run logs.

One-line judgment: a migration PR's green check is only valid for the main it was computed on. Branch CI proves a path production will never walk. Put the gate on the merge result, run it against a database with history, and bind the green check to main's SHA. When main moves, run it again.