周二上午,1 号机上同时开着三个代理,各占一个 Git 工作树:wt-a 把人民币改成四舍五入,wt-b 让日元不带小数,wt-c 给订单加一个 discount_cents 字段,顺手改了金额格式化。十分钟后三条汇报几乎同时到:
- A:“Django 测试挂了,
database "test_shop" does not exist,我先看看是不是迁移写错了。” - B:“e2e 失败,
Expected "1234",Received "1234.50",我的日元格式化好像没生效,正在重新改。” - C:“全部通过,可以提审。”
三条都不对。A 的测试库是被 B 那边的 manage.py test --noinput 删掉的;B 的 e2e 根本没打到自己的服务器,Playwright 看见 8000 端口有人应答,就直接复用了 A 起的那个;C 确实有 bug(2.675 被格式化成 2.67),它的 e2e 也跑在别人的服务器上,所以通过了。
工作树隔开的是文件。端口、开发库、测试库、正在跑的进程,三个工作树还是共用一套。人在一台机器上开三个终端时很少碰到这些问题,因为人不会在同一分钟里在三个目录各跑一遍全量测试;代理会,而且结果一出来就会照着去“修”。我更担心的是它们当真:假红让代理去改本来没问题的代码,假绿让坏分支进入审查。我们的 4、5 号机专门做审查,那边的审查代理拿到的是“全部通过”,它没有理由怀疑。
我的判断是:**给每个工作树分一个槽位(端口、开发库、测试库各一份)只是第一步。剩下两件事也得做:资源在哪台机器上,就在哪台机器上认领;e2e 开跑之前,先确认应答的服务器是这个工作树、跑的是这份代码。**下面是我在玩具仓库上的实测、一套可以直接拷走的脚本,以及做的过程中我自己写错的三处。
实验:三个分支,七种跑法
我用 Django 5.2 + Postgres 17 + Playwright 1.64 搭了一个小商城:/api/quote 格式化金额,/api/orders 写订单。三个分支的真实结论事先定好:
feat/cny-half-up(A):正确,两套测试都应该通过;feat/jpy(B):正确,两套测试都应该通过;feat/quote-discount(C):有真 bug,两套测试都应该失败,而且失败原因必须是自己的 bug(收到2.67),因为别的原因变红也算错。
每个“代理”是一个线程:随机等 0–2.5 秒,然后按随机顺序跑 python manage.py test --noinput 和 npx playwright test。Playwright 配置就是很多项目默认的写法:固定端口 8000,webServer 先 migrate 再 runserver,reuseExistingServer: !process.env.CI。开发库叫 shop,测试库是 Django 默认的 test_shop。为了让 migrate 有足够长的时间和别人重叠,catalog 应用放了 80 个迁移。每轮开始前,杀掉所有 runserver,删掉所有 shop* 数据库。
每次运行都按日志归类:结论正确、假红、假绿、红了但不是因为自己的 bug,以及我的脚本主动拒绝运行。结果如下:

依次跑的时候 18 次全对,说明测试本身没问题。三个工作树并行、什么都不改,120 次运行里有 47 次结论错误:40 次假红,3 次假绿,4 次红的原因不对。20 轮里没有一轮是干净的。也就是说,只要三个代理同时跑测试,几乎每次都至少有一个代理拿到错误的结论。
有人会说,那就给 Django 加 --keepdb,别让它删库。我也试了,错误反而从 47 次涨到 59 次。
错误结论的七个来源
我按每份日志里的第一条致命错误归类:

