Files
multica/server/internal/daemon/execenv/gitroot_lock_test.go
T
61ea48fd2e MUL-6881 fix(daemon): keep one worktree branch per conversation in local worktree mode (#7815)
* fix(daemon): keep one worktree branch per conversation in local worktree mode

Every comment on a local_directory issue started a new task, and worktree
mode gave each one a branch of its own forked from the user's HEAD. Turn two
therefore stood in a tree that did not contain turn one's work, with that work
stranded on another branch and nothing saying so.

Tasks that share a conversation — the turns of one issue, one chat session —
now share a branch, agent/<agent>/<issue>, and a follow-up checks it out at its
tip instead of forking from HEAD. Env roots stay per task, so sibling tasks
still run concurrently; when one already holds the conversation's branch, the
other forks from its tip onto agent/<agent>/<issue>-<task> rather than falling
back to HEAD.

Replaying the user's uncommitted tree onto a continued branch cannot use the
turn-one path: that merge takes the user's HEAD as its base, so it conflicts
exactly when the agent edited the lines the user had left uncommitted. Each
turn now records the snapshot it saw at refs/multica/local-state/<branch> and
the next one replays only the difference — a cherry-pick whose parent is that
snapshot. A genuine conflict leaves the branch as it stands and logs which
files did not come across, rather than stranding the conversation.

Teardown follows the same rule everywhere: a branch this prepare did not
create belongs to the turns before it, so Finalize's read-only cleanup, Discard
and the prepare rollbacks only ever drop a branch they created themselves.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): prove branch ownership and hand replay conflicts to the agent

Review of the per-conversation worktree branch found four ways it could put
the wrong tree in front of the agent. All four came from the same two gaps:
the user's snapshot did not describe their whole directory, and a branch was
recognised by its name.

The snapshot is now one commit covering tracked edits AND untracked files,
built in a private index inside the task's env root. It answers the question a
continued branch actually has to ask — what has the user changed since the
state I already carry — for every file, so a once-untracked file the agent
edited is no longer reverted to the user's older copy each turn, and their
later deletion of one carries. Redirecting GIT_INDEX_FILE also removes the
.git/index.lock race the previous capture had to retry around (#7434).

A conflict between the user's newer edits and the branch's own work is now the
agent's to resolve, per the product decision on the issue: the worktree is
handed over mid-merge, the turn's prompt names the files and says which side is
which, and Finalize refuses to deliver — preserving the worktree — while
anything is still unmerged. The snapshot only advances once the branch really
carries it, so an unresolved edit is offered again next turn instead of being
recorded as synced. Before this, the conflict was reset away and the user's
change was lost from every later turn.

Continuing a branch is decided by an ownership record — workspace, agent and
conversation ids, recorded in the snapshot commit at
refs/multica/local-state/<branch> — never by the branch name. A same-named
branch the user created is left untouched and the task takes
agent/<agent>/<issue>-<fingerprint>, which its own follow-ups then continue.
The same check keeps two workspaces that mint one issue identifier apart.

Those refs are now GC'd: every prepare drops the record of a branch that no
longer exists, and the docs say where they live and how to clear them.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): tie a task branch's record to the branch, and quote untrusted paths

Ownership proved that Multica once wrote a branch of this name, which stayed
true after the user deleted it and created their own under the same name. No
prepare runs in that gap, so the orphan sweep never sees the branch missing and
the next turn continued straight into the user's unrelated work.

The record now also pins the branch tip it was written at, as the second parent
of the record commit, and a branch is only continued while that commit is still
an ancestor of its tip. Deleted-and-recreated, force-moved and rebased branches
all fail that test; committing on top of a delivered branch — the normal case —
passes it and picks the user's commit up. Finalize re-records afterwards,
because a checkpoint written at prepare time sits on the branch's base, which is
exactly where a recreated branch would sit.

Conflicted paths reaching the turn's prompt are quoted rather than wrapped in a
code span. They come from the user's repository and git allows newlines,
backticks and quotes in a filename, so a raw one could close its list item and
continue as an instruction line of its own; the same quoting is applied to the
Finalize error. The list is bounded at 50 names with the remainder counted.

Also drops a duplicated pair of bullets in the Chinese docs and corrects two
file comments that still described the stash-and-copy replay.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): make the branch record part of delivery, and bound the conflict list by bytes

