mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* fix(execenv): let a re-dispatched task fork past its own busy-branch fallback (#9198) When the conversation branch is held by another worktree, a local_directory worktree task forks onto <branch>-<task key>. That name is deterministic, so the same task re-dispatched while the branch is still busy hit 'a branch named ... already exists' and failed every attempt. Fork a fresh timestamped branch instead, leaving the earlier attempt's branch (and any partial work on it) untouched. Co-authored-by: multica-agent <[email protected]> * fix(execenv): keep picking an unused fallback branch name on repeated re-dispatch A second-granular suffix still collides when the same task is prepared more than twice within one second. Try a bounded sequence of fallback names (<alt>, <alt>-<unix>, <alt>-<unix>-N) instead of failing on the first collision; earlier attempts' branches stay untouched. Addresses review on #9547. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-eve <[email protected]> Co-authored-by: multica-agent <[email protected]>
2243 lines
98 KiB
Go
2243 lines
98 KiB
Go
package execenv
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Local worktree mode gives every task on a local_directory resource its own
|
|
// git worktree of the user's repo, created inside the daemon-owned env root.
|
|
// Tasks on the same directory then run concurrently instead of queueing on the
|
|
// per-path mutex, and each one delivers its work as a branch in the user's own
|
|
// repo — discoverable with `git branch`, no new result channel needed.
|
|
//
|
|
// Three properties this file exists to guarantee:
|
|
//
|
|
// 1. The agent sees what the user sees. `git worktree add` alone would check
|
|
// out HEAD, silently hiding the user's uncommitted work. We replay a
|
|
// snapshot of their directory into the worktree instead — tracked edits and
|
|
// untracked files as one tree (captureUserSnapshot).
|
|
// 2. The user's directory is never written to. Everything — including the
|
|
// sidecar context files Prepare writes — lands inside the worktree, which
|
|
// is disposable. What lasts in the user's repo is the branch, plus one
|
|
// hidden ref per branch recording who owns it and what it carries.
|
|
// 3. Nothing is silently discarded. Whatever the agent leaves uncommitted is
|
|
// committed to the branch before the worktree goes away, and an edit of the
|
|
// user's that could not be merged is offered again next turn rather than
|
|
// recorded as delivered.
|
|
|
|
const (
|
|
// localWorktreeDirName is the env-root-relative directory holding the
|
|
// worktree. Kept short: on Windows the worktree path plus the deepest
|
|
// repo path must stay under MAX_PATH for tools that predate long paths.
|
|
localWorktreeDirName = "worktree"
|
|
|
|
// gitTimeout bounds every git invocation this file makes. These are all
|
|
// local-only operations (no network), so a slow one means a wedged index
|
|
// lock rather than a slow remote; failing the task beats hanging a daemon
|
|
// slot forever.
|
|
gitTimeout = 2 * time.Minute
|
|
|
|
// maxUntrackedFiles / maxUntrackedBytes bound the untracked content a task
|
|
// will replay. `--exclude-standard` already drops anything gitignored
|
|
// (node_modules, build output, venvs), so a repo hitting these limits has an
|
|
// unusual amount of untracked-but-not-ignored content, and snapshotting it
|
|
// would write every byte of it into the user's own object database. The
|
|
// task refuses instead, naming the fix.
|
|
maxUntrackedFiles = 2000
|
|
maxUntrackedBytes = 200 << 20 // 200 MiB
|
|
|
|
// snapshotIndexFileName is the private index captureUserSnapshot builds the
|
|
// user's snapshot in. It lives in the task's env root, never in the user's
|
|
// repository: pointing GIT_INDEX_FILE at our own file is what keeps the
|
|
// capture off the user's index entirely — no writes to it, and no wait on
|
|
// .git/index.lock, which used to be able to end the task (#7434).
|
|
snapshotIndexFileName = ".multica-snapshot-index"
|
|
|
|
// localStateRefPrefix namespaces the per-branch record of the user's
|
|
// directory. Outside refs/heads so it never appears in the user's
|
|
// `git branch`, and a ref rather than a loose object so `git gc` in their
|
|
// repo cannot reclaim a snapshot between two turns.
|
|
localStateRefPrefix = "refs/multica/local-state/"
|
|
)
|
|
|
|
// LocalWorktreeParams describes the worktree Prepare should build for a
|
|
// local_directory task running in worktree mode.
|
|
type LocalWorktreeParams struct {
|
|
// LocalPath is the user's configured directory. It may be the repo root
|
|
// or any subdirectory of it; the worktree always covers the whole repo,
|
|
// and the agent's cwd is the matching subdirectory inside it.
|
|
LocalPath string
|
|
// EnvRoot is the daemon-owned task env root. The worktree is created
|
|
// inside it so the ordinary env-root GC reclaims it.
|
|
EnvRoot string
|
|
// AgentName and TaskID name the branch a task with no conversation behind
|
|
// it gets: agent/<name>/<short-task-id>.
|
|
AgentName string
|
|
TaskID string
|
|
// ConversationKey names the work line this task belongs to — its issue, or
|
|
// its chat session. Tasks sharing a key share one branch, so the second
|
|
// turn of a conversation continues the first turn's work instead of
|
|
// forking from HEAD again (MUL-6881). Empty for a task with no durable
|
|
// conversation behind it; those keep the task-scoped branch.
|
|
//
|
|
// It is a DISPLAY key: `mul-6881` is what the user recognises in
|
|
// `git branch`, and two workspaces can produce the same one. Continuing a
|
|
// branch is therefore never decided by the name — see WorkspaceID /
|
|
// AgentID / ConversationID, which are what a branch's recorded owner is
|
|
// compared against.
|
|
ConversationKey string
|
|
// WorkspaceID, AgentID and ConversationID identify the conversation
|
|
// itself. They are recorded with the branch and re-checked before any
|
|
// later task continues it, so a same-named branch belonging to the user,
|
|
// to another agent, or to another workspace is never adopted. All three
|
|
// empty means "no conversation": the task gets a task-scoped branch.
|
|
WorkspaceID string
|
|
AgentID string
|
|
ConversationID string
|
|
}
|
|
|
|
// owner is the identity a branch created for this task is recorded under.
|
|
func (p LocalWorktreeParams) owner() branchOwner {
|
|
return branchOwner{WorkspaceID: p.WorkspaceID, AgentID: p.AgentID, ConversationID: p.ConversationID}
|
|
}
|
|
|
|
// localWorktreeConversation names the work line a worktree task belongs to, so
|
|
// every turn of it delivers onto one branch (MUL-6881).
|
|
//
|
|
// Two values, because they answer different questions. The key is what the
|
|
// branch is CALLED — the issue identifier is preferred there because it is what
|
|
// the user recognises in `git branch`, agent/j/mul-6881 rather than a uuid
|
|
// tail. The id is what the branch BELONGS to, and only it decides whether a
|
|
// later task may continue that branch: identifiers are per-workspace and
|
|
// human-chosen, so two workspaces can mint the same one for different issues.
|
|
// Tasks with neither an issue nor a chat session have no conversation to
|
|
// continue and get "", "".
|
|
func localWorktreeConversation(params PrepareParams) (key, id string) {
|
|
if params.Task.IssueID != "" {
|
|
if params.IssueIdentifier != "" {
|
|
return params.IssueIdentifier, params.Task.IssueID
|
|
}
|
|
return taskKey(params.Task.IssueID), params.Task.IssueID
|
|
}
|
|
if params.Task.ChatSessionID != "" {
|
|
return "chat-" + taskKey(params.Task.ChatSessionID), params.Task.ChatSessionID
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// LocalWorktree is a prepared worktree plus everything the daemon needs to
|
|
// finalize it after the agent exits.
|
|
type LocalWorktree struct {
|
|
// GitRoot is the user's repository root — the repo that owns the branch.
|
|
GitRoot string
|
|
// Path is the worktree root inside the env root.
|
|
Path string
|
|
// WorkDir is the agent's cwd: Path, plus the offset of LocalPath inside
|
|
// the repo when the user pointed the resource at a subdirectory.
|
|
WorkDir string
|
|
// Branch is the branch created for this task, in the user's repo.
|
|
Branch string
|
|
// BaseCommit is the commit the worktree started from — this turn's baseline
|
|
// when one was committed, otherwise the branch tip it continued. Finalize
|
|
// compares the delivered tip against it twice: to decide whether the task
|
|
// produced anything, and to decide whether it may be recorded at all.
|
|
//
|
|
// It is the strongest commit this turn can point at, which is why the second
|
|
// question uses it rather than the checkpoint an earlier turn recorded. That
|
|
// checkpoint is only an ancestor of it: the user may have committed on the
|
|
// delivered branch since, and this turn's own baseline sits later still.
|
|
// Measuring against the older commit let a run reset away everything this
|
|
// turn replayed and still be recorded as having delivered it, which is how
|
|
// the user's edits went missing from the turn after (MUL-6881 review).
|
|
BaseCommit string
|
|
// DirtyBaseCaptured records that the user had uncommitted tracked edits
|
|
// which were replayed into the worktree.
|
|
DirtyBaseCaptured bool
|
|
// Continued reports that this worktree checked out a branch an earlier turn
|
|
// of the same conversation left behind, instead of forking a new one from
|
|
// the user's HEAD.
|
|
Continued bool
|
|
// ReplayConflicts names the files where the user's edits since the previous
|
|
// turn could not be merged with what the branch already carries. The
|
|
// worktree is handed to the agent WITH those conflicts in it — resolving
|
|
// them is ordinary git work, and it is the only party that can judge which
|
|
// version is right — so this is what the turn's prompt tells it to fix.
|
|
// Finalize refuses to deliver while any of them are still unmerged.
|
|
ReplayConflicts []string
|
|
// createdBranch records that this prepare put the branch where it is, so
|
|
// dropping it discards nothing an earlier turn delivered. False for a
|
|
// continued branch: that one has to survive even a turn that produced
|
|
// nothing, because it carries every turn before it.
|
|
createdBranch bool
|
|
// userState is the commit describing the user's directory as this task saw
|
|
// it: their tracked edits and untracked files in one tree. Recorded against
|
|
// the branch once it actually carries them, so the next turn replays only
|
|
// what changed after that.
|
|
userState string
|
|
// owner is the conversation this branch belongs to, recorded with the
|
|
// snapshot so a later task can prove the branch is its own before
|
|
// continuing it.
|
|
owner branchOwner
|
|
// tracksState is false for a branch no later turn will continue — a
|
|
// task-scoped branch, or the one a busy sibling forked. Recording a
|
|
// snapshot for those would leave a ref nothing ever reads.
|
|
tracksState bool
|
|
// priorState is the snapshot the branch carried when this turn started, and
|
|
// the one to record when this turn could not get its own into the branch.
|
|
priorState string
|
|
// snapshotPending is set when Prepare left the user's edits unmerged in the
|
|
// worktree: the branch does not carry userState yet, and only a commit made
|
|
// after the agent resolves can put it there.
|
|
snapshotPending bool
|
|
// aborted, when set, makes Finalize refuse to commit or remove anything.
|
|
// Set by the daemon when a pre-commit step failed in a way that would make
|
|
// the committed branch wrong (see AbortWithReason).
|
|
aborted error
|
|
}
|
|
|
|
// MarshalJSON / UnmarshalJSON carry this struct's unexported state across the
|
|
// preparation helper boundary.
|
|
//
|
|
// Prepare runs in a short-lived helper process (PrepareIsolated) and the
|
|
// Environment it built comes back to the daemon as JSON, so everything Finalize
|
|
// needs has to be on the wire. Ordinary struct marshalling drops unexported
|
|
// fields silently: the daemon then finalized a worktree whose owner, snapshot
|
|
// and tracksState were all zero, which turned every proof this file makes into
|
|
// a no-op — no delivery verification, no record of the delivered tip, and a
|
|
// read-only turn keeping its branch. Nothing failed; the guarantees were simply
|
|
// absent in production while every in-process test still passed.
|
|
//
|
|
// aborted is deliberately NOT carried: it is set by the daemon after Prepare
|
|
// returns (AbortWithReason), so it belongs to the parent process only.
|
|
func (w *LocalWorktree) MarshalJSON() ([]byte, error) {
|
|
type wire LocalWorktree
|
|
return json.Marshal(struct {
|
|
*wire
|
|
CreatedBranch bool `json:"created_branch"`
|
|
UserState string `json:"user_state"`
|
|
PriorState string `json:"prior_state"`
|
|
Owner branchOwner `json:"owner"`
|
|
TracksState bool `json:"tracks_state"`
|
|
SnapshotPending bool `json:"snapshot_pending"`
|
|
}{
|
|
wire: (*wire)(w),
|
|
CreatedBranch: w.createdBranch,
|
|
UserState: w.userState,
|
|
PriorState: w.priorState,
|
|
Owner: w.owner,
|
|
TracksState: w.tracksState,
|
|
SnapshotPending: w.snapshotPending,
|
|
})
|
|
}
|
|
|
|
func (w *LocalWorktree) UnmarshalJSON(data []byte) error {
|
|
type wire LocalWorktree
|
|
aux := struct {
|
|
*wire
|
|
CreatedBranch bool `json:"created_branch"`
|
|
UserState string `json:"user_state"`
|
|
PriorState string `json:"prior_state"`
|
|
Owner branchOwner `json:"owner"`
|
|
TracksState bool `json:"tracks_state"`
|
|
SnapshotPending bool `json:"snapshot_pending"`
|
|
}{wire: (*wire)(w)}
|
|
if err := json.Unmarshal(data, &aux); err != nil {
|
|
return err
|
|
}
|
|
w.createdBranch = aux.CreatedBranch
|
|
w.userState = aux.UserState
|
|
w.priorState = aux.PriorState
|
|
w.owner = aux.Owner
|
|
w.tracksState = aux.TracksState
|
|
w.snapshotPending = aux.SnapshotPending
|
|
return nil
|
|
}
|
|
|
|
// LocalWorktreeOutcome is what a finished worktree task delivered.
|
|
type LocalWorktreeOutcome struct {
|
|
// Branch is the branch holding the task's work, or "" when the task made
|
|
// no changes at all (a read-only run) — in that case the branch is deleted
|
|
// so it never shows up in the user's `git branch` as an empty artifact.
|
|
Branch string
|
|
// AutoCommitted is true when the agent left uncommitted changes that
|
|
// Finalize committed so they would survive the worktree's removal.
|
|
AutoCommitted bool
|
|
// PreservedPath is set only when Finalize could NOT commit the agent's
|
|
// changes. The worktree at this path was intentionally left on disk because
|
|
// it is the only remaining copy of that work.
|
|
PreservedPath string
|
|
}
|
|
|
|
// PrepareLocalWorktree creates the task's worktree and replays the user's
|
|
// uncommitted state into it. It never writes to the user's working tree: the
|
|
// snapshot is built in a private index inside the task's env root, which leaves
|
|
// their index, their files and their refs exactly as they were.
|
|
func PrepareLocalWorktree(params LocalWorktreeParams, logger *slog.Logger) (*LocalWorktree, error) {
|
|
if params.LocalPath == "" {
|
|
return nil, errors.New("execenv: local worktree requires a local path")
|
|
}
|
|
if params.EnvRoot == "" {
|
|
return nil, errors.New("execenv: local worktree requires an env root")
|
|
}
|
|
if params.TaskID == "" {
|
|
return nil, errors.New("execenv: local worktree requires a task id")
|
|
}
|
|
|
|
gitRoot, err := resolveGitRoot(params.LocalPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The agent's cwd keeps the user's chosen depth: a resource pointed at
|
|
// <repo>/services/api must land the agent in <worktree>/services/api, not
|
|
// at the repo root, or the task's whole notion of "the project" shifts.
|
|
//
|
|
// Canonicalise before the comparison: gitRoot comes back canonical, while
|
|
// the configured path routinely isn't (on macOS every /tmp and /var path is
|
|
// a symlink into /private). Comparing the two forms directly reads a repo
|
|
// root as "outside itself".
|
|
localPath := params.LocalPath
|
|
if resolved, evalErr := filepath.EvalSymlinks(localPath); evalErr == nil {
|
|
localPath = resolved
|
|
}
|
|
rel, err := filepath.Rel(gitRoot, localPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execenv: locate %q inside repo %q: %w", localPath, gitRoot, err)
|
|
}
|
|
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
|
return nil, fmt.Errorf("execenv: %q is not inside its repository root %q", localPath, gitRoot)
|
|
}
|
|
|
|
worktreePath := filepath.Join(params.EnvRoot, localWorktreeDirName)
|
|
|
|
// Everything below mutates the repo's worktree admin state or its refs, so
|
|
// take the per-repo lock first. It covers the stale-path cleanup, which runs
|
|
// `git worktree remove` and would otherwise race a sibling task's `worktree
|
|
// add`, and the branch decision, which has to see a branch that another task
|
|
// is creating at the same moment. The lock is cross-process because every
|
|
// prepare runs in its own helper process (#7434).
|
|
unlock, err := lockGitRoot(gitRoot, logger)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer unlock()
|
|
|
|
if _, statErr := os.Stat(worktreePath); statErr == nil {
|
|
// Prepare wipes and recreates envRoot, so an existing worktree path
|
|
// means a stale registration in the user's repo pointing here. Remove
|
|
// both rather than failing the task.
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
}
|
|
|
|
// Self-heal registrations orphaned by a crashed daemon: their env roots are
|
|
// long gone, but the user's repo still lists them. Prune only drops entries
|
|
// whose directory no longer exists, so it can never disturb a live task.
|
|
if out, pruneErr := runGit(gitRoot, "worktree", "prune"); pruneErr != nil && logger != nil {
|
|
logger.Warn("execenv: git worktree prune failed (non-fatal)",
|
|
"git_root", gitRoot, "output", out, "error", pruneErr)
|
|
}
|
|
pruneOrphanedStateRefs(gitRoot, logger)
|
|
|
|
headSHA, err := runGitTrimmed(gitRoot, "rev-parse", "--verify", "HEAD")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("execenv: repository %q has no commit to branch from "+
|
|
"(worktree mode needs at least one commit; make an initial commit or switch the resource back to in_place): %w", gitRoot, err)
|
|
}
|
|
|
|
// Refuse an untracked payload too large to reproduce BEFORE snapshotting it:
|
|
// the snapshot writes every one of those bytes into the user's own object
|
|
// database, which is not something to do by accident on a directory full of
|
|
// un-ignored build output.
|
|
if err := checkUntrackedReplayable(gitRoot, logger); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// The commit describing the user's directory as this task sees it — their
|
|
// tracked edits and their untracked files in one tree. Everything below
|
|
// reasons about the user's state through this single object.
|
|
userState, err := captureUserSnapshot(gitRoot, params.EnvRoot, headSHA, logger)
|
|
if err != nil {
|
|
// Fail closed. The promise of this mode is that the agent reasons about
|
|
// the code the user actually has; silently starting from HEAD instead
|
|
// would have it review a tree the user never saw and report confidently
|
|
// on it. A task that does not start is recoverable — one that answers
|
|
// from the wrong sources is not.
|
|
return nil, fmt.Errorf("execenv: could not capture the uncommitted changes in %q, "+
|
|
"so the worktree would not match what you have on disk: %w", gitRoot, err)
|
|
}
|
|
|
|
plan := resolveTaskBranch(gitRoot, params, headSHA, logger)
|
|
actualBranch, createdBranch, err := addLocalWorktree(gitRoot, worktreePath, plan, params.TaskID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
wt := &LocalWorktree{
|
|
GitRoot: gitRoot,
|
|
Path: worktreePath,
|
|
WorkDir: filepath.Join(worktreePath, rel),
|
|
Branch: actualBranch,
|
|
BaseCommit: plan.base,
|
|
Continued: plan.continues,
|
|
createdBranch: createdBranch,
|
|
userState: userState,
|
|
priorState: plan.priorState,
|
|
owner: plan.owner,
|
|
// A branch a sibling task forked because the conversation's own branch
|
|
// was busy is delivered once and never continued, so it records nothing.
|
|
tracksState: plan.tracksState && actualBranch == plan.name,
|
|
}
|
|
|
|
// Tear the worktree back down on every failure below. A half-replayed tree
|
|
// is the worst outcome available: it looks like a working checkout, so
|
|
// nothing downstream questions it, while the agent silently reads different
|
|
// code than the user has. The branch goes with it only when this prepare
|
|
// created it — a continued branch carries earlier turns' work, and dropping
|
|
// it because THIS turn could not start would destroy the very thing the
|
|
// task was meant to build on.
|
|
rollback := func() {
|
|
removeLocalWorktreeDir(gitRoot, worktreePath, logger)
|
|
if createdBranch {
|
|
dropBranch(gitRoot, actualBranch, logger)
|
|
}
|
|
}
|
|
|
|
// Replay the user's directory into the worktree.
|
|
//
|
|
// A branch forked from HEAD carries none of it, so the whole snapshot goes
|
|
// in. A continued branch already carries the snapshot the previous turn
|
|
// recorded, and the agent's commits sit on top of it, so only what the user
|
|
// changed after that is new information there.
|
|
replay, replayErr := replayUserState(worktreePath, plan, userState, logger)
|
|
if replayErr != nil {
|
|
rollback()
|
|
return nil, replayErr
|
|
}
|
|
wt.ReplayConflicts = replay.conflicts
|
|
// An unresolved merge means the branch does not carry this turn's snapshot
|
|
// yet; only a commit after the agent resolves can put it there.
|
|
wt.snapshotPending = len(replay.conflicts) > 0
|
|
// Whether the user has uncommitted work at all — replayed by this turn or
|
|
// already carried by the branch it continued.
|
|
_, diffErr := runGit(gitRoot, "diff", "--quiet", headSHA, userState)
|
|
wt.DirtyBaseCaptured = diffErr != nil
|
|
|
|
// Commit the replayed state as a baseline so "did this task change
|
|
// anything?" has an exact answer later. Without it the user's own
|
|
// uncommitted work counts as a change: a read-only task on a repo with an
|
|
// untracked scratch file would auto-commit that file at the end and leave
|
|
// behind a branch the agent never touched. The baseline also makes the
|
|
// delivered branch readable — `git diff <baseline>..<branch>` is precisely
|
|
// the agent's work, with the user's WIP as its own labelled commit.
|
|
//
|
|
// Skipped entirely while the replay is unresolved: an index with unmerged
|
|
// entries cannot be committed without committing conflict markers, and the
|
|
// agent has not had its turn at them yet.
|
|
if len(replay.conflicts) == 0 {
|
|
dirty, dirtyErr := worktreeIsDirty(worktreePath)
|
|
if dirtyErr != nil {
|
|
rollback()
|
|
return nil, fmt.Errorf("execenv: could not inspect the prepared worktree for %q: %w", gitRoot, dirtyErr)
|
|
}
|
|
// A branch this prepare created gets its baseline even when there was
|
|
// nothing to replay. Two things need it. The empty case is the one that
|
|
// used to be skipped, and it is the one where the branch sits exactly on
|
|
// the user's own HEAD — so the branch had no commit of its own, and
|
|
// nothing distinguished it from a branch the user creates there
|
|
// themselves. And `git diff <baseline>..<branch>` is then the agent's
|
|
// work on every branch, not only on the ones that started dirty.
|
|
if dirty || !plan.continues {
|
|
baseline, baseErr := commitBaseline(worktreePath, plan.continues, dirty)
|
|
if baseErr != nil {
|
|
// Without a baseline the task cannot tell the user's work from the
|
|
// agent's, so it would later commit the user's files as if the agent
|
|
// had produced them. Refuse rather than deliver a misleading branch.
|
|
rollback()
|
|
return nil, fmt.Errorf("execenv: could not record a baseline commit for the replayed state of %q: %w", gitRoot, baseErr)
|
|
}
|
|
// The branch now stands on a commit that carries this turn's snapshot,
|
|
// and everything downstream measures the delivery against it.
|
|
wt.BaseCommit = baseline
|
|
}
|
|
// The branch now carries this snapshot, so record it — together with the
|
|
// owner, which is what lets the next task prove this branch is its own
|
|
// before continuing it. Recorded here as well as at Finalize so a turn
|
|
// that never reaches Finalize still leaves the branch identifiable.
|
|
if err := wt.recordState(wt.BaseCommit, logger); err != nil && logger != nil {
|
|
logger.Warn("execenv: could not record the task branch before the run (non-fatal; Finalize records the delivered tip)",
|
|
"branch", wt.Branch, "error", err)
|
|
}
|
|
}
|
|
|
|
// Note on keeping sidecars out of the delivered branch: we deliberately do
|
|
// NOT write .git/info/exclude here. A linked worktree reads info/exclude
|
|
// from the repo's COMMON git dir, so the only file that would take effect
|
|
// is the user's own .git/info/exclude — editing it would change what `git
|
|
// status` shows in the user's checkout, which is theirs, not ours. Instead
|
|
// the daemon runs the existing CleanupRuntimeConfig + CleanupSidecars pass
|
|
// over the worktree before Finalize, so the sidecars are simply gone by the
|
|
// time anything is committed. That also preserves a genuine agent edit to a
|
|
// tracked CLAUDE.md, which a blanket exclude would have swallowed.
|
|
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree ready",
|
|
"git_root", gitRoot,
|
|
"path", worktreePath,
|
|
"branch", actualBranch,
|
|
"base", wt.BaseCommit,
|
|
"continued", wt.Continued,
|
|
"dirty_base_captured", wt.DirtyBaseCaptured,
|
|
"replay_conflicts", len(wt.ReplayConflicts),
|
|
)
|
|
}
|
|
return wt, nil
|
|
}
|
|
|
|
// Finalize commits whatever the agent left behind, removes the worktree, and
|
|
// reports the branch. Called after the agent exits, before the env root is
|
|
// handed to the GC.
|
|
//
|
|
// The auto-commit is the reason a worktree task can't lose work: `git worktree
|
|
// remove --force` would happily delete uncommitted edits, and the user would
|
|
// have no way to get them back. Committing first turns "the agent edited files"
|
|
// into "the branch has a commit", which is the delivery contract for this mode.
|
|
//
|
|
// If that commit cannot be made — a repo with commit.gpgSign and no signing key
|
|
// available to the daemon, a full disk, a ref lock we lost — Finalize returns an
|
|
// error and DELIBERATELY LEAVES THE WORKTREE IN PLACE. Removing it would be the
|
|
// one operation in this file that destroys work with no way back, and a warning
|
|
// in the daemon log is not an acceptable substitute for the user's changes. The
|
|
// surviving worktree stays registered in the user's repo, so `git worktree list`
|
|
// points straight at it.
|
|
func (w *LocalWorktree) Finalize(logger *slog.Logger) (LocalWorktreeOutcome, error) {
|
|
if w == nil {
|
|
return LocalWorktreeOutcome{}, nil
|
|
}
|
|
outcome := LocalWorktreeOutcome{Branch: w.Branch}
|
|
|
|
unlock, err := lockGitRoot(w.GitRoot, logger)
|
|
if err != nil {
|
|
// Nothing has been committed or removed yet, so the agent's work is
|
|
// still sitting in the worktree. Report it as preserved rather than
|
|
// naming a branch that does not carry it.
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
return outcome, fmt.Errorf("could not lock %q to finalize branch %s: %w; "+
|
|
"the work is preserved in the worktree at %s", w.GitRoot, w.Branch, err, w.Path)
|
|
}
|
|
defer unlock()
|
|
|
|
// Something before the commit went wrong in a way that would make the
|
|
// delivered branch misleading. Commit nothing and keep the worktree: the
|
|
// agent's work is still in it, and so is whatever the caller could not
|
|
// clean up, which a human can now look at directly.
|
|
if w.aborted != nil {
|
|
// Report NO branch. One exists in the user's repo, but nothing was
|
|
// committed to it, so naming it as this task's result would point them
|
|
// at a branch that is missing the very work they are looking for. The
|
|
// preserved worktree path below is the honest pointer.
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: worktree finalize aborted; nothing committed, worktree kept for inspection",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "error", w.aborted)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"refusing to deliver branch %s: %w; the task worktree is preserved at %s (listed by `git worktree list` in %s)",
|
|
w.Branch, w.aborted, w.Path, w.GitRoot)
|
|
}
|
|
|
|
// An unresolved merge is never committed. The worktree may be carrying the
|
|
// user's edits from before this turn (replayUserState hands the conflict to
|
|
// the agent rather than dropping it), and `git add -A` would turn conflict
|
|
// markers into a delivered commit — the one way this mode can produce a
|
|
// branch that compiles nowhere and looks deliberate. Keep the worktree, say
|
|
// which files are still open, and leave the snapshot unrecorded so the next
|
|
// turn replays the same edits instead of assuming they landed.
|
|
if unmerged, unmergedErr := unmergedPaths(w.Path); unmergedErr != nil || len(unmerged) > 0 {
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
if unmergedErr != nil {
|
|
return outcome, fmt.Errorf("could not check %s for an unresolved merge: %w; "+
|
|
"the work is preserved in the worktree at %s", w.Branch, unmergedErr, w.Path)
|
|
}
|
|
if logger != nil {
|
|
logger.Error("execenv: worktree left with an unresolved merge; nothing committed, worktree kept",
|
|
"path", w.Path, "branch", w.Branch, "files", unmerged)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"refusing to deliver branch %s: your local edits to %s are still unmerged in the task worktree; "+
|
|
"the worktree is preserved at %s (listed by `git worktree list` in %s) — resolve the conflict there, "+
|
|
"or re-run the task and let the agent finish the merge",
|
|
w.Branch, quotedPaths(unmerged), w.Path, w.GitRoot)
|
|
}
|
|
|
|
// Treat "can't tell" like "dirty": committing costs an empty commit at
|
|
// worst, while assuming clean risks deleting the agent's edits.
|
|
dirty, statusErr := worktreeIsDirty(w.Path)
|
|
if statusErr != nil {
|
|
if logger != nil {
|
|
logger.Warn("execenv: inspect worktree status failed; committing defensively",
|
|
"path", w.Path, "error", statusErr)
|
|
}
|
|
dirty = true
|
|
}
|
|
if dirty {
|
|
committed, err := w.commitAll(logger)
|
|
if err != nil {
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: could not commit the agent's changes; keeping the worktree so the work is recoverable",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "error", err)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"could not commit the agent's changes to branch %s: %w; the work is preserved in the worktree at %s (listed by `git worktree list` in %s) — recover it before that directory is reclaimed",
|
|
w.Branch, err, w.Path, w.GitRoot)
|
|
}
|
|
outcome.AutoCommitted = committed
|
|
}
|
|
|
|
// A branch still sitting exactly on its base commit means the task changed
|
|
// nothing — the read-only case. Delete it so the user's branch list only
|
|
// ever grows for tasks that actually produced work. Only ever the branch
|
|
// this task created: a continued branch sits on its base precisely because
|
|
// this turn added nothing to what earlier turns delivered, and deleting it
|
|
// would take their work with it.
|
|
tip, err := runGitTrimmed(w.Path, "rev-parse", "--verify", "HEAD")
|
|
producedWork := err != nil || tip != w.BaseCommit
|
|
dropped := !producedWork && w.createdBranch
|
|
|
|
// A turn that started mid-merge only gets to advance the branch's recorded
|
|
// state if it committed something after resolving. When the branch is still
|
|
// where THIS turn found it, nothing distinguishes "the agent resolved in
|
|
// favour of the version already on the branch" from "the agent threw the
|
|
// merge away" — the tree is identical either way — so the record keeps the
|
|
// state the branch is known to carry, and the next turn offers the user's
|
|
// edits again.
|
|
//
|
|
// The cost is a merge the agent may have to conclude more than once, in the
|
|
// one case where its conclusion left no trace. The alternative is recording
|
|
// edits as delivered that may have been discarded, which is how they go
|
|
// missing without anyone seeing it.
|
|
if w.snapshotPending && tip == w.BaseCommit {
|
|
if w.priorState == "" {
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
return outcome, fmt.Errorf(
|
|
"refusing to record branch %s: the merge with your local edits was never concluded and this branch has no earlier "+
|
|
"state to fall back on; the task worktree is preserved at %s (listed by `git worktree list` in %s)",
|
|
w.Branch, w.Path, w.GitRoot)
|
|
}
|
|
if logger != nil {
|
|
logger.Info("execenv: the run concluded its merge without committing; keeping the branch's previous recorded state so the edits are offered again",
|
|
"path", w.Path, "branch", w.Branch)
|
|
}
|
|
w.userState = w.priorState
|
|
}
|
|
|
|
// Record BEFORE the worktree goes away, and treat a failure as a failure to
|
|
// deliver. The record is what makes the branch continuable: without it the
|
|
// next turn cannot prove the branch is this conversation's and starts a new
|
|
// line of work, stranding what this turn produced. Ordering it first is what
|
|
// makes that recoverable — the worktree is still there to preserve, exactly
|
|
// as for a commit that could not be made.
|
|
if !dropped {
|
|
if verifyErr := w.verifyDeliveryPoint(tip); verifyErr != nil {
|
|
outcome.Branch = ""
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: the run's delivery point cannot be recorded as this conversation's; nothing recorded, worktree kept",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "tip", tip, "base", w.BaseCommit, "error", verifyErr)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"refusing to record branch %s: %w; the task worktree is preserved at %s (listed by `git worktree list` in %s) — "+
|
|
"recover the work from there, and let the run keep the commit the worktree started from instead of resetting past it",
|
|
w.Branch, verifyErr, w.Path, w.GitRoot)
|
|
}
|
|
if recErr := w.recordState(tip, logger); recErr != nil {
|
|
outcome.PreservedPath = w.Path
|
|
if logger != nil {
|
|
logger.Error("execenv: could not record the delivered task branch; keeping the worktree",
|
|
"path", w.Path, "branch", w.Branch, "git_root", w.GitRoot, "error", recErr)
|
|
}
|
|
return outcome, fmt.Errorf(
|
|
"could not record branch %s as this conversation's: %w; the work is committed to that branch and the "+
|
|
"task worktree is preserved at %s (listed by `git worktree list` in %s) — a follow-up run will start "+
|
|
"a new branch instead of continuing this one",
|
|
w.Branch, recErr, w.Path, w.GitRoot)
|
|
}
|
|
}
|
|
|
|
if removeErr := removeLocalWorktreeDir(w.GitRoot, w.Path, logger); removeErr != nil {
|
|
outcome.PreservedPath = w.Path
|
|
return outcome, fmt.Errorf(
|
|
"could not remove finalized worktree for branch %s: %w; the task worktree remains at %s",
|
|
w.Branch, removeErr, w.Path)
|
|
}
|
|
|
|
if dropped {
|
|
dropBranch(w.GitRoot, w.Branch, logger)
|
|
outcome.Branch = ""
|
|
}
|
|
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree finalized",
|
|
"git_root", w.GitRoot,
|
|
"branch", outcome.Branch,
|
|
"auto_committed", outcome.AutoCommitted,
|
|
"produced_work", producedWork,
|
|
"continued", w.Continued,
|
|
)
|
|
}
|
|
return outcome, nil
|
|
}
|
|
|
|
// Discard tears a worktree down without delivering anything: unregister it,
|
|
// delete its directory, drop its branch.
|
|
//
|
|
// For the abandon-before-the-agent-ran case only. Finalize is the path that
|
|
// preserves work; this one assumes there is none to preserve, so callers must
|
|
// be sure nothing has run in the worktree yet.
|
|
func (w *LocalWorktree) Discard(logger *slog.Logger) {
|
|
if w == nil {
|
|
return
|
|
}
|
|
unlock, err := lockGitRoot(w.GitRoot, logger)
|
|
if err != nil {
|
|
// Best-effort by contract: every step below only logs on failure. The
|
|
// registration this leaves behind is pruned by the next prepare on
|
|
// this repo, which is the same self-heal path a crashed daemon uses.
|
|
if logger != nil {
|
|
logger.Warn("execenv: could not lock the repository to discard the task worktree",
|
|
"git_root", w.GitRoot, "path", w.Path, "branch", w.Branch, "error", err)
|
|
}
|
|
return
|
|
}
|
|
defer unlock()
|
|
removeLocalWorktreeDir(w.GitRoot, w.Path, logger)
|
|
// Same rule as every other teardown: a branch this prepare did not create
|
|
// belongs to the turns before it and outlives this task.
|
|
if w.createdBranch {
|
|
dropBranch(w.GitRoot, w.Branch, logger)
|
|
}
|
|
if logger != nil {
|
|
logger.Info("execenv: local worktree discarded before the agent ran",
|
|
"git_root", w.GitRoot, "path", w.Path, "branch", w.Branch, "branch_dropped", w.createdBranch)
|
|
}
|
|
}
|
|
|
|
// AbortWithReason marks the worktree undeliverable. Finalize will then commit
|
|
// nothing, remove nothing, and return an error naming the preserved path.
|
|
//
|
|
// This exists because the decision "is this branch safe to deliver?" is made
|
|
// outside this package — the daemon knows whether its own sidecar cleanup
|
|
// succeeded — while the only code that can act on it is Finalize. The first
|
|
// reason wins: it is the one closest to the root cause.
|
|
func (w *LocalWorktree) AbortWithReason(err error) {
|
|
if w == nil || err == nil || w.aborted != nil {
|
|
return
|
|
}
|
|
w.aborted = err
|
|
}
|
|
|
|
// commitBaseline records the user's replayed uncommitted state as its own
|
|
// commit on the task branch, returning the new tip. On a continued branch that
|
|
// state is the increment since the previous turn, so the message says so rather
|
|
// than claiming to be the branch's baseline.
|
|
//
|
|
// A new branch gets one even with nothing to record. The commit is then empty,
|
|
// and that is the point: it is the branch's first commit of its own, the thing
|
|
// that makes it distinguishable later from a branch the user creates at the
|
|
// same place — see LocalWorktree.BaseCommit.
|
|
func commitBaseline(worktreePath string, continued, dirty bool) (string, error) {
|
|
message := "chore(agent): baseline — uncommitted work from the local directory"
|
|
switch {
|
|
case continued:
|
|
message = "chore(agent): uncommitted work from the local directory since the previous turn"
|
|
case !dirty:
|
|
message = "chore(agent): baseline — the task worktree started here"
|
|
}
|
|
if _, err := commitEverything(worktreePath, message, !dirty); err != nil {
|
|
return "", err
|
|
}
|
|
tip, err := runGitTrimmed(worktreePath, "rev-parse", "--verify", "HEAD")
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve baseline commit: %w", err)
|
|
}
|
|
return tip, nil
|
|
}
|
|
|
|
// commitAll stages and commits everything the agent left behind. Returns
|
|
// whether a commit was actually created; an error means the changes are still
|
|
// only on disk and the caller must not delete the worktree.
|
|
func (w *LocalWorktree) commitAll(logger *slog.Logger) (bool, error) {
|
|
// Never --allow-empty here: an empty commit would make a read-only turn look
|
|
// like it produced work and leave its branch behind.
|
|
return commitEverything(w.Path, "chore(agent): uncommitted changes from task", false)
|
|
}
|
|
|
|
// commitEverything returns (false, nil) for the benign "there was nothing to
|
|
// commit" case and (false, err) for a real failure — the distinction callers
|
|
// need to decide whether the tree is safe to discard.
|
|
func commitEverything(worktreePath, message string, allowEmpty bool) (bool, error) {
|
|
if out, err := runGit(worktreePath, "add", "-A"); err != nil {
|
|
return false, fmt.Errorf("git add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
// --no-verify: the user's commit hooks are written for the user's own
|
|
// workflow (interactive linters, test suites, signing prompts) and a hook
|
|
// failure here would mean losing the agent's work to save a lint run. Note
|
|
// it does NOT disable commit.gpgSign, which is why the caller has to treat
|
|
// a commit failure as "keep the worktree" rather than a warning.
|
|
args := append(commitIdentityArgs(worktreePath), "commit", "--no-verify")
|
|
if allowEmpty {
|
|
args = append(args, "--allow-empty")
|
|
}
|
|
args = append(args, "-m", message)
|
|
if out, err := runGit(worktreePath, args...); err != nil {
|
|
if strings.Contains(out, "nothing to commit") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("git commit: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
// commitIdentityArgs supplies a committer identity only when the repo doesn't
|
|
// already have one. A repo with user.email configured keeps it, so commits
|
|
// still look like they came from the user's own setup.
|
|
func commitIdentityArgs(dir string) []string {
|
|
if email, err := runGitTrimmed(dir, "config", "user.email"); err == nil && email != "" {
|
|
return nil
|
|
}
|
|
return []string{
|
|
"-c", "user.name=Multica Agent",
|
|
"-c", "[email protected]",
|
|
}
|
|
}
|
|
|
|
func worktreeIsDirty(worktreePath string) (bool, error) {
|
|
out, err := runGit(worktreePath, "status", "--porcelain")
|
|
if err != nil {
|
|
return false, fmt.Errorf("git status: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return strings.TrimSpace(out) != "", nil
|
|
}
|
|
|
|
// removeLocalWorktreeDir unregisters the worktree from the user's repo and
|
|
// deletes its directory. The branch is deliberately left alone — it is the
|
|
// task's deliverable.
|
|
func removeLocalWorktreeDir(gitRoot, worktreePath string, logger *slog.Logger) error {
|
|
var removeErr error
|
|
if out, err := runGit(gitRoot, "worktree", "remove", "--force", worktreePath); err != nil {
|
|
removeErr = err
|
|
if logger != nil {
|
|
logger.Warn("execenv: git worktree remove failed; pruning registration",
|
|
"path", worktreePath, "output", out, "error", err)
|
|
}
|
|
// Fall back to deleting the directory ourselves and dropping the now
|
|
// dangling registration, so the user's repo isn't left listing a
|
|
// worktree that no longer exists.
|
|
if rmErr := os.RemoveAll(worktreePath); rmErr != nil {
|
|
removeErr = errors.Join(removeErr, rmErr)
|
|
if logger != nil {
|
|
logger.Warn("execenv: remove worktree directory failed", "path", worktreePath, "error", rmErr)
|
|
}
|
|
}
|
|
if out, pruneErr := runGit(gitRoot, "worktree", "prune"); pruneErr != nil && logger != nil {
|
|
logger.Warn("execenv: git worktree prune failed", "output", out, "error", pruneErr)
|
|
}
|
|
}
|
|
// Lstat verifies the path entry itself is gone. Stat would treat a broken
|
|
// symlink as absent even though a stale entry still occupies the handoff path.
|
|
if _, statErr := os.Lstat(worktreePath); errors.Is(statErr, os.ErrNotExist) {
|
|
return nil
|
|
} else if statErr != nil {
|
|
return fmt.Errorf("confirm worktree removal: %w", statErr)
|
|
}
|
|
if removeErr != nil {
|
|
return fmt.Errorf("worktree directory still exists after removal fallback: %w", removeErr)
|
|
}
|
|
return errors.New("worktree directory still exists after git removal reported success")
|
|
}
|
|
|
|
// deleteBranch drops a task branch that carries nothing worth keeping — an
|
|
// empty read-only run, or a prepare that aborted partway. Best-effort: a
|
|
// leftover branch is untidy, never harmful.
|
|
func deleteBranch(gitRoot, branch string, logger *slog.Logger) {
|
|
if branch == "" {
|
|
return
|
|
}
|
|
if out, err := runGit(gitRoot, "branch", "-D", branch); err != nil && logger != nil {
|
|
logger.Warn("execenv: delete task branch failed (non-fatal)",
|
|
"branch", branch, "output", out, "error", err)
|
|
}
|
|
}
|
|
|
|
// resolveGitRoot returns the repository root containing dir. Worktree mode is
|
|
// opt-in per resource, so a non-git directory here is a misconfiguration the
|
|
// user needs to see and fix — we fail closed with an actionable message rather
|
|
// than silently degrading to the in-place lock, which would leave the user
|
|
// wondering why their tasks still queue.
|
|
func resolveGitRoot(dir string) (string, error) {
|
|
root, err := runGitTrimmed(dir, "rev-parse", "--show-toplevel")
|
|
if err != nil || root == "" {
|
|
return "", fmt.Errorf("execenv: local_directory %q is not a git repository, "+
|
|
"but its project resource is set to execution_mode=worktree; "+
|
|
"initialise a repository there or switch the resource back to in_place", dir)
|
|
}
|
|
// EvalSymlinks so the root matches the path git reports from inside the
|
|
// worktree later — on macOS /tmp vs /private/tmp otherwise produce two
|
|
// different lock keys for one repo.
|
|
if resolved, evalErr := filepath.EvalSymlinks(root); evalErr == nil {
|
|
root = resolved
|
|
}
|
|
return filepath.Clean(root), nil
|
|
}
|
|
|
|
// captureUserSnapshot records the user's working directory as one commit: their
|
|
// tracked modifications AND their untracked-but-not-ignored files, in a single
|
|
// tree parented at their HEAD.
|
|
//
|
|
// One snapshot rather than the older split — a `git stash create` for tracked
|
|
// edits and a file copy for untracked ones — because a continued branch has to
|
|
// answer "what has the user changed since the turn I already carry?", and that
|
|
// question is unanswerable for a file no snapshot ever recorded: an untracked
|
|
// file the agent then edited would be re-copied from the user's older version
|
|
// every turn, or its later deletion would never carry. A tree also expresses
|
|
// deletions and mode changes, which a copy cannot.
|
|
//
|
|
// Nothing here touches the user's index, working tree or refs. GIT_INDEX_FILE
|
|
// points at a private index in the task's env root, so `git add` writes only
|
|
// blob objects and that file — which is also what makes the capture immune to
|
|
// the .git/index.lock races that used to be able to end the task (#7434): the
|
|
// only lock taken is on our own temporary file.
|
|
func captureUserSnapshot(gitRoot, envRoot, headSHA string, logger *slog.Logger) (string, error) {
|
|
if envRoot == "" {
|
|
return "", errors.New("execenv: user snapshot requires an env root to build its index in")
|
|
}
|
|
indexPath := filepath.Join(envRoot, snapshotIndexFileName)
|
|
if err := os.Remove(indexPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
return "", fmt.Errorf("clear snapshot index: %w", err)
|
|
}
|
|
// It is scratch space, not task state: the env root is handed to the agent.
|
|
defer os.Remove(indexPath)
|
|
env := []string{"GIT_INDEX_FILE=" + indexPath}
|
|
|
|
// Seed from the user's own index so git can trust its stat cache instead of
|
|
// re-hashing the whole repository on every turn. A missing or torn copy is
|
|
// not a failure — read-tree rebuilds a correct index, merely a colder one —
|
|
// so the fallback runs on any error from the add, not just from the copy.
|
|
seeded := seedSnapshotIndex(gitRoot, indexPath)
|
|
addArgs := append([]string{"add", "-A", "--"}, snapshotExcludes()...)
|
|
if out, err := runGitEnv(gitRoot, env, addArgs...); err != nil {
|
|
if !seeded {
|
|
return "", fmt.Errorf("git add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
if logger != nil {
|
|
logger.Debug("execenv: snapshot index seeded from the repository index was unusable; rebuilding it",
|
|
"git_root", gitRoot, "output", strings.TrimSpace(out), "error", err)
|
|
}
|
|
if out, resetErr := runGitEnv(gitRoot, env, "read-tree", headSHA); resetErr != nil {
|
|
return "", fmt.Errorf("git read-tree: %s: %w", strings.TrimSpace(out), resetErr)
|
|
}
|
|
if out, retryErr := runGitEnv(gitRoot, env, addArgs...); retryErr != nil {
|
|
return "", fmt.Errorf("git add: %s: %w", strings.TrimSpace(out), retryErr)
|
|
}
|
|
}
|
|
tree, err := runGitTrimmedEnv(gitRoot, env, "write-tree")
|
|
if err != nil {
|
|
return "", fmt.Errorf("git write-tree: %w", err)
|
|
}
|
|
// The identity args cover a repo with no user.email configured: writing a
|
|
// commit object needs a committer, and without them the user's uncommitted
|
|
// work would be dropped on a technicality.
|
|
args := append(commitIdentityArgs(gitRoot), "commit-tree", tree, "-p", headSHA, "-m",
|
|
"multica: local directory snapshot\n\nThe tree of this commit is the user's working directory as a task saw it.")
|
|
snapshot, err := runGitTrimmed(gitRoot, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("git commit-tree: %w", err)
|
|
}
|
|
return snapshot, nil
|
|
}
|
|
|
|
// seedSnapshotIndex copies the repository's index to path, reporting whether it
|
|
// got one. Read as a plain file rather than through git: git would want the
|
|
// index lock, and this copy exists precisely to avoid waiting on it.
|
|
func seedSnapshotIndex(gitRoot, path string) bool {
|
|
src, err := runGitTrimmed(gitRoot, "rev-parse", "--git-path", "index")
|
|
if err != nil || src == "" {
|
|
return false
|
|
}
|
|
if !filepath.IsAbs(src) {
|
|
src = filepath.Join(gitRoot, src)
|
|
}
|
|
in, err := os.Open(src)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
defer in.Close()
|
|
info, err := in.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
out, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, copyErr := io.Copy(out, in)
|
|
closeErr := out.Close()
|
|
// Git compares file timestamps with the index timestamp to identify racy
|
|
// same-size edits. Giving the copy a fresh mtime would turn those edits
|
|
// into apparently clean stat-cache hits, silently omitting user drafts.
|
|
if copyErr != nil || closeErr != nil || os.Chtimes(path, info.ModTime(), info.ModTime()) != nil {
|
|
_ = os.Remove(path)
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// snapshotExcludes keeps the daemon's own sidecars out of the user's snapshot.
|
|
// They are untracked files in the user's directory whenever an in_place task is
|
|
// mid-flight on the same path, or was killed before its cleanup ran; carrying
|
|
// them would put another issue's brief inside this task's worktree — where the
|
|
// agent would read it as its own context — and commit it to the branch.
|
|
// Matched at any depth, because an in_place resource may point at a
|
|
// subdirectory of this repo.
|
|
func snapshotExcludes() []string {
|
|
specs := make([]string, 0, len(multicaSidecarDirNames))
|
|
for _, name := range multicaSidecarDirNames {
|
|
specs = append(specs, ":(exclude,glob)**/"+name+"/**")
|
|
}
|
|
return specs
|
|
}
|
|
|
|
// branchOwner is the conversation a task branch belongs to. Recorded with the
|
|
// branch's snapshot and compared before any later task continues it: the branch
|
|
// NAME carries a human-readable issue key, which the user can also type and
|
|
// which two workspaces can mint identically, so the name alone can never
|
|
// establish that a branch is ours to append to (MUL-6881 review).
|
|
type branchOwner struct {
|
|
WorkspaceID string
|
|
AgentID string
|
|
ConversationID string
|
|
}
|
|
|
|
func (o branchOwner) valid() bool {
|
|
return o.WorkspaceID != "" && o.AgentID != "" && o.ConversationID != ""
|
|
}
|
|
|
|
// fingerprint is the stable, collision-resistant form of the same identity,
|
|
// used to name a branch when the readable name is already taken by someone
|
|
// else's. Stable across turns, so the fallback branch is continued too.
|
|
func (o branchOwner) fingerprint() string {
|
|
sum := sha256.Sum256([]byte(o.WorkspaceID + "\x00" + o.AgentID + "\x00" + o.ConversationID))
|
|
return hex.EncodeToString(sum[:])[:12]
|
|
}
|
|
|
|
const (
|
|
ownerTrailerWorkspace = "Multica-Workspace"
|
|
ownerTrailerAgent = "Multica-Agent"
|
|
ownerTrailerConversation = "Multica-Conversation"
|
|
)
|
|
|
|
// branchRecord is what refs/multica/local-state/<branch> holds: a commit whose
|
|
// TREE is the user's directory as the branch last carried it, whose SECOND
|
|
// PARENT is the branch tip at that moment, and whose message names the owner.
|
|
//
|
|
// The checkpoint is what makes the record about this BRANCH rather than merely
|
|
// about its name. Owner alone proved only that Multica once wrote a branch
|
|
// called this, and that stayed true after the user deleted it and created their
|
|
// own under the same name — the next task then continued into their work
|
|
// (MUL-6881 review). Requiring the checkpoint to still be an ancestor of the
|
|
// tip is the continuity proof: a branch deleted and recreated, force-moved onto
|
|
// unrelated history, or rebased no longer contains the commit recorded here.
|
|
type branchRecord struct {
|
|
// state is the commit whose tree is the user snapshot the branch carries.
|
|
state string
|
|
// checkpoint is the branch tip this record was written against.
|
|
checkpoint string
|
|
owner branchOwner
|
|
}
|
|
|
|
// writeBranchRecord records the branch as carrying userState at checkpoint, and
|
|
// points the branch's ref at that record.
|
|
//
|
|
// The checkpoint is passed in, never re-read from the branch ref here: the
|
|
// caller knows which commit it actually delivered, while the ref is the user's
|
|
// and can move between the delivery and this write. Recording what we delivered
|
|
// means a branch that moved in that window simply fails the ancestor test next
|
|
// time, which is the safe direction.
|
|
func writeBranchRecord(gitRoot, branch, userState, checkpoint string, owner branchOwner) (string, error) {
|
|
if checkpoint == "" {
|
|
return "", fmt.Errorf("no checkpoint to record for branch %s", branch)
|
|
}
|
|
args := append(commitIdentityArgs(gitRoot), "commit-tree", userState+"^{tree}",
|
|
"-p", userState, "-p", checkpoint, "-m", branchRecordMessage(owner))
|
|
record, err := runGitTrimmed(gitRoot, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("git commit-tree: %w", err)
|
|
}
|
|
if out, err := runGit(gitRoot, "update-ref", userStateRef(branch), record); err != nil {
|
|
return "", fmt.Errorf("git update-ref: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
func branchRecordMessage(owner branchOwner) string {
|
|
var b strings.Builder
|
|
b.WriteString("multica: task branch record\n\n")
|
|
b.WriteString("Written by Multica for a local_directory task running in worktree mode. Its\n")
|
|
b.WriteString("tree is the user's working directory as this branch last carried it, and its\n")
|
|
b.WriteString("second parent is the branch tip at that moment — together they let the next\n")
|
|
b.WriteString("turn replay only what changed since, and prove the branch is still the one\n")
|
|
b.WriteString("recorded here. Safe to delete along with the branch.\n\n")
|
|
fmt.Fprintf(&b, "%s: %s\n", ownerTrailerWorkspace, owner.WorkspaceID)
|
|
fmt.Fprintf(&b, "%s: %s\n", ownerTrailerAgent, owner.AgentID)
|
|
fmt.Fprintf(&b, "%s: %s\n", ownerTrailerConversation, owner.ConversationID)
|
|
return b.String()
|
|
}
|
|
|
|
// readBranchRecord reads back what a branch is recorded as carrying. A commit
|
|
// without all three trailers or without a second parent — anything not written
|
|
// by writeBranchRecord — yields a record that can never match a valid owner.
|
|
func readBranchRecord(gitRoot, commit string) (branchRecord, error) {
|
|
body, err := runGitTrimmed(gitRoot, "log", "-1", "--format=%B", commit)
|
|
if err != nil {
|
|
return branchRecord{}, err
|
|
}
|
|
record := branchRecord{state: commit}
|
|
for _, line := range strings.Split(body, "\n") {
|
|
key, value, found := strings.Cut(strings.TrimSpace(line), ":")
|
|
if !found {
|
|
continue
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
switch key {
|
|
case ownerTrailerWorkspace:
|
|
record.owner.WorkspaceID = value
|
|
case ownerTrailerAgent:
|
|
record.owner.AgentID = value
|
|
case ownerTrailerConversation:
|
|
record.owner.ConversationID = value
|
|
}
|
|
}
|
|
if checkpoint, err := runGitTrimmed(gitRoot, "rev-parse", "--verify", "--quiet", commit+"^2"); err == nil {
|
|
record.checkpoint = checkpoint
|
|
}
|
|
return record, nil
|
|
}
|
|
|
|
// addLocalWorktree creates the worktree, retrying once under a suffixed branch
|
|
// name when the branch already exists (a re-dispatched task keeps its id, so
|
|
// its branch can survive from the previous run).
|
|
// taskBranchPlan is the decision about which branch a task's worktree checks
|
|
// out and which commit it starts from.
|
|
type taskBranchPlan struct {
|
|
// name is the branch to check out or create.
|
|
name string
|
|
// base is the commit the worktree starts from.
|
|
base string
|
|
// continues is true when base is an earlier turn's branch tip rather than
|
|
// the user's HEAD, so the checkout already carries that turn's work.
|
|
continues bool
|
|
// priorState is the user snapshot that branch is recorded as already
|
|
// carrying. Set only when continues is true; it is the merge base for this
|
|
// turn's replay.
|
|
priorState string
|
|
// priorCheckpoint is the commit that branch was recorded at and still
|
|
// contains — this turn's proof that the branch is the conversation's. The
|
|
// branch tip this turn starts from descends from it, so everything after
|
|
// prepare measures against that tip instead.
|
|
priorCheckpoint string
|
|
// reset is true when the conversation's branch exists but is fully merged
|
|
// into HEAD, and this task restarts it there.
|
|
reset bool
|
|
// conversational is true when name is keyed to the conversation rather than
|
|
// to this one task, so a sibling task may legitimately want it too.
|
|
conversational bool
|
|
// tracksState is true when a later turn may continue this branch, and it
|
|
// therefore has to record what the user's directory looked like.
|
|
tracksState bool
|
|
// owner is the identity the branch is recorded under.
|
|
owner branchOwner
|
|
}
|
|
|
|
// altName disambiguates a branch a live sibling already holds.
|
|
func (p taskBranchPlan) altName(taskID string) string {
|
|
if p.conversational {
|
|
// Task-scoped, and readable next to the conversation branch it forked
|
|
// from: agent/j/mul-6881-<task>.
|
|
return p.name + "-" + taskKey(taskID)
|
|
}
|
|
// Already task-scoped, so only a re-run of the same task can collide here.
|
|
return fmt.Sprintf("%s-%d", p.name, time.Now().Unix())
|
|
}
|
|
|
|
// resolveTaskBranch picks the branch for this task.
|
|
//
|
|
// Tasks that belong to a conversation — the turns of one issue, one chat
|
|
// session — share a branch, because they are one line of work: the user says
|
|
// "now also fix the caller" and expects the agent to be standing on what it
|
|
// wrote a minute ago. Keying the branch to the task instead gave every turn its
|
|
// own branch forked from HEAD, so turn two started in a tree that did not
|
|
// contain turn one's work and nothing said so (MUL-6881). Per-task env roots
|
|
// stay as they are — sibling tasks still run concurrently, they just deliver
|
|
// onto the branch their conversation owns.
|
|
//
|
|
// Continuing a branch is decided by its recorded OWNER, never by its name.
|
|
// `agent/j/mul-6881` is a name the user can type, another agent of the same
|
|
// display name can produce, and a second workspace can mint for a different
|
|
// issue; appending to any of those would silently mix two lines of work. So a
|
|
// same-named branch we cannot prove is ours pushes this task onto
|
|
// `agent/j/mul-6881-<fingerprint>` — stable for this exact conversation, so its
|
|
// own follow-ups continue it — and, if that is somehow taken too, onto a
|
|
// task-scoped branch that continues nothing.
|
|
func resolveTaskBranch(gitRoot string, params LocalWorktreeParams, headSHA string, logger *slog.Logger) taskBranchPlan {
|
|
agentSegment := sanitizeName(params.AgentName)
|
|
taskScoped := taskBranchPlan{name: fmt.Sprintf("agent/%s/%s", agentSegment, taskKey(params.TaskID)), base: headSHA}
|
|
|
|
owner := params.owner()
|
|
if params.ConversationKey == "" || !owner.valid() {
|
|
return taskScoped
|
|
}
|
|
preferred := fmt.Sprintf("agent/%s/%s", agentSegment, sanitizeName(params.ConversationKey))
|
|
for _, name := range []string{preferred, preferred + "-" + owner.fingerprint()} {
|
|
plan, ok := planForConversationBranch(gitRoot, name, headSHA, owner, logger)
|
|
if ok {
|
|
return plan
|
|
}
|
|
if logger != nil {
|
|
logger.Info("execenv: branch exists but is not this conversation's; not continuing it",
|
|
"git_root", gitRoot, "branch", name)
|
|
}
|
|
}
|
|
return taskScoped
|
|
}
|
|
|
|
// planForConversationBranch reports how this task would use one candidate
|
|
// branch name, and whether it may use it at all.
|
|
func planForConversationBranch(gitRoot, name, headSHA string, owner branchOwner, logger *slog.Logger) (taskBranchPlan, bool) {
|
|
plan := taskBranchPlan{
|
|
name: name,
|
|
base: headSHA,
|
|
conversational: true,
|
|
tracksState: true,
|
|
owner: owner,
|
|
}
|
|
tip, err := runGitTrimmed(gitRoot, "rev-parse", "--verify", "--quiet", "refs/heads/"+name)
|
|
if err != nil || tip == "" {
|
|
// Free to create.
|
|
return plan, true
|
|
}
|
|
record, owned := branchOwnedBy(gitRoot, name, owner, logger)
|
|
if !owned {
|
|
return taskBranchPlan{}, false
|
|
}
|
|
// The user merged it: the branch tip carries nothing HEAD does not, so
|
|
// continuing from it would strand this task behind their own commits.
|
|
if _, mergedErr := runGit(gitRoot, "merge-base", "--is-ancestor", name, "HEAD"); mergedErr == nil {
|
|
plan.reset = true
|
|
return plan, true
|
|
}
|
|
plan.base = tip
|
|
plan.continues = true
|
|
plan.priorState = record.state
|
|
plan.priorCheckpoint = record.checkpoint
|
|
if logger != nil {
|
|
logger.Info("execenv: continuing the conversation's existing branch",
|
|
"git_root", gitRoot, "branch", name, "tip", tip)
|
|
}
|
|
return plan, true
|
|
}
|
|
|
|
// branchOwnedBy reports whether a branch is still the one this conversation
|
|
// recorded, returning the user snapshot it carries.
|
|
//
|
|
// Two questions, and both have to hold. Is the record ours — workspace, agent
|
|
// and conversation ids. And is the branch still the one it was written against
|
|
// — the recorded checkpoint has to be an ancestor of the current tip. The
|
|
// second is not pedantry: a branch the user deleted and recreated under the
|
|
// same name still satisfies the first, and continuing it would append this
|
|
// conversation onto their unrelated work.
|
|
//
|
|
// A branch with no record is not ours by definition: every branch this code
|
|
// creates writes one before its task is allowed to run.
|
|
func branchOwnedBy(gitRoot, branch string, owner branchOwner, logger *slog.Logger) (branchRecord, bool) {
|
|
ref, err := readUserStateRef(gitRoot, branch)
|
|
if err != nil || ref == "" {
|
|
return branchRecord{}, false
|
|
}
|
|
record, err := readBranchRecord(gitRoot, ref)
|
|
if err != nil || record.owner != owner || record.checkpoint == "" {
|
|
return branchRecord{}, false
|
|
}
|
|
if _, err := runGit(gitRoot, "merge-base", "--is-ancestor", record.checkpoint, "refs/heads/"+branch); err != nil {
|
|
if logger != nil {
|
|
logger.Info("execenv: branch no longer contains the commit it was recorded at; not continuing it",
|
|
"git_root", gitRoot, "branch", branch, "checkpoint", record.checkpoint)
|
|
}
|
|
return branchRecord{}, false
|
|
}
|
|
return record, true
|
|
}
|
|
|
|
// addLocalWorktree materialises the planned branch as a worktree and reports
|
|
// the branch actually used, plus whether this call is the one that put it
|
|
// there — the caller may only delete a branch it created itself.
|
|
//
|
|
// The fallback covers a sibling task already holding the conversation's branch:
|
|
// git allows one worktree per branch, and refusing to run is worse than
|
|
// delivering onto a task-scoped branch. It forks from the same base, so the
|
|
// sibling still stands on the conversation's latest work.
|
|
func addLocalWorktree(gitRoot, worktreePath string, plan taskBranchPlan, taskID string) (string, bool, error) {
|
|
var args []string
|
|
switch {
|
|
case plan.continues:
|
|
args = []string{"worktree", "add", worktreePath, plan.name}
|
|
case plan.reset:
|
|
// -B moves the branch to base. Nothing is lost: this path only runs
|
|
// once the branch has been proven an ancestor of HEAD.
|
|
args = []string{"worktree", "add", "-B", plan.name, worktreePath, plan.base}
|
|
default:
|
|
args = []string{"worktree", "add", "-b", plan.name, worktreePath, plan.base}
|
|
}
|
|
out, err := runGit(gitRoot, args...)
|
|
if err == nil {
|
|
return plan.name, !plan.continues, nil
|
|
}
|
|
if !branchUnavailable(out) {
|
|
return "", false, fmt.Errorf("execenv: git worktree add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
// The fallback name can itself be taken — by this same task. The
|
|
// conversation fallback is deterministic (<branch>-<task key>) and the
|
|
// task-scoped one is only second-granular, so a task re-dispatched under
|
|
// the same id while the branch is still busy finds its own earlier
|
|
// fallback branch already there. Failing on it made every re-dispatch of
|
|
// that task fail the same way (GH #9198). Those branches may hold earlier
|
|
// attempts' partial work, so none is reused or reset: keep picking the
|
|
// next unused name, forked from the same base. Bounded so a git that keeps
|
|
// answering "already exists" for some other reason cannot loop forever.
|
|
alt := plan.altName(taskID)
|
|
stamp := time.Now().Unix()
|
|
for i := 0; i < maxFallbackBranchAttempts; i++ {
|
|
name := alt
|
|
switch {
|
|
case i == 1:
|
|
name = fmt.Sprintf("%s-%d", alt, stamp)
|
|
case i > 1:
|
|
name = fmt.Sprintf("%s-%d-%d", alt, stamp, i)
|
|
}
|
|
out, err = runGit(gitRoot, "worktree", "add", "-b", name, worktreePath, plan.base)
|
|
if err == nil {
|
|
return name, true, nil
|
|
}
|
|
if !branchUnavailable(out) {
|
|
break
|
|
}
|
|
}
|
|
return "", false, fmt.Errorf("execenv: git worktree add: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
|
|
// maxFallbackBranchAttempts caps how many fallback names addLocalWorktree tries
|
|
// once the planned branch is unavailable.
|
|
const maxFallbackBranchAttempts = 16
|
|
|
|
// branchUnavailable recognises git refusing a branch that another worktree
|
|
// holds, or that already exists under a name we meant to create.
|
|
func branchUnavailable(out string) bool {
|
|
lower := strings.ToLower(out)
|
|
return strings.Contains(lower, "already exists") ||
|
|
strings.Contains(lower, "already checked out") ||
|
|
strings.Contains(lower, "already used by worktree")
|
|
}
|
|
|
|
// replayResult is what the replay left in the worktree.
|
|
type replayResult struct {
|
|
// conflicts names the files git could not merge. Non-empty means the
|
|
// worktree holds an unresolved merge, on purpose.
|
|
conflicts []string
|
|
}
|
|
|
|
// replayUserState brings the user's directory into the worktree.
|
|
//
|
|
// Both branch kinds run the same operation against a different starting point:
|
|
// cherry-pick the difference between the state the checkout already carries and
|
|
// the state the user is in now. For a branch forked from HEAD the first is HEAD
|
|
// itself, so the whole snapshot applies and cannot conflict. For a continued
|
|
// branch it is the snapshot that branch recorded, which is what makes this a
|
|
// replay of the user's LAST-TURN-TO-NOW edits rather than of their whole tree.
|
|
// If the local HEAD changed, first transplant the previous uncommitted edits
|
|
// onto the new HEAD. Comparing snapshots on that shared baseline excludes
|
|
// committed updates without re-proposing edits the branch already carries.
|
|
// If that transplant conflicts, use current HEAD for the affected paths while
|
|
// retaining the successfully normalized edits in all other paths.
|
|
//
|
|
// Replaying the whole tree onto a continued branch is the tempting version and
|
|
// it is wrong: that merge takes the user's HEAD as its base, so it re-proposes
|
|
// work the branch already has, and conflicts against the agent's edits to the
|
|
// same lines — which is to say, it conflicts exactly when the agent did what it
|
|
// was asked to do. Verified: with the user's directory untouched between turns,
|
|
// a plain `stash apply` onto the branch tip already fails.
|
|
//
|
|
// A conflict here is a real disagreement — the user rewrote lines the agent
|
|
// also rewrote — and it stays in the worktree for the agent to resolve with
|
|
// ordinary git commands, which is both what the agent is for and the only way
|
|
// the user's newer edit survives. Dropping it would lose that edit twice over:
|
|
// once from this turn's tree, and again from every later turn, because the
|
|
// snapshot would advance past a change the branch never took.
|
|
func replayUserState(worktreePath string, plan taskBranchPlan, snapshot string, logger *slog.Logger) (replayResult, error) {
|
|
carried := plan.base
|
|
if plan.continues {
|
|
var err error
|
|
carried, err = normalizeReplayBaseline(worktreePath, plan.priorState, snapshot, logger)
|
|
if err != nil {
|
|
return replayResult{}, err
|
|
}
|
|
}
|
|
if carried == "" {
|
|
return replayResult{}, fmt.Errorf("execenv: no baseline to replay the local directory against for branch %s", plan.name)
|
|
}
|
|
// Nothing new since the state this checkout already carries. On a follow-up
|
|
// turn that is the ordinary case: the user commented, they did not edit.
|
|
if _, err := runGit(worktreePath, "diff", "--quiet", carried, snapshot); err == nil {
|
|
return replayResult{}, nil
|
|
}
|
|
|
|
// A commit whose parent is the carried state and whose tree is the user's
|
|
// current one. Its parent is what git uses as the merge base, and that is
|
|
// the entire point: it is not reachable any other way.
|
|
args := append(commitIdentityArgs(worktreePath), "commit-tree", snapshot+"^{tree}", "-p", carried,
|
|
"-m", "multica: local directory edits to replay")
|
|
increment, err := runGitTrimmed(worktreePath, args...)
|
|
if err != nil || increment == "" {
|
|
return replayResult{}, fmt.Errorf("execenv: could not describe your local edits for replay into the task worktree: %w", err)
|
|
}
|
|
|
|
out, pickErr := runGit(worktreePath, "cherry-pick", "--no-commit", increment)
|
|
if pickErr == nil {
|
|
return replayResult{}, nil
|
|
}
|
|
conflicts, listErr := unmergedPaths(worktreePath)
|
|
if listErr != nil || len(conflicts) == 0 {
|
|
// Not a conflict, so the replay failed for a reason the agent cannot
|
|
// resolve. Fail closed rather than start on a half-applied tree.
|
|
abortCherryPick(worktreePath, logger)
|
|
return replayResult{}, fmt.Errorf("execenv: could not replay your local edits into the task worktree "+
|
|
"(the agent would have seen a different tree than you have): %s: %w", strings.TrimSpace(out), pickErr)
|
|
}
|
|
if !plan.continues {
|
|
// Unreachable by construction: a fresh branch is checked out at the
|
|
// increment's own parent, so there is nothing for git to disagree with.
|
|
// If it ever happens the tree is not one the user would recognise, and
|
|
// the old fail-closed rule is the right one.
|
|
abortCherryPick(worktreePath, logger)
|
|
return replayResult{}, fmt.Errorf("execenv: could not replay your local edits onto a fresh task worktree: %s: %w",
|
|
strings.TrimSpace(out), pickErr)
|
|
}
|
|
|
|
// Keep the conflict, drop only the sequencer state: the agent should see an
|
|
// ordinary conflicted worktree it can resolve with `git status` / `git add`,
|
|
// not a cherry-pick it is expected to conclude with a command it never
|
|
// started.
|
|
if out, quitErr := runGit(worktreePath, "cherry-pick", "--quit"); quitErr != nil && logger != nil {
|
|
logger.Warn("execenv: could not clear the cherry-pick state after a conflicting replay (non-fatal)",
|
|
"path", worktreePath, "output", strings.TrimSpace(out), "error", quitErr)
|
|
}
|
|
if logger != nil {
|
|
logger.Warn("execenv: your local edits since the previous turn conflict with the work on this branch; handing the conflict to the agent",
|
|
"path", worktreePath, "branch", plan.name, "files", conflicts)
|
|
}
|
|
return replayResult{conflicts: conflicts}, nil
|
|
}
|
|
|
|
// normalizeReplayBaseline puts the previously carried WIP on the current local
|
|
// HEAD before calculating its incremental change. Writes are limited to Git
|
|
// objects and disposable scratch files; neither live checkout/index nor the task
|
|
// branch or its record is moved.
|
|
func normalizeReplayBaseline(worktreePath, priorState, snapshot string, logger *slog.Logger) (string, error) {
|
|
// A record has two parents; its first is a snapshot or, after a conflict
|
|
// resolved without a commit, another record. Follow only that parent until
|
|
// the first one-parent commit: the actual snapshot, parented at local HEAD.
|
|
// Limit the result to that snapshot, not the repository's entire history.
|
|
previous, err := runGitTrimmed(worktreePath, "rev-list", "--first-parent", "--parents",
|
|
"--min-parents=1", "--max-parents=1", "--max-count=1", priorState)
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: could not read the previous local snapshot: %w", err)
|
|
}
|
|
fields := strings.Fields(previous)
|
|
if len(fields) != 2 {
|
|
return "", errors.New("execenv: the previous local snapshot has no captured HEAD")
|
|
}
|
|
previousSnapshot, previousHead := fields[0], fields[1]
|
|
currentHead, err := runGitTrimmed(worktreePath, "rev-parse", "--verify", snapshot+"^1")
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: could not read the current local snapshot's HEAD: %w", err)
|
|
}
|
|
if previousHead == currentHead {
|
|
return priorState, nil
|
|
}
|
|
// With no previous WIP, transplanting it is the identity operation. This
|
|
// also avoids requiring merge-tree when a clean local directory advances.
|
|
if _, err := runGit(worktreePath, "diff", "--quiet", previousHead, previousSnapshot); err == nil {
|
|
return currentHead, nil
|
|
}
|
|
|
|
// Use the captured HEAD as an explicit base. A record's second parent is
|
|
// the agent checkpoint, which may already contain newer mainline commits;
|
|
// using Git's inferred merge base could then subtract the wrong changes.
|
|
merged, err := runGitStdout(worktreePath, "merge-tree", "--write-tree", "--name-only", "--messages", "-z",
|
|
"--merge-base="+previousHead, currentHead, previousSnapshot)
|
|
tree, _, hasTree := strings.Cut(merged, "\x00")
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 {
|
|
paths, pathsErr := mergeTreeConflictPaths(merged)
|
|
if pathsErr != nil {
|
|
return "", fmt.Errorf("execenv: could not read the conflicted local snapshot: %w", pathsErr)
|
|
}
|
|
// A single conflicting draft must not re-propose every untouched
|
|
// draft against the agent's edits. Preserve the merged tree and
|
|
// replace only the conflicting paths with their current HEAD state.
|
|
if len(paths) == 0 {
|
|
// Git can report a tree-level conflict without affected paths.
|
|
// Retain the resumable current-WIP behavior in that case.
|
|
if logger != nil {
|
|
logger.Warn("execenv: local snapshot merge reported a conflict without paths; replaying current uncommitted edits",
|
|
"path", worktreePath, "previous_head", previousHead, "current_head", currentHead)
|
|
}
|
|
return currentHead, nil
|
|
}
|
|
tree, err = replaceConflictedSnapshotPaths(worktreePath, tree, currentHead, paths)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if logger != nil {
|
|
logger.Warn("execenv: previous local edits conflict with the committed HEAD update; replaying current edits only for the affected paths",
|
|
"path", worktreePath, "previous_head", previousHead, "current_head", currentHead, "files", quotedPaths(paths))
|
|
}
|
|
} else {
|
|
return "", fmt.Errorf("execenv: could not normalize previous uncommitted edits onto the current local HEAD "+
|
|
"(this operation requires Git 2.40 or later); the task branch and its previous snapshot are preserved: %w", err)
|
|
}
|
|
}
|
|
if !hasTree || tree == "" {
|
|
return "", errors.New("execenv: the normalized local snapshot has no tree")
|
|
}
|
|
// The replay commit needs a commit parent, not just a tree. This object is
|
|
// only a comparison baseline: its committed HEAD changes never enter the
|
|
// task branch, which cherry-picks only baseline-to-current-snapshot edits.
|
|
args := append(commitIdentityArgs(worktreePath), "commit-tree", tree, "-p", currentHead,
|
|
"-m", "multica: previous local edits on the current HEAD")
|
|
baseline, err := runGitTrimmed(worktreePath, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: could not describe the normalized local snapshot: %w", err)
|
|
}
|
|
return baseline, nil
|
|
}
|
|
|
|
// mergeTreeConflictPaths reads merge-tree's NUL-delimited file and message
|
|
// sections. Conflict messages also name paths for directory-rename conflicts
|
|
// that have no unmerged file entries. Never parse the human-readable message.
|
|
func mergeTreeConflictPaths(output string) ([]string, error) {
|
|
fields := strings.Split(output, "\x00")
|
|
if len(fields) < 3 || fields[0] == "" || fields[len(fields)-1] != "" {
|
|
return nil, errors.New("invalid merge-tree output")
|
|
}
|
|
var paths []string
|
|
seen := make(map[string]bool)
|
|
add := func(name string) {
|
|
if name != "" && !seen[name] {
|
|
paths = append(paths, name)
|
|
seen[name] = true
|
|
}
|
|
}
|
|
i := 1
|
|
for i < len(fields) && fields[i] != "" {
|
|
add(fields[i])
|
|
i++
|
|
}
|
|
i++ // End of the unmerged-file section.
|
|
for i < len(fields)-1 {
|
|
count, err := strconv.Atoi(fields[i])
|
|
i++
|
|
if err != nil || count < 0 || count > len(fields)-i-3 {
|
|
return nil, errors.New("invalid merge-tree conflict message")
|
|
}
|
|
if strings.HasPrefix(fields[i+count], "CONFLICT") {
|
|
for _, name := range fields[i : i+count] {
|
|
add(name)
|
|
}
|
|
}
|
|
i += count + 2 // Affected paths, conflict type, and message.
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
// replaceConflictedSnapshotPaths edits only a private index beside the task
|
|
// worktree. Resetting explicit literal paths handles additions, deletions,
|
|
// renames and file/directory conflicts without touching either live index.
|
|
func replaceConflictedSnapshotPaths(worktreePath, tree, currentHead string, paths []string) (string, error) {
|
|
if len(paths) == 0 {
|
|
return "", errors.New("execenv: no conflicted snapshot paths to replace")
|
|
}
|
|
dir, err := os.MkdirTemp(filepath.Dir(worktreePath), ".multica-normalize-")
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: create private normalization index: %w", err)
|
|
}
|
|
defer os.RemoveAll(dir)
|
|
env := []string{"GIT_INDEX_FILE=" + filepath.Join(dir, "index"), "GIT_LITERAL_PATHSPECS=1"}
|
|
if out, err := runGitEnv(worktreePath, env, "read-tree", tree); err != nil {
|
|
return "", fmt.Errorf("execenv: seed private normalization index: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
pathFile := filepath.Join(dir, "paths")
|
|
if err := os.WriteFile(pathFile, []byte(strings.Join(paths, "\x00")+"\x00"), 0o600); err != nil {
|
|
return "", fmt.Errorf("execenv: write conflicted snapshot paths: %w", err)
|
|
}
|
|
// The nonempty pathspec file selects reset's index-only form. Using a file
|
|
// avoids command-length limits and preserves whitespace in repository names.
|
|
if out, err := runGitEnv(worktreePath, env, "reset", currentHead, "--pathspec-from-file="+pathFile, "--pathspec-file-nul"); err != nil {
|
|
return "", fmt.Errorf("execenv: replace conflicted snapshot paths: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
normalized, err := runGitTrimmedEnv(worktreePath, env, "write-tree")
|
|
if err != nil {
|
|
return "", fmt.Errorf("execenv: write normalized local snapshot: %w", err)
|
|
}
|
|
return normalized, nil
|
|
}
|
|
|
|
// quotedPaths renders repository paths for a human-facing message. Quoted
|
|
// because a git path may contain newlines, quotes or control characters, and
|
|
// this string is read in logs and task errors where a raw one would look like
|
|
// several entries.
|
|
func quotedPaths(paths []string) string {
|
|
quoted := make([]string, 0, len(paths))
|
|
for _, path := range paths {
|
|
quoted = append(quoted, strconv.Quote(path))
|
|
}
|
|
return strings.Join(quoted, ", ")
|
|
}
|
|
|
|
// verifyDeliveryPoint checks that tip is a commit this conversation can put its
|
|
// name on before it becomes the branch's recorded checkpoint.
|
|
//
|
|
// Two things are asserted, and they are the two ways a delivery can be
|
|
// something other than what this task built. The tip has to BE the task's
|
|
// branch — a run that checked out something else, or a branch someone moved
|
|
// underneath it, delivers a commit this record has no business describing. And
|
|
// it has to still contain the commit this turn started from — this turn's own
|
|
// baseline when it made one, otherwise the branch tip it continued. A run that
|
|
// resets its worktree back to the user's own HEAD passes neither test but the
|
|
// second is the one that matters, twice over: recording a plain user commit as
|
|
// the checkpoint is what makes a branch they later recreate there look like
|
|
// ours, and a tip without this turn's starting point no longer carries the
|
|
// snapshot about to be recorded as delivered (MUL-6881 review).
|
|
func (w *LocalWorktree) verifyDeliveryPoint(tip string) error {
|
|
if !w.tracksState {
|
|
// Nothing will be recorded for this branch, so there is nothing to prove.
|
|
return nil
|
|
}
|
|
if tip == "" {
|
|
return errors.New("the task worktree has no resolvable HEAD")
|
|
}
|
|
branchTip, err := runGitTrimmed(w.GitRoot, "rev-parse", "--verify", "refs/heads/"+w.Branch)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve branch %s: %w", w.Branch, err)
|
|
}
|
|
if branchTip != tip {
|
|
return fmt.Errorf("the worktree delivered %s while branch %s points at %s, so the run did not deliver onto its own branch",
|
|
shortID(tip), w.Branch, shortID(branchTip))
|
|
}
|
|
if w.BaseCommit == "" {
|
|
return fmt.Errorf("branch %s has no commit of this task's own to prove it by", w.Branch)
|
|
}
|
|
if _, err := runGit(w.GitRoot, "merge-base", "--is-ancestor", w.BaseCommit, tip); err != nil {
|
|
return fmt.Errorf("the delivered commit %s no longer contains %s, the commit this turn started from",
|
|
shortID(tip), shortID(w.BaseCommit))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeliveryProblem is a refusal Finalize is about to make that the agent can
|
|
// still repair from inside its own worktree: the run left its work somewhere
|
|
// verifyDeliveryPoint will not record, but the work itself is intact and the
|
|
// fix is ordinary git (#9404).
|
|
type DeliveryProblem struct {
|
|
// Branch is the task branch the delivery has to end on.
|
|
Branch string
|
|
// BaseCommit is the commit the delivery has to contain — this turn's
|
|
// baseline, or the branch tip it continued.
|
|
BaseCommit string
|
|
// Tip is the commit the worktree's HEAD points at now.
|
|
Tip string
|
|
// Reason is verifyDeliveryPoint's own explanation, as Finalize would
|
|
// report it.
|
|
Reason string
|
|
}
|
|
|
|
// RepairCommitMessage is the commit message RepairSteps uses for the one
|
|
// commit it makes.
|
|
const RepairCommitMessage = "chore(agent): put this turn's work back on its task branch"
|
|
|
|
// RepairSteps is one git sequence that resolves any DeliveryProblem while
|
|
// keeping the worktree's files exactly as they are: stage everything, point
|
|
// HEAD back at the task branch without touching files or index, move the
|
|
// branch to BaseCommit, and commit the staged tree on top of it. The result is
|
|
// a single commit whose parent is BaseCommit and whose tree is what the agent
|
|
// left — so it passes verifyDeliveryPoint by construction, and the guard
|
|
// itself is unchanged.
|
|
//
|
|
// Each step is a git argv (without the leading "git"). Rendered into the
|
|
// repair prompt and executed verbatim by the tests, so the instruction the
|
|
// agent gets is the one that is proven to work.
|
|
func (p *DeliveryProblem) RepairSteps() [][]string {
|
|
return [][]string{
|
|
{"add", "-A"},
|
|
{"symbolic-ref", "HEAD", "refs/heads/" + p.Branch},
|
|
{"reset", "--soft", p.BaseCommit},
|
|
{"commit", "-m", RepairCommitMessage},
|
|
}
|
|
}
|
|
|
|
// CheckDelivery reports, without changing anything, whether Finalize would
|
|
// refuse to record this turn's delivery because of where the run left its
|
|
// history — and returns nil whenever it would not, or when it would refuse for
|
|
// a reason a repair turn should not be asked to undo.
|
|
//
|
|
// It predicts the same verifyDeliveryPoint call Finalize makes, including the
|
|
// auto-commit Finalize adds first: uncommitted leftovers are committed onto
|
|
// whatever HEAD points at, so they only reach the task branch when HEAD is
|
|
// attached to it.
|
|
//
|
|
// A tip that is an ancestor of BaseCommit is deliberately NOT offered for
|
|
// repair. That is the run resetting back to where the turn started or earlier
|
|
// — the MUL-6881 case, where the user's replayed edits were thrown away — and
|
|
// the guard exists to stop exactly that being recorded. Asking the agent to
|
|
// re-commit such a tree on top of the baseline would turn a refused discard
|
|
// into a delivered revert of the user's edits. Repair is only for a tip that
|
|
// carries something the baseline line does not: a squash or rebase that
|
|
// dropped the baseline, or work committed onto another branch.
|
|
func (w *LocalWorktree) CheckDelivery() *DeliveryProblem {
|
|
if w == nil || w.aborted != nil || !w.tracksState || w.BaseCommit == "" {
|
|
return nil
|
|
}
|
|
// An unresolved merge fails Finalize before the delivery point is ever
|
|
// checked, and the prompt already told the agent to resolve it.
|
|
if unmerged, err := unmergedPaths(w.Path); err != nil || len(unmerged) > 0 {
|
|
return nil
|
|
}
|
|
tip, err := runGitTrimmed(w.Path, "rev-parse", "--verify", "HEAD")
|
|
if err != nil || tip == "" {
|
|
return nil
|
|
}
|
|
dirty, err := worktreeIsDirty(w.Path)
|
|
if err != nil {
|
|
dirty = true // Finalize commits defensively on the same uncertainty.
|
|
}
|
|
if !dirty && tip == w.BaseCommit {
|
|
// Nothing produced: Finalize drops a branch it created and records a
|
|
// continued one as-is. Neither is refused.
|
|
return nil
|
|
}
|
|
if tip != w.BaseCommit {
|
|
if _, err := runGit(w.GitRoot, "merge-base", "--is-ancestor", tip, w.BaseCommit); err == nil {
|
|
// Reset back past the baseline (see the doc comment): Finalize
|
|
// refuses it, and that refusal is not ours to talk the agent around.
|
|
return nil
|
|
}
|
|
}
|
|
if dirty && !headAttachedTo(w.Path, w.Branch) {
|
|
if w.repairWouldRevertBase(tip) {
|
|
return nil
|
|
}
|
|
return &DeliveryProblem{
|
|
Branch: w.Branch,
|
|
BaseCommit: w.BaseCommit,
|
|
Tip: tip,
|
|
Reason: fmt.Sprintf("the worktree has uncommitted changes while HEAD is not on branch %s, so they would not be delivered onto it",
|
|
w.Branch),
|
|
}
|
|
}
|
|
if verifyErr := w.verifyDeliveryPoint(tip); verifyErr != nil {
|
|
if w.repairWouldRevertBase(tip) {
|
|
return nil
|
|
}
|
|
return &DeliveryProblem{Branch: w.Branch, BaseCommit: w.BaseCommit, Tip: tip, Reason: verifyErr.Error()}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// repairWouldRevertBase reports whether re-committing the worktree's current
|
|
// files on top of BaseCommit could lose something BaseCommit carries — the
|
|
// user's replayed edits, or an earlier turn's work — that the agent's files do
|
|
// not.
|
|
//
|
|
// RepairSteps keeps the agent's files exactly as they are. That is right for a
|
|
// squash (the files still carry everything; only the ancestry dropped the
|
|
// baseline) and wrong for work done on a line that never saw the baseline's
|
|
// content: a detached checkout of the user's HEAD, say, when the baseline holds
|
|
// their uncommitted edits. Delivered on top of the baseline, those files are a
|
|
// silent revert of the edits — the loss MUL-6881 guards against.
|
|
//
|
|
// So the repair is offered only when the agent's files provably already contain
|
|
// every change BaseCommit made relative to its common ancestor with tip
|
|
// (mergeBase). Per path BaseCommit changed:
|
|
//
|
|
// - the agent's file mode must equal BaseCommit's: a user's chmod +x is a
|
|
// change the content comparison alone cannot see;
|
|
// - the agent's file equals BaseCommit's: kept as-is;
|
|
// - otherwise a three-way merge (base = mergeBase, ours = the agent's file,
|
|
// theirs = BaseCommit's) must be clean AND leave the agent's file
|
|
// unchanged — i.e. BaseCommit's change is already in it, and the agent
|
|
// only added to it. A merge that would change the agent's file means the
|
|
// change is missing; repairing would deliver its revert (MUL-8139 review).
|
|
//
|
|
// The agent's side is read from the tree `git add -A` would stage, i.e. what
|
|
// RepairSteps actually commits. Anything else — a deletion on either side, a
|
|
// symlink or submodule, a binary
|
|
// file, a conflict, git failing to answer — refuses the repair. That includes
|
|
// an agent edit on a line touching one the baseline changed, which merge-file
|
|
// reports as a conflict even when the agent did build on the baseline.
|
|
// Conservative by design: Finalize then makes its usual refusal and nothing is
|
|
// lost.
|
|
func (w *LocalWorktree) repairWouldRevertBase(tip string) bool {
|
|
mergeBase, err := runGitTrimmed(w.GitRoot, "merge-base", tip, w.BaseCommit)
|
|
if err != nil || mergeBase == "" {
|
|
return true // Unrelated histories: nothing to prove against.
|
|
}
|
|
if mergeBase == w.BaseCommit {
|
|
return false // tip already descends from the baseline: nothing to lose.
|
|
}
|
|
out, err := runGitStdout(w.GitRoot, "diff", "--name-only", "--no-renames", "-z", mergeBase, w.BaseCommit)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
// Judge the agent's side as the repair will commit it: RepairSteps starts
|
|
// with `git add -A`, so the tree that stages — content after filters, and
|
|
// the file mode git records (core.filemode decides whether the worktree's
|
|
// executable bit or the index's mode wins) — is what reaches the branch.
|
|
agentTree, err := stagedWorktreeTree(w.Path)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
scratch := ""
|
|
defer func() {
|
|
if scratch != "" {
|
|
_ = os.RemoveAll(scratch)
|
|
}
|
|
}()
|
|
for _, path := range strings.Split(out, "\x00") {
|
|
if path == "" {
|
|
continue
|
|
}
|
|
baseBlob, ok := regularBlob(w.GitRoot, w.BaseCommit, path)
|
|
if !ok {
|
|
return true // Deleted, or not a plain file, on the baseline side.
|
|
}
|
|
baseMode := treeEntryMode(w.GitRoot, w.BaseCommit, path)
|
|
if baseMode != treeEntryMode(w.GitRoot, agentTree, path) {
|
|
return true // A mode change (chmod +x, say) the agent's files lack.
|
|
}
|
|
ours, ok := regularBlob(w.GitRoot, agentTree, path)
|
|
if !ok {
|
|
return true // The agent's side dropped it, or it is not a plain file.
|
|
}
|
|
if ours == baseBlob {
|
|
continue
|
|
}
|
|
ancestor := ""
|
|
if blob, ok := regularBlob(w.GitRoot, mergeBase, path); ok {
|
|
ancestor = blob
|
|
} else if exists, _ := pathInCommit(w.GitRoot, mergeBase, path); exists {
|
|
return true // Present but not a plain file in the ancestor.
|
|
}
|
|
if scratch == "" {
|
|
if scratch, err = os.MkdirTemp("", "multica-repair-check-"); err != nil {
|
|
scratch = ""
|
|
return true
|
|
}
|
|
}
|
|
merged, clean := mergeFileContents(scratch, ours, ancestor, baseBlob)
|
|
if !clean || merged != ours {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// regularBlob returns the content of path in commit when it is a regular
|
|
// (non-symlink, non-submodule) text file there. ok is false otherwise,
|
|
// including when the path does not exist or holds binary content.
|
|
func regularBlob(gitRoot, commit, path string) (string, bool) {
|
|
entry, err := runGitStdout(gitRoot, "ls-tree", "-z", commit, "--", path)
|
|
if err != nil || entry == "" {
|
|
return "", false
|
|
}
|
|
mode, _, _ := strings.Cut(entry, " ")
|
|
if mode != "100644" && mode != "100755" {
|
|
return "", false
|
|
}
|
|
content, err := runGitStdout(gitRoot, "cat-file", "blob", commit+":"+path)
|
|
if err != nil || strings.ContainsRune(content, 0) {
|
|
return "", false
|
|
}
|
|
return content, true
|
|
}
|
|
|
|
// treeEntryMode returns the git file mode of path in a commit or tree
|
|
// ("100644", "100755", "120000", ...), or "" when it is absent or git fails.
|
|
func treeEntryMode(gitRoot, treeish, path string) string {
|
|
entry, err := runGitStdout(gitRoot, "ls-tree", "-z", treeish, "--", path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
mode, _, _ := strings.Cut(entry, " ")
|
|
return mode
|
|
}
|
|
|
|
// stagedWorktreeTree returns the tree `git add -A` would stage in dir, built in
|
|
// a private copy of dir's index so neither the agent's index nor its files
|
|
// change. Errors — including a missing index to seed from, without which the
|
|
// recorded modes cannot be predicted — fail the caller closed.
|
|
func stagedWorktreeTree(dir string) (string, error) {
|
|
tmp, err := os.MkdirTemp("", "multica-repair-index-")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
index := filepath.Join(tmp, "index")
|
|
if !seedSnapshotIndex(dir, index) {
|
|
return "", errors.New("execenv: cannot copy the worktree index")
|
|
}
|
|
env := []string{"GIT_INDEX_FILE=" + index}
|
|
if out, err := runGitEnv(dir, env, "add", "-A"); err != nil {
|
|
return "", fmt.Errorf("execenv: stage worktree in private index: %s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return runGitTrimmedEnv(dir, env, "write-tree")
|
|
}
|
|
|
|
func pathInCommit(gitRoot, commit, path string) (bool, error) {
|
|
entry, err := runGitStdout(gitRoot, "ls-tree", "-z", commit, "--", path)
|
|
return entry != "", err
|
|
}
|
|
|
|
// mergeFileContents runs `git merge-file -p ours base theirs` on scratch copies
|
|
// and reports the merged content and whether it merged without conflicts.
|
|
// merge-file works on plain files, so it needs no repository and is available
|
|
// in every git the daemon supports.
|
|
func mergeFileContents(scratch, ours, base, theirs string) (string, bool) {
|
|
paths := make([]string, 3)
|
|
for i, content := range []string{ours, base, theirs} {
|
|
paths[i] = filepath.Join(scratch, strconv.Itoa(i))
|
|
if err := os.WriteFile(paths[i], []byte(content), 0o600); err != nil {
|
|
return "", false
|
|
}
|
|
}
|
|
merged, err := runGitStdout(scratch, "merge-file", "-p", paths[0], paths[1], paths[2])
|
|
if err != nil {
|
|
return "", false // Non-zero exit: conflicts, or merge-file failed.
|
|
}
|
|
return merged, true
|
|
}
|
|
|
|
// headAttachedTo reports whether the worktree's HEAD is the symbolic ref of
|
|
// branch, i.e. a commit made now would advance that branch.
|
|
func headAttachedTo(worktreePath, branch string) bool {
|
|
ref, err := runGitTrimmed(worktreePath, "symbolic-ref", "--quiet", "HEAD")
|
|
return err == nil && ref == "refs/heads/"+branch
|
|
}
|
|
|
|
// unmergedPaths lists the files git considers unresolved in a worktree.
|
|
func unmergedPaths(worktreePath string) ([]string, error) {
|
|
out, err := runGitStdout(worktreePath, "diff", "--name-only", "--diff-filter=U", "-z")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var paths []string
|
|
for _, name := range strings.Split(out, "\x00") {
|
|
if name != "" {
|
|
paths = append(paths, name)
|
|
}
|
|
}
|
|
return paths, nil
|
|
}
|
|
|
|
// abortCherryPick returns the worktree to the branch tip. Used only where the
|
|
// conflict is not something the agent can act on; the ordinary conflict path
|
|
// deliberately leaves the worktree as git left it.
|
|
func abortCherryPick(worktreePath string, logger *slog.Logger) {
|
|
for _, args := range [][]string{{"cherry-pick", "--quit"}, {"reset", "--hard", "HEAD"}, {"clean", "-fdq"}} {
|
|
if out, err := runGit(worktreePath, args...); err != nil && logger != nil {
|
|
logger.Warn("execenv: could not restore the task worktree after a failed replay",
|
|
"path", worktreePath, "command", args[0], "output", strings.TrimSpace(out), "error", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// userStateRef is where a branch's record lives: the snapshot of the user's
|
|
// directory it already carries, the conversation it belongs to, and the tip it
|
|
// was recorded at.
|
|
func userStateRef(branch string) string {
|
|
return localStateRefPrefix + branch
|
|
}
|
|
|
|
// readUserStateRef returns the recorded snapshot, or "" when the branch has
|
|
// none.
|
|
func readUserStateRef(gitRoot, branch string) (string, error) {
|
|
return runGitTrimmed(gitRoot, "rev-parse", "--verify", "--quiet", userStateRef(branch))
|
|
}
|
|
|
|
// recordState pins the user's directory as this task saw it together with the
|
|
// commit the branch stands at. Two things depend on the record: the next turn
|
|
// replays from its tree, and every later task proves the branch is still its
|
|
// own from its owner and checkpoint.
|
|
//
|
|
// The checkpoint must be a commit the branch could not plausibly be sitting at
|
|
// WITHOUT this conversation's work — that is the whole proof. A tip that is
|
|
// still the user's own HEAD is not one: it is exactly where a branch they
|
|
// delete and recreate lands, and recording it would authorise a later turn to
|
|
// append onto their unrelated work. Prepare therefore records only once the
|
|
// branch carries a commit of ours, and Finalize records the tip it actually
|
|
// delivered.
|
|
func (w *LocalWorktree) recordState(checkpoint string, logger *slog.Logger) error {
|
|
if w == nil || !w.tracksState || w.Branch == "" || w.userState == "" {
|
|
return nil
|
|
}
|
|
if _, err := writeBranchRecord(w.GitRoot, w.Branch, w.userState, checkpoint, w.owner); err != nil {
|
|
return err
|
|
}
|
|
if logger != nil {
|
|
logger.Debug("execenv: recorded the local-directory snapshot for the task branch",
|
|
"branch", w.Branch, "checkpoint", checkpoint)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// dropBranch deletes a task branch that carries nothing worth keeping, together
|
|
// with its recorded snapshot — the two are meaningless apart.
|
|
func dropBranch(gitRoot, branch string, logger *slog.Logger) {
|
|
if branch == "" {
|
|
return
|
|
}
|
|
deleteBranch(gitRoot, branch, logger)
|
|
if out, err := runGit(gitRoot, "update-ref", "-d", userStateRef(branch)); err != nil && logger != nil {
|
|
logger.Debug("execenv: no local-directory snapshot to drop for task branch",
|
|
"branch", branch, "output", strings.TrimSpace(out))
|
|
}
|
|
}
|
|
|
|
// pruneOrphanedStateRefs drops the snapshot of any branch that is no longer
|
|
// there. Multica deletes both together, but the branch is the user's to delete,
|
|
// rename or merge away at any time, and a ref left behind would pin their whole
|
|
// working tree as of some past turn against `git gc` forever.
|
|
//
|
|
// Best-effort and non-fatal: this is housekeeping in the user's repository, not
|
|
// a precondition for the task.
|
|
func pruneOrphanedStateRefs(gitRoot string, logger *slog.Logger) {
|
|
out, err := runGitTrimmed(gitRoot, "for-each-ref", "--format=%(refname)", localStateRefPrefix)
|
|
if err != nil {
|
|
if logger != nil {
|
|
logger.Debug("execenv: could not list local-directory snapshots", "git_root", gitRoot, "error", err)
|
|
}
|
|
return
|
|
}
|
|
for _, ref := range strings.Split(out, "\n") {
|
|
ref = strings.TrimSpace(ref)
|
|
if ref == "" {
|
|
continue
|
|
}
|
|
branch := strings.TrimPrefix(ref, localStateRefPrefix)
|
|
if branch == ref {
|
|
continue
|
|
}
|
|
if _, headErr := runGit(gitRoot, "show-ref", "--verify", "--quiet", "refs/heads/"+branch); headErr == nil {
|
|
continue
|
|
}
|
|
if out, delErr := runGit(gitRoot, "update-ref", "-d", ref); delErr != nil {
|
|
if logger != nil {
|
|
logger.Warn("execenv: could not drop the snapshot of a deleted task branch (non-fatal)",
|
|
"ref", ref, "output", strings.TrimSpace(out), "error", delErr)
|
|
}
|
|
continue
|
|
}
|
|
if logger != nil {
|
|
logger.Info("execenv: dropped the local-directory snapshot of a branch that no longer exists",
|
|
"git_root", gitRoot, "branch", branch)
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkUntrackedReplayable refuses a directory whose untracked content is too
|
|
// large to reproduce faithfully, before anything is written anywhere.
|
|
//
|
|
// The bounds are the same ones the older file-copy replay enforced, and they
|
|
// exist for the same reason: `--exclude-standard` already drops everything
|
|
// gitignored, so a repo past them is one whose build output was never ignored.
|
|
// Snapshotting it would write every byte into the user's own object database.
|
|
// The untracked symlink case is refused for a narrower reason — it is content
|
|
// the user can see, and this replay does not decide whether to reproduce the
|
|
// link or its target, including targets outside the repo.
|
|
func checkUntrackedReplayable(gitRoot string, logger *slog.Logger) error {
|
|
out, err := runGitStdout(gitRoot, "ls-files", "--others", "--exclude-standard", "-z")
|
|
if err != nil {
|
|
return fmt.Errorf("execenv: could not list the untracked files in %q: %w", gitRoot, err)
|
|
}
|
|
var (
|
|
files int
|
|
budget int64 = maxUntrackedBytes
|
|
skipped int
|
|
)
|
|
for _, rel := range strings.Split(out, "\x00") {
|
|
if rel == "" || isMulticaSidecarPath(rel) {
|
|
continue
|
|
}
|
|
info, statErr := os.Lstat(filepath.Join(gitRoot, rel))
|
|
if statErr != nil {
|
|
// Listed a moment ago, unreadable now: the tree changed under us.
|
|
// git will simply not find it either, so this is not a refusal.
|
|
continue
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
skipped++
|
|
if logger != nil {
|
|
logger.Warn("execenv: untracked symlink cannot be replayed into a worktree", "file", rel)
|
|
}
|
|
continue
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
// Sockets, FIFOs, devices: not content, and git will not add them.
|
|
continue
|
|
}
|
|
files++
|
|
budget -= info.Size()
|
|
if files > maxUntrackedFiles || budget < 0 {
|
|
skipped++
|
|
}
|
|
}
|
|
if skipped == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("execenv: cannot replay every untracked file from %q into a task worktree "+
|
|
"(%d left over; the replay covers regular files up to %d files / %d MiB and does not follow symlinks) "+
|
|
"— gitignore or clean up the untracked files, or switch the resource back to in_place",
|
|
gitRoot, skipped, maxUntrackedFiles, maxUntrackedBytes>>20)
|
|
}
|
|
|
|
// multicaSidecarDirNames are the directories Prepare writes into a workdir. A
|
|
// task running in_place on the same directory leaves these present as
|
|
// untracked files for the length of its run, so a concurrent worktree snapshot
|
|
// sees them. CLAUDE.md / AGENTS.md are deliberately absent: those are
|
|
// ordinarily the user's own tracked files, and the runtime only injects a
|
|
// marker block into them, which CleanupRuntimeConfig removes.
|
|
var multicaSidecarDirNames = []string{
|
|
".agent_context",
|
|
".multica",
|
|
}
|
|
|
|
// isMulticaSidecarPath reports whether a repo-relative path is one of the
|
|
// daemon's own sidecars rather than the user's content. Matched as a whole
|
|
// path segment at ANY depth, not just the repo root: an in_place resource may
|
|
// point at a subdirectory of this repo, in which case its sidecars sit at
|
|
// <subdir>/.agent_context — replaying those would put another issue's brief
|
|
// inside this task's worktree and commit it to the delivered branch.
|
|
func isMulticaSidecarPath(rel string) bool {
|
|
for _, seg := range strings.Split(filepath.ToSlash(rel), "/") {
|
|
for _, name := range multicaSidecarDirNames {
|
|
if seg == name {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runGit runs git in dir and returns combined output. Callers inspect the
|
|
// output for git's own error text, so stdout and stderr stay merged.
|
|
func runGit(dir string, args ...string) (string, error) {
|
|
return runGitEnv(dir, nil, args...)
|
|
}
|
|
|
|
// runGitEnv is runGit with extra environment entries, for the one caller that
|
|
// has to redirect GIT_INDEX_FILE.
|
|
func runGitEnv(dir string, extraEnv []string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
|
defer cancel()
|
|
|
|
full := append([]string{"-C", dir}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", full...)
|
|
if len(extraEnv) > 0 {
|
|
cmd.Env = append(os.Environ(), extraEnv...)
|
|
}
|
|
cmd.WaitDelay = 5 * time.Second
|
|
out, err := cmd.CombinedOutput()
|
|
return string(out), err
|
|
}
|
|
|
|
// runGitTrimmed runs git for its stdout value, discarding stderr so a
|
|
// diagnostic line can't be mistaken for the value (`rev-parse` output, a
|
|
// config value, a stash sha).
|
|
func runGitTrimmed(dir string, args ...string) (string, error) {
|
|
out, err := runGitStdout(dir, args...)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// runGitTrimmedEnv is runGitTrimmed with extra environment entries.
|
|
func runGitTrimmedEnv(dir string, extraEnv []string, args ...string) (string, error) {
|
|
out, err := runGitEnv(dir, extraEnv, args...)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s: %w", strings.TrimSpace(out), err)
|
|
}
|
|
return strings.TrimSpace(out), nil
|
|
}
|
|
|
|
// runGitStdout is runGitTrimmed without the trimming, for output where
|
|
// whitespace is significant — NUL-separated file listings, where a leading or
|
|
// trailing space is part of a filename. Retain partial stdout on error for
|
|
// plumbing like merge-tree, which emits a useful tree on a conflict.
|
|
func runGitStdout(dir string, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), gitTimeout)
|
|
defer cancel()
|
|
|
|
full := append([]string{"-C", dir}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", full...)
|
|
cmd.WaitDelay = 5 * time.Second
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return string(out), withGitStderr(err)
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
// withGitStderr renders git's stderr into the error.
|
|
//
|
|
// cmd.Output() already captures stderr into ExitError.Stderr, but nothing ever
|
|
// read it back out, so every failure from this path reached the user as a bare
|
|
// "exit status 1" — git's own explanation was collected and then thrown away
|
|
// at the point it was needed. Discarding stderr from the RESULT is deliberate
|
|
// (see runGitTrimmed: a diagnostic line must never be mistaken for a value);
|
|
// discarding it from the error was not.
|
|
func withGitStderr(err error) error {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
if msg := strings.TrimSpace(string(exitErr.Stderr)); msg != "" {
|
|
return fmt.Errorf("%s: %w", msg, err)
|
|
}
|
|
}
|
|
return err
|
|
}
|