Files
multica/server/internal/handler/agent_work_dir_test.go
T
Bohan Jiangandmultica-agent 3622db21cf MUL-6491 fix(daemon): give each task an exclusively claimed env root (#7347)
* fix(daemon): derive task env roots and branches from the full task id

Task ids are UUIDv7, whose leading 8 hex chars are the high 32 bits of a
48-bit millisecond timestamp: they only advance once every 2^16 ms
(~65.5s). Deriving the env-root segment from that prefix meant every task
created inside one such window landed on the same directory.

Prepare removes an existing env root before recreating it, guarded only by
the comment "task IDs are unique". That premise held for the id but not for
the truncation, so the second task's Prepare ran os.RemoveAll over a live
sibling's env root — taking its workdir (including a worktree with
uncommitted work), output/, logs/ and the 0700 multica-config/ with it.
Overwritten CLAUDE.md / AGENTS.md identity files were the visible symptom;
the deletion was the actual damage.

Use the whole id for the env root and for the agent/<name>/<task> branch
names, which truncated the same way and collided for concurrent tasks of
one agent. shortID stays for logs and display only. Prepare now derives its
root through PredictRootDir so the two can no longer drift apart.

Existing short-prefix directories need no migration: the GC enumerates task
dirs with ReadDir and reads .gc_meta.json, so it still reclaims them.

Fixes #7326

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

* fix(daemon): take the task path segment from the id's random tail

The first attempt used the whole 32-char task id, which overflowed Windows
MAX_PATH: a branch name becomes a path under .git/refs/heads/ inside an
already-deep task checkout, and CI failed with

    cannot lock ref 'refs/heads/agent/windows-codex/<32 chars>':
    Filename too long

Length was never the fix. The bug was which end the segment came from: a
UUIDv7 leads with a 48-bit millisecond timestamp, so its first 8 hex chars
are the high 32 bits and only advance once every ~65.5s. Read from the tail
instead, where the id is random, and 12 chars are enough — 4 more than the
original segment rather than 24.

That trades a certainty for a probability, so make the destructive step
prove itself instead of assuming: Prepare stamps a .task_owner marker into
the env root and refuses to run os.RemoveAll over a root another task owns.
A rerun of the same task still resets its own directory. A task that fails
to start is recoverable; one that deletes a running sibling's uncommitted
work is not.

Two existing fixtures used synthetic ids differing only in the head and so
shared a tail. The guard caught them; they now differ where a real UUIDv7
does.

Fixes #7326

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

* fix(daemon): make the env-root claim atomic instead of check-then-delete

Review caught that the ownership guard did not cover the case it was added
for. It read .task_owner, then deleted, then wrote the marker — three steps,
so two tasks sharing a taskKey could both observe "no owner", and the slower
one would still os.RemoveAll the faster one's live directory. The guard only
held when a foreign marker was already durably in place, i.e. serially.

Replace it with a claim protocol built on two atomic filesystem primitives:
os.Mkdir fails if the directory exists, and O_EXCL fails if the marker
exists. Exactly one racing caller wins each, and a caller that wins neither
never reaches the destructive path.

The claim is then held for the rest of Prepare. A rerun clears the env
root's CONTENTS and leaves the marker in place rather than removing and
recreating the directory, so there is no window in which the root looks
unowned to a racing task.

A directory that holds files but names no owner now fails closed instead of
being deleted as abandoned. The marker is written before any content, so the
only unowned directory that is safe to adopt is an empty one — the residue
of a crash between Mkdir and the marker write, which would otherwise wedge
the task permanently.

Tests: a barrier-synchronised concurrent Prepare asserting exactly one task
starts and the winner's tree survives (fails against the previous sequence
with "directory not empty" / "invalid argument" — the deletion racing the
create), plus fail-closed and empty-adoption cases. repocache carried a
third copy of the task-segment rule with no test; it now has the same
contract test as the handler.

Fixes #7326

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

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-21 00:36:59 +08:00

229 lines
7.1 KiB
Go

