feat(daemon): add per-task isolated execution environments

Introduce the `execenv` package that creates isolated working directories
for each agent task. Supports git worktree mode (code tasks) and plain
directory mode (non-code tasks), with `.agent_context/issue_context.md`
injected into the workdir for Claude Code to discover.

Key changes:
- New `server/internal/daemon/execenv/` package (Prepare/Cleanup)
- `runTask()` now creates isolated env instead of using shared reposRoot
- Prompt updated to reference `.agent_context/` files
- Add `WorkspacesRoot` config (default ~/multica_workspaces)
- Add `KeepEnvAfterTask` config for debugging
- Default agent timeout increased from 20min to 2h
- `CompleteTask` now forwards branch name to server

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-03-25 12:41:52 +08:00
parent 0ce25597d6
commit 678266ec87
10 changed files with 841 additions and 66 deletions

View file

@ -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 {

View file

@ -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,

View file

@ -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:

View file

@ -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()

View file

@ -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()
}

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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"`
}