Docs

Worktree lifecycle

Bridge cuts a Git worktree for an isolated chat, for every isolated worker, and for a pull request checked out for review. Creating one is cheap; forgetting one used to be expensive, because agents ran builds inside these checkouts and a populated target/ and node_modules turned a 30 MB checkout into a 4 GB one.

Two mechanisms, in the order they matter. Build caches stop the cost accruing: the expensive directories live once per repository, outside every checkout. The inventory and its retention policy manage what is left.

This describes the inventory that records them and the retention policy that reclaims them. It does not change the three hierarchies: forking a conversation still does not undo filesystem changes, and ending a session still does not discard a worktree. Reclaiming is a decision the worktree coordinator makes against the filesystem, never a side effect of a conversation moving — and, because branch refs survive, never a decision that loses a session its project.

Build caches

Every agent session process, and every verification command, runs with CARGO_TARGET_DIR, BUN_INSTALL_CACHE_DIR and npm_config_cache pointed at one cache per repository under build-caches/ in the data directory. Keyed on the repository's main worktree, so every checkout of a project shares one cache and a second worker starts warm — dependencies dominate a build directory and they are identical across branches.

What moves, and what does not. CARGO_TARGET_DIR relocates the whole Cargo build directory: that is the 3.4 GB of the 3.9 GB measured. The other two are download caches — bun documents --cache-dir as "store & load cached data", npm's cache likewise, and neither offers a supported way to put the installed tree anywhere but <cwd>/node_modules. So a second worker's install is fast, but its node_modules still lives in its own checkout. Worker creation now seeds an independent copy-on-write tree only when tracked dependency manifests and lockfiles match the source. A mismatch or unsupported clone leaves the normal install workflow in place, never a partial tree or shared writable symlink. See the audit for measurement and platform limits.

Two costs are paid knowingly. Concurrent builds of one repository serialize on cargo's lock rather than building in parallel, which is the right trade against a multi-gigabyte target directory per worker (max_concurrent_workers is 2). And switching branches rebuilds changed crates, while unchanged dependencies — the bulk — are reused.

A variable already set in Bridge's environment is never overridden: an operator who pointed a cache somewhere deliberately outranks this. A process with no resolvable repository gets no cache rather than a wrong shared one. CARGO_HOME is deliberately not redirected — it holds registry credentials, which makes relocating it a security decision rather than a disk one.

Read-only sandboxed workers are excluded. Their seatbelt permits writes only under their own output directory, and widening that to reach a shared cache would trade away part of the read-only guarantee to speed up the one class of worker that is not supposed to be building.

What exists

Every worktree Bridge creates gets a row in worktrees, keyed by its canonicalized path, recording its kind, the repository it was cut from, the session and workspace that own it, the commit it was cut from, when it was last used, its measured size, and the last assessment of what may be done with it.

Kind Path Branch Owner
orchestrator worktrees/orchestrators/<workspace>/<session> bridge/<workspace>-<session8> the chat session
worker worktrees/workers/<task>/<session> <task-branch>-worker-<session> the worker session
github worktrees/github/pr-<n>-<branch> the PR head branch the workspace node

Orchestrator checkouts previously had no record of any kind — they were named only by sessions.cwd, which no cleanup path consulted — so nothing could find one, let alone reclaim it. That is the class this table exists for.

Paths are stored canonicalized. git worktree list reports resolved paths, so on a machine where a worktree sits under a symlink (/var and /tmp on macOS, a symlinked home or mount anywhere) the path Bridge recorded and the path git reports are different strings for the same directory. Comparing them raw makes Bridge file its own worktrees as somebody else's.

Reconcile

