Nicholas Clooney

我给编程 agent 做了一个共享 worktree 池

所属系列

最近和 Claude、Codex 一起做 Stone Age 重制版,非常开心。多数时候都有几个 agent 同时工作:一个做 UI 页面,一个追查导入器 bug,还有几个子 agent 做研究或清理。每个 agent 都在自己的 git worktree 中工作,谁也不会踩到别人的检出目录。

它们都通过 tmux-agents 运行,这是我昨天发布的小工具,项目页上也有。每个 Claude 或 Codex 子 agent 都有自己的 tmux 窗格,我可以看着它们工作,需要我时介入,也能让它们互相交接任务。正是它,让同时运行这么多 agent 变得轻松;而这么多 worktree 会堆起来,也有它的一份原因。

Claude 的请求抵达 Codex 窗格 Codex 的回复返回 Claude 窗格 tmux-agents 列表展示子 agent 的状态、父级,以及所选会话的实时预览
Claude 用 tmux-ask 向 Codex 提问,回复以新消息的形式返回;prefix + a 列出所有子 agent 并提供实时预览。

这一部分运作得很好。我没想过的是,它们会留下什么。

一不留神,磁盘就满了

项目带着大约 8GB 的游戏资源。worktree 对 git 来说成本很低,但这个项目每次新检出,都要一份自己的资源副本,再加一份自己的 Godot 导入缓存。agent 创建 worktree,做完任务,就走了。没人清理,agent 没有,说实话,我也没有。

于是 worktree 越积越多,直到磁盘空间告急。今天早上,我打开 DaisyDisk 清理了一遍。估计删掉了 300 到 400GB 的 worktree。这只是估计,不是仔细测量的结果。

并行工作容易,清理才是真正的活

把并行任务交给 agent 很容易,每个 agent 工具都能用一条命令或一句指令做到。但没人替你制定这些任务所消耗资源的规则。每个 agent 都做出了局部合理的选择:“我需要一个隔离的检出目录,那就建一个。”这些选择加起来,就把磁盘装满了。

这改变了我对这套工作流的看法。如果我想继续在自己在意的项目上同时运行多个 agent,那么管理它们留下的东西就是工作流的一部分,不能事后才想起。这和人与人合作一样:一个只创建分支、从不删除分支的团队,迟早会被分支淹没。

规则:借一个位置,用完归还

所以,我给项目中的所有 agent 加了一条规则:不要创建 worktree,从池里借一个。

这个池是一个小 Python 脚本,维护一组固定、可复用的 worktree 槽位,pool-a、pool-b 等。agent 为某个分支申请槽位,完成工作,在分支合并后释放。下一个 agent 拿到同一个目录时,环境已经准备好了。

几个细节让它真正可用:

  • 从小规模开始,自动增长,但有上限。 池最初有五个槽位,全部忙碌时会继续创建,最多十个。超过十个就停止,告诉 agent 来问我。我同意后,它再带 --approved-by-user 重新运行。脚本无法验证到底是谁批准的。它是一个减速带,把“悄悄再建一个 8GB 副本”变成一个问题,而不是安全边界。
  • 租约保存在 git 公共目录中。 每个 worktree 共享仓库的 .git 目录,因此其中的租约文件对所有 worktree 可见,又不会被 git 跟踪。每次获取和释放都加文件锁,防止两个 agent 同时抢到一个槽位。
  • 释放条件刻意设得严格。 槽位里有未提交修改,或分支尚未合并,就拒绝释放。agent 可以传入 --abandon,明确放弃未合并的工作;无论如何,分支及其提交都会保留。

共享大目录

复用槽位可以阻止 worktree 数量不断增长。另一半工作,是让每个槽位足够便宜。

多数任务只读取资源,所以在我的项目中,只读目录以及通常情况下的游戏资源,都通过符号链接指回主检出目录。八个槽位指向同一份资源,几乎不额外占空间。

有些任务确实需要修改资源,例如调整导入器。这时 agent 会申请独立副本,脚本则创建 APFS 写时复制克隆,也就是 macOS 上的 cp -c。克隆在实际发生变化前,与原件共享磁盘块,因此创建很快,空间只会随任务修改的内容增长。每个槽位的 Godot 导入缓存也采用相同方式,只要主检出目录的缓存更新,就刷新它。

下面是在临时仓库中的一小段操作:

