mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
* 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]>
316 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|