mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-02 18:13:27 +02:00
* feat(daemon): add worktree_pool mode for local_directory (MUL-3483) ## What changed Squad workflows bound to the same `local_directory` resource used to serialise on a single path mutex — a documented pain point from GitHub issue #4377. This introduces an opt-in `worktree_pool` mode on the `local_directory` project resource. When enabled, each task gets its own `git worktree add` under a daemon-managed pool root, so sibling tasks on the same base repo now run truly in parallel while `git worktree add/remove/prune` stays serialised behind a per-repo mutex. ## Shape - `local_directory.resource_ref` gains three optional fields: `mode` ("in_place" default / "worktree_pool"), `pool_root` (defaults to `<parent>/.multica-worktrees/<base>`), `max_parallel` (defaults to 4). Legacy rows are byte-identical after round-trip: the server validator strips the pool fields on the default in_place path so older clients keep behaving exactly as before. - New `WorktreePoolManager` (`server/internal/daemon/worktree_pool.go`) owns pool allocation, per-repo git-metadata mutex, and cleanup. - `acquireLocalDirectoryLockIfNeeded` now branches on the ref's mode. in_place stays on `LocalPathLocker` and the shared tree; worktree_pool routes through the pool manager, publishes a lease keyed by task ID, and pins the agent to the freshly allocated worktree in `execenv.PrepareParams.LocalWorkDir`. - Pool saturation is a structured wait_reason (`worktree_pool saturated (N/M) on <path> (holders: ...)`), retrying on the existing cancel-poll interval — same UX as the historical path-mutex wait. ## Safety guardrails (also known footguns from prior art) - Repos with initialised submodules are refused up front. Multi-checkout of a superproject is explicitly unsupported by `git worktree(1)` BUGS and the per-worktree `modules/` directories bloat disk by pool size ×. - Dirty worktrees are NEVER `--force` removed on release. If the agent left uncommitted changes behind we keep the directory (and free the slot) so users can inspect. This is the failure mode claude-code#55724 documented and the pool must not regress into. - The per-repo mutex covers every `git worktree add/remove/prune` and `submodule status` invocation for a given base, matching the in-process-queue fix Anthropic settled on for claude-code#34645 (`.git/config.lock` races on concurrent add). - Task UUID is the source of truth for both branch (`multica/<uuid>`) and worktree path (`<pool_root>/<uuid>`) so a single agent running multiple worker tasks in parallel can never collide. - Non-empty leftover directories at the target path abort the allocation instead of silently starting the agent in an unknown state. ## Explicit MVP non-goals (deferred, tracked as follow-up work) - Windows worktree-remove retry (permission-denied on locked handles). - Detached-HEAD fast path for read-only exploration tasks. - `post-checkout` hook opt-out / serialisation. - Automatic `git lfs install`. - UI surfacing of the pool state / dirty worktree list. ## Tests - `worktree_pool_test.go` (new): full acquire→release lifecycle, parallel allocation, saturation with holder list, slot re-use after release, dirty-worktree preservation, concurrent-acquire serialisation (the config.lock guard), submodule refusal, missing base rejection, pool root auto-mkdir, non-empty leftover refusal, ctx cancel. - Handler validator gains three rejection cases (unknown mode, relative pool_root, negative max_parallel) and a round-trip test that pins the normalised JSON shape for both modes. - Daemon `localDirectoryRef` helpers get a defaults test and the pool root path derivation is pinned. ## Wire-compat and rollout - Default off. Existing rows keep the historical shape (no `mode`, `pool_root`, or `max_parallel` in the JSON) and behave exactly as before. - Opt-in via `--ref '{"local_path":"...","daemon_id":"...","mode":"worktree_pool"}'` today. CLI flag shortcuts (`--mode`, `--pool-root`, `--max-parallel`) can follow in a small tail PR — not blocking. - No DB migration. No UI change required. Co-authored-by: multica-agent <github@multica.ai> * feat(daemon): address worktree_pool review nits (MUL-3483) Follow-up to #4986. Three non-blocking review points from GPT-Boy: 1. **Daemon integration test for lease → runTask plumbing.** `TestAcquireLocalDirectory_WorktreePoolPublishesLease` (and its in_place counterpart) pin the exact contract runTask relies on when it reads `d.localLeases.Load(task.ID)` and feeds `lease.WorkDir` into `execenv.PrepareParams.LocalWorkDir`. A future refactor that drops the Store, mistypes the key, or swaps back to `assignment.AbsPath` on the pool branch will now fail here rather than silently defeat the whole point of worktree_pool mode. 2. **Untracked-only dirty case now classifies as dirty.** `worktreeIsDirty` used `--untracked-files=no`, which meant a worktree with only untracked files was reported "clean" and hit the `git worktree remove` branch — git itself would then refuse the removal because the file exists (so no data was lost), but the log path lied about what happened on disk. Switching to `--untracked-files=normal` routes agents' fresh drafts directly through the "leaving on disk for user inspection" branch, and `TestWorktreePool_UntrackedOnlyIsKept` pins the guarantee so nobody quietly reverts the flag later. 3. **Skill doc note on default `pool_root` location.** `multica-projects-and-resources/SKILL.md` now spells out the three new ref fields (`mode`, `pool_root`, `max_parallel`), the default `<parent>/.multica-worktrees/<repo>` location (next to the repo, not inside it), the write-permission requirement on the parent directory, and the submodule restriction — so agents advising self-host users hit the right doc line rather than reading source. Existing test suite still green: - `go vet ./...` clean - `go test ./internal/daemon/... ./internal/handler/... ./internal/service/...` all pass Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
473 lines
19 KiB
Go
473 lines
19 KiB
Go
package daemon
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log/slog"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// WorktreePoolManager allocates per-task `git worktree` checkouts under a
|
||
// pool root so multiple agent tasks bound to the same local_directory can
|
||
// run in parallel instead of serialising on the shared working tree.
|
||
//
|
||
// Contract (MUL-3483 MVP):
|
||
//
|
||
// - The pool is opt-in via `local_directory.mode = "worktree_pool"`.
|
||
// When the ref stays on the default in_place mode the manager is
|
||
// unused and the daemon retains the historical single-tree semantics.
|
||
// - Every `git worktree add/remove/prune/submodule` invocation for a
|
||
// given base repo is serialised through a per-repo mutex so
|
||
// concurrent worker tasks never race on `.git/config.lock`
|
||
// (Anthropic hit the same class of bug — claude-code#34645 — and
|
||
// also settled on an in-process queue as the fix).
|
||
// - Slots are counted by live leases rather than by inspecting
|
||
// `git worktree list`, so a task that crashes without releasing does
|
||
// not leak a slot forever: the daemon-crash case is handled by
|
||
// `git worktree prune` on the next allocation attempt, and the
|
||
// in-memory count is authoritative for the running daemon.
|
||
// - Repos that ship initialised submodules are refused up front. The
|
||
// BUGS section of git-worktree(1) is explicit that multi-checkout
|
||
// support for superprojects is incomplete, and the shared
|
||
// `$GIT_COMMON_DIR/config` writes from `git submodule` would still
|
||
// bottleneck the pool anyway. Refusing early is friendlier than
|
||
// surfacing a torn worktree once the agent has started running.
|
||
// - Dirty worktrees are never `--force` removed. If the agent left
|
||
// uncommitted changes behind we keep the directory (marking the
|
||
// lease with the path so callers can surface it to the user) and
|
||
// let the next `git worktree prune` reap the metadata once the
|
||
// directory is manually deleted. Losing user code because of an
|
||
// over-eager cleanup is the exact failure mode Anthropic reported
|
||
// in claude-code#55724, and it must never regress here.
|
||
type WorktreePoolManager struct {
|
||
logger *slog.Logger
|
||
|
||
// repoMu is the per-repo git-metadata mutex. The key is the
|
||
// symlink-resolved base path (canonical form the underlying
|
||
// `.git/worktrees` shares) so two ref rows pointing at the same tree
|
||
// through different symlink paths collapse to one lock.
|
||
repoMu sync.Mutex
|
||
repoMutex map[string]*sync.Mutex
|
||
repoLeases map[string]*repoPoolState
|
||
|
||
// clock is injected for tests; production always uses time.Now.
|
||
clock func() time.Time
|
||
}
|
||
|
||
// repoPoolState tracks the leases outstanding for a single base repo. The
|
||
// tasks slice is a debug aid — the count is what enforces MaxParallel.
|
||
// A slice is fine at pool sizes users would actually configure (single
|
||
// digits); if we ever grow to hundreds of concurrent leases per pool we'd
|
||
// switch to a set, but the linear scan is simpler here and shows up on
|
||
// hot paths only during acquire.
|
||
type repoPoolState struct {
|
||
leases []poolLease
|
||
}
|
||
|
||
// poolLease is the live view of a single allocated worktree. It survives
|
||
// only inside the manager's map — the caller receives the Path via the
|
||
// return of Acquire and a release func that removes the lease on unlock.
|
||
type poolLease struct {
|
||
TaskID string
|
||
Path string // canonical worktree path (used for `git worktree remove`)
|
||
RealPath string // symlink-resolved form; matches what LocalPathLocker keys on
|
||
Branch string
|
||
Acquired time.Time
|
||
}
|
||
|
||
// PoolAllocation is what Acquire hands back to the daemon. WorkDir is the
|
||
// path the agent should run inside; RealPath is what the daemon must feed
|
||
// to LocalPathLocker so two paths that alias the same on-disk tree still
|
||
// serialise. Release must be invoked exactly once (deferred is safe).
|
||
type PoolAllocation struct {
|
||
WorkDir string
|
||
RealPath string
|
||
Branch string
|
||
Release func()
|
||
}
|
||
|
||
// errPoolSaturated is a sentinel returned by Acquire when the base repo
|
||
// already has MaxParallel leases outstanding. Callers unwrap it to build
|
||
// the wait_reason hint (holders list). The variable form is used with
|
||
// errors.Is; the concrete PoolSaturatedError carries the holder metadata.
|
||
var errPoolSaturated = errors.New("worktree pool saturated")
|
||
|
||
// PoolSaturatedError is returned when a request would exceed MaxParallel.
|
||
// The Holders slice is a snapshot of the live lease tasks so the caller
|
||
// can render a "waiting for tasks A, B, C" hint in the UI without another
|
||
// round trip.
|
||
type PoolSaturatedError struct {
|
||
BasePath string
|
||
MaxParallel int
|
||
Holders []string
|
||
}
|
||
|
||
func (e *PoolSaturatedError) Error() string {
|
||
return fmt.Sprintf("worktree pool saturated (%d/%d) for %s", len(e.Holders), e.MaxParallel, e.BasePath)
|
||
}
|
||
func (e *PoolSaturatedError) Unwrap() error { return errPoolSaturated }
|
||
|
||
// NewWorktreePoolManager returns a ready-to-use manager. Safe for
|
||
// concurrent use across every daemon slot.
|
||
func NewWorktreePoolManager(logger *slog.Logger) *WorktreePoolManager {
|
||
if logger == nil {
|
||
logger = slog.Default()
|
||
}
|
||
return &WorktreePoolManager{
|
||
logger: logger.With("component", "worktree_pool"),
|
||
repoMutex: make(map[string]*sync.Mutex),
|
||
repoLeases: make(map[string]*repoPoolState),
|
||
clock: time.Now,
|
||
}
|
||
}
|
||
|
||
// Acquire allocates a fresh worktree for taskID under the base repo's pool
|
||
// root. It runs entirely under the per-repo git-metadata mutex so callers
|
||
// never race on `.git/config.lock` or on the in-memory lease count.
|
||
//
|
||
// The flow:
|
||
//
|
||
// 1. Take the per-repo mutex.
|
||
// 2. Refuse repos that ship an initialised submodule (see the
|
||
// WorktreePoolManager doc for why).
|
||
// 3. `git worktree prune` — reap any stale metadata from a previous
|
||
// daemon lifetime so a lease count doesn't include ghosts.
|
||
// 4. Enforce MaxParallel against the in-memory lease list. When
|
||
// saturated we return PoolSaturatedError with the current holders
|
||
// so the caller can post a structured wait_reason.
|
||
// 5. `git worktree add --lock --reason ... -b multica/<uuid>
|
||
// <pool_root>/<uuid> HEAD` — task UUID is used both as directory
|
||
// and branch suffix so a re-run of the same task can never collide
|
||
// with an in-flight one. `--lock` prevents `git worktree prune`
|
||
// from clearing the tree mid-task.
|
||
// 6. Register the lease and return a release closure that reverses the
|
||
// add (best-effort remove + prune) under the same per-repo mutex.
|
||
func (m *WorktreePoolManager) Acquire(ctx context.Context, base, poolRoot string, maxParallel int, taskID string) (*PoolAllocation, error) {
|
||
if base == "" {
|
||
return nil, errors.New("worktree pool: base path required")
|
||
}
|
||
if taskID == "" {
|
||
return nil, errors.New("worktree pool: taskID required")
|
||
}
|
||
if poolRoot == "" {
|
||
return nil, errors.New("worktree pool: pool_root required")
|
||
}
|
||
if maxParallel <= 0 {
|
||
maxParallel = defaultWorktreePoolMaxParallel
|
||
}
|
||
baseAbs, err := filepath.Abs(base)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("worktree pool: resolve base %q: %w", base, err)
|
||
}
|
||
baseReal, err := filepath.EvalSymlinks(baseAbs)
|
||
if err != nil {
|
||
// Fall back to the cleaned absolute form so callers see a
|
||
// consistent key even when the symlink chain is currently
|
||
// broken. Validation elsewhere surfaces the underlying error
|
||
// with better context.
|
||
baseReal = filepath.Clean(baseAbs)
|
||
}
|
||
baseReal = filepath.Clean(baseReal)
|
||
|
||
poolRootAbs, err := filepath.Abs(poolRoot)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("worktree pool: resolve pool_root %q: %w", poolRoot, err)
|
||
}
|
||
|
||
mu := m.repoLock(baseReal)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
log := m.logger.With("base", baseReal, "task", shortID(taskID))
|
||
|
||
if err := m.rejectSubmoduleRepo(ctx, baseReal); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
// Best-effort: silently ignore prune failures because the returned
|
||
// error is almost always "repo has never had a worktree yet". Failing
|
||
// hard here would block every fresh install.
|
||
m.pruneLocked(ctx, baseReal, log)
|
||
|
||
state := m.repoLeases[baseReal]
|
||
if state == nil {
|
||
state = &repoPoolState{}
|
||
m.repoLeases[baseReal] = state
|
||
}
|
||
if len(state.leases) >= maxParallel {
|
||
holders := make([]string, 0, len(state.leases))
|
||
for _, l := range state.leases {
|
||
holders = append(holders, l.TaskID)
|
||
}
|
||
return nil, &PoolSaturatedError{BasePath: baseReal, MaxParallel: maxParallel, Holders: holders}
|
||
}
|
||
|
||
// Ensure the pool root exists before git worktree adds under it —
|
||
// git refuses when the parent is missing.
|
||
if err := os.MkdirAll(poolRootAbs, 0o755); err != nil {
|
||
return nil, fmt.Errorf("worktree pool: create pool_root %q: %w", poolRootAbs, err)
|
||
}
|
||
|
||
// Task UUID is the source of truth for the directory + branch name.
|
||
// A short id would be shorter to read but collides with taskbin
|
||
// (agent) UUIDs shortened the same way, and the MUL-3483 spec
|
||
// specifically calls out that the branch name must not depend on
|
||
// agent id (a single squad agent can hold multiple worker tasks at
|
||
// once and would otherwise collide on branch names).
|
||
wtName := taskID
|
||
wtPath := filepath.Join(poolRootAbs, wtName)
|
||
branch := "multica/" + wtName
|
||
reason := "multica task " + wtName
|
||
|
||
// Attempt to remove any pre-existing directory at the target path
|
||
// only when it's an EMPTY leftover from a previous daemon lifetime.
|
||
// If somebody put real files there we bail — refusing to clobber is
|
||
// safer than silently starting the agent in an unknown state.
|
||
if err := ensureWorktreePathAvailable(wtPath); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
addArgs := []string{"-C", baseReal, "worktree", "add", "--lock", "--reason", reason, "-b", branch, wtPath, "HEAD"}
|
||
if out, err := runGit(ctx, addArgs...); err != nil {
|
||
// Surface stderr verbatim so the caller's FailTask message
|
||
// tells the user exactly what git said (missing base ref,
|
||
// disk-full, permission denied). Wrapping alone would hide it.
|
||
return nil, fmt.Errorf("worktree pool: git worktree add failed: %s: %w", strings.TrimSpace(out), err)
|
||
}
|
||
|
||
wtRealPath, err := filepath.EvalSymlinks(wtPath)
|
||
if err != nil {
|
||
wtRealPath = filepath.Clean(wtPath)
|
||
}
|
||
wtRealPath = filepath.Clean(wtRealPath)
|
||
|
||
lease := poolLease{
|
||
TaskID: taskID,
|
||
Path: wtPath,
|
||
RealPath: wtRealPath,
|
||
Branch: branch,
|
||
Acquired: m.clock(),
|
||
}
|
||
state.leases = append(state.leases, lease)
|
||
log.Info("worktree pool: allocated", "path", wtPath, "branch", branch, "in_use", len(state.leases), "max_parallel", maxParallel)
|
||
|
||
var releaseOnce sync.Once
|
||
release := func() {
|
||
releaseOnce.Do(func() {
|
||
m.release(baseReal, taskID, wtPath, branch)
|
||
})
|
||
}
|
||
|
||
return &PoolAllocation{
|
||
WorkDir: wtPath,
|
||
RealPath: wtRealPath,
|
||
Branch: branch,
|
||
Release: release,
|
||
}, nil
|
||
}
|
||
|
||
// release runs the reverse of Acquire under the per-repo mutex: unlock
|
||
// the worktree, remove it (only when clean), prune metadata, and drop
|
||
// the in-memory lease. Any error from the git side is logged rather
|
||
// than surfaced — the caller cannot do anything with a cleanup failure
|
||
// beyond what the manual `git worktree prune` step already handles.
|
||
func (m *WorktreePoolManager) release(baseReal, taskID, wtPath, branch string) {
|
||
mu := m.repoLock(baseReal)
|
||
mu.Lock()
|
||
defer mu.Unlock()
|
||
|
||
log := m.logger.With("base", baseReal, "task", shortID(taskID), "worktree", wtPath)
|
||
|
||
// Unlock first — remove refuses --lock'd worktrees. Best-effort.
|
||
if out, err := runGit(context.Background(), "-C", baseReal, "worktree", "unlock", wtPath); err != nil {
|
||
// It's normal for unlock to fail when the worktree has already
|
||
// been removed manually (e.g. during a bench test cleanup); log
|
||
// at debug so users don't see noise.
|
||
log.Debug("worktree pool: unlock failed (best-effort)", "output", strings.TrimSpace(out), "error", err)
|
||
}
|
||
|
||
dirty, err := worktreeIsDirty(wtPath)
|
||
switch {
|
||
case err != nil:
|
||
log.Warn("worktree pool: cannot determine cleanliness, leaving worktree in place", "error", err)
|
||
case dirty:
|
||
log.Warn("worktree pool: worktree has uncommitted changes; leaving on disk for user inspection", "path", wtPath)
|
||
default:
|
||
// Clean → tell git to remove it. We deliberately do NOT pass
|
||
// --force: the dirty check above already guaranteed there is
|
||
// nothing to lose, and refusing on any surprise diff is a
|
||
// stronger safeguard than trying to guess intent.
|
||
if out, err := runGit(context.Background(), "-C", baseReal, "worktree", "remove", wtPath); err != nil {
|
||
log.Warn("worktree pool: git worktree remove failed", "output", strings.TrimSpace(out), "error", err)
|
||
}
|
||
}
|
||
|
||
// Prune metadata unconditionally so a stale `.git/worktrees/<uuid>`
|
||
// entry never inflates the lease count on the next allocation.
|
||
if out, err := runGit(context.Background(), "-C", baseReal, "worktree", "prune"); err != nil {
|
||
log.Debug("worktree pool: prune failed (best-effort)", "output", strings.TrimSpace(out), "error", err)
|
||
}
|
||
|
||
// The branch itself lingers so a follow-up task or a human can
|
||
// inspect the work. MUL-3483 explicitly calls for keeping the branch
|
||
// when the worktree stayed on disk (dirty case); we apply the same
|
||
// rule in the clean case for consistency — costs almost nothing and
|
||
// avoids destroying refs the agent may have pushed.
|
||
|
||
// Drop the lease from the in-memory ledger even when git bookkeeping
|
||
// above failed. The slot must return to the pool or the next task
|
||
// hits a spurious PoolSaturatedError.
|
||
if state, ok := m.repoLeases[baseReal]; ok {
|
||
out := state.leases[:0]
|
||
for _, l := range state.leases {
|
||
if l.Path != wtPath {
|
||
out = append(out, l)
|
||
}
|
||
}
|
||
state.leases = out
|
||
}
|
||
log.Info("worktree pool: released", "branch", branch)
|
||
}
|
||
|
||
// repoLock returns the per-repo mutex for baseReal, creating it lazily on
|
||
// first access. Guarded by m.repoMu for the map read/write only — the
|
||
// returned mutex is what callers actually serialise on.
|
||
func (m *WorktreePoolManager) repoLock(baseReal string) *sync.Mutex {
|
||
m.repoMu.Lock()
|
||
defer m.repoMu.Unlock()
|
||
mu, ok := m.repoMutex[baseReal]
|
||
if !ok {
|
||
mu = &sync.Mutex{}
|
||
m.repoMutex[baseReal] = mu
|
||
}
|
||
return mu
|
||
}
|
||
|
||
// rejectSubmoduleRepo returns an error when the base has an initialised
|
||
// submodule. We check for the presence of `.gitmodules` first — that's a
|
||
// cheap file-existence test — and only shell out to `git submodule status`
|
||
// when needed. An unchecked-out submodule prefixed with "-" in the status
|
||
// output is not initialised and does not count.
|
||
//
|
||
// The refusal is intentionally strict rather than a warning: the shared
|
||
// `$GIT_COMMON_DIR/config` writes from `git submodule` would still
|
||
// bottleneck the pool, and the per-worktree `modules/` directories bloat
|
||
// disk usage by pool_size×. Both mitigations are out of scope for the MVP.
|
||
func (m *WorktreePoolManager) rejectSubmoduleRepo(ctx context.Context, baseReal string) error {
|
||
if _, err := os.Stat(filepath.Join(baseReal, ".gitmodules")); err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("worktree pool: stat .gitmodules for %q: %w", baseReal, err)
|
||
}
|
||
out, err := runGit(ctx, "-C", baseReal, "submodule", "status")
|
||
if err != nil {
|
||
return fmt.Errorf("worktree pool: git submodule status failed: %s: %w", strings.TrimSpace(out), err)
|
||
}
|
||
for _, line := range strings.Split(out, "\n") {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
continue
|
||
}
|
||
// `git submodule status` prefixes each line with one of:
|
||
// ' ' (space) initialised and up-to-date
|
||
// '+' initialised but with different commit
|
||
// 'U' merge conflict
|
||
// '-' not initialised → safe to ignore
|
||
// We refuse on anything but '-'.
|
||
prefix := line[0]
|
||
if prefix == '-' {
|
||
continue
|
||
}
|
||
return fmt.Errorf("worktree pool: repository %q has initialised submodules — worktree_pool mode does not yet support submodule superprojects (MUL-3483 MVP scope); either uninitialise submodules or keep this resource on mode=in_place", baseReal)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// pruneLocked runs `git worktree prune` and swallows the error. Callers
|
||
// hold the per-repo mutex so parallel invocations are already impossible;
|
||
// this is best-effort housekeeping to reclaim leases from a previous
|
||
// daemon lifetime before we hand out a new slot.
|
||
func (m *WorktreePoolManager) pruneLocked(ctx context.Context, baseReal string, log *slog.Logger) {
|
||
if out, err := runGit(ctx, "-C", baseReal, "worktree", "prune"); err != nil {
|
||
log.Debug("worktree pool: prune failed (best-effort)", "output", strings.TrimSpace(out), "error", err)
|
||
}
|
||
}
|
||
|
||
// ensureWorktreePathAvailable is a defensive check for the case where a
|
||
// prior release couldn't remove the worktree (e.g. the daemon crashed
|
||
// mid-cleanup) and a re-run picks the same task UUID. Empty directories
|
||
// are safe to `os.Remove` (git will recreate); a directory that still
|
||
// holds files is a signal that the previous run left work behind and we
|
||
// refuse the allocation so the user can inspect.
|
||
func ensureWorktreePathAvailable(wtPath string) error {
|
||
entries, err := os.ReadDir(wtPath)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil
|
||
}
|
||
return fmt.Errorf("worktree pool: probe %q: %w", wtPath, err)
|
||
}
|
||
if len(entries) == 0 {
|
||
if err := os.Remove(wtPath); err != nil {
|
||
return fmt.Errorf("worktree pool: clear empty leftover %q: %w", wtPath, err)
|
||
}
|
||
return nil
|
||
}
|
||
return fmt.Errorf("worktree pool: target path %q already exists and is non-empty; remove it before retrying", wtPath)
|
||
}
|
||
|
||
// worktreeIsDirty reports whether the worktree at path has uncommitted
|
||
// changes OR untracked files. Returning err != nil means we couldn't
|
||
// tell — the caller treats that the same as dirty (better to leak a
|
||
// directory than lose work).
|
||
//
|
||
// We include untracked files (`--untracked-files=normal`) because the
|
||
// whole point of the dirty check is "did the agent leave anything the
|
||
// user might want to inspect?", and a fresh file the agent created but
|
||
// hasn't committed is exactly that. Excluding it here would let us log
|
||
// "clean → remove", then have `git worktree remove` refuse the removal
|
||
// because untracked files exist (git itself has always refused this) —
|
||
// the outcome (directory preserved) is correct, but the log path is
|
||
// misleading and future maintainers seeing "worktree removed" would
|
||
// assume the disk was actually reclaimed. Including untracked keeps
|
||
// the observability honest with what actually happens on disk.
|
||
//
|
||
// Porcelain=v1 is the historically-stable format; -z avoids
|
||
// escape-quoting weirdness; any non-empty content means dirty.
|
||
func worktreeIsDirty(path string) (bool, error) {
|
||
cmd := exec.Command("git", "-C", path, "status", "--porcelain=v1", "-z", "--untracked-files=normal")
|
||
out, err := cmd.Output()
|
||
if err != nil {
|
||
if runtime.GOOS == "windows" {
|
||
// On Windows a permission-denied on the working tree is
|
||
// common after antivirus scans; treating that as dirty
|
||
// keeps our safety guarantee even though we couldn't
|
||
// confirm the state.
|
||
return true, err
|
||
}
|
||
return true, err
|
||
}
|
||
// -z gives NUL-terminated entries; any non-empty content means dirty.
|
||
return len(strings.Trim(string(out), "\x00")) > 0, nil
|
||
}
|
||
|
||
// runGit runs a git subcommand and returns its combined stdout+stderr.
|
||
// Failures include the process error verbatim so callers can wrap it
|
||
// with additional context.
|
||
func runGit(ctx context.Context, args ...string) (string, error) {
|
||
if ctx == nil {
|
||
ctx = context.Background()
|
||
}
|
||
cmd := exec.CommandContext(ctx, "git", args...)
|
||
out, err := cmd.CombinedOutput()
|
||
return string(out), err
|
||
}
|