allod change record: guard against a moved checkout and a swept concurrent edit #118

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

allod change record can commit into a checkout that another agent moved, and can sweep another agent's edits into your commit, in both cases without saying anything. Add the two refusals that make a concurrent collision loud.

Part of allod/tools#115. The automatic-rebase half of this issue's original scope moved to allod/tools#124.

Primary goals:

  • Refuse a moved checkout — if HEAD is no longer the branch begin handed out, record exits with a message naming the expected and actual branch instead of committing wherever it landed.
  • Refuse to sweep a concurrent edit, where sweeping is possible — in a shared checkout, staging refuses when it would include tracked modifications the caller did not name, and names the offending paths. Inside a linked worktree the tree belongs to one agent by construction, so git add -u stays the default there.
  • Keep the low-friction flow low-friction — no additional command and no added ceremony for committing directly to a repo's default branch. Naming files becomes a required argument in a shared checkout, which is what allod/memory git-workflow.md already instructs.
  • Preflight and touch nothing on failure — both refusals happen before anything is staged or committed, so a refused record leaves the tree exactly as it found it.

Current state

record resolves the current branch and checks it against protected-branches (allod:345) but has no notion of which branch the caller was given, so an agent whose checkout was switched out from under it commits to whatever HEAD now points at. That is precisely what happened in the incident in allod/tools#115: one agent's HEAD was moved by another agent between begin and the commit.

Staging defaults to git add -u (allod:350), which stages every tracked modification in the tree. Untracked files are safely skipped, so a concurrent agent's new files cannot ride along, but a concurrent agent's edits to an already-tracked file will. For the shared memory and planning-doc flow, where two agents may both be appending to the same tracked file, that is the realistic collision and it is silent.

Why the sweep guard is worktree-aware rather than blanket

The second goal's original wording asked staging to skip modifications the caller "did not name and did not make". The second half is not implementable. In the in-place default-branch flow begin is never called, so there is no handoff, no baseline tree, and no signal that attributes an edit to an agent. The rule therefore collapses to a positional one — named versus unnamed — which is friction on exactly the flow the third goal protects.

Scoping it by checkout type resolves that, because the hazard is not uniform:

Where record runs Staging default Why
Linked worktree git add -u, unchanged The tree belongs to one agent by construction; after allod/tools#116 this is where every branch change happens
Main checkout Refuse unnamed tracked modifications, naming the paths The only place another agent's edits can be swept

Worktree detection is git rev-parse --path-format=absolute --git-dir differing from --git-common-dir, already implemented as main_repo_dir_for_dir (allod:111).

Promoting the file-naming rule from prose into the tool is architecture principle 14 working as designed, and the override stays standard git — git add -u && git commit — rather than a bypass flag, per principle 4.

Suggested shape

For the moved-checkout guard, begin records the branch it created at $(git rev-parse --path-format=absolute --git-dir)/allod-change-branch inside the new worktree, and record compares against it and exits non-zero on mismatch. In a linked worktree that path is the private <main>/.git/worktrees/<name> directory, so the file is per-worktree rather than per-repo and git worktree remove deletes it, which means it needs no cleanup path of its own. A missing file means begin was never called and record proceeds unchanged, which is what keeps the in-place flow unaffected. This is contract C3 in allod/strategy dev-plans/concurrent-agent-workspace.md and depends on allod/tools#116 writing it.

For the sweep guard, refuse when no -f was given and git diff --name-only is non-empty in a main checkout, listing the paths and pointing at -f. Note that the existing -f path already stages only what it is given (allod:348), so the sweep is confined to the bare invocation.

Scope

In: allod change record's staging step and its branch check, allod change begin's handoff of the branch name, and tests covering a moved checkout, an unnamed concurrent modification in a main checkout, an unnamed modification inside a worktree that must still be allowed, and a refusal leaving the tree untouched. Out: automatic push resolution (allod/tools#124), worktree creation policy (allod/tools#116), and hook-level enforcement (allod/tools#119).

`allod change record` can commit into a checkout that another agent moved, and can sweep another agent's edits into your commit, in both cases without saying anything. Add the two refusals that make a concurrent collision loud. Part of allod/tools#115. The automatic-rebase half of this issue's original scope moved to allod/tools#124. Primary goals: - **Refuse a moved checkout** — if HEAD is no longer the branch `begin` handed out, `record` exits with a message naming the expected and actual branch instead of committing wherever it landed. - **Refuse to sweep a concurrent edit, where sweeping is possible** — in a shared checkout, staging refuses when it would include tracked modifications the caller did not name, and names the offending paths. Inside a linked worktree the tree belongs to one agent by construction, so `git add -u` stays the default there. - **Keep the low-friction flow low-friction** — no additional command and no added ceremony for committing directly to a repo's default branch. Naming files becomes a required argument in a shared checkout, which is what `allod/memory` `git-workflow.md` already instructs. - **Preflight and touch nothing on failure** — both refusals happen before anything is staged or committed, so a refused `record` leaves the tree exactly as it found it. ### Current state `record` resolves the current branch and checks it against `protected-branches` (`allod:345`) but has no notion of which branch the caller was given, so an agent whose checkout was switched out from under it commits to whatever HEAD now points at. That is precisely what happened in the incident in allod/tools#115: one agent's HEAD was moved by another agent between `begin` and the commit. Staging defaults to `git add -u` (`allod:350`), which stages every tracked modification in the tree. Untracked files are safely skipped, so a concurrent agent's new files cannot ride along, but a concurrent agent's edits to an already-tracked file will. For the shared memory and planning-doc flow, where two agents may both be appending to the same tracked file, that is the realistic collision and it is silent. ### Why the sweep guard is worktree-aware rather than blanket The second goal's original wording asked staging to skip modifications the caller "did not name and did not make". The second half is not implementable. In the in-place default-branch flow `begin` is never called, so there is no handoff, no baseline tree, and no signal that attributes an edit to an agent. The rule therefore collapses to a positional one — named versus unnamed — which is friction on exactly the flow the third goal protects. Scoping it by checkout type resolves that, because the hazard is not uniform: | Where `record` runs | Staging default | Why | |---|---|---| | Linked worktree | `git add -u`, unchanged | The tree belongs to one agent by construction; after allod/tools#116 this is where every branch change happens | | Main checkout | Refuse unnamed tracked modifications, naming the paths | The only place another agent's edits can be swept | Worktree detection is `git rev-parse --path-format=absolute --git-dir` differing from `--git-common-dir`, already implemented as `main_repo_dir_for_dir` (`allod:111`). Promoting the file-naming rule from prose into the tool is architecture principle 14 working as designed, and the override stays standard git — `git add -u && git commit` — rather than a bypass flag, per principle 4. ### Suggested shape For the moved-checkout guard, `begin` records the branch it created at `$(git rev-parse --path-format=absolute --git-dir)/allod-change-branch` inside the new worktree, and `record` compares against it and exits non-zero on mismatch. In a linked worktree that path is the private `<main>/.git/worktrees/<name>` directory, so the file is per-worktree rather than per-repo and `git worktree remove` deletes it, which means it needs no cleanup path of its own. A missing file means `begin` was never called and `record` proceeds unchanged, which is what keeps the in-place flow unaffected. This is contract C3 in `allod/strategy` `dev-plans/concurrent-agent-workspace.md` and depends on allod/tools#116 writing it. For the sweep guard, refuse when no `-f` was given and `git diff --name-only` is non-empty in a main checkout, listing the paths and pointing at `-f`. Note that the existing `-f` path already stages only what it is given (`allod:348`), so the sweep is confined to the bare invocation. ### Scope In: `allod change record`'s staging step and its branch check, `allod change begin`'s handoff of the branch name, and tests covering a moved checkout, an unnamed concurrent modification in a main checkout, an unnamed modification inside a worktree that must still be allowed, and a refusal leaving the tree untouched. Out: automatic push resolution (allod/tools#124), worktree creation policy (allod/tools#116), and hook-level enforcement (allod/tools#119).
Member

Rescope analysis. This issue carries three mechanisms with three different blast radii, three different rollback stories, and — the part that was not visible before — three different dependencies. Two of them are not blocked by anything and one is.

The conflict is between goals 2 and 4, not between goals 2 and 3

Goal 2 says staging must not include modifications to tracked files the caller "did not name and did not make". The second half is not implementable: in the in-place default-branch flow begin is never called, so there is no handoff, no baseline tree, and no principled way to attribute an edit to an agent. The rule therefore collapses to a positional one — named versus unnamed — which is friction on exactly the flow goal 4 promises not to burden. That tension is internal to goals 2 and 4 and survives deleting goal 3 entirely.

It also dissolves cleanly once the guard is made worktree-aware. The sweep hazard exists only in a shared checkout; inside a linked worktree the tree belongs to one agent by construction and git add -u is safe there. So:

Where record runs Staging default Rationale
Linked worktree (C2 detection) git add -u unchanged Tree is the agent's alone; after allod/tools#116 this is where all branch work happens
Main checkout Refuse unnamed tracked modifications, naming the paths The only place another agent's edits can be swept

That keeps goal 4 literally true for every isolated flow and reduces the in-place flow's cost to one flag per file — which allod/memory git-workflow.md already instructs agents to pass. Promoting that prose rule into the tool is architecture principle 14 working as intended, and the override stays standard git (git add -u && git commit) per principle 4, not a bypass flag.

Goal 4 should be restated honestly as part of this rescope: it adds no additional command or ceremony, rather than no required argument.

The auto-rebase is safer than previously assessed, and separable

Tested against a two-clone fixture rather than reasoned about:

  • Another agent has uncommitted work in the shared treegit pull --rebase refuses outright (cannot pull with rebase: You have unstaged changes), touches nothing, and leaves the other agent's edit intact. It fails closed. My earlier assessment in allod/tools#115 that this reintroduces the arc's own failure class was too strong.
  • Tree clean, both agents appended to the same file — conflict leaves rebase-in-progress, HEAD detached, and conflict markers written into the shared working tree. git rebase --abort restores the branch, the working tree, and the local commit completely.

Two implementation facts follow, both easy to get wrong. The fail-closed property depends on rebase.autoStash being false; with autostash on, git stashes the other agent's uncommitted work, rebases, and unstashes, which is the genuinely dangerous path. This VM sets pull.rebase true and leaves autostash unset, so the property holds today by default and would silently stop holding if that config changed — the implementation must pass -c rebase.autoStash=false explicitly rather than inherit. And the conflict path must --abort and report, per principle 11's "touches nothing on failure".

So it is implementable safely. Whether it is worth implementing is a separate question: it saves one manual git pull --rebase && git push in a flow where the failure is already loud (allod runs set -euo pipefail, so the rejected push already exits non-zero) and the recovery is already documented.

Recommendation

Split along the safety/convenience seam, which is also the dependency seam:

  • This issue keeps both refusals — the moved-checkout guard and the worktree-aware sweep guard. Both are pure refusals, both preflight and touch nothing on failure, both are testable in the same two-agent fixture, and neither can leave state uncertain. Residual risk drops to R1 and the review depth drops from a convergence cycle to a single light pass.
  • The auto-rebase moves to its own issue, deferred rather than dropped, with a decision criterion instead of a date: build it when push rejection in the in-place flow is observed to cost real recovery cycles, which is measurable only after the guards land. Principle 15 puts minimalism first and principle 14 says gates that cannot demonstrate their value get deleted rather than elaborated.

The dependency argument is the one that actually changes the schedule. The moved-checkout guard reads the C3 branch handoff and cannot land before allod/tools#116 writes it. The sweep guard depends on nothing in the arc — C2 worktree detection already exists as main_repo_dir_for_dir (allod:111). If the sweep guard should land immediately rather than waiting on allod/tools#116, split three ways instead of two and let it go first; the cost is a third small PR against the same function.

Principles consulted: 15 (decompose so each step has its own blast radius and rollback; minimalism first), 11 (fail loud, preflight, touch nothing on failure), 14 (lessons become mechanism; do not elaborate gates that cannot show their worth), 4 (enforce below prose, ship no bypass flag).

Rescope analysis. This issue carries three mechanisms with three different blast radii, three different rollback stories, and — the part that was not visible before — three different dependencies. Two of them are not blocked by anything and one is. ### The conflict is between goals 2 and 4, not between goals 2 and 3 Goal 2 says staging must not include modifications to tracked files the caller "did not name and did not make". The second half is not implementable: in the in-place default-branch flow `begin` is never called, so there is no handoff, no baseline tree, and no principled way to attribute an edit to an agent. The rule therefore collapses to a positional one — named versus unnamed — which is friction on exactly the flow goal 4 promises not to burden. That tension is internal to goals 2 and 4 and survives deleting goal 3 entirely. It also dissolves cleanly once the guard is made worktree-aware. The sweep hazard exists only in a shared checkout; inside a linked worktree the tree belongs to one agent by construction and `git add -u` is safe there. So: | Where `record` runs | Staging default | Rationale | |---|---|---| | Linked worktree (C2 detection) | `git add -u` unchanged | Tree is the agent's alone; after allod/tools#116 this is where all branch work happens | | Main checkout | Refuse unnamed tracked modifications, naming the paths | The only place another agent's edits can be swept | That keeps goal 4 literally true for every isolated flow and reduces the in-place flow's cost to one flag per file — which `allod/memory` `git-workflow.md` already instructs agents to pass. Promoting that prose rule into the tool is architecture principle 14 working as intended, and the override stays standard git (`git add -u && git commit`) per principle 4, not a bypass flag. Goal 4 should be restated honestly as part of this rescope: it adds no additional command or ceremony, rather than no required argument. ### The auto-rebase is safer than previously assessed, and separable Tested against a two-clone fixture rather than reasoned about: - **Another agent has uncommitted work in the shared tree** — `git pull --rebase` refuses outright (`cannot pull with rebase: You have unstaged changes`), touches nothing, and leaves the other agent's edit intact. It fails closed. My earlier assessment in allod/tools#115 that this reintroduces the arc's own failure class was too strong. - **Tree clean, both agents appended to the same file** — conflict leaves rebase-in-progress, HEAD detached, and conflict markers written into the shared working tree. `git rebase --abort` restores the branch, the working tree, and the local commit completely. Two implementation facts follow, both easy to get wrong. The fail-closed property depends on `rebase.autoStash` being false; with autostash on, git stashes the other agent's uncommitted work, rebases, and unstashes, which is the genuinely dangerous path. This VM sets `pull.rebase true` and leaves autostash unset, so the property holds today by default and would silently stop holding if that config changed — the implementation must pass `-c rebase.autoStash=false` explicitly rather than inherit. And the conflict path must `--abort` and report, per principle 11's "touches nothing on failure". So it is implementable safely. Whether it is worth implementing is a separate question: it saves one manual `git pull --rebase && git push` in a flow where the failure is already loud (`allod` runs `set -euo pipefail`, so the rejected push already exits non-zero) and the recovery is already documented. ### Recommendation Split along the safety/convenience seam, which is also the dependency seam: - **This issue keeps both refusals** — the moved-checkout guard and the worktree-aware sweep guard. Both are pure refusals, both preflight and touch nothing on failure, both are testable in the same two-agent fixture, and neither can leave state uncertain. Residual risk drops to R1 and the review depth drops from a convergence cycle to a single light pass. - **The auto-rebase moves to its own issue**, deferred rather than dropped, with a decision criterion instead of a date: build it when push rejection in the in-place flow is observed to cost real recovery cycles, which is measurable only after the guards land. Principle 15 puts minimalism first and principle 14 says gates that cannot demonstrate their value get deleted rather than elaborated. The dependency argument is the one that actually changes the schedule. The moved-checkout guard reads the C3 branch handoff and cannot land before allod/tools#116 writes it. The sweep guard depends on nothing in the arc — C2 worktree detection already exists as `main_repo_dir_for_dir` (`allod:111`). If the sweep guard should land immediately rather than waiting on allod/tools#116, split three ways instead of two and let it go first; the cost is a third small PR against the same function. Principles consulted: 15 (decompose so each step has its own blast radius and rollback; minimalism first), 11 (fail loud, preflight, touch nothing on failure), 14 (lessons become mechanism; do not elaborate gates that cannot show their worth), 4 (enforce below prose, ship no bypass flag).
Sign in to join this conversation.
No description provided.