feat(daemon): agent-driven repo checkout with bare clone cache

Agents now decide which repo to use based on issue context and check out
repos on demand via `multica repo checkout <url>`. Workspace repos are
cached locally as bare clones for fast worktree creation.

Key changes:
- Add repocache package for bare clone management (clone, fetch, worktree)
- Add `multica repo checkout` CLI command that talks to local daemon
- Add POST /repo/checkout endpoint on daemon health server
- Pass workspace repos metadata through register + task claim responses
- Remove pre-created worktrees from execenv (workdir starts empty)
- Update CLAUDE.md template to instruct agents to use `multica repo checkout`
- Pass MULTICA_DAEMON_PORT, WORKSPACE_ID, AGENT_NAME, TASK_ID env vars to agent
This commit is contained in:
Jiayuan 2026-03-29 19:37:48 +08:00
parent ab4058b1e4
commit cdc1ac708e
15 changed files with 1064 additions and 255 deletions

View file

@ -1,6 +1,6 @@
// 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.
// Each task gets its own directory with injected context files. Repositories are
// checked out on demand by the agent via `multica repo checkout`.
package execenv
import (
@ -10,18 +10,15 @@ import (
"path/filepath"
)
// WorkspaceType indicates how the working directory was set up.
type WorkspaceType string
const (
WorkspaceTypeGitWorktree WorkspaceType = "git_worktree"
WorkspaceTypeDirectory WorkspaceType = "directory"
)
// RepoContextForEnv describes a workspace repo available for checkout.
type RepoContextForEnv struct {
URL string // remote URL
Description string // human-readable description
}
// 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)
RepoPath string // source git repo path (for worktree creation), provided per-task by server
TaskID string // task UUID — used for directory name
AgentName string // for git branch naming only
Provider string // agent provider ("claude", "codex") — determines skill injection paths
@ -34,6 +31,7 @@ type TaskContextForEnv struct {
AgentName string
AgentInstructions string // agent identity/persona instructions, injected into CLAUDE.md
AgentSkills []SkillContextForEnv
Repos []RepoContextForEnv // workspace repos available for checkout
}
// SkillContextForEnv represents a skill to be written into the execution environment.
@ -55,18 +53,15 @@ type Environment struct {
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
// CodexHome is the path to the per-task CODEX_HOME directory (set only for codex provider).
CodexHome string
gitRoot string // source repo root (for cleanup)
logger *slog.Logger // for cleanup logging
logger *slog.Logger // for cleanup logging
}
// Prepare creates an isolated execution environment for a task.
// The workdir starts empty (no repo checkouts). The agent checks out repos
// on demand via `multica repo checkout <url>`.
func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
if params.WorkspacesRoot == "" {
return nil, fmt.Errorf("execenv: workspaces root is required")
@ -95,35 +90,9 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
env := &Environment{
RootDir: envRoot,
WorkDir: workDir,
Type: WorkspaceTypeDirectory,
logger: logger,
}
// Detect git repo and set up worktree if available.
if params.RepoPath != "" {
if gitRoot, ok := detectGitRepo(params.RepoPath); 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.Warn("execenv: git worktree setup failed, falling back to directory mode", "error", err)
} else {
env.Type = WorkspaceTypeGitWorktree
env.BranchName = branchName
env.gitRoot = gitRoot
// Exclude injected directories from git tracking.
for _, pattern := range []string{".agent_context", ".claude", "CLAUDE.md", "AGENTS.md"} {
if err := excludeFromGit(workDir, pattern); err != nil {
logger.Warn("execenv: failed to exclude from git", "pattern", pattern, "error", err)
}
}
}
}
}
// Write context files into workdir (skills go to provider-native paths).
if err := writeContextFiles(workDir, params.Provider, params.Task); err != nil {
return nil, fmt.Errorf("execenv: write context files: %w", err)
@ -143,7 +112,7 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
env.CodexHome = codexHome
}
logger.Info("execenv: prepared env", "root", envRoot, "type", env.Type, "branch", env.BranchName)
logger.Info("execenv: prepared env", "root", envRoot, "repos_available", len(params.Task.Repos))
return env, nil
}
@ -155,11 +124,6 @@ func (env *Environment) Cleanup(removeAll bool) error {
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.Warn("execenv: cleanup removeAll failed", "error", err)

View file

@ -3,7 +3,6 @@ package execenv
import (
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@ -51,52 +50,31 @@ func TestSanitizeName(t *testing.T) {
}
}
func TestDetectGitRepo(t *testing.T) {
func TestRepoNameFromURL(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)
tests := []struct {
input, want string
}{
{"https://github.com/org/my-repo.git", "my-repo"},
{"https://github.com/org/my-repo", "my-repo"},
{"git@github.com:org/my-repo.git", "my-repo"},
{"https://github.com/org/repo/", "repo"},
{"my-repo", "my-repo"},
{"", "repo"},
}
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")
for _, tt := range tests {
if got := repoNameFromURL(tt.input); got != tt.want {
t.Errorf("repoNameFromURL(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
func TestPrepareDirectoryMode(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
reposRoot := t.TempDir() // not a git repo
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentName: "Test Agent",
Task: TaskContextForEnv{
@ -111,13 +89,6 @@ func TestPrepareDirectoryMode(t *testing.T) {
}
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)
@ -145,67 +116,64 @@ func TestPrepareDirectoryMode(t *testing.T) {
if !strings.Contains(string(skillContent), "Be concise.") {
t.Fatal("SKILL.md missing content")
}
}
func TestPrepareGitWorktreeMode(t *testing.T) {
func TestPrepareWithRepoContext(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()
taskCtx := TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
Repos: []RepoContextForEnv{
{URL: "https://github.com/org/backend", Description: "Go backend"},
{URL: "https://github.com/org/frontend", Description: "React frontend"},
},
}
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: reposRoot,
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
AgentName: "Code Reviewer",
Task: TaskContextForEnv{
IssueID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
},
Provider: "claude",
Task: taskCtx,
}, 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)
// Inject runtime config (done separately in daemon, replicate here).
if err := InjectRuntimeConfig(env.WorkDir, "claude", taskCtx); err != nil {
t.Fatalf("InjectRuntimeConfig failed: %v", err)
}
// Verify worktree is listed.
cmd := exec.Command("git", "-C", reposRoot, "worktree", "list")
out, err := cmd.Output()
// Workdir should be empty (no pre-created repo dirs).
entries, err := os.ReadDir(env.WorkDir)
if err != nil {
t.Fatalf("git worktree list failed: %v", err)
t.Fatalf("failed to read workdir: %v", err)
}
if !strings.Contains(string(out), "workdir") {
t.Fatalf("worktree not listed: %s", out)
for _, e := range entries {
name := e.Name()
if name != ".agent_context" && name != "CLAUDE.md" && name != ".claude" {
t.Errorf("unexpected entry in workdir: %s", name)
}
}
// 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")
// CLAUDE.md should contain repo info.
content, err := os.ReadFile(filepath.Join(env.WorkDir, "CLAUDE.md"))
if err != nil {
t.Fatalf("failed to read CLAUDE.md: %v", err)
}
s := string(content)
for _, want := range []string{
"multica repo checkout",
"https://github.com/org/backend",
"Go backend",
"https://github.com/org/frontend",
"React frontend",
} {
if !strings.Contains(s, want) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
}
@ -348,56 +316,37 @@ func TestWriteContextFilesClaudeNativeSkills(t *testing.T) {
}
}
func TestCleanupGitWorktree(t *testing.T) {
func TestCleanupPreservesLogs(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,
RepoPath: reposRoot,
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
AgentName: "Cleanup Test",
Task: TaskContextForEnv{IssueID: "cleanup-test-id"},
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, testLogger())
if err != nil {
t.Fatalf("Prepare failed: %v", err)
}
branchName := env.BranchName
rootDir := env.RootDir
// Write something to logs/.
os.WriteFile(filepath.Join(env.RootDir, "logs", "test.log"), []byte("log data"), 0o644)
// Cleanup with removeAll=true.
if err := env.Cleanup(true); err != nil {
// Cleanup with removeAll=false.
if err := env.Cleanup(false); 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")
// workdir should be gone.
if _, err := os.Stat(env.WorkDir); !os.IsNotExist(err) {
t.Fatal("expected workdir 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)
// 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")
}
}
@ -437,12 +386,6 @@ func TestInjectRuntimeConfigClaude(t *testing.T) {
t.Errorf("CLAUDE.md missing %q", want)
}
}
// Skills are now discovered natively — no path references in CLAUDE.md.
for _, absent := range []string{"go-conventions/SKILL.md", ".agent_context/skills/"} {
if strings.Contains(s, absent) {
t.Errorf("CLAUDE.md should NOT contain path %q — skills are discovered natively", absent)
}
}
}
func TestInjectRuntimeConfigCodex(t *testing.T) {
@ -512,41 +455,6 @@ func TestInjectRuntimeConfigUnknownProvider(t *testing.T) {
}
}
func TestCleanupPreservesLogs(t *testing.T) {
t.Parallel()
workspacesRoot := t.TempDir()
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
RepoPath: t.TempDir(), // not a git repo
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueID: "preserve-test-id"},
}, 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")
}
}
func TestPrepareCodexHomeSeedsFromShared(t *testing.T) {
// Cannot use t.Parallel() with t.Setenv.

View file

@ -11,30 +11,65 @@ import (
"time"
)
// detectGitRepo checks if dir is inside a git repository.
// detectGitRepo checks if dir is inside a git repository (regular or bare).
// Returns the git root path and true if found.
func detectGitRepo(dir string) (string, bool) {
// Try regular repo first.
cmd := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return "", false
if out, err := cmd.Output(); err == nil {
return strings.TrimSpace(string(out)), true
}
return strings.TrimSpace(string(out)), true
// Try bare repo: git-dir is "." for bare repos when -C points at the repo.
cmd = exec.Command("git", "-C", dir, "rev-parse", "--is-bare-repository")
if out, err := cmd.Output(); err == nil && strings.TrimSpace(string(out)) == "true" {
return dir, true
}
return "", false
}
// 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"
// fetchOrigin runs `git fetch origin` to ensure the local repo has the latest remote refs.
func fetchOrigin(gitRoot string) error {
cmd := exec.Command("git", "-C", gitRoot, "fetch", "origin")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("git fetch origin: %s: %w", strings.TrimSpace(string(out)), err)
}
return strings.TrimSpace(string(out))
return nil
}
// getRemoteDefaultBranch returns "origin/<branch>" for the remote's default branch.
// Falls back to "origin/main", then "HEAD".
func getRemoteDefaultBranch(gitRoot string) string {
// Try symbolic-ref of origin/HEAD (set by `git clone` or `git remote set-head`).
cmd := exec.Command("git", "-C", gitRoot, "symbolic-ref", "refs/remotes/origin/HEAD")
if out, err := cmd.Output(); err == nil {
ref := strings.TrimSpace(string(out))
// ref looks like "refs/remotes/origin/main" — return "origin/main".
if strings.HasPrefix(ref, "refs/remotes/") {
return strings.TrimPrefix(ref, "refs/remotes/")
}
return ref
}
// Fallback: check if origin/main exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/main")
if err := cmd.Run(); err == nil {
return "origin/main"
}
// Fallback: check if origin/master exists.
cmd = exec.Command("git", "-C", gitRoot, "rev-parse", "--verify", "origin/master")
if err := cmd.Run(); err == nil {
return "origin/master"
}
return "HEAD"
}
// 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.
// Remove the workdir created by caller — 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)
}
@ -112,6 +147,32 @@ func excludeFromGit(worktreePath, pattern string) error {
return nil
}
// repoNameFromURL extracts a short directory name from a git remote URL.
// e.g. "https://github.com/org/my-repo.git" → "my-repo"
func repoNameFromURL(url string) string {
// Strip trailing slashes and .git suffix.
url = strings.TrimRight(url, "/")
url = strings.TrimSuffix(url, ".git")
// Take the last path segment.
if i := strings.LastIndex(url, "/"); i >= 0 {
url = url[i+1:]
}
// Also handle SSH-style "host:org/repo".
if i := strings.LastIndex(url, ":"); i >= 0 {
url = url[i+1:]
if j := strings.LastIndex(url, "/"); j >= 0 {
url = url[j+1:]
}
}
name := strings.TrimSpace(url)
if name == "" {
return "repo"
}
return name
}
// shortID returns the first 8 characters of a UUID string (dashes stripped).
func shortID(uuid string) string {
s := strings.ReplaceAll(uuid, "-", "")

View file

@ -54,14 +54,37 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n\n")
// Inject available repositories section.
if len(ctx.Repos) > 0 {
b.WriteString("## Repositories\n\n")
b.WriteString("The following code repositories are available in this workspace.\n")
b.WriteString("Use `multica repo checkout <url>` to check out a repository into your working directory.\n\n")
b.WriteString("| URL | Description |\n")
b.WriteString("|-----|-------------|\n")
for _, repo := range ctx.Repos {
desc := repo.Description
if desc == "" {
desc = "—"
}
fmt.Fprintf(&b, "| %s | %s |\n", repo.URL, desc)
}
b.WriteString("\nThe checkout command creates a git worktree with a dedicated branch. You can check out one or more repos as needed.\n\n")
}
b.WriteString("### Workflow\n")
b.WriteString("You are responsible for managing the issue status throughout your work.\n\n")
fmt.Fprintf(&b, "1. Run `multica issue get %s --output json` to understand your task\n", ctx.IssueID)
fmt.Fprintf(&b, "2. Run `multica issue status %s in_progress`\n", ctx.IssueID)
b.WriteString("3. Read comments for additional context or human instructions\n")
b.WriteString("4. If the task requires code changes:\n")
b.WriteString(" a. Create a new branch\n")
b.WriteString(" b. Implement the changes and commit\n")
if len(ctx.Repos) > 0 {
b.WriteString(" a. Run `multica repo checkout <url>` to check out the appropriate repository\n")
b.WriteString(" b. `cd` into the checked-out directory\n")
b.WriteString(" c. Implement the changes and commit\n")
} else {
b.WriteString(" a. Create a new branch\n")
b.WriteString(" b. Implement the changes and commit\n")
}
b.WriteString(" c. Push the branch to the remote\n")
b.WriteString(" d. Create a pull request (decide the target branch based on the repo's conventions)\n")
fmt.Fprintf(&b, " e. Post the PR link as a comment: `multica issue comment add %s --content \"PR: <url>\"`\n", ctx.IssueID)