The record could still authorise a branch that was not ours. Finalize wrote it
after removing the worktree and treated a failure as non-fatal, so a failed
update-ref left the ref pointing at the record Prepare had written — whose
checkpoint is the branch's base, which is exactly where a branch the user
deletes and recreates from HEAD sits. The ancestor check then passed on their
work.

Two changes close it. A checkpoint is only written when it is a commit the
branch could not sit at without this conversation's work: Finalize records the
tip it actually delivered, and Prepare records early only once the branch has
moved off the user's HEAD — which is the baseline commit, or a continued
branch's own tip. And the record is now part of delivering: Finalize writes it
BEFORE removing the worktree and fails the task if it cannot, so the worktree is
still there to preserve, exactly as for a commit that could not be made. The
checkpoint is the commit the caller delivered rather than a re-read of the
branch ref, so a branch moved in that window fails the next ancestor check
instead of being recorded as current.

A sibling racing the very first turn of a conversation on a clean tree now finds
no record yet and starts its own line of work rather than forking from a branch
it cannot prove is ours. That is the price of the rule and it is the safe side.

The conflict list in the prompt is bounded by rendered bytes rather than by
entries — a git path is as long as the filesystem allows, so 50 entries bounded
nothing — with the remainder counted and a single oversized path unable to
overrun the budget either.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): give every task branch a commit of its own, and verify what a run delivers

The checkpoint rule — record only a commit the branch could not sit at without
this conversation's work — held in Prepare and not in Finalize. Finalize took
whatever the worktree's HEAD was, so a run that reset its worktree back to the
user's own HEAD had that plain user commit recorded as the branch's checkpoint,
and a branch the user later recreated there passed the ownership check.

Two changes make the rule total. Every branch this mode creates now gets a
baseline commit of its own, empty when the user's tree was clean: without it the
branch sits exactly where the user's HEAD does and nothing tells it apart from a
branch they create at the same place. It also makes `git diff <baseline>..
<branch>` the agent's work on every branch rather than only on the ones that
started dirty, which is what the surrounding comments already claimed.

And Finalize verifies the delivery point before recording it: the tip has to BE
the task's branch — not a detached checkout, not a branch someone moved
underneath the run — and it has to still contain the anchor, which is that
baseline or the checkpoint the previous turn recorded and this one verified. A
delivery that fails either test is not recorded at all: the task fails, the
worktree is preserved, and the error names what is missing.

A sibling racing the first turn of a conversation forks from it again, since
every branch now carries a recordable checkpoint from the moment it is prepared.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): keep the anchor on the commit that carries this turn's user edits

A follow-up turn commits the user's newest edits as its own baseline, but the
anchor stayed on the checkpoint the previous turn had recorded. A run that then
reset its worktree back to the previous tip still passed verification — the old
checkpoint is an ancestor of it — so the turn was recorded as having delivered
edits the branch no longer had. The turn after that computed its replay from a
state the branch never took, and the user's edits were simply absent from the
worktree with nothing to indicate it.

The anchor now follows every baseline this task commits, so it always names the
newest commit carrying the state about to be recorded. Resetting past it fails
the turn and preserves the worktree, as for the first-turn case.

The conflict path had the same hole from the other end: there the branch does
not carry the snapshot until the agent resolves and something is committed. When
a turn that started mid-merge ends with the branch exactly where it began, the
record keeps the state the branch is known to carry instead of advancing, and
the next turn offers those edits again. A resolution in favour of the branch's
own side leaves the same empty trace as discarding the merge, so this trades a
merge that may have to be concluded twice against recording edits as delivered
that may have been dropped.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): measure a delivery against the commit its own turn started from

A turn that starts mid-merge only carries the user's edits once the agent
resolves and something is committed, and the guard for that compared the
delivered tip against the checkpoint an earlier turn had recorded. Those are not
the same commit: the user committing on a delivered branch is supported and
tested, so the branch tip a later turn starts from can already be past that
checkpoint. A resolution that committed nothing then left the tip equal to the
turn's own starting point but not to the older checkpoint, the guard did not
fire, and the edits were recorded as delivered — missing from the turn after
with nothing to indicate it.

Both questions now measure against LocalWorktree.BaseCommit, the commit this
turn actually started from. It is at least as new as the earlier checkpoint in
every case, so the separate anchor field is gone: a delivered tip that contains
this turn's starting point contains everything an earlier turn recorded, and a
tip that does not is a run that reset away work already delivered.

