Make the workspace toolchain safe for concurrent agents in one VM #115

Open
opened 2026-07-27 15:51:29 +01:00 by vnprc-agent · 1 comment
Contributor

Several coding agents now run concurrently inside a single dev VM, and the workspace tooling was designed for one. This issue records that topology decision and tracks the work that makes allod change, the git hooks, and the workspace collectors safe under it.

Primary goals:

  • Treat multi-agent-per-VM as supported — several agents sharing one dev VM is a first-class configuration, not an expedient, so concurrency safety belongs in the tooling rather than in operator discipline.
  • Isolate exactly when a branch is required — a change that needs an agent/* branch gets its own worktree; an in-place commit to a repo's default branch stays in the shared checkout with zero added friction.
  • Make the rails survive isolation — the branch-protection and signing guards must keep working inside a worktree, which today they do not.
  • Keep in-flight agent work visible — the cross-repo working view must not go blind precisely when several agents are running.
  • Fail loud on collision — an agent whose checkout moved under it, or whose commit would sweep another agent's edits, gets a refusal rather than a silent merge.

Incident that surfaced this

Two agents independently picked the same issue and worked in the shared forgejo-config checkout at the same time. Both created branches with git switch -c (agent/repo-archive-cleanup and agent/vps-archive-cleanup), which is what the workflow prose instructs for unprotected repos. Because both branches pointed at the same commit, the second switch rewrote no files and git raised no objection, so neither agent saw anything wrong — HEAD simply moved out from under the first agent while it kept writing. Detection came from an agent harness's read-before-write staleness check on a shared file, not from git and not from any rail.

Reflog timeline: 01:06:15 first branch created, 01:07:28 second created and HEAD leaves the first, 01:0801:09 both agents write files into the one tree, 01:10:52 manual recovery, 01:12:39 first agent commits.

Root cause

Isolation is coupled to branch protection. allod change begin returns the shared repo path untouched for any repo absent from protected-branches (allod:267-272), which is 12 of 19 checkouts on a typical dev VM. The archived design confirms this was deliberate rather than an oversight: allod/strategy archive/dev-plans/allod-change-dev-plan.md:27 describes begin as setting up "isolation if needed", where "needed" meant branch protection, and :32 specifies the no-op passthrough for unprotected repos. Concurrency between agents was never a design input.

The deeper point is where the failure sits on the enforcement ladder in allod/memory architecture.md principle 4. Neither agent called allod change begin at all; both ran bare git, because prose told them to. A fix that lives only in the tool is a tool-default fix for a prose-layer failure, so the hook layer has to carry part of the load.

Children

  • allod/tools#116allod change begin always isolates a branch change in its own worktree
  • allod/tools#117 — make the workspace collector worktree-aware so in-flight agent work stays visible
  • allod/tools#118allod change record refuses a moved checkout and a swept concurrent edit
  • allod/tools#119 — refuse agent-branch commits from a main checkout
  • allod/tools#112 — key the protection rails off remote identity (prerequisite for the hook rule)
  • allod/tools#124allod change record resolves a concurrent push (deferred; split out of allod/tools#118, gated on a decision criterion)

Sequencing, contracts, and per-issue review depth live in allod/strategy dev-plans/concurrent-agent-workspace.md. Only allod/tools#119 has a hard dependency, and only on allod/tools#112 — see the triage comment below for why allod/tools#112 is not an arc-wide prerequisite.

Scope

In: allod/toolsallod change, git-hooks/protected-refs-policy, lib/workspace.sh and its consumers. Out: agent identity as a general feature, which is worth its own issue if lock ownership or attribution is wanted but does not address any rail's repo lookup; per-VM agent topology, which architecture.md principle 7 already covers and which is not the chosen configuration here.

Two adjacent concerns were checked and need no work. flake-update-cascade already refuses a repo that is not on its default branch (flake/flake-update-cascade:204), so it cannot stomp an agent branch. pull-all --switch can currently move a clean, fully-pushed agent branch back to the default branch, but allod/tools#48 already made that opt-in, and once agents stop holding branches in shared checkouts there is nothing left for it to move.

Several coding agents now run concurrently inside a single dev VM, and the workspace tooling was designed for one. This issue records that topology decision and tracks the work that makes `allod change`, the git hooks, and the workspace collectors safe under it. Primary goals: - **Treat multi-agent-per-VM as supported** — several agents sharing one dev VM is a first-class configuration, not an expedient, so concurrency safety belongs in the tooling rather than in operator discipline. - **Isolate exactly when a branch is required** — a change that needs an `agent/*` branch gets its own worktree; an in-place commit to a repo's default branch stays in the shared checkout with zero added friction. - **Make the rails survive isolation** — the branch-protection and signing guards must keep working inside a worktree, which today they do not. - **Keep in-flight agent work visible** — the cross-repo working view must not go blind precisely when several agents are running. - **Fail loud on collision** — an agent whose checkout moved under it, or whose commit would sweep another agent's edits, gets a refusal rather than a silent merge. ### Incident that surfaced this Two agents independently picked the same issue and worked in the shared `forgejo-config` checkout at the same time. Both created branches with `git switch -c` (`agent/repo-archive-cleanup` and `agent/vps-archive-cleanup`), which is what the workflow prose instructs for unprotected repos. Because both branches pointed at the same commit, the second switch rewrote no files and git raised no objection, so neither agent saw anything wrong — HEAD simply moved out from under the first agent while it kept writing. Detection came from an agent harness's read-before-write staleness check on a shared file, not from git and not from any rail. Reflog timeline: `01:06:15` first branch created, `01:07:28` second created and HEAD leaves the first, `01:08`–`01:09` both agents write files into the one tree, `01:10:52` manual recovery, `01:12:39` first agent commits. ### Root cause Isolation is coupled to branch protection. `allod change begin` returns the shared repo path untouched for any repo absent from `protected-branches` (`allod:267-272`), which is 12 of 19 checkouts on a typical dev VM. The archived design confirms this was deliberate rather than an oversight: `allod/strategy` `archive/dev-plans/allod-change-dev-plan.md:27` describes `begin` as setting up "isolation if needed", where "needed" meant branch protection, and `:32` specifies the no-op passthrough for unprotected repos. Concurrency between agents was never a design input. The deeper point is where the failure sits on the enforcement ladder in `allod/memory` `architecture.md` principle 4. Neither agent called `allod change begin` at all; both ran bare git, because prose told them to. A fix that lives only in the tool is a tool-default fix for a prose-layer failure, so the hook layer has to carry part of the load. ### Children - [ ] allod/tools#116 — `allod change begin` always isolates a branch change in its own worktree - [ ] allod/tools#117 — make the workspace collector worktree-aware so in-flight agent work stays visible - [ ] allod/tools#118 — `allod change record` refuses a moved checkout and a swept concurrent edit - [ ] allod/tools#119 — refuse agent-branch commits from a main checkout - [ ] allod/tools#112 — key the protection rails off remote identity (prerequisite for the hook rule) - [ ] allod/tools#124 — `allod change record` resolves a concurrent push (deferred; split out of allod/tools#118, gated on a decision criterion) Sequencing, contracts, and per-issue review depth live in `allod/strategy` `dev-plans/concurrent-agent-workspace.md`. Only allod/tools#119 has a hard dependency, and only on allod/tools#112 — see the triage comment below for why allod/tools#112 is not an arc-wide prerequisite. ### Scope In: `allod/tools` — `allod change`, `git-hooks/protected-refs-policy`, `lib/workspace.sh` and its consumers. Out: agent identity as a general feature, which is worth its own issue if lock ownership or attribution is wanted but does not address any rail's repo lookup; per-VM agent topology, which `architecture.md` principle 7 already covers and which is not the chosen configuration here. Two adjacent concerns were checked and need no work. `flake-update-cascade` already refuses a repo that is not on its default branch (`flake/flake-update-cascade:204`), so it cannot stomp an agent branch. `pull-all --switch` can currently move a clean, fully-pushed agent branch back to the default branch, but allod/tools#48 already made that opt-in, and once agents stop holding branches in shared checkouts there is nothing left for it to move.
Member

Triage of the five children, plus the arc's shared contracts, now live at allod/strategy dev-plans/concurrent-agent-workspace.md. That document is this arc's home for anything more than one child depends on; allod/tools#116 and allod/tools#118 get their own dev plans that inherit from it.

Review depth per child, set by the blast radius of each change rather than by the severity of the bug it fixes:

Issue Risk Review depth
allod/tools#117 collector R1 One-shot, no dev plan
allod/tools#116 always isolate R2 Dev plan + one review pass, different model
allod/tools#118 record guards R2–R3 Dev plan + review cycle to convergence
allod/tools#119 hook refusal R3 Dev plan + one review pass, different model
allod/tools#112 remote-identity rails R3 Dev plan + review cycle to convergence

allod/tools#118 earns a convergence cycle because two of its three mechanisms carry unresolved tension. Its auto-rebase goal rewrites history and moves the working tree in the shared checkout, where by this arc's premise another agent may be mid-write, so it can reintroduce the exact failure class the arc exists to close. And because nothing can tell who edited a tracked file, the only implementable sweep guard is "refuse any modification not passed through -f", which converts the git add -u default into explicit-files-required for every caller — in direct tension with that issue's fourth goal. Worth considering splitting the two refusals from the auto-rebase.

Sequencing correction

allod/tools#112 is not an arc-wide prerequisite. It is a hard dependency for allod/tools#119 only, which needs the hook to resolve identity from inside a worktree. The argument that unconditional isolation would move every agent change into an unguarded zone does not hold in practice: inside a linked worktree on an agent/* branch, allowed-external-remotes and active-pr-branches are remote-keyed and still fire, the agent/* force-push block is branch-name-keyed and still fires, signing-required-branches is currently an empty file, and the protected-branches check is unreachable because git refuses to check out the protected branch in a second worktree while the main checkout holds it. The gap is latent, activating the day a branch is added to signing-required-branches. Details and the forge-side finding that drives this are in the plan's Risk Assessment.

Recommended order: allod/tools#117 first, since work-diff is already blind to the /tmp worktrees that exist today and the fix pays off before anything else lands. Then allod/tools#116, then allod/tools#118. allod/tools#112 and allod/tools#119 are a second wave that can run in parallel, since they share no files with the first.

Contracts the children must not redecide

Five facts are fixed in the plan so the children cannot drift: worktree siting under ~/changes (C1); worktree detection and repo identity via --git-dir vs --git-common-dir plus worktree list --porcelain (C2); the branch handoff file at $(git rev-parse --git-dir)/allod-change-branch, which is per-worktree and dies with git worktree remove (C3); -d as the isolation switch (C4); and workspace_collect_repos's output set staying frozen so the mutating consumers keep seeing registry checkouts only (C5). C2 and C3 are verified against real worktrees rather than assumed.

Keeping it current: each child's PR description states which contracts it implements and whether any changed. A contract change lands in the plan in the same PR that changes the behavior, never after — that is the one update rule worth enforcing, since a stale contract here is worse than no contract at all. When the last child merges, the plan moves to archive/dev-plans/.

Triage of the five children, plus the arc's shared contracts, now live at `allod/strategy` `dev-plans/concurrent-agent-workspace.md`. That document is this arc's home for anything more than one child depends on; allod/tools#116 and allod/tools#118 get their own dev plans that inherit from it. Review depth per child, set by the blast radius of each change rather than by the severity of the bug it fixes: | Issue | Risk | Review depth | |---|---|---| | allod/tools#117 collector | R1 | One-shot, no dev plan | | allod/tools#116 always isolate | R2 | Dev plan + one review pass, different model | | allod/tools#118 record guards | R2–R3 | Dev plan + review cycle to convergence | | allod/tools#119 hook refusal | R3 | Dev plan + one review pass, different model | | allod/tools#112 remote-identity rails | R3 | Dev plan + review cycle to convergence | allod/tools#118 earns a convergence cycle because two of its three mechanisms carry unresolved tension. Its auto-rebase goal rewrites history and moves the working tree in the shared checkout, where by this arc's premise another agent may be mid-write, so it can reintroduce the exact failure class the arc exists to close. And because nothing can tell who edited a tracked file, the only implementable sweep guard is "refuse any modification not passed through `-f`", which converts the `git add -u` default into explicit-files-required for every caller — in direct tension with that issue's fourth goal. Worth considering splitting the two refusals from the auto-rebase. ### Sequencing correction allod/tools#112 is not an arc-wide prerequisite. It is a hard dependency for allod/tools#119 only, which needs the hook to resolve identity from inside a worktree. The argument that unconditional isolation would move every agent change into an unguarded zone does not hold in practice: inside a linked worktree on an `agent/*` branch, `allowed-external-remotes` and `active-pr-branches` are remote-keyed and still fire, the `agent/*` force-push block is branch-name-keyed and still fires, `signing-required-branches` is currently an empty file, and the `protected-branches` check is unreachable because git refuses to check out the protected branch in a second worktree while the main checkout holds it. The gap is latent, activating the day a branch is added to `signing-required-branches`. Details and the forge-side finding that drives this are in the plan's Risk Assessment. Recommended order: allod/tools#117 first, since `work-diff` is already blind to the `/tmp` worktrees that exist today and the fix pays off before anything else lands. Then allod/tools#116, then allod/tools#118. allod/tools#112 and allod/tools#119 are a second wave that can run in parallel, since they share no files with the first. ### Contracts the children must not redecide Five facts are fixed in the plan so the children cannot drift: worktree siting under `~/changes` (C1); worktree detection and repo identity via `--git-dir` vs `--git-common-dir` plus `worktree list --porcelain` (C2); the branch handoff file at `$(git rev-parse --git-dir)/allod-change-branch`, which is per-worktree and dies with `git worktree remove` (C3); `-d` as the isolation switch (C4); and `workspace_collect_repos`'s output set staying frozen so the mutating consumers keep seeing registry checkouts only (C5). C2 and C3 are verified against real worktrees rather than assumed. Keeping it current: each child's PR description states which contracts it implements and whether any changed. A contract change lands in the plan in the same PR that changes the behavior, never after — that is the one update rule worth enforcing, since a stale contract here is worse than no contract at all. When the last child merges, the plan moves to `archive/dev-plans/`.
Sign in to join this conversation.
No description provided.