Nicholas Clooney

I Gave My Coding Agents a Shared Worktree Pool

Part of a series

I've been having a lot of fun building my Stone Age remake with Claude and Codex. Most days there are several agents going at once: one on a UI screen, one chasing an importer bug, a couple of sub-agents doing research or cleanup. Each of them works in its own git worktree, so nobody steps on anybody else's checkout.

They all run through tmux-agents, the little tool I shipped yesterday (it's on my projects page too). Every Claude or Codex sub-agent gets its own tmux pane, so I can watch them all work, step in when one needs me, and let them hand tasks to each other. It's what makes running this many agents at once feel easy, which is also part of how so many worktrees piled up.

A request from Claude arriving in Codex's pane Codex's reply arriving back in Claude's pane The tmux-agents list showing sub agents with their status and parent, and a live preview of the selected session
Claude asks Codex with tmux-ask, the reply comes back as a new message, and prefix + a lists every sub agent with a live preview.

That part worked well. The part I hadn't thought about was what they left behind.

The disk filled up while I wasn't looking

The project carries roughly 8GB of game assets. A worktree is cheap for git, but every fresh checkout of this project also wants its own copy of those assets, plus its own Godot import cache. Agents create a worktree, do their task, and move on. Nobody cleans up. Not the agents, and honestly, not me either.

So the worktrees piled up until my disk ran low. This morning I opened DaisyDisk and cleared them out. My estimate is that I removed somewhere between 300 and 400GB of worktrees. That's an estimate, not a careful measurement.

Parallel work is easy, cleanup is the real job

Handing agents parallel tasks is the easy part. Every agent tool makes it one command or one instruction away. What nobody hands you is a rule for the resources those tasks consume. Each agent made a locally sensible choice ("I need an isolated checkout, I'll make one") and the sum of those choices was a full disk.

That changed how I think about this workflow. If I want to keep running several agents on a project I care about, then managing what they leave behind is part of the workflow, not an afterthought. It's the same as with people: a team that only ever creates branches and never deletes them eventually drowns in them.

The rule: borrow a slot, give it back

So I introduced one rule for every agent on the project: you don't create worktrees, you borrow one from the pool.

The pool is a small Python script. It keeps a fixed set of reusable worktree slots (pool-a, pool-b, ...). An agent acquires a slot for a branch, does its work, and releases the slot when the branch is merged. The next agent gets the same folder back, already set up.

A few details make it work in practice:

  • It starts small and grows on its own, up to a limit. The pool starts with five slots and creates more when every slot is busy, up to ten. Past ten it stops and tells the agent to ask me. If I say yes, the agent reruns with --approved-by-user. The script can't verify who actually approved it. It's a speed bump that turns "quietly make another 8GB copy" into a question, not a security boundary.
  • Leases live in the git common directory. Every worktree shares the repo's .git directory, so the lease file there is visible from all of them, and git never tracks it. A file lock around every acquire and release keeps two agents from grabbing the same slot at the same time.
  • Release is strict on purpose. A slot with uncommitted changes, or a branch that isn't merged yet, is refused. An agent can pass --abandon to drop unmerged work deliberately, and the branch and its commits stay either way.

Sharing the heavy folders

Reusing slots stops the number of worktrees from growing. The other half is making each slot cheap.

Most tasks only read the assets, so in my project the read-only folders, and normally the game assets too, are symlinks back to the main checkout. Eight slots pointing at one copy of the assets cost almost nothing.

Some tasks do need to write to the assets, for example when I'm changing an importer. For those, the agent asks for its own copy, and the script makes an APFS copy-on-write clone (cp -c on macOS). The clone shares disk blocks with the original until something actually changes, so it's fast to create and only grows by what the task modifies. The Godot import cache gets the same treatment in every slot, refreshed whenever the main checkout's cache is newer.

Here's a short session from a throwaway repo:

$ 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)

Agents get the slot's path from acquire and cd there. I added a short section to the project's AGENTS.md telling Claude and Codex to always go through the pool, and to ask me rather than pass --approved-by-user on their own.

A version you can adapt

My original script is tailored to this one project: macOS, Godot, and hardcoded folder names. For this post I made a more general version. It's one file with no dependencies beyond Python 3.8 and git, and it reads a small worktree-pool.json that you commit with your project:

{
  "symlink": ["references"],
  "clone": [".cache/import"],
  "symlink_unless_owned": ["assets/raw"]
}
  • symlink: folders every slot reads but never writes.
  • clone: folders every slot gets its own copy of, like build or import caches.
  • symlink_unless_owned: shared by default, copied when a task asks with --own PATH.

Copies use APFS clones on macOS, reflinks where Linux supports them, and plain copies otherwise. The base branch is detected from your remote, and the pool lives next to your checkout in <repo>.worktrees/. Run worktree_pool.py init to get an example config.

One gotcha I hit while testing: every configured folder has to be gitignored, and the pattern has to match a symlink too. assets/ with a trailing slash only matches directories, so git reports the symlink in each slot as a new file. Write assets instead. The script warns you when this happens.

		
  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()

To be clear about what this is: the general version is new. I've exercised it in scratch repos on macOS, but I haven't run it on Linux, or under a heavy load of concurrent agents, or for long enough to say anything about its reliability. The 300 to 400GB is what I deleted by hand, not a saving I've measured from the pool. I think the approach is useful well beyond my project, but I haven't shown that yet.

What I learned about agent infrastructure

I didn't set out to design an agent framework. I set out to make a game, and my agents created a real problem in a project I love working on. The fix turned out to be small and boring: a lease file, a lock, some symlinks, and one rule.

That's probably the lesson. Agents are very good at doing the task in front of them and very bad at noticing what their task costs everyone else. Disk is just the first resource where I felt it. The workflows that last will be the ones where the shared resources (disk, ports, simulators, API budgets) have an owner, a limit, and a way to be handed back.