A maintenance pass brings the inventory, git's registrations, and the filesystem back into agreement. Drift is ordinary — a crash between creating a checkout and writing its row, a directory deleted by hand, an out-of-band git worktree prune — and what matters is that afterwards nothing is invisible.

  • A row whose directory is gone becomes removed, and git's leftover registration is pruned.
  • A directory under the namespace root with no row is adopted as orphaned, carrying whatever branch and head git reports.
  • A directory git cannot read at all is adopted as unverifiable.
  • A checkout of a repository Bridge knows that sits outside the namespace root is recorded external. It is reported and never reclaimed: a developer's own worktree is not Bridge's to collect.

Symlinks are skipped rather than followed. is_dir follows them, so a symlink dropped into a layout slot — which anything with write access to its own checkout can create as a sibling — would otherwise be inventoried as a Bridge checkout at its resolved path, outside the namespace entirely. Namespace containment is re-checked when the sweep selects a candidate and again immediately before deletion, so a row that names a path outside the namespace, however it came to be written, is never removed.

What may be reclaimed

Classification runs at deletion time, not when the inventory was scanned, and it must prove a checkout is expendable. The dispositions:

Disposition Meaning Swept
reclaimable Clean, and nothing landed past the commit it was cut from yes, past its TTL or under cap pressure
pushed_unmerged Clean, holds commits, all of them exist on a remote only when the policy says delete
at_risk Uncommitted changes, or commits that exist nowhere else never
retained A live session or worker is in it, or a worker's output there is still awaiting adopt-or-discard never
unverifiable Git cannot read it, so nothing about it can be established never

Every question that cannot be answered counts against deletion. A checkout whose recorded base commit is unreadable, or whose remotes cannot be consulted, is at_risk rather than assumed empty.

"In use" is not a question status alone can answer. A chat whose provider is up but has no turn in flight sits at ready, so a status-only test would call a live adapter's checkout idle. sessions.adapter_pid is the durable claim that a real process owns the session, and boot recovery clears it for every process that is actually gone — so a live adapter always retains its checkout, and a stale ready row left by a crashed run does not pin one forever.

The classification that authorizes a deletion is re-run in full immediately before it, against facts read at that moment. This is not covered by the git refusal underneath: git worktree remove needs --force only for a dirty or locked tree, not for a clean one carrying a commit that exists nowhere else. A commit made between the scan and the deletion would otherwise go with the directory.

Idleness is measured from the owning session's own most recent recorded activity, not from when the checkout was created — otherwise an isolated chat someone works in daily would age out of its own TTL.

Caps

Per repository: at most 12 Bridge worktrees and 10 GiB, with idle TTLs of 24 hours for a settled worker and 7 days for a chat or a PR checkout. Defaults live in WorktreeRetention.

The sweep first collects expendable checkouts past their TTL. If a cap is still breached it keeps taking expendable checkouts, least recently used first, even inside their TTL — a cap is a promise about the machine. If the only checkouts left are ones nothing may delete, the breach is reported (over_budget_bytes) rather than forced. There is no --force path anywhere in this system.

The sweep reduces to strictly below each cap rather than merely to it. The creation gate refuses at >= max_per_repo, so stopping at equality left a repository permanently full: the tick reclaimed nothing and every queued delegation waited on a TTL with no reason to expire. Clearing a cap has to leave room for the work the cap is blocking.

Capacity is also part of routing. child_worktrees_available previously asked only whether the namespace root had a parent directory — true for every path that is not the filesystem root — so the policy engine's ChildWorktreeUnavailable refusal could never fire. It now asks the inventory, so a repository at its cap queues the delegation instead of cutting another checkout, and the creation path refuses with a reason naming the budget.

The stopped worker

A worker that stops never settles, so its binding stays pending_adoption and every collector skips it. Being unsettled is not the same as holding work: when the checkout is clean and nothing landed past its base commit, there is nothing for anyone to adopt, and leaking a directory to protect an empty diff is a bad trade. Those settle as empty and release. A worker that stopped holding real changes — committed or uncommitted — keeps its checkout, and the decision stays the user's.

Where it runs

