diff --git a/server/internal/daemon/client.go b/server/internal/daemon/client.go index 07b5aef2..bd060987 100644 --- a/server/internal/daemon/client.go +++ b/server/internal/daemon/client.go @@ -97,10 +97,12 @@ func (c *Client) ReportProgress(ctx context.Context, taskID, summary string, ste }, nil) } -func (c *Client) CompleteTask(ctx context.Context, taskID, output string) error { - return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), map[string]any{ - "output": output, - }, nil) +func (c *Client) CompleteTask(ctx context.Context, taskID, output, branchName string) error { + body := map[string]any{"output": output} + if branchName != "" { + body["branch_name"] = branchName + } + return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/complete", taskID), body, nil) } func (c *Client) FailTask(ctx context.Context, taskID, errMsg string) error { diff --git a/server/internal/daemon/config.go b/server/internal/daemon/config.go index 3bf588ce..628a31d6 100644 --- a/server/internal/daemon/config.go +++ b/server/internal/daemon/config.go @@ -17,7 +17,7 @@ const ( DefaultDaemonConfigPath = ".multica/daemon.json" DefaultPollInterval = 3 * time.Second DefaultHeartbeatInterval = 15 * time.Second - DefaultAgentTimeout = 20 * time.Minute + DefaultAgentTimeout = 2 * time.Hour DefaultRuntimeName = "Local Agent" ) @@ -31,6 +31,8 @@ type Config struct { RuntimeName string Agents map[string]AgentEntry // "claude" -> entry, "codex" -> entry ReposRoot string // parent directory containing all repos + WorkspacesRoot string // base path for execution envs (default: ~/multica_workspaces) + KeepEnvAfterTask bool // preserve env after task for debugging PollInterval time.Duration HeartbeatInterval time.Duration AgentTimeout time.Duration @@ -42,6 +44,7 @@ type Overrides struct { ServerURL string WorkspaceID string ReposRoot string + WorkspacesRoot string ConfigPath string PollInterval time.Duration HeartbeatInterval time.Duration @@ -172,6 +175,27 @@ func LoadConfig(overrides Overrides) (Config, error) { runtimeName = overrides.RuntimeName } + // Workspaces root: override > env > default (~/multica_workspaces) + workspacesRoot := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACES_ROOT")) + if overrides.WorkspacesRoot != "" { + workspacesRoot = overrides.WorkspacesRoot + } + if workspacesRoot == "" { + home, _ := os.UserHomeDir() + if home != "" { + workspacesRoot = filepath.Join(home, "multica_workspaces") + } else { + workspacesRoot = filepath.Join(reposRoot, "multica_workspaces") + } + } + workspacesRoot, err = filepath.Abs(workspacesRoot) + if err != nil { + return Config{}, fmt.Errorf("resolve absolute workspaces root: %w", err) + } + + // Keep env after task: env > default (false) + keepEnv := os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "true" || os.Getenv("MULTICA_KEEP_ENV_AFTER_TASK") == "1" + return Config{ ServerBaseURL: serverBaseURL, ConfigPath: configPath, @@ -181,6 +205,8 @@ func LoadConfig(overrides Overrides) (Config, error) { RuntimeName: runtimeName, Agents: agents, ReposRoot: reposRoot, + WorkspacesRoot: workspacesRoot, + KeepEnvAfterTask: keepEnv, PollInterval: pollInterval, HeartbeatInterval: heartbeatInterval, AgentTimeout: agentTimeout, diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go index cf800161..ac84ca26 100644 --- a/server/internal/daemon/daemon.go +++ b/server/internal/daemon/daemon.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/multica-ai/multica/server/internal/daemon/execenv" "github.com/multica-ai/multica/server/pkg/agent" ) @@ -254,7 +255,7 @@ func (d *Daemon) handleTask(ctx context.Context, task Task) { } default: d.logger.Printf("task %s completed status=%s", task.ID, result.Status) - if err := d.client.CompleteTask(ctx, task.ID, result.Comment); err != nil { + if err := d.client.CompleteTask(ctx, task.ID, result.Comment, result.BranchName); err != nil { d.logger.Printf("complete task %s failed: %v", task.ID, err) } } @@ -267,9 +268,32 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { return TaskResult{}, fmt.Errorf("no agent configured for provider %q", provider) } - workdir := ResolveTaskWorkdir(d.cfg.ReposRoot) + // Prepare isolated execution environment. + env, err := execenv.Prepare(execenv.PrepareParams{ + WorkspacesRoot: d.cfg.WorkspacesRoot, + ReposRoot: d.cfg.ReposRoot, + TaskID: task.ID, + AgentName: task.Context.Agent.Name, + Task: execenv.TaskContextForEnv{ + IssueTitle: task.Context.Issue.Title, + IssueDescription: task.Context.Issue.Description, + AcceptanceCriteria: task.Context.Issue.AcceptanceCriteria, + ContextRefs: task.Context.Issue.ContextRefs, + WorkspaceContext: task.Context.WorkspaceContext, + AgentName: task.Context.Agent.Name, + AgentSkills: task.Context.Agent.Skills, + }, + }, d.logger) + if err != nil { + return TaskResult{}, fmt.Errorf("prepare execution environment: %w", err) + } + defer func() { + if cleanupErr := env.Cleanup(!d.cfg.KeepEnvAfterTask); cleanupErr != nil { + d.logger.Printf("cleanup env for task %s: %v", task.ID, cleanupErr) + } + }() - prompt := BuildPrompt(task, workdir) + prompt := BuildPrompt(task) backend, err := agent.New(provider, agent.Config{ ExecutablePath: entry.Path, @@ -280,12 +304,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { } d.logger.Printf( - "starting %s task=%s workdir=%s model=%s timeout=%s", - provider, task.ID, workdir, entry.Model, d.cfg.AgentTimeout, + "starting %s task=%s workdir=%s branch=%s env_type=%s model=%s timeout=%s", + provider, task.ID, env.WorkDir, env.BranchName, env.Type, entry.Model, d.cfg.AgentTimeout, ) session, err := backend.Execute(ctx, prompt, agent.ExecOptions{ - Cwd: workdir, + Cwd: env.WorkDir, Model: entry.Model, Timeout: d.cfg.AgentTimeout, }) @@ -312,7 +336,12 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) { if result.Output == "" { return TaskResult{}, fmt.Errorf("%s returned empty output", provider) } - return TaskResult{Status: "completed", Comment: result.Output}, nil + return TaskResult{ + Status: "completed", + Comment: result.Output, + BranchName: env.BranchName, + EnvType: string(env.Type), + }, nil case "timeout": return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout) default: diff --git a/server/internal/daemon/daemon_test.go b/server/internal/daemon/daemon_test.go index de2df295..5a9e5a0a 100644 --- a/server/internal/daemon/daemon_test.go +++ b/server/internal/daemon/daemon_test.go @@ -18,17 +18,7 @@ func TestNormalizeServerBaseURL(t *testing.T) { } } -func TestResolveTaskWorkdirReturnsRoot(t *testing.T) { - t.Parallel() - - root := t.TempDir() - got := ResolveTaskWorkdir(root) - if got != root { - t.Fatalf("expected %s, got %s", root, got) - } -} - -func TestBuildPromptIncludesIssueAndSkills(t *testing.T) { +func TestBuildPromptIncludesIssueAndContext(t *testing.T) { t.Parallel() prompt := BuildPrompt(Task{ @@ -37,22 +27,48 @@ func TestBuildPromptIncludesIssueAndSkills(t *testing.T) { Title: "Fix failing test", Description: "Investigate and fix the test failure.", AcceptanceCriteria: []string{"tests pass"}, - ContextRefs: []string{"log snippet"}, }, Agent: AgentContext{ Name: "Local Codex", Skills: "Be concise.", }, }, - }, "/tmp/work") + }) - for _, want := range []string{"Fix failing test", "Investigate and fix the test failure.", "tests pass", "log snippet", "Be concise."} { + for _, want := range []string{ + "Fix failing test", + "Investigate and fix the test failure.", + "tests pass", + ".agent_context/issue_context.md", + } { if !strings.Contains(prompt, want) { t.Fatalf("prompt missing %q", want) } } } +func TestBuildPromptTruncatesLongDescription(t *testing.T) { + t.Parallel() + + longDesc := strings.Repeat("x", 300) + prompt := BuildPrompt(Task{ + Context: TaskContext{ + Issue: IssueContext{ + Title: "Long desc", + Description: longDesc, + }, + Agent: AgentContext{Name: "Test"}, + }, + }) + + if strings.Contains(prompt, longDesc) { + t.Fatal("expected long description to be truncated in prompt") + } + if !strings.Contains(prompt, "...") { + t.Fatal("expected truncation marker") + } +} + func TestIsWorkspaceNotFoundError(t *testing.T) { t.Parallel() diff --git a/server/internal/daemon/execenv/context.go b/server/internal/daemon/execenv/context.go new file mode 100644 index 00000000..eeceecb4 --- /dev/null +++ b/server/internal/daemon/execenv/context.go @@ -0,0 +1,69 @@ +package execenv + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// writeContextFiles renders and writes .agent_context/issue_context.md into workDir. +func writeContextFiles(workDir string, ctx TaskContextForEnv) error { + contextDir := filepath.Join(workDir, ".agent_context") + if err := os.MkdirAll(contextDir, 0o755); err != nil { + return fmt.Errorf("create .agent_context dir: %w", err) + } + + content := renderIssueContext(ctx) + path := filepath.Join(contextDir, "issue_context.md") + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + return fmt.Errorf("write issue_context.md: %w", err) + } + return nil +} + +// renderIssueContext builds the markdown content for issue_context.md. +// Sections with empty content are omitted. +func renderIssueContext(ctx TaskContextForEnv) string { + var b strings.Builder + + if ctx.IssueTitle != "" { + fmt.Fprintf(&b, "# Issue: %s\n\n", ctx.IssueTitle) + } + + if ctx.IssueDescription != "" { + b.WriteString("## Description\n\n") + b.WriteString(ctx.IssueDescription) + b.WriteString("\n\n") + } + + if len(ctx.AcceptanceCriteria) > 0 { + b.WriteString("## Acceptance Criteria\n\n") + for _, item := range ctx.AcceptanceCriteria { + fmt.Fprintf(&b, "- %s\n", item) + } + b.WriteString("\n") + } + + if len(ctx.ContextRefs) > 0 { + b.WriteString("## Context References\n\n") + for _, ref := range ctx.ContextRefs { + fmt.Fprintf(&b, "- %s\n", ref) + } + b.WriteString("\n") + } + + if ctx.WorkspaceContext != "" { + b.WriteString("## Workspace Context\n\n") + b.WriteString(ctx.WorkspaceContext) + b.WriteString("\n\n") + } + + if ctx.AgentSkills != "" { + b.WriteString("## Agent Instructions\n\n") + b.WriteString(ctx.AgentSkills) + b.WriteString("\n") + } + + return b.String() +} diff --git a/server/internal/daemon/execenv/execenv.go b/server/internal/daemon/execenv/execenv.go new file mode 100644 index 00000000..1b5f3894 --- /dev/null +++ b/server/internal/daemon/execenv/execenv.go @@ -0,0 +1,148 @@ +// Package execenv manages isolated per-task execution environments for the daemon. +// Each task gets its own directory with a git worktree (for code tasks) or plain +// directory (for non-code tasks), plus injected context files. +package execenv + +import ( + "fmt" + "log" + "os" + "path/filepath" +) + +// WorkspaceType indicates how the working directory was set up. +type WorkspaceType string + +const ( + WorkspaceTypeGitWorktree WorkspaceType = "git_worktree" + WorkspaceTypeDirectory WorkspaceType = "directory" +) + +// PrepareParams holds all inputs needed to set up an execution environment. +type PrepareParams struct { + WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces) + ReposRoot string // source git repo (for worktree creation) + TaskID string // task UUID — used for directory name + AgentName string // for git branch naming only + Task TaskContextForEnv // context data for writing files +} + +// TaskContextForEnv is the subset of task context used for writing context files. +type TaskContextForEnv struct { + IssueTitle string + IssueDescription string + AcceptanceCriteria []string + ContextRefs []string + WorkspaceContext string + AgentName string + AgentSkills string +} + +// Environment represents a prepared, isolated execution environment. +type Environment struct { + // RootDir is the top-level env directory ({workspacesRoot}/{task_id_short}/). + RootDir string + // WorkDir is the directory to pass as Cwd to the agent ({RootDir}/workdir/). + WorkDir string + // Type indicates git_worktree or directory. + Type WorkspaceType + // BranchName is the git branch name (empty for directory type). + BranchName string + + gitRoot string // source repo root (for cleanup) + logger *log.Logger // for cleanup logging +} + +// Prepare creates an isolated execution environment for a task. +func Prepare(params PrepareParams, logger *log.Logger) (*Environment, error) { + if params.WorkspacesRoot == "" { + return nil, fmt.Errorf("execenv: workspaces root is required") + } + if params.TaskID == "" { + return nil, fmt.Errorf("execenv: task ID is required") + } + + envRoot := filepath.Join(params.WorkspacesRoot, shortID(params.TaskID)) + + // Remove existing env if present (defensive — task IDs are unique). + if _, err := os.Stat(envRoot); err == nil { + if err := os.RemoveAll(envRoot); err != nil { + return nil, fmt.Errorf("execenv: remove existing env: %w", err) + } + } + + // Create directory tree. + workDir := filepath.Join(envRoot, "workdir") + for _, dir := range []string{workDir, filepath.Join(envRoot, "output"), filepath.Join(envRoot, "logs")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("execenv: create directory %s: %w", dir, err) + } + } + + env := &Environment{ + RootDir: envRoot, + WorkDir: workDir, + Type: WorkspaceTypeDirectory, + logger: logger, + } + + // Detect git repo and set up worktree if available. + if params.ReposRoot != "" { + if gitRoot, ok := detectGitRepo(params.ReposRoot); ok { + branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID)) + + // Get the default branch as base ref. + baseRef := getDefaultBranch(gitRoot) + + if err := setupGitWorktree(gitRoot, workDir, branchName, baseRef); err != nil { + logger.Printf("execenv: git worktree setup failed, falling back to directory mode: %v", err) + } else { + env.Type = WorkspaceTypeGitWorktree + env.BranchName = branchName + env.gitRoot = gitRoot + + // Exclude .agent_context from git tracking. + if err := excludeFromGit(workDir, ".agent_context"); err != nil { + logger.Printf("execenv: failed to exclude .agent_context from git: %v", err) + } + } + } + } + + // Write context files into workdir. + if err := writeContextFiles(workDir, params.Task); err != nil { + return nil, fmt.Errorf("execenv: write context files: %w", err) + } + + logger.Printf("execenv: prepared env root=%s type=%s branch=%s", envRoot, env.Type, env.BranchName) + return env, nil +} + +// Cleanup tears down the execution environment. +// If removeAll is true, the entire env root is deleted. Otherwise, workdir is +// removed but output/ and logs/ are preserved for debugging. +func (env *Environment) Cleanup(removeAll bool) error { + if env == nil { + return nil + } + + // Remove git worktree first (must happen before directory deletion). + if env.Type == WorkspaceTypeGitWorktree && env.gitRoot != "" { + removeGitWorktree(env.gitRoot, env.WorkDir, env.BranchName, env.logger) + } + + if removeAll { + if err := os.RemoveAll(env.RootDir); err != nil { + env.logger.Printf("execenv: cleanup removeAll failed: %v", err) + return err + } + return nil + } + + // Partial cleanup: remove workdir, keep output/ and logs/. + if err := os.RemoveAll(env.WorkDir); err != nil { + env.logger.Printf("execenv: cleanup workdir failed: %v", err) + return err + } + return nil +} diff --git a/server/internal/daemon/execenv/execenv_test.go b/server/internal/daemon/execenv/execenv_test.go new file mode 100644 index 00000000..85143a92 --- /dev/null +++ b/server/internal/daemon/execenv/execenv_test.go @@ -0,0 +1,363 @@ +package execenv + +import ( + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func testLogger() *log.Logger { + return log.New(os.Stderr, "[test] ", log.LstdFlags) +} + +func TestShortID(t *testing.T) { + t.Parallel() + tests := []struct { + input, want string + }{ + {"a1b2c3d4-e5f6-7890-abcd-ef1234567890", "a1b2c3d4"}, + {"abcdef12", "abcdef12"}, + {"ab", "ab"}, + {"a1b2c3d4e5f67890", "a1b2c3d4"}, + } + for _, tt := range tests { + if got := shortID(tt.input); got != tt.want { + t.Errorf("shortID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestSanitizeName(t *testing.T) { + t.Parallel() + tests := []struct { + input, want string + }{ + {"Code Reviewer", "code-reviewer"}, + {"my_agent!@#v2", "my-agent-v2"}, + {" spaces ", "spaces"}, + {"UPPERCASE", "uppercase"}, + {"a-very-long-name-that-exceeds-thirty-characters-total", "a-very-long-name-that-exceeds"}, + {"", "agent"}, + {"---", "agent"}, + {"日本語テスト", "agent"}, + } + for _, tt := range tests { + if got := sanitizeName(tt.input); got != tt.want { + t.Errorf("sanitizeName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestDetectGitRepo(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + cmd := exec.Command("git", "init", dir) + if err := cmd.Run(); err != nil { + t.Skipf("git not available: %v", err) + } + + root, ok := detectGitRepo(dir) + if !ok { + t.Fatal("expected git repo to be detected") + } + if root == "" { + t.Fatal("expected non-empty git root") + } + + // Subdirectory should also detect. + subdir := filepath.Join(dir, "sub") + os.MkdirAll(subdir, 0o755) + root2, ok2 := detectGitRepo(subdir) + if !ok2 { + t.Fatal("expected subdirectory to detect git repo") + } + if root2 != root { + t.Fatalf("expected same root, got %q vs %q", root2, root) + } +} + +func TestDetectGitRepoFalse(t *testing.T) { + t.Parallel() + dir := t.TempDir() + _, ok := detectGitRepo(dir) + if ok { + t.Fatal("expected non-git dir to return false") + } +} + +func TestPrepareDirectoryMode(t *testing.T) { + t.Parallel() + workspacesRoot := t.TempDir() + reposRoot := t.TempDir() // not a git repo + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + ReposRoot: reposRoot, + TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + AgentName: "Test Agent", + Task: TaskContextForEnv{ + IssueTitle: "Fix the bug", + IssueDescription: "There is a bug in the login flow.", + AcceptanceCriteria: []string{ + "Login works", + "Tests pass", + }, + AgentSkills: "Be concise.", + }, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if env.Type != WorkspaceTypeDirectory { + t.Fatalf("expected directory type, got %s", env.Type) + } + if env.BranchName != "" { + t.Fatalf("expected empty branch name, got %s", env.BranchName) + } + + // Verify directory structure. + for _, sub := range []string{"workdir", "output", "logs"} { + path := filepath.Join(env.RootDir, sub) + if _, err := os.Stat(path); os.IsNotExist(err) { + t.Fatalf("expected %s to exist", path) + } + } + + // Verify context file. + content, err := os.ReadFile(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")) + if err != nil { + t.Fatalf("failed to read issue_context.md: %v", err) + } + for _, want := range []string{"Fix the bug", "login flow", "Login works", "Tests pass", "Be concise."} { + if !strings.Contains(string(content), want) { + t.Fatalf("issue_context.md missing %q", want) + } + } +} + +func TestPrepareGitWorktreeMode(t *testing.T) { + t.Parallel() + + // Create a temporary git repo with an initial commit. + reposRoot := t.TempDir() + for _, args := range [][]string{ + {"init", reposRoot}, + {"-C", reposRoot, "commit", "--allow-empty", "-m", "initial"}, + } { + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git setup failed: %s: %v", out, err) + } + } + + workspacesRoot := t.TempDir() + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + ReposRoot: reposRoot, + TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901", + AgentName: "Code Reviewer", + Task: TaskContextForEnv{ + IssueTitle: "Add feature", + IssueDescription: "Add a new feature.", + }, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + defer env.Cleanup(true) + + if env.Type != WorkspaceTypeGitWorktree { + t.Fatalf("expected git_worktree type, got %s", env.Type) + } + if env.BranchName == "" { + t.Fatal("expected non-empty branch name") + } + if !strings.HasPrefix(env.BranchName, "agent/code-reviewer/") { + t.Fatalf("unexpected branch name: %s", env.BranchName) + } + + // Verify worktree is listed. + cmd := exec.Command("git", "-C", reposRoot, "worktree", "list") + out, err := cmd.Output() + if err != nil { + t.Fatalf("git worktree list failed: %v", err) + } + if !strings.Contains(string(out), "workdir") { + t.Fatalf("worktree not listed: %s", out) + } + + // Verify context file exists in workdir. + if _, err := os.Stat(filepath.Join(env.WorkDir, ".agent_context", "issue_context.md")); os.IsNotExist(err) { + t.Fatal("expected .agent_context/issue_context.md to exist in workdir") + } +} + +func TestWriteContextFiles(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueTitle: "Test Issue", + IssueDescription: "A detailed description.", + AcceptanceCriteria: []string{"Criterion A", "Criterion B"}, + ContextRefs: []string{"ref-1", "ref-2"}, + WorkspaceContext: "We use Go and TypeScript.", + AgentSkills: "Follow Go conventions.", + } + + if err := writeContextFiles(dir, ctx); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md")) + if err != nil { + t.Fatalf("failed to read: %v", err) + } + + s := string(content) + for _, want := range []string{ + "# Issue: Test Issue", + "## Description", + "A detailed description.", + "## Acceptance Criteria", + "- Criterion A", + "- Criterion B", + "## Context References", + "- ref-1", + "## Workspace Context", + "Go and TypeScript", + "## Agent Instructions", + "Go conventions", + } { + if !strings.Contains(s, want) { + t.Errorf("content missing %q", want) + } + } +} + +func TestWriteContextFilesOmitsEmpty(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + ctx := TaskContextForEnv{ + IssueTitle: "Minimal Issue", + } + + if err := writeContextFiles(dir, ctx); err != nil { + t.Fatalf("writeContextFiles failed: %v", err) + } + + content, err := os.ReadFile(filepath.Join(dir, ".agent_context", "issue_context.md")) + if err != nil { + t.Fatalf("failed to read: %v", err) + } + + s := string(content) + if !strings.Contains(s, "Minimal Issue") { + t.Error("expected title to be present") + } + for _, absent := range []string{"## Description", "## Acceptance Criteria", "## Context References", "## Workspace Context", "## Agent Instructions"} { + if strings.Contains(s, absent) { + t.Errorf("expected %q to be omitted for empty content", absent) + } + } +} + +func TestCleanupGitWorktree(t *testing.T) { + t.Parallel() + + // Create a temp git repo. + reposRoot := t.TempDir() + for _, args := range [][]string{ + {"init", reposRoot}, + {"-C", reposRoot, "commit", "--allow-empty", "-m", "initial"}, + } { + cmd := exec.Command("git", args...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("git setup failed: %s: %v", out, err) + } + } + + workspacesRoot := t.TempDir() + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + ReposRoot: reposRoot, + TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012", + AgentName: "Cleanup Test", + Task: TaskContextForEnv{IssueTitle: "Cleanup test"}, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + + branchName := env.BranchName + rootDir := env.RootDir + + // Cleanup with removeAll=true. + if err := env.Cleanup(true); err != nil { + t.Fatalf("Cleanup failed: %v", err) + } + + // Verify env root is removed. + if _, err := os.Stat(rootDir); !os.IsNotExist(err) { + t.Fatal("expected env root to be removed") + } + + // Verify branch is deleted. + cmd := exec.Command("git", "-C", reposRoot, "branch", "--list", branchName) + out, _ := cmd.Output() + if strings.TrimSpace(string(out)) != "" { + t.Fatalf("expected branch %s to be deleted", branchName) + } +} + +func TestCleanupPreservesLogs(t *testing.T) { + t.Parallel() + workspacesRoot := t.TempDir() + + env, err := Prepare(PrepareParams{ + WorkspacesRoot: workspacesRoot, + ReposRoot: t.TempDir(), // not a git repo + TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123", + AgentName: "Preserve Test", + Task: TaskContextForEnv{IssueTitle: "Preserve test"}, + }, testLogger()) + if err != nil { + t.Fatalf("Prepare failed: %v", err) + } + + // Write something to logs/. + os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644) + + // Cleanup with removeAll=false. + if err := env.Cleanup(false); err != nil { + t.Fatalf("Cleanup failed: %v", err) + } + + // workdir should be gone. + if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) { + t.Fatal("expected workdir to be removed") + } + + // logs should still exist. + logFile := filepath.Join(env.RootDir, "logs", "test.log") + if _, err := os.Stat(logFile); os.IsNotExist(err) { + t.Fatal("expected logs/test.log to be preserved") + } +} diff --git a/server/internal/daemon/execenv/git.go b/server/internal/daemon/execenv/git.go new file mode 100644 index 00000000..aa35d06d --- /dev/null +++ b/server/internal/daemon/execenv/git.go @@ -0,0 +1,139 @@ +package execenv + +import ( + "fmt" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" +) + +// detectGitRepo checks if dir is inside a git repository. +// Returns the git root path and true if found. +func detectGitRepo(dir string) (string, bool) { + cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel") + out, err := cmd.Output() + if err != nil { + return "", false + } + return strings.TrimSpace(string(out)), true +} + +// getDefaultBranch returns the current branch name of the git repo, falling back to HEAD. +func getDefaultBranch(gitRoot string) string { + cmd := exec.Command("git", "-C", gitRoot, "symbolic-ref", "--short", "HEAD") + out, err := cmd.Output() + if err != nil { + return "HEAD" + } + return strings.TrimSpace(string(out)) +} + +// setupGitWorktree creates a git worktree at worktreePath with a new branch. +func setupGitWorktree(gitRoot, worktreePath, branchName, baseRef string) error { + // Remove the workdir created by Prepare — git worktree add needs to create it. + if err := os.Remove(worktreePath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove placeholder workdir: %w", err) + } + + err := runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef) + if err != nil && strings.Contains(err.Error(), "already exists") { + // Branch name collision: append timestamp and retry once. + branchName = fmt.Sprintf("%s-%d", branchName, time.Now().Unix()) + err = runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef) + } + return err +} + +func runGitWorktreeAdd(gitRoot, worktreePath, branchName, baseRef string) error { + cmd := exec.Command("git", "-C", gitRoot, "worktree", "add", "-b", branchName, worktreePath, baseRef) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git worktree add: %s: %w", strings.TrimSpace(string(out)), err) + } + return nil +} + +// removeGitWorktree removes a worktree and its branch. Best-effort: logs errors. +func removeGitWorktree(gitRoot, worktreePath, branchName string, logger *log.Logger) { + // Remove the worktree. + cmd := exec.Command("git", "-C", gitRoot, "worktree", "remove", "--force", worktreePath) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Printf("execenv: git worktree remove: %s: %v", strings.TrimSpace(string(out)), err) + } + + // Delete the branch (best-effort). + if branchName != "" { + cmd = exec.Command("git", "-C", gitRoot, "branch", "-D", branchName) + if out, err := cmd.CombinedOutput(); err != nil { + logger.Printf("execenv: git branch -D %s: %s: %v", branchName, strings.TrimSpace(string(out)), err) + } + } +} + +// excludeFromGit adds a pattern to the worktree's .git/info/exclude file. +func excludeFromGit(worktreePath, pattern string) error { + // Resolve the actual git dir for this worktree. + cmd := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir") + out, err := cmd.Output() + if err != nil { + return fmt.Errorf("resolve git dir: %w", err) + } + + gitDir := strings.TrimSpace(string(out)) + if !filepath.IsAbs(gitDir) { + gitDir = filepath.Join(worktreePath, gitDir) + } + + excludePath := filepath.Join(gitDir, "info", "exclude") + + // Ensure the info directory exists. + if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil { + return fmt.Errorf("create info dir: %w", err) + } + + // Check if pattern is already present. + existing, _ := os.ReadFile(excludePath) + if strings.Contains(string(existing), pattern) { + return nil + } + + f, err := os.OpenFile(excludePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return fmt.Errorf("open exclude file: %w", err) + } + defer f.Close() + + if _, err := fmt.Fprintf(f, "\n%s\n", pattern); err != nil { + return fmt.Errorf("write exclude pattern: %w", err) + } + return nil +} + +// shortID returns the first 8 characters of a UUID string (dashes stripped). +func shortID(uuid string) string { + s := strings.ReplaceAll(uuid, "-", "") + if len(s) > 8 { + return s[:8] + } + return s +} + +var nonAlphanumeric = regexp.MustCompile(`[^a-z0-9]+`) + +// sanitizeName produces a git-branch-safe name from a human-readable string. +func sanitizeName(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = nonAlphanumeric.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if len(s) > 30 { + s = s[:30] + s = strings.TrimRight(s, "-") + } + if s == "" { + s = "agent" + } + return s +} diff --git a/server/internal/daemon/prompt.go b/server/internal/daemon/prompt.go index 2f25c9f2..5bc772db 100644 --- a/server/internal/daemon/prompt.go +++ b/server/internal/daemon/prompt.go @@ -6,60 +6,41 @@ import ( ) // BuildPrompt constructs the task prompt for an agent CLI. -func BuildPrompt(task Task, workdir string) string { +// Full context is available in .agent_context/issue_context.md (written by execenv). +// The prompt contains a brief summary for immediate context. +func BuildPrompt(task Task) string { var b strings.Builder b.WriteString("You are running as a local coding agent for a Multica workspace.\n") - b.WriteString("Complete the assigned issue using the local environment.\n") - b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n") - b.WriteString("If you cannot complete the task because context, files, or permissions are missing, return status \"blocked\" and explain the blocker in the comment.\n\n") + b.WriteString("Complete the assigned issue using the local environment.\n\n") - fmt.Fprintf(&b, "Working directory: %s\n", workdir) - fmt.Fprintf(&b, "Agent: %s\n", task.Context.Agent.Name) - fmt.Fprintf(&b, "Issue title: %s\n\n", task.Context.Issue.Title) + b.WriteString("## Context\n\n") + b.WriteString("Full issue context is available in `.agent_context/issue_context.md` in your working directory.\n") + b.WriteString("Read this file first for the complete issue description, acceptance criteria, and instructions.\n\n") + + fmt.Fprintf(&b, "**Issue:** %s\n", task.Context.Issue.Title) + fmt.Fprintf(&b, "**Agent:** %s\n\n", task.Context.Agent.Name) if task.Context.Issue.Description != "" { - b.WriteString("Issue description:\n") - b.WriteString(task.Context.Issue.Description) - b.WriteString("\n\n") + desc := task.Context.Issue.Description + if len(desc) > 200 { + desc = desc[:200] + "..." + } + fmt.Fprintf(&b, "**Summary:** %s\n\n", desc) } if len(task.Context.Issue.AcceptanceCriteria) > 0 { - b.WriteString("Acceptance criteria:\n") + b.WriteString("## Acceptance Criteria\n\n") for _, item := range task.Context.Issue.AcceptanceCriteria { fmt.Fprintf(&b, "- %s\n", item) } b.WriteString("\n") } - if len(task.Context.Issue.ContextRefs) > 0 { - b.WriteString("Context refs:\n") - for _, item := range task.Context.Issue.ContextRefs { - fmt.Fprintf(&b, "- %s\n", item) - } - b.WriteString("\n") - } - - if task.Context.WorkspaceContext != "" { - b.WriteString("Workspace context:\n") - b.WriteString(task.Context.WorkspaceContext) - b.WriteString("\n\n") - } - - if task.Context.Agent.Skills != "" { - b.WriteString("Agent skills/instructions:\n") - b.WriteString(task.Context.Agent.Skills) - b.WriteString("\n\n") - } - - b.WriteString("Comment requirements:\n") + b.WriteString("## Output Requirements\n\n") + b.WriteString("Return a concise Markdown comment suitable for posting back to the issue.\n") b.WriteString("- Lead with the outcome.\n") b.WriteString("- Mention concrete files or commands if you changed anything.\n") - b.WriteString("- Mention blockers or follow-up actions if relevant.\n") + b.WriteString("- If blocked, explain the blocker clearly.\n") return b.String() } - -// ResolveTaskWorkdir determines the working directory for a task. -func ResolveTaskWorkdir(reposRoot string) string { - return reposRoot -} diff --git a/server/internal/daemon/types.go b/server/internal/daemon/types.go index dd28eac7..8c4991ed 100644 --- a/server/internal/daemon/types.go +++ b/server/internal/daemon/types.go @@ -77,6 +77,8 @@ type RuntimeContext struct { // TaskResult is the outcome of executing a task. type TaskResult struct { - Status string `json:"status"` - Comment string `json:"comment"` + Status string `json:"status"` + Comment string `json:"comment"` + BranchName string `json:"branch_name,omitempty"` + EnvType string `json:"env_type,omitempty"` }