$ worktree_pool.py acquire --owner claude --branch feat/inventory --task "inventory screen"
~/projects/game.worktrees/pool-a
$ worktree_pool.py acquire --owner codex --branch fix/sprite-import --task "fix sprite importer" --own assets/raw
~/projects/game.worktrees/pool-b
$ worktree_pool.py status
pool-a  claude         feat/inventory             0.0h  inventory screen
pool-b  codex          fix/sprite-import          0.0h  fix sprite importer
pool-c  free (created on first use)
pool-d  free (created on first use)
pool-e  free (created on first use)
pool: 2 created, 2 leased; grows by itself up to 10, more needs a human
$ worktree_pool.py release pool-b
fix/sprite-import is not merged into main; merge it first, or rerun with --abandon (the branch and its commits stay).
$ git merge fix/sprite-import && worktree_pool.py release pool-b
released pool-b (branch fix/sprite-import kept)

agent 从 acquire 获得槽位路径,再 cd 进去。我在项目的 AGENTS.md 中加了一小节,告诉 Claude 和 Codex 始终通过这个池操作,而且要先问我,不能自行传入 --approved-by-user。

一个可以自行改造的版本

最初的脚本专为这个项目定制:macOS、Godot,以及硬编码的目录名。为这篇文章,我做了一个更通用的版本。它只有一个文件,除了 Python 3.8 和 git,没有其他依赖,会读取随项目提交的一个小型 worktree-pool.json:

{
  "symlink": ["references"],
  "clone": [".cache/import"],
  "symlink_unless_owned": ["assets/raw"]
}
  • symlink:每个槽位都只读、不写的目录。
  • clone:每个槽位都需要独立副本的目录,比如构建或导入缓存。
  • symlink_unless_owned:默认共享,任务通过 --own PATH 申请时才复制。

复制在 macOS 上使用 APFS 克隆,在支持的 Linux 环境中使用 reflink,否则使用普通复制。基础分支从远程仓库检测,池位于检出目录旁边的 <repo>.worktrees/。运行 worktree_pool.py init 可以得到示例配置。

