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