Files
multica/server/internal/daemon/local_directory.go
Multica Eve 7bb8076ed0 feat(daemon): worktree_pool mode for local_directory (MUL-3483) (#4986)
* 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>
2026-07-07 14:10:07 +08:00

564 lines
22 KiB
Go

package daemon
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
)
// localDirectoryResourceType is the project_resource discriminator the daemon
// looks for when deciding whether a task should run against an existing
// user directory rather than a fresh git worktree. Mirrors the server-side
// constant — keep in sync if the type string is ever renamed.
const localDirectoryResourceType = "local_directory"
// localDirectoryModeInPlace / localDirectoryModeWorktreePool mirror the
// server-side mode strings. Kept as a private, deliberately-duplicated pair
// to avoid an import cycle between the daemon and the handler package —
// this constant list is one line, and if the server ever renames either
// string the daemon must be updated in lockstep anyway.
const (
localDirectoryModeInPlace = "in_place"
localDirectoryModeWorktreePool = "worktree_pool"
)
// defaultWorktreePoolMaxParallel is the fallback max_parallel when the
// resource_ref left the field zero. Four covers a typical squad (1 leader
// + 3 workers) without opening up the machine to unbounded worktree fan-out.
// The user can raise it by setting max_parallel explicitly on the ref.
const defaultWorktreePoolMaxParallel = 4
// localDirectoryRef mirrors the server-side ref shape for local_directory
// project resources. Defined locally so the daemon does not have to import
// the server handler package.
//
// Mode + PoolRoot + MaxParallel are the MUL-3483 worktree_pool opt-in
// fields. They are omitted (zero-valued) for pre-existing rows and for
// clients that never learnt about them, in which case the daemon behaves
// exactly as before — one shared working tree, one path mutex.
type localDirectoryRef struct {
LocalPath string `json:"local_path"`
DaemonID string `json:"daemon_id"`
Label string `json:"label,omitempty"`
Mode string `json:"mode,omitempty"`
PoolRoot string `json:"pool_root,omitempty"`
MaxParallel int `json:"max_parallel,omitempty"`
}
// modeOrDefault returns the mode string with the empty default folded to
// "in_place". Callers should always route through this so a `switch` on
// the return value never needs a fall-through arm.
func (r localDirectoryRef) modeOrDefault() string {
m := strings.TrimSpace(r.Mode)
if m == "" {
return localDirectoryModeInPlace
}
return m
}
// maxParallelOrDefault applies defaultWorktreePoolMaxParallel to zero-valued
// requests. Negative values shouldn't reach the daemon (server-side
// validation rejects them) — clamp them to the default here for safety
// so a bad row can't make the pool refuse every task.
func (r localDirectoryRef) maxParallelOrDefault() int {
if r.MaxParallel <= 0 {
return defaultWorktreePoolMaxParallel
}
return r.MaxParallel
}
// localDirectoryLease is the runtime view of a task's local_directory
// binding, published by acquireLocalDirectoryLockIfNeeded so runTask
// (and the GC meta writer) can pick the correct WorkDir without
// re-computing pool allocation. Mode mirrors the ref's mode; WorkDir is
// the path the agent should actually execute inside — for in_place this
// is the ref's LocalPath, for worktree_pool it is the freshly allocated
// worktree.
type localDirectoryLease struct {
Mode string
WorkDir string
Branch string // non-empty for worktree_pool mode; the branch git created
}
// defaultWorktreePoolRoot returns the fallback pool_root for the given
// base repo when the ref left the field empty. Keeping the default under
// a daemon-managed hidden directory (rather than inside the user's repo)
// stops pool worktrees from polluting `git status` on the base tree and
// keeps them out of the way when the user manually cleans up.
func defaultWorktreePoolRoot(base string) string {
// filepath.Join is safe on both POSIX and Windows; the ".multica-worktrees"
// name mirrors "multica" branding used elsewhere in agent workdirs.
return filepath.Join(filepath.Dir(base), ".multica-worktrees", filepath.Base(base))
}
// localDirectoryAssignment is the resolved view of a task's local_directory
// resource: the absolute path the daemon will use as the agent's workdir,
// plus the underlying ref for callers that still need the raw label / daemon
// id (validation log messages, mostly). RealPath is the symlink-resolved
// absolute path; the path mutex keys on it so two different routes to the
// same directory are serialised.
type localDirectoryAssignment struct {
Ref localDirectoryRef
AbsPath string // user-provided path, cleaned but not symlink-resolved
RealPath string // canonical key for the path mutex
}
// localDirectoryAssignmentForTask returns the local_directory assignment a task
// should execute inside. Squad-leader tasks are coordinators: they may create
// child issues or comments, but should not bind to the user's repo worktree or
// hold the path mutex while downstream workers are ready to write.
func localDirectoryAssignmentForTask(task Task, daemonID string) (*localDirectoryAssignment, error) {
if task.IsLeaderTask {
return nil, nil
}
return findLocalDirectoryAssignment(task.ProjectResources, daemonID)
}
// findLocalDirectoryAssignment scans the task's project resources for one of
// type local_directory whose daemon_id matches this daemon. Returns nil
// (without error) when no such resource exists — the task takes the regular
// github_repo / worktree code path. Returns an error only when the matching
// resource is structurally broken (bad JSON, missing fields) OR when more
// than one resource is pinned to this daemon — that's a server-side
// invariant violation, and silently picking the first match would let the
// agent write into an arbitrary directory the user didn't intend.
//
// Server-side `findLocalDirectoryConflict` enforces a single local_directory
// per (project, daemon), so two matches here means either the constraint
// was bypassed (older API client) or the data was corrupted. Either way,
// fail fast rather than guess.
func findLocalDirectoryAssignment(resources []ProjectResourceData, daemonID string) (*localDirectoryAssignment, error) {
var match *localDirectoryAssignment
for _, r := range resources {
if r.ResourceType != localDirectoryResourceType {
continue
}
var ref localDirectoryRef
if err := json.Unmarshal(r.ResourceRef, &ref); err != nil {
return nil, fmt.Errorf("local_directory: parse resource_ref: %w", err)
}
ref.DaemonID = strings.TrimSpace(ref.DaemonID)
if ref.DaemonID == "" {
return nil, errors.New("local_directory: resource_ref missing daemon_id")
}
if ref.DaemonID != daemonID {
// A different daemon owns this resource. Skip silently; the
// project may have multiple local_directory resources, one
// per daemon, and other daemons will resolve their own row.
continue
}
if match != nil {
// Server-side invariant: at most one local_directory per
// (project, daemon). Two matches here means the constraint
// was bypassed by an older API client or by direct DB writes.
// Either way, refuse to guess which directory the user meant.
return nil, fmt.Errorf(
"local_directory: project has multiple local_directory resources for this daemon (%q and %q); remove the extra in project settings",
match.AbsPath,
strings.TrimSpace(ref.LocalPath),
)
}
absPath, err := normalizeLocalPath(ref.LocalPath)
if err != nil {
return nil, err
}
realPath, err := resolveRealPath(absPath)
if err != nil {
return nil, err
}
match = &localDirectoryAssignment{
Ref: ref,
AbsPath: absPath,
RealPath: realPath,
}
}
return match, nil
}
// normalizeLocalPath strips whitespace and resolves the path to an absolute
// cleaned form. It does NOT touch the filesystem (no symlink resolution, no
// existence check) — callers do that separately via validateLocalPath.
func normalizeLocalPath(p string) (string, error) {
trimmed := strings.TrimSpace(p)
if trimmed == "" {
return "", errors.New("local_directory: local_path is empty")
}
if !filepath.IsAbs(trimmed) {
return "", fmt.Errorf("local_directory: local_path must be absolute, got %q", trimmed)
}
return filepath.Clean(trimmed), nil
}
// resolveRealPath returns the symlink-resolved absolute form of path. The
// path mutex keys on this value so a task on `/Users/u/proj` and another on
// `/private/var/folders/.../proj-symlink → /Users/u/proj` collapse to one
// lock. When EvalSymlinks fails (path is missing or not yet a real link),
// fall back to the cleaned absolute form so callers can still proceed to
// the existence-check stage which surfaces a clearer error.
func resolveRealPath(absPath string) (string, error) {
real, err := filepath.EvalSymlinks(absPath)
if err != nil {
// validateLocalPath will surface the underlying error with better
// context; for the mutex key the cleaned absolute path is a safe
// fallback (it just slightly weakens the dedup on broken symlinks).
return absPath, nil
}
return real, nil
}
// validateLocalPath enforces the daemon-side preconditions for running an
// agent against a user-supplied directory:
//
// - the path is absolute and not in the system blacklist (root, $HOME,
// /Users, /home, the current user's $HOME — picking one of those would
// scope the agent to the entire account, which is never what the user
// intended);
// - the symlink-resolved target is ALSO not in the blacklist — without
// this a symlink like /Users/me/proj/home -> /Users/me would slip the
// literal-equality check above while still routing every daemon write
// into $HOME;
// - the path exists, is a directory (not a regular file or device);
// - the daemon process can read and write inside it (the agent will need
// both — read for context discovery, write for the issue's edits).
//
// Each failure returns a typed error message so the daemon can forward it
// onto the task's fail comment verbatim.
func validateLocalPath(absPath string) error {
if absPath == "" {
return errors.New("local_directory: local_path is empty")
}
if !filepath.IsAbs(absPath) {
return fmt.Errorf("local_directory: local_path must be absolute, got %q", absPath)
}
if reason, blocked := isBlacklistedLocalPath(absPath); blocked {
return fmt.Errorf("local_directory: %s (%q)", reason, absPath)
}
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("local_directory: path does not exist: %q", absPath)
}
return fmt.Errorf("local_directory: stat %q: %w", absPath, err)
}
if !info.IsDir() {
return fmt.Errorf("local_directory: path is not a directory: %q", absPath)
}
// Re-check the blacklist after resolving symlinks. Two ways the
// literal check can be bypassed even when absPath itself is clean:
//
// 1. A user-created symlink (or a parent component) routes writes
// into a banned target. Example: ~/proj/home-link -> /Users/me.
// 2. The user directly selects a canonical OS path that aliases a
// banned root via an OS-level symlink. Example on macOS: typing
// /private/tmp slips past the /tmp entry because the literal
// strings don't match, and EvalSymlinks is a no-op since the
// input is already canonical. This must be checked
// unconditionally — not gated on realPath != absPath — or the
// direct-canonical case is silently allowed.
//
// EvalSymlinks walks intermediate components too, so a non-symlink
// absPath whose parent is a symlink also fails closed.
realPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
return fmt.Errorf("local_directory: resolve symlinks for %q: %w", absPath, err)
}
realPath = filepath.Clean(realPath)
if reason, blocked := isBlacklistedRealPath(realPath); blocked {
if realPath != filepath.Clean(absPath) {
return fmt.Errorf("local_directory: %s (symlink target of %q is %q)", reason, absPath, realPath)
}
return fmt.Errorf("local_directory: %s (canonical path %q)", reason, absPath)
}
if err := checkDirReadWrite(absPath); err != nil {
return fmt.Errorf("local_directory: %w", err)
}
return nil
}
// isBlacklistedLocalPath rejects paths that map to the whole machine or an
// entire user profile. The intent is to keep the daemon from accidentally
// stamping context files (.agent_context/, .claude/skills/, .multica/) at
// the root of a user's account or the OS — a misconfiguration on the UI
// side should fail fast rather than litter the user's home.
//
// The check is by literal equality after Clean(), not prefix containment:
// a legitimate project under /Users/<user>/code/proj should pass.
func isBlacklistedLocalPath(absPath string) (reason string, blocked bool) {
cleaned := filepath.Clean(absPath)
if isDriveRoot(cleaned) {
return fmt.Sprintf("path is a drive root %q", cleaned), true
}
for _, banned := range systemRootBlacklist() {
if cleaned == banned {
return fmt.Sprintf("path is a protected system root %q", banned), true
}
}
if home, err := os.UserHomeDir(); err == nil {
if cleaned == filepath.Clean(home) {
return "path is the user's home directory", true
}
}
return "", false
}
// isBlacklistedRealPath is the canonical-aware variant of
// isBlacklistedLocalPath. It compares the symlink-resolved realPath against
// the symlink-resolved form of each blacklist entry so OS-level redirects
// (notably macOS's /etc -> /private/etc, /tmp -> /private/tmp, /var ->
// /private/var) cannot be used to slip a candidate past the literal
// blacklist — whether the redirect is reached via a user-created symlink
// (~/proj/home-link -> /Users/me) or by directly typing the canonical form
// (/private/tmp), which is identical to the OS view of /tmp.
func isBlacklistedRealPath(realPath string) (reason string, blocked bool) {
realClean := filepath.Clean(realPath)
if isDriveRoot(realClean) {
return fmt.Sprintf("path is a drive root %q", realClean), true
}
for _, banned := range systemRootBlacklist() {
bannedClean := filepath.Clean(banned)
if realClean == bannedClean {
return fmt.Sprintf("path is a protected system root %q", banned), true
}
if r, err := filepath.EvalSymlinks(banned); err == nil {
if filepath.Clean(r) == realClean {
return fmt.Sprintf("path is a protected system root %q", banned), true
}
}
}
if home, err := os.UserHomeDir(); err == nil {
homeClean := filepath.Clean(home)
if realClean == homeClean {
return "path is the user's home directory", true
}
if r, err := filepath.EvalSymlinks(home); err == nil {
if filepath.Clean(r) == realClean {
return "path is the user's home directory", true
}
}
}
return "", false
}
// isDriveRoot reports whether absPath is the root of a Windows volume — any
// of `C:\`, `D:\`, ..., `Z:\`, plus less common cases like `\\server\share`
// (filepath.VolumeName treats UNC roots as volumes too). On non-Windows
// this is always false because POSIX has no concept of drive letters and
// `/` is covered by systemRootBlacklist.
//
// We rely on filepath.VolumeName rather than enumerating drive letters
// statically: removable / network drives can be mounted at any letter
// (`G:\`, `H:\`, ...), and Windows installs are increasingly happy to put
// the user profile on a non-C drive. A static list (C..F) would miss them
// all.
func isDriveRoot(absPath string) bool {
if runtime.GOOS != "windows" {
return false
}
vol := filepath.VolumeName(absPath)
if vol == "" {
return false
}
// VolumeName returns the volume without trailing separator (`C:` or
// `\\srv\share`). A drive root is volume + one separator (or, after
// filepath.Clean, just the volume on bare-volume input).
rest := absPath[len(vol):]
return rest == "" || rest == `\` || rest == "/"
}
// systemRootBlacklist returns the per-OS list of paths the daemon never
// allows as a local_directory root. POSIX systems get `/`, `/Users`, `/home`
// (and macOS's `/Users/Shared` for good measure); Windows gets the
// well-known account / shared trees under C:. Drive roots themselves are
// handled by isDriveRoot so we don't have to enumerate G:\, H:\, etc.
// The list is intentionally conservative — it errs on the side of
// rejecting more, since the desktop UI is expected to surface a friendly
// picker that never produces these values.
func systemRootBlacklist() []string {
if runtime.GOOS == "windows" {
return []string{`C:\Users`, `C:\ProgramData`, `C:\Program Files`, `C:\Program Files (x86)`, `C:\Windows`}
}
return []string{"/", "/Users", "/Users/Shared", "/home", "/root", "/var", "/etc", "/tmp", "/usr", "/opt"}
}
// checkDirReadWrite verifies the daemon process can both read directory
// contents and create/remove a probe file inside dir. The probe filename is
// long, hidden, and unlikely to clash with user files; we delete it
// immediately and ignore the delete error (best-effort cleanup is fine —
// the worst case is leaving a 0-byte file the user can ignore).
func checkDirReadWrite(dir string) error {
if _, err := os.ReadDir(dir); err != nil {
return fmt.Errorf("read %q: %w", dir, err)
}
probe, err := os.CreateTemp(dir, ".multica-rwcheck-*")
if err != nil {
return fmt.Errorf("write %q: %w", dir, err)
}
probePath := probe.Name()
_ = probe.Close()
_ = os.Remove(probePath)
return nil
}
// isGitWorkTree reports whether path is the working tree of a git repo. The
// daemon uses this to skip branch / worktree machinery when the user has
// already pointed the project at their own clone — the agent operates on
// the current branch in place. Returns false on any error (git not on PATH,
// path not in a repo, exec failure) so the caller can treat "not a git
// tree" and "can't tell" the same way: skip the git-specific path.
func isGitWorkTree(ctx context.Context, path string) bool {
cmd := exec.CommandContext(ctx, "git", "-C", path, "rev-parse", "--is-inside-work-tree")
out, err := cmd.Output()
if err != nil {
return false
}
return strings.TrimSpace(string(out)) == "true"
}
// LocalPathLocker serialises agent tasks that share the same on-disk path.
// The lock is owned for the entire lifetime of a task (claim → context
// write → agent execution → result report), not just the agent execution
// window, because the context files and skill scratch directories the
// daemon writes at task-prepare time can race with a sibling task on the
// same path.
//
// Implementation: per-key sync.Mutex inside a map guarded by mu. When a
// task can't take the lock immediately, the waiter blocks on the per-key
// Mutex itself — that gives FIFO-ish behaviour from the Go scheduler
// (sufficient for our load; the issue body asks for a wait queue, not a
// strict-priority queue). Holder bookkeeping (current holder task id) is
// surfaced via Holder so callers can build a UI-friendly wait_reason.
type LocalPathLocker struct {
mu sync.Mutex
locks map[string]*pathLockEntry
}
type pathLockEntry struct {
mu sync.Mutex // serialises holders for this key
mu2 sync.Mutex // guards holderID under contention
holderID string // current owner, for UI hints; empty when free
}
// NewLocalPathLocker returns an empty locker. Safe for concurrent use.
func NewLocalPathLocker() *LocalPathLocker {
return &LocalPathLocker{locks: make(map[string]*pathLockEntry)}
}
// Holder returns the task id currently holding the lock for realPath, or
// "" if no task holds it. Used to populate the wait_reason hint the daemon
// posts to the server when it parks a task — the UI then shows "waiting for
// <path> (held by task <short id>)".
func (l *LocalPathLocker) Holder(realPath string) string {
l.mu.Lock()
entry, ok := l.locks[realPath]
l.mu.Unlock()
if !ok {
return ""
}
entry.mu2.Lock()
defer entry.mu2.Unlock()
return entry.holderID
}
// Acquire takes the lock for realPath on behalf of taskID. If the lock is
// already held, onWait is invoked (synchronously, before this goroutine
// blocks) with the current holder id so callers can flip the task into the
// server-side waiting_local_directory state. onWait may be nil for callers
// that don't need the side effect.
//
// Returns a release func that the caller must invoke (typically deferred)
// to free the lock. The release is idempotent.
//
// Acquire is cancellable via ctx. When ctx is cancelled while the goroutine
// is blocked on the lock, Acquire returns ctx.Err() and the lock is NOT
// taken. This is the same contract as sync.Mutex.Lock paired with
// context-aware cancellation — a daemon shutdown won't wedge inside the
// per-path wait queue.
func (l *LocalPathLocker) Acquire(ctx context.Context, realPath, taskID string, onWait func(holder string)) (func(), error) {
if realPath == "" {
return nil, errors.New("local_directory: realpath required for lock")
}
if taskID == "" {
return nil, errors.New("local_directory: taskID required for lock")
}
l.mu.Lock()
entry, ok := l.locks[realPath]
if !ok {
entry = &pathLockEntry{}
l.locks[realPath] = entry
}
l.mu.Unlock()
// Try the fast path first — no allocation, no waiter goroutine.
if entry.mu.TryLock() {
entry.mu2.Lock()
entry.holderID = taskID
entry.mu2.Unlock()
return l.releaser(realPath, entry), nil
}
// Slow path: somebody else holds the lock. Fire onWait once with the
// current holder so the daemon can stamp the server-side wait state,
// then block until either we win the lock or ctx is cancelled.
if onWait != nil {
entry.mu2.Lock()
holder := entry.holderID
entry.mu2.Unlock()
onWait(holder)
}
acquired := make(chan struct{})
go func() {
entry.mu.Lock()
close(acquired)
}()
select {
case <-acquired:
entry.mu2.Lock()
entry.holderID = taskID
entry.mu2.Unlock()
return l.releaser(realPath, entry), nil
case <-ctx.Done():
// We lost the wait — the goroutine above will still complete and
// take the lock. Spin off a clean-up goroutine that releases it
// the moment the acquire returns so a future caller isn't stuck
// behind a phantom holder. The bookkeeping is best-effort: no
// holder id is set, since this task never owned the lock.
go func() {
<-acquired
entry.mu.Unlock()
}()
return nil, ctx.Err()
}
}
// releaser returns the unlock callback. Idempotent via a once flag so a
// deferred release is safe even when the caller has already explicitly
// released after task completion.
func (l *LocalPathLocker) releaser(realPath string, entry *pathLockEntry) func() {
var once sync.Once
return func() {
once.Do(func() {
entry.mu2.Lock()
entry.holderID = ""
entry.mu2.Unlock()
entry.mu.Unlock()
// We deliberately keep the entry in the map even when nothing
// is queued. The cost is one *pathLockEntry per distinct path
// the daemon has ever served, which is bounded by the number
// of local_directory project resources a workspace has — tiny
// in practice. Pruning would race with a sibling caller that
// just looked up the same entry and is about to TryLock.
_ = realPath
})
}
}