测试时碰到一个坑:所有配置的目录都必须被 gitignore 忽略,而且匹配规则也得能匹配符号链接。带尾斜杠的 assets/ 只匹配目录,所以 git 会把每个槽位中的符号链接报告为新文件。要写成 assets。遇到这种情况,脚本会发出警告。

		
  1. #!/usr/bin/env python3
  2. """A shared pool of git worktrees for coding agents (and their sub-agents).
  3. Agents borrow a slot instead of creating a fresh worktree each time. Slots are
  4. reused between tasks, so the expensive parts of a checkout are paid for once:
  5. symlink ignored folders every slot reads but never writes
  6. (vendored tools, reference material, big assets)
  7. clone ignored folders every slot gets its own copy of,
  8. refreshed when the main checkout's copy changes
  9. (build or import caches)
  10. symlink_unless_owned symlinked by default; a task that needs to write to
  11. one asks for its own copy with --own PATH
  12. Copies are copy-on-write where the filesystem allows it (APFS clones on macOS,
  13. reflinks on Btrfs/XFS on Linux) and plain copies elsewhere.
  14. The pool starts at `start` slots and grows by itself up to `max`. Past `max`
  15. it refuses and tells the agent to ask a human, then rerun with
  16. --approved-by-user. The script does not check who gave that approval; it is a
  17. speed bump, not an access control.
  18. The config is committed with the project, in the main checkout. Leases live
  19. in the git common dir (usually .git/), which every worktree shares and git
  20. never tracks:
  21. worktree-pool.json config (optional; `init` writes an example)
  22. .git/worktree-pool-leases.json who holds which slot
  23. .git/worktree-pool.lock flock that serialises concurrent agents
  24. Every configured path must be gitignored, and the pattern must match a
  25. symlink too: write `assets`, not `assets/` (a trailing slash matches folders only).
  26. worktree_pool.py init
  27. worktree_pool.py status
  28. worktree_pool.py acquire --owner claude --branch feat/x --task "..."
  29. worktree_pool.py acquire ... --own assets/raw # this task writes to assets/raw
  30. worktree_pool.py release pool-b # branch merged into base
  31. worktree_pool.py release pool-b --abandon # drop unmerged work on purpose
  32. Requires Python 3.8+, git 2.23+ and a Unix-like OS (it uses fcntl).
  33. """
  34. import argparse
  35. import fcntl
  36. import json
  37. import os
  38. import shutil
  39. import string
  40. import subprocess
  41. import sys
  42. import time
  43. from contextlib import contextmanager
  44. from pathlib import Path
  45. DEFAULTS = {
  46. 'base': None, # None: origin's default branch, else main, else master
  47. 'pool_dir': None, # None: <repo>.worktrees next to the main checkout
  48. 'start': 5,
  49. 'max': 10,
  50. 'stale_hours': 12,
  51. 'symlink': [],
  52. 'clone': [],
  53. 'symlink_unless_owned': [],
  54. }
  55. EXAMPLE = dict(DEFAULTS, symlink=['node_modules'], clone=['.cache/build'],
  56. symlink_unless_owned=['assets/raw'])
  57. def git(*args, cwd=None, check=True):
  58. result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
  59. if check and result.returncode != 0:
  60. raise SystemExit('git %s failed: %s' % (' '.join(args), result.stderr.strip()))
  61. return result.stdout.strip()
  62. def git_ok(*args, cwd=None) -> bool:
  63. return subprocess.run(['git', *args], cwd=cwd, capture_output=True).returncode == 0
  64. def main_checkout() -> Path:
  65. """The first entry of `git worktree list` is always the main working tree."""
  66. first = git('worktree', 'list', '--porcelain').splitlines()[0]
  67. return Path(first[len('worktree '):])
  68. COMMON = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir'))
  69. MAIN = main_checkout()
  70. CONFIG = MAIN / 'worktree-pool.json'
  71. STATE = COMMON / 'worktree-pool-leases.json'
  72. LOCK = COMMON / 'worktree-pool.lock'
  73. def load_config(path: Path, resolve_base=True) -> dict:
  74. config = dict(DEFAULTS)
  75. if path.exists():
  76. config.update(json.loads(path.read_text()))
  77. if not config['base'] and resolve_base:
  78. config['base'] = default_base()
  79. config['pool_dir'] = (MAIN / config['pool_dir']) if config['pool_dir'] else MAIN.parent / (MAIN.name + '.worktrees')
  80. return config
  81. def default_base() -> str:
  82. remote = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD', cwd=MAIN, check=False)
  83. if remote.startswith('origin/'):
  84. return remote[len('origin/'):]
  85. for name in ('main', 'master'):
  86. if git_ok('show-ref', '--verify', '--quiet', 'refs/heads/' + name, cwd=MAIN):
  87. return name
  88. raise SystemExit('could not guess the base branch; set "base" in %s' % CONFIG)
  89. def check_ignored(config: dict) -> None:
  90. """Symlinks or copies of tracked paths would show up as changes in every slot."""
  91. for key in ('symlink', 'clone', 'symlink_unless_owned'):
  92. for rel in config[key]:
  93. if not git_ok('check-ignore', '-q', rel, cwd=MAIN):
  94. raise SystemExit('%s (from "%s") is not gitignored in %s; ignore it first.' % (rel, key, MAIN))
  95. @contextmanager
  96. def locked():
  97. with open(LOCK, 'w') as handle:
  98. fcntl.flock(handle, fcntl.LOCK_EX)
  99. try:
  100. yield
  101. finally:
  102. fcntl.flock(handle, fcntl.LOCK_UN)
  103. def load() -> dict:
  104. if STATE.exists():
  105. return json.loads(STATE.read_text())
  106. return {'created': [], 'slots': {}, 'clones': {}}
  107. def save(state: dict) -> None:
  108. tmp = STATE.with_suffix('.tmp')
  109. tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2) + '\n')
  110. tmp.replace(STATE)
  111. def slot_names(count: int) -> list:
  112. return ['pool-' + string.ascii_lowercase[i] for i in range(count)]
  113. def registered_worktrees() -> set:
  114. paths = set()
  115. for line in git('worktree', 'list', '--porcelain').splitlines():
  116. if line.startswith('worktree '):
  117. paths.add(str(Path(line[len('worktree '):]).resolve()))
  118. return paths
  119. def link(path: Path, target: Path) -> None:
  120. """Points path at target, replacing a stale link or an old copy."""
  121. if path.is_symlink():
  122. if Path(os.readlink(path)) == target:
  123. return
  124. path.unlink()
  125. elif path.is_dir():
  126. shutil.rmtree(path)
  127. elif path.exists():
  128. path.unlink()
  129. path.parent.mkdir(parents=True, exist_ok=True)
  130. path.symlink_to(target)
  131. def clone(source: Path, dest: Path) -> None:
  132. """Copy-on-write copy where the filesystem supports it, a plain copy otherwise."""
  133. if dest.is_symlink() or dest.is_file():
  134. dest.unlink()
  135. elif dest.exists():
  136. shutil.rmtree(dest)
  137. dest.parent.mkdir(parents=True, exist_ok=True)
  138. flag = '-c' if sys.platform == 'darwin' else '--reflink=auto'
  139. if subprocess.run(['cp', flag, '-R', str(source), str(dest)], capture_output=True).returncode == 0:
  140. return
  141. if dest.exists():
  142. shutil.rmtree(dest)
  143. shutil.copytree(source, dest, symlinks=True)
  144. def fingerprint(folder: Path) -> float:
  145. """Cheap change marker: newest mtime of the folder and its direct children."""
  146. times = [folder.stat().st_mtime]
  147. times += [child.lstat().st_mtime for child in folder.iterdir()]
  148. return max(times)
  149. def prepare(config: dict, state: dict, name: str, owned: list) -> None:
  150. """Makes the slot's ignored folders match the config."""
  151. path = config['pool_dir'] / name
  152. stamps = state.setdefault('clones', {}).setdefault(name, {})
  153. for rel in config['symlink'] + [r for r in config['symlink_unless_owned'] if r not in owned]:
  154. if (MAIN / rel).exists():
  155. link(path / rel, MAIN / rel)
  156. stamps.pop(rel, None)
  157. for rel in config['clone'] + owned:
  158. source = MAIN / rel
  159. if not source.exists():
  160. continue
  161. dest = path / rel
  162. current = fingerprint(source)
  163. if dest.is_symlink() or not dest.exists() or stamps.get(rel, -1.0) < current:
  164. clone(source, dest)
  165. stamps[rel] = current
  166. def dirty(path: Path) -> list:
  167. out = git('status', '--porcelain', cwd=path)
  168. return [line for line in out.splitlines() if line.strip()]
  169. def cmd_init(args, _config) -> None:
  170. if args.config.exists():
  171. raise SystemExit('%s already exists.' % args.config)
  172. args.config.write_text(json.dumps(EXAMPLE, indent=2) + '\n')
  173. print('wrote %s; edit the folder lists to match your project, then commit it.' % args.config)
  174. def cmd_status(_args, config) -> None:
  175. state = load()
  176. created = state.get('created', [])
  177. now = time.time()
  178. for name in slot_names(max(config['start'], len(created))):
  179. lease = state['slots'].get(name)
  180. if not lease:
  181. print('%-7s free%s' % (name, '' if name in created else ' (created on first use)'))
  182. continue
  183. hours = (now - lease['since']) / 3600
  184. stale = ' STALE?' if hours > config['stale_hours'] else ''
  185. print('%-7s %-14s %-24s %5.1fh %s%s' % (name, lease['owner'], lease['branch'], hours, lease.get('task', ''), stale))
  186. print('pool: %d created, %d leased; grows by itself up to %d, more needs a human' % (len(created), len(state['slots']), config['max']))
  187. def cmd_acquire(args, config) -> None:
  188. owned = [str(Path(rel)) for rel in args.own]
  189. for rel in owned:
  190. if rel not in config['symlink_unless_owned']:
  191. raise SystemExit('--own %s: only paths listed in "symlink_unless_owned" can be owned.' % rel)
  192. check_ignored(config)
  193. base = args.base or config['base']
  194. with locked():
  195. state = load()
  196. created = state.setdefault('created', [])
  197. free = [name for name in created if name not in state['slots']]
  198. if free:
  199. chosen = free[0]
  200. else:
  201. if len(created) >= config['max'] and not args.approved_by_user:
  202. cmd_status(args, config)
  203. raise SystemExit('pool is full (%d slots, all leased). Growing past %d needs a human: ask them, then rerun with --approved-by-user.' % (len(created), config['max']))
  204. if len(created) >= len(string.ascii_lowercase):
  205. raise SystemExit('pool is at %d slots, the most this script names.' % len(created))
  206. chosen = slot_names(len(created) + 1)[-1]
  207. path = config['pool_dir'] / chosen
  208. if str(path.resolve()) not in registered_worktrees():
  209. if path.exists():
  210. raise SystemExit('%s exists but is not a registered worktree; inspect it before reusing.' % path)
  211. config['pool_dir'].mkdir(parents=True, exist_ok=True)
  212. git('worktree', 'add', '--detach', str(path), config['base'], cwd=MAIN)
  213. if chosen not in created:
  214. created.append(chosen)
  215. save(state)
  216. leftover = dirty(path)
  217. if leftover:
  218. raise SystemExit('%s has uncommitted changes from an earlier lease:\n%s' % (chosen, '\n'.join(leftover[:20])))
  219. if git_ok('show-ref', '--verify', '--quiet', 'refs/heads/' + args.branch, cwd=MAIN):
  220. git('switch', args.branch, cwd=path)
  221. else:
  222. git('switch', '-c', args.branch, base, cwd=path)
  223. prepare(config, state, chosen, owned)
  224. state['slots'][chosen] = {'owner': args.owner, 'branch': args.branch, 'base': base,
  225. 'task': args.task, 'since': time.time(), 'owned': owned}
  226. save(state)
  227. unignored = dirty(path)
  228. if unignored:
  229. print('warning: git sees the pool\'s links as changes in %s:\n%s\n'
  230. 'A pattern with a trailing slash ("assets/") matches folders, not symlinks; drop the slash.'
  231. % (chosen, '\n'.join(unignored[:20])), file=sys.stderr)
  232. print(path)
  233. def resolve_slot(text: str, state: dict) -> str:
  234. name = Path(text).name
  235. if name not in state['slots']:
  236. raise SystemExit('%s is not leased (see status).' % name)
  237. return name
  238. def cmd_release(args, config) -> None:
  239. with locked():
  240. state = load()
  241. name = resolve_slot(args.slot, state)
  242. lease = state['slots'][name]
  243. base = lease.get('base', config['base'])
  244. path = config['pool_dir'] / name
  245. if path.exists():
  246. leftover = dirty(path)
  247. if leftover and not args.abandon:
  248. raise SystemExit('%s has uncommitted changes; commit them, or rerun with --abandon to drop them:\n%s' % (name, '\n'.join(leftover[:20])))
  249. merged = git_ok('merge-base', '--is-ancestor', lease['branch'], base, cwd=MAIN)
  250. if not merged and not args.abandon:
  251. raise SystemExit('%s is not merged into %s; merge it first, or rerun with --abandon (the branch and its commits stay).' % (lease['branch'], base))
  252. if leftover:
  253. git('reset', '--hard', cwd=path)
  254. git('clean', '-fd', cwd=path)
  255. git('switch', '--detach', config['base'], cwd=path)
  256. prepare(config, state, name, [])
  257. del state['slots'][name]
  258. save(state)
  259. print('released %s (branch %s kept)' % (name, lease['branch']))
  260. def main() -> None:
  261. parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
  262. parser.add_argument('--config', type=Path, default=CONFIG, help='config file (default: %(default)s)')
  263. sub = parser.add_subparsers(dest='command', required=True)
  264. sub.add_parser('init', help='write an example config')
  265. sub.add_parser('status', help='show slots and leases')
  266. acquire = sub.add_parser('acquire', help='lease a slot and switch it to a branch')
  267. acquire.add_argument('--owner', required=True, help='agent name, e.g. codex-1')
  268. acquire.add_argument('--branch', required=True)
  269. acquire.add_argument('--base', help='branch to start a new branch from (default: config base)')
  270. acquire.add_argument('--task', default='', help='one line saying what the lease is for')
  271. acquire.add_argument('--own', action='append', default=[], metavar='PATH',
  272. help='copy this "symlink_unless_owned" path instead of linking it; repeatable')
  273. acquire.add_argument('--approved-by-user', action='store_true',
  274. help='grow past the max; only after a human said yes (not verified)')
  275. release = sub.add_parser('release', help='return a slot to the pool')
  276. release.add_argument('slot', help='pool-<letter> or its path')
  277. release.add_argument('--abandon', action='store_true', help='release although unmerged or dirty (drops uncommitted changes)')
  278. args = parser.parse_args()
  279. config = load_config(args.config, resolve_base=args.command != 'init')
  280. {'init': cmd_init, 'status': cmd_status, 'acquire': cmd_acquire, 'release': cmd_release}[args.command](args, config)
  281. if __name__ == '__main__':
  282. main()

需要说清楚这个版本的成熟度:通用版本刚写出来。我在 macOS 的临时仓库中测试过,但没有在 Linux 上运行过,也没经过大量 agent 并发压力测试,更没有长期使用到足以评价可靠性的程度。300 到 400GB 是我手动删除的量,不是测得的池化节省空间。我认为这个思路在我的项目之外也有用,但目前还没有证明。

关于 agent 基础设施,我学到了什么

我一开始并不是想设计 agent 框架。我是想做游戏,而 agent 在我热爱的项目里制造了一个真实问题。最后的解决办法很小,也很朴素:租约文件、一把锁、几个符号链接,以及一条规则。

这大概就是教训。agent 很擅长完成眼前的任务,却很不擅长察觉任务给其他人带来的成本。磁盘只是第一个让我切身体会到这一点的资源。能长期持续的工作流,会让共享资源,包括磁盘、端口、模拟器、API 预算,都有负责人、有上限,也有归还方式。