**测试库被别人删掉(12 次)。**Django 跑测试时如果发现 test_shop 已经存在,加了 --noinput 就不会询问,直接 “Destroying old test database”。那个库可能正被另一个代理用着。31 份 Django 日志里出现了这句话,被删的那一方看到的是 database "test_shop" does not exist,代理通常会去怀疑自己的迁移。
**两个代理同时创建测试库(8 次)。**两边都看到库不存在,都去 CREATE DATABASE,后到的那个拿到 already exists。
**复用的服务器被主人关掉(11 次)。**这是 reuseExistingServer 最隐蔽的地方。B 的 e2e 复用了 A 起的服务器;A 的测试先跑完,Playwright 收工时把自己起的服务器关掉,B 跑到一半就是 ECONNREFUSED。B 看到的是“服务器挂了”,会去查自己的代码是不是有崩溃。
**检查时端口空着,绑定时被抢(5 次)。**Playwright 先探测 8000 端口,没人应答,就去启动服务器;migrate 跑了一秒,这期间另一个代理的服务器先绑上了,That port is already in use。falq 的作者写过真实代理遇到这个报错的做法:把端口硬编码成 3001。到下次三个代理都改成 3001,问题又回来了,只是换了个端口。
**两个 migrate 同时改同一个库(5 次,keepdb 下 13 次)。**三个 webServer 都对 shop 跑 migrate,同一批 AddField 被执行两次,报 DuplicateColumn。
库里是别的分支的表结构(keepdb 下 24 次)。--keepdb 不删库了,于是 C 的迁移加上的 discount_cents NOT NULL 一直留在共用的 test_shop 里。A 和 B 的代码不知道这个字段,插入订单时报 NotNullViolation。这就是 --keepdb 让情况更糟的原因:删库竞争少了,但别的分支的表结构留了下来。
**e2e 跑在别的分支的服务器上(4 次,keepdb 下 5 次)。**最危险的 3 次假绿都在这一类里:C 的 e2e 打到了 A 或 B 的服务器,那边金额格式化是对的,测试就通过了。实际上,60 次 e2e 里有 17 次根本没有启动自己的服务器,用的是别人起的。没变红的那几次,只是运气好,别人的代码恰好满足了断言。
Playwright 文档对 reuseExistingServer 的描述是:如果端口或 URL 上已经有服务器在应答,就直接用它。它只认端口,不认这个服务器是哪个目录起的。有人在真实项目里撞上过同一件事:TruePPM 的一次并行批次里,6 个分支中有 3 个的 Playwright 测到了兄弟分支的构建产物,既有假失败,也有假通过;saasicat 后来专门提交了一次修改,标题是“让 e2e 服务器证明自己服务的是哪个检出”。
我写的套件:分槽位,在服务器上认领,先问 whoami