Co-authored-by: multica-agent <[email protected]>

* test(daemon): cover the claim-to-branch wiring with a two-turn Prepare test

Every multi-turn test so far drove PrepareLocalWorktree with hand-filled
params, which skips the step that turns a claim into them: which field becomes
the branch name, and which become the identity the branch is recorded under.
This one runs two turns of one issue through Prepare itself, and a third for
another workspace carrying the same issue identifier. Both halves of that
mapping are load-bearing — blanking the conversation key or fixing the
workspace id makes it fail.

Co-authored-by: multica-agent <[email protected]>

* fix(daemon): carry the worktree's own state across the preparation helper

Prepare runs in a short-lived helper process and the Environment it built comes
back to the daemon as JSON. LocalWorktree keeps everything Finalize reasons
about in unexported fields, and ordinary struct marshalling drops those without
a word — so the daemon finalized a worktree whose owner, snapshot, tracksState
and createdBranch were all zero.

Nothing failed; the guarantees were simply absent in production. No delivery
verification ran, Finalize recorded nothing (leaving the checkpoint at the
baseline Prepare had written inside the helper), a conflicted turn could not
hold its snapshot back, and a read-only turn kept its branch. Every in-process
test passed throughout, because they call PrepareLocalWorktree directly.

Custom Marshal/Unmarshal now carry that state. aborted stays out of the wire
deliberately: the daemon sets it after Prepare returns, so it belongs to the
parent. The regression tests run two turns through PrepareIsolated — the real
boundary — and assert the state arrives, that Finalize's record names the tip it
delivered rather than the baseline, and that a read-only turn still drops its
branch.

Found by running the whole stack: a real daemon, a real local_directory in
worktree mode, and two real turns.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-01 13:50:52 +08:00

316 lines
10 KiB
Go

package execenv
import (
"context"
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// gitRootLockHolderMode re-execs this test binary as a process that takes the
// repository lock and holds it. A second process is the only way to test what
// broke here: the previous lock was an in-process mutex, which is exactly the
// scope that does NOT cover production, where every prepare runs in its own
// helper process.
const gitRootLockHolderMode = "execenv-gitroot-lock-holder"
// TestGitRootLockHolderProcess is a no-op in the parent and the lock holder in
// the child.
func TestGitRootLockHolderProcess(t *testing.T) {
if len(os.Args) == 0 || os.Args[len(os.Args)-1] != gitRootLockHolderMode {
return
}
repo := os.Getenv("MULTICA_TEST_LOCK_REPO")
readyPath := os.Getenv("MULTICA_TEST_LOCK_READY")
releasePath := os.Getenv("MULTICA_TEST_LOCK_RELEASE")
unlock, err := lockGitRoot(repo, worktreeTestLogger())
if err != nil {
os.Exit(3)
}
if err := os.WriteFile(readyPath, []byte("held"), 0o644); err != nil {
os.Exit(4)
}
deadline := time.Now().Add(60 * time.Second)
for {
if _, err := os.Stat(releasePath); err == nil {
break
}
if time.Now().After(deadline) {
os.Exit(5)
}
time.Sleep(10 * time.Millisecond)
}
unlock()
os.Exit(0)
}
func waitForFile(t *testing.T, path string, within time.Duration) {
t.Helper()
deadline := time.Now().Add(within)
for {
if _, err := os.Stat(path); err == nil {
return
}
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", path)
}
time.Sleep(10 * time.Millisecond)
}
}
// startLockHolder runs another PROCESS that takes repo's lock and holds it
// until the returned release func is called. Another process is the only scope
// that tests what broke here: the previous lock was an in-process mutex, and
// production never has two contenders inside one process.
func startLockHolder(t *testing.T, repo string) (release func()) {
t.Helper()
signals := t.TempDir()
readyPath := filepath.Join(signals, "ready")
releasePath := filepath.Join(signals, "release")
child := exec.Command(os.Args[0], "-test.run=^TestGitRootLockHolderProcess$", "--", gitRootLockHolderMode)
child.Env = append(os.Environ(),
"MULTICA_TEST_LOCK_REPO="+repo,
"MULTICA_TEST_LOCK_READY="+readyPath,
"MULTICA_TEST_LOCK_RELEASE="+releasePath,
)
if err := child.Start(); err != nil {
t.Fatalf("start lock holder: %v", err)
}
var once sync.Once
stop := func() {
once.Do(func() {
_ = os.WriteFile(releasePath, []byte("go"), 0o644)
_ = child.Wait()
})
}
t.Cleanup(stop)
waitForFile(t, readyPath, 30*time.Second)
return stop
}
// The lock must exclude ANOTHER PROCESS, not just another goroutine. Against
// the in-process mutex this test fails immediately: lockGitRoot returned while
// the child still held the repository, which is the state that let two
// prepares run `git stash create` on one index.
func TestLockGitRootExcludesOtherProcesses(t *testing.T) {
repo := newTestRepo(t)
release := startLockHolder(t, repo)
acquired := make(chan error, 1)
go func() {
unlock, err := lockGitRoot(repo, worktreeTestLogger())
if unlock != nil {
unlock()
}
acquired <- err
}()
// The holder is alive and has not released, so this must not come back.
select {
case err := <-acquired:
t.Fatalf("lockGitRoot returned (err=%v) while another process held the repository lock", err)
case <-time.After(500 * time.Millisecond):
}
release()
select {
case err := <-acquired:
if err != nil {
t.Fatalf("lockGitRoot after release: %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("lockGitRoot never acquired the lock after the holder released it")
}
}
// One repository, one lock — including across its linked worktrees.
//
// The section under this lock writes refs and worktrees/ in the COMMON git
// dir, which every working tree of a repo shares. Keying on the per-worktree
// git dir gives two resources bound to two linked worktrees two different
// locks while they still race on that shared state.
func TestGitRootLockPathIsRepoWide(t *testing.T) {
repo := newTestRepo(t)
path, err := gitRootLockPath(repo)
if err != nil {
t.Fatalf("gitRootLockPath: %v", err)
}
want := filepath.Join(repo, ".git", gitRootLockFileName)
if path != want {
t.Fatalf("lock path = %q, want %q", path, want)
}
linked := filepath.Join(t.TempDir(), "linked")
gitRun(t, repo, "worktree", "add", "-b", "linked-branch", linked)
if resolved, evalErr := filepath.EvalSymlinks(linked); evalErr == nil {
linked = resolved
}
linkedPath, err := gitRootLockPath(linked)
if err != nil {
t.Fatalf("gitRootLockPath(linked): %v", err)
}
if linkedPath != path {
t.Fatalf("linked worktree locks %q, want the repository's single lock %q", linkedPath, path)
}
// The per-worktree git dir is still what the index.lock hint must read:
// that is where a linked worktree's own index lives.
linkedGitDir, err := gitDirFor(linked)
if err != nil {
t.Fatalf("gitDirFor(linked): %v", err)
}
if !strings.Contains(filepath.ToSlash(linkedGitDir), "/worktrees/") {
t.Fatalf("linked worktree git dir = %q, want it under worktrees/", linkedGitDir)
}
}
// A held lock file is never stale: the kernel drops the lock when the holder
// exits, and the leftover file means nothing on its own. Telling the user to
// delete it would undo this whole change — the lock binds to an open file
// description, not to the path, so unlinking it lets the next task create and
// lock a NEW file while the holder still owns the old inode, and both then
// believe they are alone in the section.
func TestGitRootLockTimeoutDoesNotAdviseDeletingTheLock(t *testing.T) {
repo := newTestRepo(t)
original := gitRootLockWait
gitRootLockWait = 200 * time.Millisecond
t.Cleanup(func() { gitRootLockWait = original })
startLockHolder(t, repo)
unlock, err := lockGitRoot(repo, worktreeTestLogger())
if unlock != nil {
unlock()
}
if err == nil {
t.Fatal("lockGitRoot succeeded while another process held the lock")
}
if !errors.Is(err, errGitRootLockBusy) {
t.Fatalf("error is not errGitRootLockBusy: %v", err)
}
msg := err.Error()
for _, banned := range []string{"can be deleted", "stale lock", "delete the lock"} {
if strings.Contains(msg, banned) {
t.Errorf("timeout message advises %q, which breaks the exclusion: %v", banned, err)
}
}
if !strings.Contains(msg, "wait for that task to finish or stop it") {
t.Errorf("timeout message does not say what to actually do: %v", err)
}
}
// The index-lock race that used to end the task cannot happen any more: the
// snapshot is built in a private index inside the task's env root, so the only
// index lock it takes is on its own file. This is the regression test for the
// property, not for the retry that used to work around the absence of it
// (#7434).
func TestCaptureUserSnapshotIgnoresTheRepositoryIndexLock(t *testing.T) {
repo := newTestRepo(t)
writeFile(t, filepath.Join(repo, "tracked.txt"), "edited by the user\n")
writeFile(t, filepath.Join(repo, "brand-new.txt"), "untracked\n")
lock := filepath.Join(repo, ".git", "index.lock")
if err := os.WriteFile(lock, nil, 0o644); err != nil {
t.Fatalf("hold index.lock: %v", err)
}
defer os.Remove(lock)
head := gitRun(t, repo, "rev-parse", "HEAD")
snapshot, err := captureUserSnapshot(repo, t.TempDir(), head, worktreeTestLogger())
if err != nil {
t.Fatalf("captureUserSnapshot with index.lock held: %v", err)
}
if got := gitRun(t, repo, "show", snapshot+":tracked.txt"); got != "edited by the user" {
t.Errorf("snapshot tracked.txt = %q, want the user's edit", got)
}
if got := gitRun(t, repo, "show", snapshot+":brand-new.txt"); got != "untracked" {
t.Errorf("snapshot brand-new.txt = %q, want the untracked file", got)
}
// The user's own index is theirs; the capture may not have touched it.
if _, err := os.Stat(lock); err != nil {
t.Errorf("the capture removed the user's index.lock: %v", err)
}
}
// Every failure through runGitStdout used to arrive as "exit status 1": stderr
// was captured by cmd.Output() and then dropped on the floor.
func TestRunGitSurfacesStderrInTheError(t *testing.T) {
repo := newTestRepo(t)
_, err := runGitTrimmed(repo, "rev-parse", "--verify", "definitely-not-a-ref")
if err == nil {
t.Fatal("rev-parse of a bogus ref succeeded")
}
if !strings.Contains(err.Error(), "fatal:") {
t.Errorf("error dropped git's stderr: %v", err)
}
}
// The production shape: two tasks on one local_directory, each preparing in
// its own helper process. Both must get a worktree; before the cross-process
// lock one of them routinely died in `git stash create`.
func TestConcurrentIsolatedPreparesOnOneRepo(t *testing.T) {
repo := newTestRepo(t)
writeFile(t, filepath.Join(repo, "tracked.txt"), "user work in progress\n")
writeFile(t, filepath.Join(repo, "untracked.txt"), "new file\n")
workspacesRoot := t.TempDir()
logger := worktreeTestLogger()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
type result struct {
env *Environment
err error
}
taskIDs := []string{
"aaaaaaaa-1111-4111-8111-aaaaaaaaaaa1",
"aaaaaaaa-1111-4111-8111-aaaaaaaaaaa2",
}
results := make(chan result, len(taskIDs))
for _, taskID := range taskIDs {
go func(taskID string) {
env, err := PrepareIsolated(ctx, preparationHelperTestCommand(), PrepareParams{
WorkspacesRoot: workspacesRoot,
WorkspaceID: "ws-concurrent-worktree",
TaskID: taskID,
Provider: "claude",
AgentName: "J",
Task: TaskContextForEnv{IssueID: "issue-" + taskID},
LocalWorktree: &LocalWorktreeParams{LocalPath: repo},
}, logger)
results <- result{env: env, err: err}
}(taskID)
}
branches := map[string]bool{}
for range taskIDs {
got := <-results
if got.err != nil {
t.Fatalf("concurrent PrepareIsolated failed: %v", got.err)
}
wt := got.env.LocalWorktree
if wt == nil {
t.Fatal("prepared environment has no worktree")
}
if branches[wt.Branch] {
t.Fatalf("two concurrent tasks landed on branch %q", wt.Branch)
}
branches[wt.Branch] = true
// Each task must see the user's uncommitted state, which is precisely
// what the lost capture would have silently replaced with a clean HEAD.
if content := readFile(t, filepath.Join(wt.Path, "tracked.txt")); content != "user work in progress\n" {
t.Fatalf("worktree %s missing the user's edit: %q", wt.Path, content)
}
if content := readFile(t, filepath.Join(wt.Path, "untracked.txt")); content != "new file\n" {
t.Fatalf("worktree %s missing the user's untracked file: %q", wt.Path, content)
}
}
}