0008 — Git worktree per work item; containers for real isolation

Status: accepted · Date: 2026-08-14

Owes: nothing — mechanism (ADR-0020)

Context

Phase 2 builds several work items in parallel, each driven by a different engine. Two agents editing the same checkout corrupt each other: one rewrites a file mid-edit, tests fail for unrelated reasons, and the failure is attributed to the wrong item.

Decision

Every build.implement job gets its own git worktree and branch. Integration happens on a dedicated integration worktree.

.vibey/worktrees/1/
├── item-001/      branch vibey/1/item-001
├── item-004/      branch vibey/1/item-004
└── integration/   branch vibey/1/integration

Naming lives in domain/worktree.py (.vibey/worktrees/<cycle>/<item_id>, branch vibey/<cycle>/<item_id>); the integration worktree is the reserved item id integration (infrastructure/git/integration_branch.py). Item branches are cut from the cycle's integration branch once it exists, so later items stack on already-integrated code. build.integrate merges into it one job at a time under a Postgres advisory lock (ADR-0029).

Additionally, three isolation levels are designed, selectable per project as [isolation] level in vibey.toml:

Level Mechanism Protects against Status
worktree (default) git worktree + engine destructive-command denies concurrent-edit corruption implemented
container Docker/Podman, worktree mounted, hardened defaults (infrastructure/container/: no network, read-only root, all capabilities dropped, no-new-privileges) filesystem escape, exfiltration executor implemented, not wired into dispatch
vm Firecracker / Lima microVM kernel-level escape designed, not implemented

The config parser accepts all three levels and an egress list, but every dispatch path today builds its RunSpec with IsolationLevel.WORKTREE. A level only selects per-descriptor argv flags, and those are empty for every engine except agyloop's --safe; the conformance suite found the other engines' isolation flags had been invented and removed them. OciContainerExecutor is not referenced by the composition root or any handler.

Rationale

Worktrees remove the shared mutable resource rather than trying to lock it, which is the standard answer to a standard concurrency problem. This became the convergent industry pattern during 2026 — multiple agent CLIs shipped one-worktree-per-write-capable-agent within the same period.

A worktree is not a sandbox, and this is worth stating loudly because the convenience of worktrees invites the confusion. A worktree stops agent A from overwriting agent B's file. It does nothing to stop either from running rm -rf ~, reading ~/.ssh, or POSTing the repository somewhere. For unattended overnight runs — which is vibey's whole premise — that gap matters.

Hence container as the recommended level for autonomous operation, with egress restricted to the provider API hosts the engines need. That is the target, not the present: the container executor's default is no network at all, an allow-list is not implemented, and a vibey doctor warning for level = "worktree" on an unattended Phase 2 is planned but does not exist. Until container dispatch lands, unattended runs have worktree isolation plus whatever the runners themselves deny.

Consequences

Good. Parallel builds are conflict-free by construction. Conflicts surface at integration, where they can be reasoned about, rather than as mysterious mid-build corruption. Each worktree is independently savepoint-able and unwind-able.

Bad. Disk cost — N checkouts of the repo. Some toolchains (node_modules, virtualenvs, build caches) are expensive to rebuild per worktree.

Mitigation (partial). The worktree manager is crash-safe: SIGKILL mid-create leaves no orphan, and create() heals a half-registered path. WorktreeManager.reclaim_orphans() removes directories git no longer recognizes, but nothing calls it yet and there is no vibey gc command. Reclaiming worktrees on item completion and a per-project cache-sharing bootstrap hook in vibey.toml are planned; today worktrees stay on disk until removed by hand.

Bad. container mode adds a Docker dependency and slows iteration. Accepted: it is opt-in, and the default stays worktree for supervised work.

Alternatives rejected

  • A shared checkout with file locks. Locks the symptom; agents still race on tests, caches, and generated files.
  • A full clone per item. The same isolation as a worktree at N times the disk, without the shared object store, and integration becomes a fetch between repositories.
  • Containers instead of worktrees. A container over one shared checkout still shares the checkout. The two compose; neither substitutes for the other.