Skip to content

Agent git writes are worktree-isolated off origin/main; the primary checkout is the human's

Status: open/accepted

  • No agent process mutates the primary checkout’s working tree, index, or HEAD. Every agent git write happens in an ephemeral worktree created off origin/main and lands via push (or PR). The primary checkout belongs to the human.
  • This is the isolation half of serverless concurrency. Leasing (github-ref-leases/README) already owns the coordination half — who may work a task; this ADR owns where that work physically happens. The two compose and neither replaces the other.
  • It generalizes a discipline already proven in task-work and task-close-out (B-P502-commit-task-state-and-planning-docs-via-ephemeral-worktrees) into a universal, enforced invariant, retiring the shared-checkout race class.
  • State-write strategy (push-per-op vs an append-only event log) is out of scope here — this ADR fixes where writes happen, not how state is modeled. The event log (B-M9JX-append-only-event-log-with-frontmatter-cache) is the declared endgame, decided separately.

Accepted and in force — every agent state-write flow runs through the isolation path below. Extends D-VSLI-distributed-work-runner-architecture (which names lease coordination the invariant that lets parallel agents work without a server) by adding the complementary execution-isolation invariant. Does not modify the lease protocol or the entity substrate.

SDLC ran many concurrent agents against one git working checkout, using branches as the unit of work. But a working tree, its index, and HEAD are singular, mutable, shared state — so operations that switch, reset, stage, or commit on the shared checkout race each other. The same root cause surfaced five times in one session:

SymptomRoot
lint-state-origin false-positives (#470)state commits pile on local main, ahead of origin
docs-drift false-positives (#471)regen reads the shared dirty tree → a sibling’s edits contaminate the union docs
close-out autostash sweep (#472)git pull --rebase --autostash stashes the whole shared tree
backlog-create desync (#475)branch-switch + regen on the shared checkout
caller stranded on backlog-capture (B-0H4P)git checkout -B on the shared checkout yanks its branch

Coordination was already solved: the lease CAS-claim at refs/sdlc/tasks/<id> prevents double-dispatch, and none of these races were double-dispatch. What was not solved was isolation — where the owned work runs. Some flows isolated (task-work worktrees, the task close-commit op); others mutated the shared checkout (backlog-create, start_task/ensure_ready local-main commits, the docs regen). The race lived in exactly that gap.

Adopt the isolation invariant below and run every agent git-write flow through it. All formerly shared-checkout flows — start_task/ensure_ready state commits, backlog-create, close-out — are converted.

No agent process mutates the primary checkout’s working tree, index, or HEAD. All agent git writes happen in an ephemeral worktree created off origin/main (or off a freshly-fetched ref) and land via git push HEAD:main or an opened PR. The worktree is torn down after. The primary checkout is read-only from the agents’ perspective — it is the human’s.

A worktree gives each unit of work its own working tree, index, and detached HEAD, sharing only the object database. Creating it off origin/main means the tree starts clean and current, so: regen runs against an uncontaminated corpus (no sibling-edit forward contamination); there is no shared tree to autostash; and there is no local-main-ahead-of-origin divergence to make gates false-positive. A push race (another worker advanced main) is recovered by re-applying onto the fresh tip — reset the worktree to the new origin/main, re-apply the mutation, re-regenerate (deterministic off the new base, so union artifacts never conflict), re-push. This is the task close-commit pattern (#472), generalized.

This ADR does not touch task selection or ownership. Leasing (github-ref-leases/README) remains the coordination layer: a CAS-claimed ref answers who owns a task. Isolation answers where the owner runs. Even once the event log lands, leasing stays — an append-only log gives history, but mutual exclusion needs a compare-and-swap primitive, which is exactly the lease ref. Log = append-for-state; lease = CAS-for-ownership.

Enforcement is structural plus a tripwire. Structural: every task-state call site — start_task, ensure_ready_mutate --commit-on main, task close-commit — commits through the shared commitToMainViaWorktree helper (plugin/lib/services/git/commit-to-main.ts); backlog-create commits captures through its own ephemeral commit worktree. Tripwire: plugin/lib/services/git/tests/primary-checkout-isolation.test.ts asserts each task-state path leaves the primary checkout’s HEAD, tree, and index byte-identical while the change lands on origin/main — a regression fails the test that names the broken path. worktree-scope-guard (sdlc gate worktree-scope) remains the pre-commit net on the primary checkout: it self-skips inside a linked worktree and rejects commits on main whose staged paths collide with an active worktree.

start_task/ensure_ready push each state commit to origin/main directly through the shared helper, retry-on-race — the task close-commit template. Push-per-op relocates contention to the main ref; the append-only event log (B-M9JX-append-only-event-log-with-frontmatter-cache) is the declared endgame that retires the retry-by-reapply — it is decided separately, not bundled here.

  • The shared-checkout race class is gone: no agent branch-switch on the human’s tree, no autostash sweep, no dirty-tree regen contamination, no local-main divergence.
  • The human’s checkout becomes stable — agents never touch it. The B-0H4P strand cannot recur.
  • Per-flow gate workarounds collapse: a clean worktree regenerates clean, so #471’s staged-index concern is subsumed for converted flows.
  • Reading task state in Obsidian is preserved. Status stays in the frontmatter on main; the invariant only changes where agents commit it. The former parallel churn on the shared checkout is replaced by a stable local vault, at the cost of a git pull to pull in the latest statuses (commits land on origin, not local main — “stable, pull-to-refresh” instead of “live-but-chaotic”).
  • This trade is isolation’s only Obsidian cost, and it is favorable. The deeper file-first trade-off — losing the ability to hand-edit status in Obsidian and have it stick — belongs to the event log, not this ADR: there, status becomes a projection of the log, so a raw frontmatter edit would be overwritten unless the projection treats it as an appended event. That constraint (“preserve Obsidian read AND write ergonomics; keep the cache materialized and committed”) is recorded here as input to the future event-log decision.
  • Each isolated op pays worktree setup (~hundreds of ms + object-shared disk). Tolerable at handful-of-agents scale; a worktree pool or --no-checkout/sparse is the mitigation if it bites.
  • Push-per-state-commit makes origin/main a hotter write ref. Bounded by retry-by-reapply; unbounded parallelism is the argument for bringing the event log forward.
  • The state model. Whether state is mutable frontmatter or an append-only event log (B-M9JX-append-only-event-log-with-frontmatter-cache) is a separate decision; this ADR only fixes where writes happen.
  • Push cadence. The invariant fixes where state commits happen, not how often; any batching affordance over the current push-per-op cadence is an implementation call.
  • Scale. At dozens of concurrent agents, does main-ref push contention argue for pushing state to per-agent refs a reconciler folds in (never main directly), i.e. pulling the event log forward sooner?
  • Worktree cost. Is a reusable worktree pool worth it, or is per-op create/teardown fine at the target concurrency?
  • Lease ↔ log coexistence. When the event log lands, what is the exact boundary — lease ref for ownership CAS, log for everything else — and how does a hand-edit become an appended event so the file stays Obsidian-editable?

← Back to Decisions