live_turn::start_worktree_maintenance runs one pass as soon as the host is serving and then every 30 minutes. Deliberately not on the boot path, for the reason the history-snapshot export documents: the pass shells out to git per repository and measures directory sizes, and boot has to reach a bound socket before the desktop shell's start deadline.

Reads and writes to the database happen under the shared mutex; classification, size measurement, and every git invocation happen without it.

Auditing

Every reclaim writes a worktree.reclaimed event naming the path, branch, head, disposition, and bytes freed. A refusal writes worktree.retained with the reason. worktrees/list and worktrees/usage expose the inventory and the totals against the caps in force; both report the last assessment rather than recomputing, since deciding a disposition costs several git invocations per checkout. All four methods are reachable from the CLI without a bespoke subcommand — bridge exec --json --method worktrees/list_worktrees — because exec forwards any registry method.

Scratch branches

A worker's <task>-worker-<uuid> branch is deleted once its checkout is gone and its commits are provably elsewhere: either the branch never moved off its base, or its tip is already reachable from the task branch that adopted it. Thirty such refs were sitting in one repository when this was written, every one belonging to a worker that had long since settled.

The branch must be one Bridge minted, and that is established by reconstructing the name prepare_isolated_worker would have produced — from the base branch and the session slug — rather than by matching on -worker-. A task branch may itself be called bridge/foo, so no prefix rule can tell an orchestrator branch from a worker branch cut off it. git branch -d refuses anything still unmerged, so a wrong answer fails closed.

Both paths that reclaim a checkout share this: settlement, and the retention sweep once it settles an empty binding.

Reclaiming on request

worktrees/reclaim_worktree takes one checkout; worktrees/sweep_worktrees runs a full pass. A refusal is a result, not an error — "no, and here is why" is the useful answer for a button — so at-risk, retained and unverifiable checkouts come back with their reason, and only an unknown id is an error. Both go through the same removal path as the sweep and inherit its deletion-time re-classification.

One deliberate difference: an explicit request may reclaim a checkout whose every commit is already on a remote, which the unattended sweep retains. The difference is who is asking, and whether they have been told what they are discarding.

Settings → Storage renders this: total bytes against the cap, a per-repository rollup, and a row per checkout. A row either offers Reclaim or states in words why nothing may touch it — no disabled button with a tooltip, because "at risk" without "uncommitted changes" tells a person nothing they can act on.

Archiving a chat

Settings -> Archived chats lists and reads archived conversations. Unarchiving restores visibility only: no checkout is recreated and no model starts. A later explicit resume is a separate operation.

sessions/archive_chat files one conversation away and reclaims the checkout that chat owns. It is deliberately not archive_workspace, which deletes every session in a workspace: a workspace holds many chats, so a per-chat button wired to it would destroy hundreds of unrelated conversations to reclaim one directory.

History is kept — the session row, its forest entries and its evidence all survive, and the projection simply stops listing the chat and its descendants. A checkout that cannot be proven expendable is kept rather than blocking the archive, and the reason is reported: putting a conversation away should not require first resolving its uncommitted work. A chat is refused while it is running or while it still holds a live adapter process: a ready chat has no turn in flight but its provider is up, and hiding it would take away the only route to a process that goes on holding memory and a model session.

This is the affordance whose absence caused the accumulation in the first place.

Branch refs are never deleted by the retention sweep. Only checkouts are reclaimed — which is what makes reclaiming reversible. start_chat takes sessions.cwd verbatim and only creates the directory, so a resumed chat whose worktree had been collected would otherwise start in an empty non-repository where its project used to be. restore_if_reclaimed cuts the checkout again from the recorded branch before the session starts, the same restore worktree_coordinator already performs for a pull-request checkout whose tree was reclaimed. It refuses to recreate anything Bridge did not cut.

Related: delegation-policy.md for the worker adoption lifecycle, session-forest.md for the three hierarchies, local-history.md for the equivalent retention policy over history snapshots.

Source: docs/worktree-lifecycle.md