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

@ -0,0 +1,92 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/spf13/cobra"
)
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage repositories",
}
var repoCheckoutCmd = &cobra.Command{
Use: "checkout <url>",
Short: "Check out a repository into the working directory",
Long: "Creates a git worktree from the daemon's bare clone cache. Used by agents to check out repos on demand.",
Args: cobra.ExactArgs(1),
RunE: runRepoCheckout,
}
func init() {
repoCmd.AddCommand(repoCheckoutCmd)
}
func runRepoCheckout(cmd *cobra.Command, args []string) error {
repoURL := args[0]
daemonPort := os.Getenv("MULTICA_DAEMON_PORT")
if daemonPort == "" {
return fmt.Errorf("MULTICA_DAEMON_PORT not set (this command is intended to be run by an agent inside a daemon task)")
}
workspaceID := os.Getenv("MULTICA_WORKSPACE_ID")
agentName := os.Getenv("MULTICA_AGENT_NAME")
taskID := os.Getenv("MULTICA_TASK_ID")
// Use current working directory as the checkout target.
workDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("get working directory: %w", err)
}
reqBody := map[string]string{
"url": repoURL,
"workspace_id": workspaceID,
"workdir": workDir,
"agent_name": agentName,
"task_id": taskID,
}
data, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("encode request: %w", err)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Post(
fmt.Sprintf("http://127.0.0.1:%s/repo/checkout", daemonPort),
"application/json",
bytes.NewReader(data),
)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("checkout failed: %s", string(body))
}
var result struct {
Path string `json:"path"`
BranchName string `json:"branch_name"`
}
if err := json.Unmarshal(body, &result); err != nil {
return fmt.Errorf("parse response: %w", err)
}
fmt.Fprintf(os.Stdout, "%s\n", result.Path)
fmt.Fprintf(os.Stderr, "Checked out %s → %s (branch: %s)\n", repoURL, result.Path, result.BranchName)
return nil
}

View file

@ -31,6 +31,7 @@ func init() {
rootCmd.AddCommand(workspaceCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(issueCmd)
rootCmd.AddCommand(repoCmd)
rootCmd.AddCommand(versionCmd)
}