第一步是 scripts/wt-slot。在工作树里运行一次,它在 ~/.cache/wt-slots/<仓库>/ 下用 set -C(也就是 O_EXCL)创建 N.lease,谁先创建成功,槽位就归谁。然后写出不提交的 .env.wt:
WT_ID=m1:/home/dev/shop/wt-a
WT_SLOT=1
WT_PORT=8010
WT_DB=shop_m1_1
WT_TEST_DB=test_shop_m1_1
settings.py 和 playwright.config.ts 都从这里读端口和库名。主检出是槽位 0,保持原来的 8000 和 shop,CI 也不受影响。有一条规则比分配本身更重要:**在工作树里如果没有 .env.wt,settings 直接报错,不回落到默认值。**代理很少主动去读 README,但报错信息它一定会读,报错里写着“先运行 scripts/wt-slot”。
第二步是数据库认领。我原来以为分了槽位,数据库自然也隔开了。等我模拟两台开发机共用 7 号机上的 Postgres 时,才发现不是这样:每台机器的租约目录都在本机,于是两台机器各自拿到“槽位 1”,库名都是 test_shop_1。图里倒数第三行就是这个情况:10 轮里有 8 轮出错,其中 8 次是 A 的服务器在 C 迁移过的 shop_1 上写订单。
本机的锁看不到另一台机器,所以认领要在数据库服务器上做。wt_db.claim() 先查这个库的 COMMENT:如果不存在,就 CREATE DATABASE。这一步在 Postgres 里只有一方能成功,失败的一方会收到 DuplicateDatabase。成功的一方把 机器:路径|迁移哈希 写进 COMMENT。之后每次使用都要核对:主人不是自己,就拒绝,报错里写明是谁的;主人是自己但迁移哈希变了(比如切了分支),就删掉重建。TEST_RUNNER 换成一个先认领、再用 keepdb 的子类,所以 Django 那条“已存在就删掉”的路径不会再走到。只加认领、库名不带机器号时,错误结论是 0,但 40 次里有 20 次被拒绝运行,每轮都有两次运行被挡下来,报错里写着这个库归谁。库名里加上机器号以后,40 次全对。
第三步是 e2e 的身份检查。reuseExistingServer 我保留了,本地改一行就重新起服务器太慢,代理一小时可能跑几十次 e2e。服务器启动时记下自己的检出路径和全部 .py 文件的哈希,在 DEBUG 下通过 /__whoami 返回。Playwright 的第一个 setup 项目去请求它,和当前目录、当前代码的哈希比对,不一致就让整次运行失败,并写明端口被哪个检出占着,或者“这是你自己的旧服务器,请重启”。
槽位分好以后,还有两种情况只有这一步拦得住,我用 stale.sh 各复现了一次:
- 槽位被回收。
wt-old在 8010 上起了服务器,然后工作树被删掉,进程还在。新的wt-a拿到同一个槽位 1。没有检查时,A 的 e2e 在 C 的代码上跑,收到2.67,变红,代理会去改本来正确的人民币舍入。有检查时,运行直接失败:“8010 由/…/wt-old服务”。 - 自己的旧服务器。
runserver --noreload起来以后,代理改了日元格式化,又补了一个测试。没有检查时,新测试打到旧代码,收到1234.50,变红,代理会以为自己的修改没生效。有检查时,运行失败并提示“代码指纹不一致,请重启”。
加上全部三步以后,三个工作树并行 20 轮,120 次结论全对。每轮耗时大约 13 秒,和什么都不做的并行差不多,依次跑要 26 秒。
我写错的三处
**第一处,C 的 bug 一开始根本不是 bug。**我想用浮点舍入造一个分币误差,写的是 round(10.005, 2),结果 Python 给出 10.01,测试通过了。换成 2.675(在二进制里实际略小于 2.675)才稳定得到 2.67。所以做实验之前,要先单独确认那个“坏分支”在单独跑时确实是坏的。
**第二处,代码指纹一开始太严格。**我最初把 .ts 文件也算进指纹,代理每改一次 e2e 测试,检查就判定“服务器是旧的”,要求重启。可是测试文件改了,服务器并不需要重启。后来只算服务端会加载的 .py 文件。如果你的项目有模板或者前端构建产物,要按服务器实际加载的内容来算。
**第三处,被删掉的工作树把库也占住了。**槽位回收那个实验第一次跑时,新的 wt-a 根本没跑到 e2e:database shop_m1_1 belongs to m1:/…/wt-old。租约文件会检查持有者的目录还在不在,但数据库上的认领不会。后来我加了一条规则:如果认领者是同一台机器、它的路径已经不存在,就视为遗弃,删掉重建。另一台机器的路径在本机看不到,所以跨机器的认领不会被自动回收,只能由 wt-slot --release 或人工清理。我宁可让它报错,也不想让它猜。
什么时候不需要这套
- **每个代理一个容器。**每个工作树一个 devcontainer 或
docker compose -p wt-a,端口和库天然隔开,这其实是更彻底的做法。代价是每台开发机同时跑 N 套 Postgres 和 N 份镜像,冷启动也要几十秒,三个代理再各带一套容器,内存和 CPU 都会吃紧。如果机器资源足够,直接上容器,第一步和第二步都不用做,但第三步还是要做:容器内的服务器同样可能是代理自己没重启的旧进程。 - **SQLite 内存库和不启动服务器的测试。**测试只用
:memory:库、不起 HTTP 服务的项目,第二、三步的问题基本不会出现。 - **CI。**CI 每次都是干净的环境,
reuseExistingServer在 CI 里也是关掉的,所以这套只是给开发机用的。 - **它管不到的情况。**同一个工作树里同时跑两次测试(比如代理开了两个子任务),两次运行会认领到同一个库,这套脚本拦不住;端口段也可能和机器上的其他服务冲突。脚本只解决“不同工作树之间”的问题。
最有力的反对意见是:“本地测试本来就不可信,只信 CI。”这个说法我理解,但它忽略了代理的工作方式:代理每改一步就跑一次本地测试,然后根据结果决定下一步。本地结果一错,它会沿着错误的方向连续改上二三十步,等 CI 把它拦下来,已经浪费了一个小时,还多出一堆不该有的改动交给 4、5 号机审查。CI 能把坏代码挡在合并之前,但挡不住代理在本地被错误的结果带偏。
明天就能照做的四步
- 在每台开发机上
git config wt.machine m1(m2、m3……),把scripts/wt-slot加到代理启动脚本或AGENTS.md的第一步:“进入工作树后先运行scripts/wt-slot”。 - 让 settings 在“工作树里没有
.env.wt”时直接报错,端口、开发库和测试库都从.env.wt读,库名带上机器号。 - 如果数据库在共享服务器上,用
CREATE DATABASE加 COMMENT 认领,把TEST_RUNNER换成先认领、再用 keepdb 的版本。 - 保留
reuseExistingServer,但加一个/__whoami(只在 DEBUG 下开放)和一个 Playwright setup 项目,核对检出路径和代码指纹。在配置旁边写一句注释:“不要改回固定端口”,代理在“简化配置”时会看到。
完整的脚本、玩具仓库、七种跑法的复现脚本和本文的原始结果都在这里:worktree-runtime-kit.tar.gz。需要一个本机 Postgres,再执行 npm i,然后按 README 的顺序跑。各个场景不要同时跑,它们共用同一个 Postgres 和同一组端口。
Tuesday morning, machine 1 has three agents running at once, each in its own Git worktree: wt-a switches CNY to round-half-up, wt-b drops decimals for JPY, and wt-c adds a discount_cents field to orders and touches amount formatting on the way. Ten minutes later three reports arrive almost together:
- A: "Django tests fail with
database "test_shop" does not exist. Checking whether my migration is wrong." - B: "e2e fails:
Expected "1234",Received "1234.50". My JPY formatting doesn't seem to apply, reworking it." - C: "Everything passes, ready for review."
All three are wrong. A's test database was dropped by manage.py test --noinput running on B's side. B's e2e never reached B's server: Playwright saw something answering on port 8000 and reused the server A had started. C really has a bug (2.675 formats as 2.67), and its e2e also ran on someone else's server, so it passed.
Worktrees isolate files. Ports, the dev database, the test database, and the running processes are still shared by all three. A person with three terminals on one machine rarely hits this, because nobody runs the full suite in three directories in the same minute. Agents do, and they act on whatever comes back. What worries me more is that they believe it: a false red sends an agent to fix code that was fine, and a false green sends a broken branch to review. Our machines 4 and 5 do review, and the reviewing agent there gets "everything passes" with no reason to doubt it.
My conclusion: giving each worktree a slot (its own port, dev DB and test DB) is only the first step. Two more things are needed: claim each resource on the machine where it lives, and before e2e starts, confirm that the server answering belongs to this worktree and runs this code. Below are measurements from a toy repo, a kit you can copy, and three places where I got it wrong myself.
The experiment: three branches, seven ways to run them
I built a small shop with Django 5.2, Postgres 17 and Playwright 1.64: /api/quote formats amounts and /api/orders writes orders. The true verdict for each branch is fixed in advance:
feat/cny-half-up(A): correct, both suites should pass;feat/jpy(B): correct, both suites should pass;feat/quote-discount(C): a real bug, both suites should fail, and they must fail because of that bug (receiving2.67). Going red for any other reason also counts as wrong.
Each "agent" is a thread: it waits a random 0–2.5 s, then runs python manage.py test --noinput and npx playwright test in random order. The Playwright config is the one many projects use by default: fixed port 8000, a webServer that runs migrate and then runserver, and reuseExistingServer: !process.env.CI. The dev DB is shop and the test DB is Django's default test_shop. To give migrate enough time to overlap with others, the catalog app has 80 migrations. Before each round every runserver is killed and every shop* database is dropped.
Every run is classified from its log: right verdict, false red, false green, red but not for its own bug, or my scripts refusing to run. The results:

Run one after another, all 18 runs are right, so the tests themselves are fine. Three worktrees in parallel with nothing changed: 47 of 120 runs gave the wrong verdict, made up of 40 false reds, 3 false greens and 4 reds for the wrong reason. None of the 20 rounds was clean. In other words, whenever three agents test at the same time, at least one of them almost always gets a wrong verdict.
Someone will say: just add --keepdb so Django stops dropping the DB. I tried that too. Wrong verdicts went up from 47 to 59.
Seven sources of wrong verdicts
I classified each log by its first fatal error:

The test DB dropped by someone else (12). When Django finds test_shop already exists and --noinput is set, it doesn't ask; it prints "Destroying old test database" and drops it. Another agent may be using it. That line appears in 31 Django logs. The side that lost its DB sees database "test_shop" does not exist, and the agent usually suspects its own migration.
Two agents creating the test DB at once (8). Both see no DB, both run CREATE DATABASE, and the later one gets already exists.
The reused server shut down by its owner (11). This is the sneakiest part of reuseExistingServer. B's e2e reuses the server A started. A's tests finish first, Playwright shuts down the server it started, and halfway through B gets ECONNREFUSED. B sees "the server crashed" and goes looking for a crash in its own code.
Port free at check, taken at bind (5). Playwright probes port 8000, gets no answer, and starts a server. migrate takes a second, and in that second another agent's server binds first: That port is already in use. The author of falq describes what real agents do with this error: hardcode the port to 3001. The next time all three agents have switched to 3001, the problem is back on a different port.
Two migrates altering one DB at once (5, and 13 with keepdb). All three webServers run migrate against shop, the same AddField runs twice, and one gets DuplicateColumn.
Another branch's schema in the DB (24 with keepdb). With --keepdb nothing is dropped, so the discount_cents NOT NULL column added by C's migration stays in the shared test_shop. A's and B's code doesn't know about that column, and inserting an order fails with NotNullViolation. That is why --keepdb made things worse: fewer drop races, but other branches' schemas stay behind.
e2e ran on another branch's server (4, and 5 with keepdb). The 3 most dangerous results, the false greens, are all in this group: C's e2e hit A's or B's server, whose amount formatting was correct, so the test passed. In fact, 17 of the 60 e2e runs never started their own server and used someone else's. The ones that didn't go red were just lucky: the other code happened to satisfy the assertions.
The Playwright docs describe reuseExistingServer this way: if a server is already answering on the port or URL, use it. It only checks the port, not which directory that server was started from. Real projects have hit exactly this. In one parallel batch at TruePPM, 3 of 6 branches had their Playwright tests hit a sibling branch's build, producing both false failures and false passes. saasicat later landed a commit titled "Make the e2e servers prove which checkout they serve".
My kit: allocate slots, claim on the server, ask whoami first

Step one is scripts/wt-slot. Run once inside a worktree, it creates N.lease under ~/.cache/wt-slots/<repo>/ with set -C (that is, O_EXCL); whoever creates the file first owns the slot. It then writes a git-ignored .env.wt:
WT_ID=m1:/home/dev/shop/wt-a
WT_SLOT=1
WT_PORT=8010
WT_DB=shop_m1_1
WT_TEST_DB=test_shop_m1_1
settings.py and playwright.config.ts read the port and DB names from it. The main checkout is slot 0 and keeps the old 8000 and shop, and CI is unaffected. One rule matters more than the allocation itself: inside a worktree without .env.wt, settings raises an error instead of falling back to the defaults. Agents rarely read the README on their own, but they always read error messages, and this one says "run scripts/wt-slot first".
Step two is claiming databases. I assumed that once slots were allocated, databases were isolated too. When I simulated two dev machines sharing the Postgres on machine 7, that turned out to be false: each machine keeps its lease directory locally, so both machines get "slot 1" and both use test_shop_1. That is the third row from the bottom in the chart: wrong verdicts in 8 of 10 rounds, 8 of them from A's server writing orders into a shop_1 that C had migrated.
A local lock can't see another machine, so the claim has to happen on the database server. wt_db.claim() first checks the DB's COMMENT. If the DB doesn't exist, it runs CREATE DATABASE; in Postgres only one caller can succeed, and the other gets DuplicateDatabase. The winner writes machine:path|migration-hash into the COMMENT. Every later use checks it: if the owner isn't you, refuse, and say whose it is; if it's yours but the migration hash changed (say, after switching branches), drop and recreate. The TEST_RUNNER becomes a subclass that claims first and then uses keepdb, so Django's "exists, drop it" path is never reached. With the claim but without the machine in the DB name, wrong verdicts drop to 0, but 20 of 40 runs are refused: in every round two runs are stopped, with an error naming the owner. With the machine in the DB name, 40 of 40 are right.
Step three is checking who the e2e server is. I kept reuseExistingServer: restarting the server after every one-line change is too slow, and an agent may run e2e dozens of times an hour. At startup the server records its checkout path and a hash of all its .py files, and returns them from /__whoami under DEBUG. The first Playwright setup project requests it and compares with the current directory and the current code's hash. On a mismatch the whole run fails, saying which checkout holds the port, or "this is your own old server, restart it".
With slots in place, there are still two cases only this step catches. I reproduced each once with stale.sh:
- A recycled slot.
wt-oldstarted a server on 8010, then its worktree was deleted while the process kept running. The newwt-agets the same slot 1. Without the check, A's e2e runs against C's code, receives2.67, goes red, and the agent sets out to change CNY rounding that was correct. With the check, the run fails immediately: "8010 is served by/…/wt-old". - Your own old server. After
runserver --noreloadstarted, the agent changed JPY formatting and added a test. Without the check, the new test hits the old code, receives1234.50, goes red, and the agent concludes its change didn't take. With the check, the run fails with "code fingerprint mismatch, restart it".
With all three steps, three worktrees in parallel for 20 rounds gave 120 of 120 right verdicts. Each round takes about 13 seconds, about the same as parallel with nothing done; running one after another takes 26.
Three places I got it wrong
First, C's bug was not a bug at first. I wanted float rounding to lose a cent and wrote round(10.005, 2). Python returned 10.01 and the test passed. Switching to 2.675 (which in binary is slightly below 2.675) reliably gave 2.67. Before running an experiment like this, confirm that the "bad branch" really fails when run alone.
Second, the code fingerprint was too strict at first. I originally included .ts files, so every time the agent edited an e2e test, the check declared the server stale and demanded a restart. But changing a test file doesn't require restarting the server. I now hash only the .py files the server loads. If your project has templates or front-end build output, hash whatever the server actually loads.
Third, a deleted worktree kept its databases claimed. The first time I ran the recycled-slot case, the new wt-a never got to its e2e: database shop_m1_1 belongs to m1:/…/wt-old. The lease file checks whether its holder's directory still exists, but the DB claim did not. I added a rule: if the claimant is the same machine and its path no longer exists, treat it as abandoned and drop and recreate it. Another machine's paths can't be seen locally, so cross-machine claims are never reclaimed automatically; that is left to wt-slot --release or a person. I'd rather it error out than guess.
When you don't need this
- One container per agent. A devcontainer or
docker compose -p wt-aper worktree isolates ports and DBs by construction, and is the more thorough approach. The cost is N Postgres instances and N images per dev machine, cold starts of tens of seconds, and with three agents each carrying a stack, memory and CPU get tight. If your machines can afford it, use containers and skip steps one and two. Keep step three: the server inside a container can just as well be an old process the agent never restarted. - SQLite in-memory DBs and tests without a server. For projects whose tests only use
:memory:and never start an HTTP server, the problems behind steps two and three mostly don't occur. - CI. CI starts clean every time and
reuseExistingServeris off there, so this kit is for dev machines only. - What it doesn't cover. Two test runs at the same time in the same worktree (say, an agent spawning two subtasks) claim the same DB, and this kit won't stop that. The port range may also collide with other services on the machine. The scripts only solve the problem between different worktrees.
The strongest objection: "Local tests can't be trusted anyway; only trust CI." I understand it, but it ignores how agents work. An agent runs local tests after each step and decides the next step from the result. Once a local result is wrong, it keeps changing things in the wrong direction for twenty or thirty steps, and by the time CI stops it, an hour is gone and machines 4 and 5 have a pile of unneeded changes to review. CI keeps bad code from being merged, but it can't keep an agent from being misled by a wrong local result.
Four steps you can do tomorrow
- On each dev machine run
git config wt.machine m1(m2, m3, ...), and makescripts/wt-slotthe first step in the agent launcher or inAGENTS.md: "after entering a worktree, runscripts/wt-slotfirst". - Make settings raise an error when a worktree has no
.env.wt, read the port, dev DB and test DB from.env.wt, and put the machine in DB names. - If the DB lives on a shared server, claim it with
CREATE DATABASEplus a COMMENT, and switchTEST_RUNNERto a version that claims first and then uses keepdb. - Keep
reuseExistingServer, but add a/__whoami(only under DEBUG) and a Playwright setup project that checks the checkout path and code fingerprint. Next to the config, add a comment: "do not change this back to a fixed port". An agent "simplifying the config" will see it.
All scripts, the toy repo, the reproduction scripts for the seven ways of running, and the raw results behind this post: worktree-runtime-kit.tar.gz. You need a local Postgres; run npm i, then follow the README. Don't run scenarios at the same time: they share one Postgres and one set of ports.