package handler
import (
"testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/daemon/execenv"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestRelativeWorkDir covers the privacy-safe display derivation that
// agent-transcript dialogs render in the work_dir chip. Two regression
// concerns drive the table:
//
// 1. Standard tasks must strip the daemon's workspaces root so the chip
// doesn't expose the user's home directory or username (the bug in
// PR #3379 that this fix replaces).
// 2. local_directory tasks have a work_dir outside the envRoot layout —
// we must NOT leak `/Users/<name>/...`, `/home/<name>/...`, or
// `<drive>:/Users/<name>/...` even on shallow paths like
// `/Users/alice/foo`. The function strips recognised home prefixes
// and otherwise falls back to the basename, which can never carry a
// username segment.
func TestRelativeWorkDir(t *testing.T) {
const (
wsID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
// The env-root segment is the TAIL of the task id, not a leading
// prefix: UUIDv7 puts a timestamp in front, so a leading slice is
// shared by every task created in the same ~65.5s window (#7326).
taskSeg = "a548b2390cb2"
)
tests := []struct {
name string
workDir string
wsID string
taskID string
expected string
}{
{
name: "empty work_dir returns empty",
workDir: "",
wsID: wsID,
taskID: taskID,
expected: "",
},
{
name: "standard envRoot path strips workspaces root",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/" + taskSeg + "/workdir",
wsID: wsID,
taskID: taskID,
expected: wsID + "/" + taskSeg + "/workdir",
},
{
name: "standard envRoot path without trailing workdir",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/" + taskSeg,
wsID: wsID,
taskID: taskID,
expected: wsID + "/" + taskSeg,
},
{
name: "local_directory path under /Users home is stripped",
workDir: "/Users/df007df/repos/foo",
wsID: wsID,
taskID: taskID,
expected: "repos/foo",
},
{
name: "local_directory deep path under home keeps full remainder",
workDir: "/Users/df007df/code/work/projects/multica/foo",
wsID: wsID,
taskID: taskID,
expected: "code/work/projects/multica/foo",
},
{
name: "shallow /Users home path strips username segment",
workDir: "/Users/alice/foo",
wsID: wsID,
taskID: taskID,
expected: "foo",
},
{
name: "shallow Linux /home path strips username segment",
workDir: "/home/alice/project",
wsID: wsID,
taskID: taskID,
expected: "project",
},
{
name: "shallow Windows /Users path strips username segment",
workDir: `C:\Users\alice\foo`,
wsID: wsID,
taskID: taskID,
expected: "foo",
},
{
name: "exact home directory returns empty (would only render username)",
workDir: "/Users/alice",
wsID: wsID,
taskID: taskID,
expected: "",
},
{
name: "exact home directory with trailing slash returns empty",
workDir: "/Users/alice/",
wsID: wsID,
taskID: taskID,
expected: "",
},
{
name: "Windows local_directory path under home strips username",
workDir: `C:\Users\alice\repos\foo`,
wsID: wsID,
taskID: taskID,
expected: "repos/foo",
},
{
name: "non-home local path falls back to basename only",
workDir: "/opt/foo",
wsID: wsID,
taskID: taskID,
expected: "foo",
},
{
name: "non-home deep local path falls back to basename only",
workDir: "/srv/git/repo",
wsID: wsID,
taskID: taskID,
expected: "repo",
},
{
name: "single-segment local path returns the segment",
workDir: "/foo",
wsID: wsID,
taskID: taskID,
expected: "foo",
},
{
name: "Windows backslash separators are normalized",
workDir: `C:\Users\alice\multica_workspaces\` + wsID + `\` + taskSeg + `\workdir`,
wsID: wsID,
taskID: taskID,
expected: wsID + "/" + taskSeg + "/workdir",
},
{
name: "missing workspace_id under home strips home prefix instead of envRoot",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/" + taskSeg + "/workdir",
wsID: "",
taskID: taskID,
expected: "multica_workspaces/" + wsID + "/" + taskSeg + "/workdir",
},
{
name: "missing task_id under home strips home prefix instead of envRoot",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/" + taskSeg + "/workdir",
wsID: wsID,
taskID: "",
expected: "multica_workspaces/" + wsID + "/" + taskSeg + "/workdir",
},
{
name: "trailing slash on envRoot path is preserved in returned suffix",
workDir: "/Users/alice/multica_workspaces/" + wsID + "/" + taskSeg + "/workdir/",
wsID: wsID,
taskID: taskID,
expected: wsID + "/" + taskSeg + "/workdir/",
},
{
name: "wsID prefix appearing elsewhere falls back to basename when not under home",
workDir: "/var/" + wsID + "/something/else",
wsID: wsID,
taskID: taskID,
expected: "else",
},
{
name: "case-insensitive /users matches the same as /Users",
workDir: "/users/alice/repos/foo",
wsID: wsID,
taskID: taskID,
expected: "repos/foo",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := relativeWorkDir(tc.workDir, tc.wsID, tc.taskID)
if got != tc.expected {
t.Fatalf("relativeWorkDir(%q, %q, %q) = %q, want %q",
tc.workDir, tc.wsID, tc.taskID, got, tc.expected)
}
})
}
}
func TestTaskToResponseDerivesPrivateDurableWorkDir(t *testing.T) {
response := taskToResponse(db.AgentTaskQueue{
DurableWorkDir: pgtype.Text{
String: "/Users/alice/repos/multica",
Valid: true,
},
}, "")
if response.DurableWorkDir != "/Users/alice/repos/multica" {
t.Fatalf("durable_work_dir = %q, want absolute clipboard value", response.DurableWorkDir)
}
if response.RelativeDurableWorkDir != "repos/multica" {
t.Fatalf("relative_durable_work_dir = %q, want privacy-safe display value", response.RelativeDurableWorkDir)
}
}
// TestTaskDirSegmentMatchesDaemon pins taskDirSegment() to
// execenv.PredictRootDir's path layout. Both helpers consume the same task
// UUID; if the daemon's taskKey logic drifts, this test trips loudly instead
// of letting the UI silently fall back to the "tail two segments" branch.
// Without this guard, a daemon-side change back to a truncated prefix would
// not break a build — it would just quietly degrade every standard-task
// work_dir chip into the local_directory fallback.
func TestTaskDirSegmentMatchesDaemon(t *testing.T) {
const (
workspacesRoot = "/tmp/workspaces"
workspaceID = "a05b0e10-ee7a-4603-a72d-a548b2390cb2"
taskID = "5c57b65b-ee7a-4603-a72d-a548b2390cb2"
)
daemonRoot := execenv.PredictRootDir(workspacesRoot, workspaceID, taskID)
expected := workspacesRoot + "/" + workspaceID + "/" + taskDirSegment(taskID)
if daemonRoot != expected {
t.Fatalf("daemon PredictRootDir = %q, handler-side reconstruction = %q — taskDirSegment is out of sync with execenv.taskKey", daemonRoot, expected)
}
}