Merge pull request #172 from multica-ai/forrestchang/agent-repo-context
feat(daemon): agent-driven repo checkout with bare clone cache
This commit is contained in:
commit
a10515fa74
15 changed files with 1065 additions and 264 deletions
92
server/cmd/multica/cmd_repo.go
Normal file
92
server/cmd/multica/cmd_repo.go
Normal 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
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ func init() {
|
|||
rootCmd.AddCommand(workspaceCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
rootCmd.AddCommand(issueCmd)
|
||||
rootCmd.AddCommand(repoCmd)
|
||||
rootCmd.AddCommand(versionCmd)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ func (c *CLIConfig) RemoveWatchedWorkspace(id string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
|
||||
// CLIConfigPath returns the default path for the CLI config file.
|
||||
func CLIConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
|
|
|
|||
|
|
@ -152,14 +152,18 @@ func (c *Client) Deregister(ctx context.Context, runtimeIDs []string) error {
|
|||
}, nil)
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) ([]Runtime, error) {
|
||||
var resp struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
}
|
||||
// RegisterResponse holds the server's response to a daemon registration.
|
||||
type RegisterResponse struct {
|
||||
Runtimes []Runtime `json:"runtimes"`
|
||||
Repos []RepoData `json:"repos"`
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req map[string]any) (*RegisterResponse, error) {
|
||||
var resp RegisterResponse
|
||||
if err := c.postJSON(ctx, "/api/daemon/register", req, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Runtimes, nil
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBody any) error {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/cli"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/execenv"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
||||
"github.com/multica-ai/multica/server/internal/daemon/usage"
|
||||
"github.com/multica-ai/multica/server/pkg/agent"
|
||||
)
|
||||
|
|
@ -23,9 +25,10 @@ type workspaceState struct {
|
|||
|
||||
// Daemon is the local agent runtime that polls for and executes tasks.
|
||||
type Daemon struct {
|
||||
cfg Config
|
||||
client *Client
|
||||
logger *slog.Logger
|
||||
cfg Config
|
||||
client *Client
|
||||
repoCache *repocache.Cache
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
workspaces map[string]*workspaceState
|
||||
|
|
@ -35,9 +38,11 @@ type Daemon struct {
|
|||
|
||||
// New creates a new Daemon instance.
|
||||
func New(cfg Config, logger *slog.Logger) *Daemon {
|
||||
cacheRoot := filepath.Join(cfg.WorkspacesRoot, ".repos")
|
||||
return &Daemon{
|
||||
cfg: cfg,
|
||||
client: NewClient(cfg.ServerBaseURL),
|
||||
repoCache: repocache.New(cacheRoot, logger),
|
||||
logger: logger,
|
||||
workspaces: make(map[string]*workspaceState),
|
||||
runtimeIndex: make(map[string]Runtime),
|
||||
|
|
@ -130,23 +135,31 @@ func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
|
|||
|
||||
var registered int
|
||||
for _, ws := range cfg.WatchedWorkspaces {
|
||||
runtimes, err := d.registerRuntimesForWorkspace(ctx, ws.ID)
|
||||
resp, err := d.registerRuntimesForWorkspace(ctx, ws.ID)
|
||||
if err != nil {
|
||||
d.logger.Error("failed to register runtimes", "workspace_id", ws.ID, "name", ws.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
runtimeIDs := make([]string, len(runtimes))
|
||||
for i, rt := range runtimes {
|
||||
runtimeIDs := make([]string, len(resp.Runtimes))
|
||||
for i, rt := range resp.Runtimes {
|
||||
runtimeIDs[i] = rt.ID
|
||||
d.logger.Info("registered runtime", "workspace_id", ws.ID, "runtime_id", rt.ID, "provider", rt.Provider)
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[ws.ID] = &workspaceState{workspaceID: ws.ID, runtimeIDs: runtimeIDs}
|
||||
for _, rt := range runtimes {
|
||||
for _, rt := range resp.Runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
d.mu.Unlock()
|
||||
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(runtimes))
|
||||
|
||||
// Sync workspace repos to local cache.
|
||||
if d.repoCache != nil && len(resp.Repos) > 0 {
|
||||
if err := d.repoCache.Sync(ws.ID, repoDataToInfo(resp.Repos)); err != nil {
|
||||
d.logger.Warn("repo cache sync failed", "workspace_id", ws.ID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(resp.Runtimes), "repos", len(resp.Repos))
|
||||
registered++
|
||||
}
|
||||
|
||||
|
|
@ -188,7 +201,7 @@ func (d *Daemon) providerToRuntimeMap() map[string]string {
|
|||
return m
|
||||
}
|
||||
|
||||
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) ([]Runtime, error) {
|
||||
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) (*RegisterResponse, error) {
|
||||
var runtimes []map[string]string
|
||||
for name, entry := range d.cfg.Agents {
|
||||
version, err := agent.DetectVersion(ctx, entry.Path)
|
||||
|
|
@ -214,14 +227,14 @@ func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID s
|
|||
"runtimes": runtimes,
|
||||
}
|
||||
|
||||
rts, err := d.client.Register(ctx, req)
|
||||
resp, err := d.client.Register(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("register runtimes: %w", err)
|
||||
}
|
||||
if len(rts) == 0 {
|
||||
if len(resp.Runtimes) == 0 {
|
||||
return nil, fmt.Errorf("register runtimes: empty response")
|
||||
}
|
||||
return rts, nil
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// configWatchLoop periodically checks for config file changes and reloads workspaces.
|
||||
|
|
@ -286,21 +299,29 @@ func (d *Daemon) reloadWorkspaces(ctx context.Context) {
|
|||
// Register runtimes for newly added workspaces.
|
||||
for id, name := range newIDs {
|
||||
if !currentIDs[id] {
|
||||
runtimes, err := d.registerRuntimesForWorkspace(ctx, id)
|
||||
resp, err := d.registerRuntimesForWorkspace(ctx, id)
|
||||
if err != nil {
|
||||
d.logger.Error("register runtimes for new workspace failed", "workspace_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
runtimeIDs := make([]string, len(runtimes))
|
||||
for i, rt := range runtimes {
|
||||
runtimeIDs := make([]string, len(resp.Runtimes))
|
||||
for i, rt := range resp.Runtimes {
|
||||
runtimeIDs[i] = rt.ID
|
||||
}
|
||||
d.mu.Lock()
|
||||
d.workspaces[id] = &workspaceState{workspaceID: id, runtimeIDs: runtimeIDs}
|
||||
for _, rt := range runtimes {
|
||||
for _, rt := range resp.Runtimes {
|
||||
d.runtimeIndex[rt.ID] = rt
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
// Sync workspace repos to local cache.
|
||||
if d.repoCache != nil && len(resp.Repos) > 0 {
|
||||
if err := d.repoCache.Sync(id, repoDataToInfo(resp.Repos)); err != nil {
|
||||
d.logger.Warn("repo cache sync failed", "workspace_id", id, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
d.logger.Info("now watching workspace", "workspace_id", id, "name", name)
|
||||
}
|
||||
}
|
||||
|
|
@ -594,11 +615,14 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
|
|||
}
|
||||
|
||||
// Prepare isolated execution environment.
|
||||
// Repos are passed as metadata only — the agent checks them out on demand
|
||||
// via `multica repo checkout <url>`.
|
||||
taskCtx := execenv.TaskContextForEnv{
|
||||
IssueID: task.IssueID,
|
||||
AgentName: agentName,
|
||||
AgentInstructions: instructions,
|
||||
AgentSkills: convertSkillsForEnv(skills),
|
||||
Repos: convertReposForEnv(task.Repos),
|
||||
}
|
||||
|
||||
// Try to reuse the workdir from a previous task on the same (agent, issue) pair.
|
||||
|
|
@ -630,11 +654,15 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
|
|||
|
||||
prompt := BuildPrompt(task)
|
||||
|
||||
// Pass the daemon's auth credentials so the spawned agent CLI can call
|
||||
// the Multica API (e.g. `multica issue get`, `multica issue comment add`).
|
||||
// Pass the daemon's auth credentials and context so the spawned agent CLI
|
||||
// can call the Multica API and the local daemon (e.g. `multica repo checkout`).
|
||||
agentEnv := map[string]string{
|
||||
"MULTICA_TOKEN": d.client.Token(),
|
||||
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
|
||||
"MULTICA_TOKEN": d.client.Token(),
|
||||
"MULTICA_SERVER_URL": d.cfg.ServerBaseURL,
|
||||
"MULTICA_DAEMON_PORT": fmt.Sprintf("%d", d.cfg.HealthPort),
|
||||
"MULTICA_WORKSPACE_ID": d.workspaceIDForRuntime(task.RuntimeID),
|
||||
"MULTICA_AGENT_NAME": agentName,
|
||||
"MULTICA_TASK_ID": task.ID,
|
||||
}
|
||||
// Point Codex to the per-task CODEX_HOME so it discovers skills natively
|
||||
// without polluting the system ~/.codex/skills/.
|
||||
|
|
@ -650,7 +678,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
|
|||
return TaskResult{}, fmt.Errorf("create agent backend: %w", err)
|
||||
}
|
||||
|
||||
d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "reused", task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir, "branch", env.BranchName, "env_type", env.Type, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID)
|
||||
d.logger.Info("starting agent", "provider", provider, "task_id", task.ID, "workdir", env.WorkDir, "reused", task.PriorWorkDir != "" && env.WorkDir == task.PriorWorkDir, "model", entry.Model, "timeout", d.cfg.AgentTimeout.String(), "resume_session", task.PriorSessionID)
|
||||
|
||||
session, err := backend.Execute(ctx, prompt, agent.ExecOptions{
|
||||
Cwd: env.WorkDir,
|
||||
|
|
@ -682,12 +710,10 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
|
|||
return TaskResult{}, fmt.Errorf("%s returned empty output", provider)
|
||||
}
|
||||
return TaskResult{
|
||||
Status: "completed",
|
||||
Comment: result.Output,
|
||||
BranchName: env.BranchName,
|
||||
EnvType: string(env.Type),
|
||||
SessionID: result.SessionID,
|
||||
WorkDir: env.WorkDir,
|
||||
Status: "completed",
|
||||
Comment: result.Output,
|
||||
SessionID: result.SessionID,
|
||||
WorkDir: env.WorkDir,
|
||||
}, nil
|
||||
case "timeout":
|
||||
return TaskResult{}, fmt.Errorf("%s timed out after %s", provider, d.cfg.AgentTimeout)
|
||||
|
|
@ -700,6 +726,40 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string) (TaskR
|
|||
}
|
||||
}
|
||||
|
||||
// repoDataToInfo converts daemon RepoData to repocache RepoInfo.
|
||||
func repoDataToInfo(repos []RepoData) []repocache.RepoInfo {
|
||||
info := make([]repocache.RepoInfo, len(repos))
|
||||
for i, r := range repos {
|
||||
info[i] = repocache.RepoInfo{URL: r.URL, Description: r.Description}
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// workspaceIDForRuntime returns the workspace ID that a runtime belongs to.
|
||||
func (d *Daemon) workspaceIDForRuntime(runtimeID string) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
for _, ws := range d.workspaces {
|
||||
for _, rid := range ws.runtimeIDs {
|
||||
if rid == runtimeID {
|
||||
return ws.workspaceID
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func convertReposForEnv(repos []RepoData) []execenv.RepoContextForEnv {
|
||||
if len(repos) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]execenv.RepoContextForEnv, len(repos))
|
||||
for i, r := range repos {
|
||||
result[i] = execenv.RepoContextForEnv{URL: r.URL, Description: r.Description}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func convertSkillsForEnv(skills []SkillData) []execenv.SkillContextForEnv {
|
||||
if len(skills) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -157,23 +126,15 @@ func Reuse(workDir, provider string, task TaskContextForEnv, logger *slog.Logger
|
|||
env := &Environment{
|
||||
RootDir: filepath.Dir(workDir),
|
||||
WorkDir: workDir,
|
||||
Type: WorkspaceTypeDirectory,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
// Detect if this is a git worktree.
|
||||
if gitRoot, ok := detectGitRepo(workDir); ok {
|
||||
env.Type = WorkspaceTypeGitWorktree
|
||||
env.BranchName = getDefaultBranch(workDir)
|
||||
env.gitRoot = gitRoot
|
||||
}
|
||||
|
||||
// Refresh context files (issue_context.md, skills).
|
||||
if err := writeContextFiles(workDir, provider, task); err != nil {
|
||||
logger.Warn("execenv: refresh context files failed", "error", err)
|
||||
}
|
||||
|
||||
logger.Info("execenv: reusing env", "workdir", workDir, "type", env.Type, "branch", env.BranchName)
|
||||
logger.Info("execenv: reusing env", "workdir", workDir)
|
||||
return env
|
||||
}
|
||||
|
||||
|
|
@ -185,11 +146,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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "-", "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/multica-ai/multica/server/internal/daemon/repocache"
|
||||
)
|
||||
|
||||
// HealthResponse is returned by the daemon's local health endpoint.
|
||||
|
|
@ -38,6 +40,15 @@ func (d *Daemon) listenHealth() (net.Listener, error) {
|
|||
return ln, nil
|
||||
}
|
||||
|
||||
// repoCheckoutRequest is the body of a POST /repo/checkout request.
|
||||
type repoCheckoutRequest struct {
|
||||
URL string `json:"url"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
WorkDir string `json:"workdir"`
|
||||
AgentName string `json:"agent_name"`
|
||||
TaskID string `json:"task_id"`
|
||||
}
|
||||
|
||||
// serveHealth runs the health HTTP server on the given listener.
|
||||
// Blocks until ctx is cancelled.
|
||||
func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt time.Time) {
|
||||
|
|
@ -73,6 +84,48 @@ func (d *Daemon) serveHealth(ctx context.Context, ln net.Listener, startedAt tim
|
|||
json.NewEncoder(w).Encode(resp)
|
||||
})
|
||||
|
||||
mux.HandleFunc("/repo/checkout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req repoCheckoutRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.URL == "" {
|
||||
http.Error(w, "url is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.WorkDir == "" {
|
||||
http.Error(w, "workdir is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if d.repoCache == nil {
|
||||
http.Error(w, "repo cache not initialized", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
result, err := d.repoCache.CreateWorktree(repocache.WorktreeParams{
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
RepoURL: req.URL,
|
||||
WorkDir: req.WorkDir,
|
||||
AgentName: req.AgentName,
|
||||
TaskID: req.TaskID,
|
||||
})
|
||||
if err != nil {
|
||||
d.logger.Error("repo checkout failed", "url", req.URL, "error", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
||||
go func() {
|
||||
|
|
|
|||
345
server/internal/daemon/repocache/cache.go
Normal file
345
server/internal/daemon/repocache/cache.go
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
// Package repocache manages bare git clone caches for workspace repositories.
|
||||
// The daemon uses these caches as the source for creating per-task worktrees.
|
||||
package repocache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RepoInfo describes a repository to cache.
|
||||
type RepoInfo struct {
|
||||
URL string
|
||||
Description string
|
||||
}
|
||||
|
||||
// CachedRepo describes a cached bare clone ready for worktree creation.
|
||||
type CachedRepo struct {
|
||||
URL string // remote URL
|
||||
Description string // human-readable description
|
||||
LocalPath string // absolute path to the bare clone
|
||||
}
|
||||
|
||||
// Cache manages bare git clones for workspace repositories.
|
||||
type Cache struct {
|
||||
root string // base directory for all caches (e.g. ~/multica_workspaces/.repos)
|
||||
logger *slog.Logger
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// New creates a new repo cache rooted at the given directory.
|
||||
func New(root string, logger *slog.Logger) *Cache {
|
||||
return &Cache{root: root, logger: logger}
|
||||
}
|
||||
|
||||
// Sync ensures all repos for a workspace are cloned (or fetched if already cached).
|
||||
// Repos no longer in the list are left in place (cheap to keep, avoids re-cloning
|
||||
// if a repo is temporarily removed and re-added).
|
||||
func (c *Cache) Sync(workspaceID string, repos []RepoInfo) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
wsDir := filepath.Join(c.root, workspaceID)
|
||||
if err := os.MkdirAll(wsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create workspace cache dir: %w", err)
|
||||
}
|
||||
|
||||
var firstErr error
|
||||
for _, repo := range repos {
|
||||
if repo.URL == "" {
|
||||
continue
|
||||
}
|
||||
barePath := filepath.Join(wsDir, bareDirName(repo.URL))
|
||||
|
||||
if isBareRepo(barePath) {
|
||||
// Already cached — fetch latest.
|
||||
c.logger.Info("repo cache: fetching", "url", repo.URL, "path", barePath)
|
||||
if err := gitFetch(barePath); err != nil {
|
||||
c.logger.Warn("repo cache: fetch failed", "url", repo.URL, "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not cached — bare clone.
|
||||
c.logger.Info("repo cache: cloning", "url", repo.URL, "path", barePath)
|
||||
if err := gitCloneBare(repo.URL, barePath); err != nil {
|
||||
c.logger.Error("repo cache: clone failed", "url", repo.URL, "error", err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// Lookup returns the local bare clone path for a repo URL within a workspace.
|
||||
// Returns "" if not cached.
|
||||
func (c *Cache) Lookup(workspaceID, url string) string {
|
||||
barePath := filepath.Join(c.root, workspaceID, bareDirName(url))
|
||||
if isBareRepo(barePath) {
|
||||
return barePath
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Fetch runs `git fetch origin` on a cached bare clone to get latest refs.
|
||||
func (c *Cache) Fetch(barePath string) error {
|
||||
return gitFetch(barePath)
|
||||
}
|
||||
|
||||
// bareDirName derives a directory name from a repo URL.
|
||||
// e.g. "https://github.com/org/my-repo.git" → "my-repo.git"
|
||||
func bareDirName(url string) string {
|
||||
url = strings.TrimRight(url, "/")
|
||||
name := url
|
||||
if i := strings.LastIndex(url, "/"); i >= 0 {
|
||||
name = url[i+1:]
|
||||
}
|
||||
// Handle SSH-style "host:org/repo".
|
||||
if i := strings.LastIndex(name, ":"); i >= 0 {
|
||||
name = name[i+1:]
|
||||
if j := strings.LastIndex(name, "/"); j >= 0 {
|
||||
name = name[j+1:]
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(name, ".git") {
|
||||
name += ".git"
|
||||
}
|
||||
if name == ".git" {
|
||||
name = "repo.git"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// isBareRepo checks if a path looks like a bare git repository.
|
||||
func isBareRepo(path string) bool {
|
||||
// A bare repo has a HEAD file at the root.
|
||||
_, err := os.Stat(filepath.Join(path, "HEAD"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func gitCloneBare(url, dest string) error {
|
||||
cmd := exec.Command("git", "clone", "--bare", url, dest)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
// Clean up partial clone.
|
||||
os.RemoveAll(dest)
|
||||
return fmt.Errorf("git clone --bare: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
// Ensure fetch refspec is configured so `git fetch` updates local branches.
|
||||
// `git clone --bare` doesn't set this by default.
|
||||
cmd = exec.Command("git", "-C", dest, "config", "remote.origin.fetch", "+refs/heads/*:refs/heads/*")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("configure fetch refspec: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitFetch(barePath string) error {
|
||||
cmd := exec.Command("git", "-C", barePath, "fetch", "origin")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("git fetch: %s: %w", strings.TrimSpace(string(out)), err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WorktreeParams holds inputs for creating a worktree from a cached bare clone.
|
||||
type WorktreeParams struct {
|
||||
WorkspaceID string // workspace that owns the repo
|
||||
RepoURL string // remote URL to look up in the cache
|
||||
WorkDir string // parent directory for the worktree (e.g. task workdir)
|
||||
AgentName string // for branch naming
|
||||
TaskID string // for branch naming uniqueness
|
||||
}
|
||||
|
||||
// WorktreeResult describes a successfully created worktree.
|
||||
type WorktreeResult struct {
|
||||
Path string `json:"path"` // absolute path to the worktree
|
||||
BranchName string `json:"branch_name"` // git branch created for this worktree
|
||||
}
|
||||
|
||||
// CreateWorktree looks up the bare cache for a repo, fetches latest, and creates
|
||||
// a git worktree in the agent's working directory.
|
||||
func (c *Cache) CreateWorktree(params WorktreeParams) (*WorktreeResult, error) {
|
||||
barePath := c.Lookup(params.WorkspaceID, params.RepoURL)
|
||||
if barePath == "" {
|
||||
return nil, fmt.Errorf("repo not found in cache: %s (workspace: %s)", params.RepoURL, params.WorkspaceID)
|
||||
}
|
||||
|
||||
// Fetch latest from origin.
|
||||
if err := gitFetch(barePath); err != nil {
|
||||
c.logger.Warn("repo checkout: fetch failed (continuing with cached state)", "url", params.RepoURL, "error", err)
|
||||
}
|
||||
|
||||
// Determine the default branch to base the worktree on.
|
||||
baseRef := getRemoteDefaultBranch(barePath)
|
||||
|
||||
// Build branch name: agent/{sanitized-name}/{short-task-id}
|
||||
branchName := fmt.Sprintf("agent/%s/%s", sanitizeName(params.AgentName), shortID(params.TaskID))
|
||||
|
||||
// Derive directory name from repo URL.
|
||||
dirName := repoNameFromURL(params.RepoURL)
|
||||
worktreePath := filepath.Join(params.WorkDir, dirName)
|
||||
|
||||
// Create the worktree.
|
||||
if err := createWorktree(barePath, worktreePath, branchName, baseRef); err != nil {
|
||||
return nil, fmt.Errorf("create worktree: %w", err)
|
||||
}
|
||||
|
||||
// Exclude agent context files from git tracking.
|
||||
for _, pattern := range []string{".agent_context", "CLAUDE.md", "AGENTS.md", ".claude"} {
|
||||
_ = excludeFromGit(worktreePath, pattern)
|
||||
}
|
||||
|
||||
c.logger.Info("repo checkout: worktree created",
|
||||
"url", params.RepoURL,
|
||||
"path", worktreePath,
|
||||
"branch", branchName,
|
||||
"base", baseRef,
|
||||
)
|
||||
|
||||
return &WorktreeResult{
|
||||
Path: worktreePath,
|
||||
BranchName: branchName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createWorktree creates a git worktree at the given path with a new branch.
|
||||
func createWorktree(gitRoot, worktreePath, branchName, baseRef string) error {
|
||||
err := runWorktreeAdd(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 = runWorktreeAdd(gitRoot, worktreePath, branchName, baseRef)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func runWorktreeAdd(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
|
||||
}
|
||||
|
||||
// getRemoteDefaultBranch returns the default branch ref for a bare repo.
|
||||
// Tries HEAD, then falls back to "main", then "master".
|
||||
func getRemoteDefaultBranch(barePath string) string {
|
||||
// In a bare repo, HEAD points to the default branch.
|
||||
cmd := exec.Command("git", "-C", barePath, "symbolic-ref", "HEAD")
|
||||
if out, err := cmd.Output(); err == nil {
|
||||
ref := strings.TrimSpace(string(out))
|
||||
// ref looks like "refs/heads/main" — return just the branch name.
|
||||
if strings.HasPrefix(ref, "refs/heads/") {
|
||||
return strings.TrimPrefix(ref, "refs/heads/")
|
||||
}
|
||||
return ref
|
||||
}
|
||||
|
||||
// Fallback: check if main branch exists.
|
||||
cmd = exec.Command("git", "-C", barePath, "rev-parse", "--verify", "main")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return "main"
|
||||
}
|
||||
|
||||
cmd = exec.Command("git", "-C", barePath, "rev-parse", "--verify", "master")
|
||||
if err := cmd.Run(); err == nil {
|
||||
return "master"
|
||||
}
|
||||
|
||||
return "HEAD"
|
||||
}
|
||||
|
||||
// excludeFromGit adds a pattern to the worktree's .git/info/exclude file.
|
||||
func excludeFromGit(worktreePath, pattern string) error {
|
||||
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")
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(excludePath), 0o755); err != nil {
|
||||
return fmt.Errorf("create info dir: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
url = strings.TrimRight(url, "/")
|
||||
url = strings.TrimSuffix(url, ".git")
|
||||
|
||||
if i := strings.LastIndex(url, "/"); i >= 0 {
|
||||
url = url[i+1:]
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
262
server/internal/daemon/repocache/cache_test.go
Normal file
262
server/internal/daemon/repocache/cache_test.go
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
package repocache
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testLogger() *slog.Logger {
|
||||
return slog.Default()
|
||||
}
|
||||
|
||||
func TestBareDirName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"https://github.com/org/my-repo.git", "my-repo.git"},
|
||||
{"https://github.com/org/my-repo", "my-repo.git"},
|
||||
{"git@github.com:org/my-repo.git", "my-repo.git"},
|
||||
{"git@github.com:org/my-repo", "my-repo.git"},
|
||||
{"https://github.com/org/repo/", "repo.git"},
|
||||
{"my-repo", "my-repo.git"},
|
||||
{"", "repo.git"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := bareDirName(tt.input); got != tt.want {
|
||||
t.Errorf("bareDirName(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsBareRepo(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A directory with a HEAD file should be detected as bare.
|
||||
dir := t.TempDir()
|
||||
os.WriteFile(filepath.Join(dir, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644)
|
||||
if !isBareRepo(dir) {
|
||||
t.Error("expected bare repo to be detected")
|
||||
}
|
||||
|
||||
// An empty directory should not.
|
||||
emptyDir := t.TempDir()
|
||||
if isBareRepo(emptyDir) {
|
||||
t.Error("expected empty dir to not be detected as bare repo")
|
||||
}
|
||||
}
|
||||
|
||||
// createTestRepo creates a local git repo with an initial commit and returns its path.
|
||||
func createTestRepo(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for _, args := range [][]string{
|
||||
{"init", dir},
|
||||
{"-C", dir, "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)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestSyncAndLookup(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
|
||||
// Sync should clone the repo.
|
||||
err := cache.Sync("ws-123", []RepoInfo{
|
||||
{URL: sourceRepo, Description: "test repo"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Lookup should find the cached repo.
|
||||
path := cache.Lookup("ws-123", sourceRepo)
|
||||
if path == "" {
|
||||
t.Fatal("expected to find cached repo")
|
||||
}
|
||||
if !isBareRepo(path) {
|
||||
t.Fatalf("expected bare repo at %s", path)
|
||||
}
|
||||
|
||||
// Lookup for unknown URL should return empty.
|
||||
if got := cache.Lookup("ws-123", "https://github.com/org/unknown"); got != "" {
|
||||
t.Fatalf("expected empty for unknown URL, got %q", got)
|
||||
}
|
||||
|
||||
// Lookup for unknown workspace should return empty.
|
||||
if got := cache.Lookup("ws-999", sourceRepo); got != "" {
|
||||
t.Fatalf("expected empty for unknown workspace, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncFetchesExisting(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
|
||||
// First sync: clone.
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("first sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Record the HEAD commit hash in the cache.
|
||||
barePath := cache.Lookup("ws-1", sourceRepo)
|
||||
oldHead := gitHead(t, barePath)
|
||||
|
||||
// Add a commit to source.
|
||||
cmd := exec.Command("git", "-C", sourceRepo, "commit", "--allow-empty", "-m", "second")
|
||||
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.Fatalf("add commit failed: %s: %v", out, err)
|
||||
}
|
||||
sourceHead := gitHead(t, sourceRepo)
|
||||
if sourceHead == oldHead {
|
||||
t.Fatal("source HEAD should differ after new commit")
|
||||
}
|
||||
|
||||
// Second sync: should fetch (not re-clone).
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("second sync failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the cache HEAD was updated.
|
||||
newHead := gitHead(t, barePath)
|
||||
if newHead == oldHead {
|
||||
t.Fatal("expected cache HEAD to be updated after fetch")
|
||||
}
|
||||
if newHead != sourceHead {
|
||||
t.Fatalf("expected cache HEAD %s to match source HEAD %s", newHead, sourceHead)
|
||||
}
|
||||
}
|
||||
|
||||
func gitHead(t *testing.T, repoPath string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", "-C", repoPath, "rev-parse", "HEAD")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("git rev-parse HEAD failed in %s: %v", repoPath, err)
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func TestWorktreeFromCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
barePath := cache.Lookup("ws-1", sourceRepo)
|
||||
if barePath == "" {
|
||||
t.Fatal("expected cached repo")
|
||||
}
|
||||
|
||||
// Create a worktree from the bare cache — this is the actual use case.
|
||||
worktreeDir := filepath.Join(t.TempDir(), "work")
|
||||
cmd := exec.Command("git", "-C", barePath, "worktree", "add", "-b", "test-branch", worktreeDir, "HEAD")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("worktree add failed: %s: %v", out, err)
|
||||
}
|
||||
defer exec.Command("git", "-C", barePath, "worktree", "remove", "--force", worktreeDir).Run()
|
||||
|
||||
// Verify worktree exists and is on the right branch.
|
||||
cmd = exec.Command("git", "-C", worktreeDir, "branch", "--show-current")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("show branch failed: %v", err)
|
||||
}
|
||||
if got := trimLine(string(out)); got != "test-branch" {
|
||||
t.Fatalf("expected branch 'test-branch', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRepo := createTestRepo(t)
|
||||
cacheRoot := t.TempDir()
|
||||
|
||||
cache := New(cacheRoot, testLogger())
|
||||
if err := cache.Sync("ws-1", []RepoInfo{{URL: sourceRepo}}); err != nil {
|
||||
t.Fatalf("sync failed: %v", err)
|
||||
}
|
||||
|
||||
workDir := t.TempDir()
|
||||
result, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: sourceRepo,
|
||||
WorkDir: workDir,
|
||||
AgentName: "Code Reviewer",
|
||||
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify the worktree was created.
|
||||
if _, err := os.Stat(result.Path); os.IsNotExist(err) {
|
||||
t.Fatalf("worktree path does not exist: %s", result.Path)
|
||||
}
|
||||
|
||||
// Verify branch name format.
|
||||
if !strings.HasPrefix(result.BranchName, "agent/code-reviewer/") {
|
||||
t.Errorf("expected branch to start with 'agent/code-reviewer/', got %q", result.BranchName)
|
||||
}
|
||||
|
||||
// Verify the worktree is on the correct branch.
|
||||
cmd := exec.Command("git", "-C", result.Path, "branch", "--show-current")
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("show branch failed: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got != result.BranchName {
|
||||
t.Errorf("expected branch %q, got %q", result.BranchName, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktreeNotCached(t *testing.T) {
|
||||
t.Parallel()
|
||||
cacheRoot := t.TempDir()
|
||||
cache := New(cacheRoot, testLogger())
|
||||
|
||||
_, err := cache.CreateWorktree(WorktreeParams{
|
||||
WorkspaceID: "ws-1",
|
||||
RepoURL: "https://github.com/org/nonexistent",
|
||||
WorkDir: t.TempDir(),
|
||||
AgentName: "Agent",
|
||||
TaskID: "test-task-id",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uncached repo")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not found in cache") {
|
||||
t.Errorf("expected 'not found in cache' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func trimLine(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
|
@ -14,6 +14,12 @@ type Runtime struct {
|
|||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// RepoData holds repository information from the workspace.
|
||||
type RepoData struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// Task represents a claimed task from the server.
|
||||
// Agent data (name, skills) is populated by the claim endpoint.
|
||||
type Task struct {
|
||||
|
|
@ -22,6 +28,7 @@ type Task struct {
|
|||
RuntimeID string `json:"runtime_id"`
|
||||
IssueID string `json:"issue_id"`
|
||||
Agent *AgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
|
||||
}
|
||||
|
|
|
|||
|
|
@ -81,6 +81,13 @@ func agentToResponse(a db.Agent) AgentResponse {
|
|||
}
|
||||
}
|
||||
|
||||
// RepoData holds repository information included in claim responses so the
|
||||
// daemon can set up worktrees for each workspace repo.
|
||||
type RepoData struct {
|
||||
URL string `json:"url"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type AgentTaskResponse struct {
|
||||
ID string `json:"id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
|
|
@ -94,6 +101,7 @@ type AgentTaskResponse struct {
|
|||
Result any `json:"result"`
|
||||
Error *string `json:"error"`
|
||||
Agent *TaskAgentData `json:"agent,omitempty"`
|
||||
Repos []RepoData `json:"repos,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
|
||||
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
|
||||
|
|
|
|||
|
|
@ -51,7 +51,8 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
writeError(w, http.StatusBadRequest, "at least one runtime is required")
|
||||
return
|
||||
}
|
||||
if _, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID)); err != nil {
|
||||
ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(req.WorkspaceID))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "workspace not found")
|
||||
return
|
||||
}
|
||||
|
|
@ -106,7 +107,16 @@ func (h *Handler) DaemonRegister(w http.ResponseWriter, r *http.Request) {
|
|||
"runtimes": resp,
|
||||
})
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp})
|
||||
// Include workspace repos so the daemon can cache them locally.
|
||||
var repos []RepoData
|
||||
if ws.Repos != nil {
|
||||
json.Unmarshal(ws.Repos, &repos)
|
||||
}
|
||||
if repos == nil {
|
||||
repos = []RepoData{}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"runtimes": resp, "repos": repos})
|
||||
}
|
||||
|
||||
// DaemonDeregister marks runtimes as offline when the daemon shuts down.
|
||||
|
|
@ -217,6 +227,16 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
// Include workspace repos so the daemon can set up worktrees.
|
||||
if issue, err := h.Queries.GetIssue(r.Context(), task.IssueID); err == nil {
|
||||
if ws, err := h.Queries.GetWorkspace(r.Context(), issue.WorkspaceID); err == nil && ws.Repos != nil {
|
||||
var repos []RepoData
|
||||
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
|
||||
resp.Repos = repos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the prior session for this (agent, issue) pair so the daemon
|
||||
// can resume the Claude Code conversation context.
|
||||
if prior, err := h.Queries.GetLastTaskSession(r.Context(), db.GetLastTaskSessionParams{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue