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>
280 lines
8 KiB
Go
280 lines
8 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
DefaultServerURL = "ws://localhost:8080/ws"
|
|
DefaultDaemonConfigPath = ".multica/daemon.json"
|
|
DefaultPollInterval = 3 * time.Second
|
|
DefaultHeartbeatInterval = 15 * time.Second
|
|
DefaultAgentTimeout = 2 * time.Hour
|
|
DefaultRuntimeName = "Local Agent"
|
|
)
|
|
|
|
// Config holds all daemon configuration.
|
|
type Config struct {
|
|
ServerBaseURL string
|
|
ConfigPath string
|
|
WorkspaceID string
|
|
DaemonID string
|
|
DeviceName string
|
|
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
|
|
}
|
|
|
|
// Overrides allows CLI flags to override environment variables and defaults.
|
|
// Zero values are ignored and the env/default value is used instead.
|
|
type Overrides struct {
|
|
ServerURL string
|
|
WorkspaceID string
|
|
ReposRoot string
|
|
WorkspacesRoot string
|
|
ConfigPath string
|
|
PollInterval time.Duration
|
|
HeartbeatInterval time.Duration
|
|
AgentTimeout time.Duration
|
|
DaemonID string
|
|
DeviceName string
|
|
RuntimeName string
|
|
}
|
|
|
|
// LoadConfig builds the daemon configuration from environment variables,
|
|
// persisted config, and optional CLI flag overrides.
|
|
func LoadConfig(overrides Overrides) (Config, error) {
|
|
// Server URL: override > env > default
|
|
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
|
|
if overrides.ServerURL != "" {
|
|
rawServerURL = overrides.ServerURL
|
|
}
|
|
serverBaseURL, err := NormalizeServerBaseURL(rawServerURL)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// Config path
|
|
rawConfigPath := strings.TrimSpace(os.Getenv("MULTICA_DAEMON_CONFIG"))
|
|
if overrides.ConfigPath != "" {
|
|
rawConfigPath = overrides.ConfigPath
|
|
}
|
|
configPath, err := resolveDaemonConfigPath(rawConfigPath)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// Load persisted config
|
|
persisted, err := LoadPersistedConfig(configPath)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
|
|
// Workspace ID: override > env > persisted
|
|
workspaceID := strings.TrimSpace(os.Getenv("MULTICA_WORKSPACE_ID"))
|
|
if workspaceID == "" {
|
|
workspaceID = persisted.WorkspaceID
|
|
}
|
|
if overrides.WorkspaceID != "" {
|
|
workspaceID = overrides.WorkspaceID
|
|
}
|
|
|
|
// Probe available agent CLIs
|
|
agents := map[string]AgentEntry{}
|
|
claudePath := envOrDefault("MULTICA_CLAUDE_PATH", "claude")
|
|
if _, err := exec.LookPath(claudePath); err == nil {
|
|
agents["claude"] = AgentEntry{
|
|
Path: claudePath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_CLAUDE_MODEL")),
|
|
}
|
|
}
|
|
codexPath := envOrDefault("MULTICA_CODEX_PATH", "codex")
|
|
if _, err := exec.LookPath(codexPath); err == nil {
|
|
agents["codex"] = AgentEntry{
|
|
Path: codexPath,
|
|
Model: strings.TrimSpace(os.Getenv("MULTICA_CODEX_MODEL")),
|
|
}
|
|
}
|
|
if len(agents) == 0 {
|
|
return Config{}, fmt.Errorf("no agent CLI found: install claude or codex and ensure it is on PATH")
|
|
}
|
|
|
|
// Host info
|
|
host, err := os.Hostname()
|
|
if err != nil || strings.TrimSpace(host) == "" {
|
|
host = "local-machine"
|
|
}
|
|
|
|
// Repos root: override > env > cwd
|
|
reposRoot := strings.TrimSpace(os.Getenv("MULTICA_REPOS_ROOT"))
|
|
if overrides.ReposRoot != "" {
|
|
reposRoot = overrides.ReposRoot
|
|
}
|
|
if reposRoot == "" {
|
|
reposRoot, err = os.Getwd()
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("resolve working directory: %w", err)
|
|
}
|
|
}
|
|
reposRoot, err = filepath.Abs(reposRoot)
|
|
if err != nil {
|
|
return Config{}, fmt.Errorf("resolve absolute repos root: %w", err)
|
|
}
|
|
|
|
// Durations: override > env > default
|
|
pollInterval, err := durationFromEnv("MULTICA_DAEMON_POLL_INTERVAL", DefaultPollInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.PollInterval > 0 {
|
|
pollInterval = overrides.PollInterval
|
|
}
|
|
|
|
heartbeatInterval, err := durationFromEnv("MULTICA_DAEMON_HEARTBEAT_INTERVAL", DefaultHeartbeatInterval)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.HeartbeatInterval > 0 {
|
|
heartbeatInterval = overrides.HeartbeatInterval
|
|
}
|
|
|
|
agentTimeout, err := durationFromEnv("MULTICA_AGENT_TIMEOUT", DefaultAgentTimeout)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
if overrides.AgentTimeout > 0 {
|
|
agentTimeout = overrides.AgentTimeout
|
|
}
|
|
|
|
// String overrides
|
|
daemonID := envOrDefault("MULTICA_DAEMON_ID", host)
|
|
if overrides.DaemonID != "" {
|
|
daemonID = overrides.DaemonID
|
|
}
|
|
|
|
deviceName := envOrDefault("MULTICA_DAEMON_DEVICE_NAME", host)
|
|
if overrides.DeviceName != "" {
|
|
deviceName = overrides.DeviceName
|
|
}
|
|
|
|
runtimeName := envOrDefault("MULTICA_AGENT_RUNTIME_NAME", DefaultRuntimeName)
|
|
if overrides.RuntimeName != "" {
|
|
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,
|
|
WorkspaceID: workspaceID,
|
|
DaemonID: daemonID,
|
|
DeviceName: deviceName,
|
|
RuntimeName: runtimeName,
|
|
Agents: agents,
|
|
ReposRoot: reposRoot,
|
|
WorkspacesRoot: workspacesRoot,
|
|
KeepEnvAfterTask: keepEnv,
|
|
PollInterval: pollInterval,
|
|
HeartbeatInterval: heartbeatInterval,
|
|
AgentTimeout: agentTimeout,
|
|
}, nil
|
|
}
|
|
|
|
// NormalizeServerBaseURL converts a WebSocket or HTTP URL to a base HTTP URL.
|
|
func NormalizeServerBaseURL(raw string) (string, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid MULTICA_SERVER_URL: %w", err)
|
|
}
|
|
switch u.Scheme {
|
|
case "ws":
|
|
u.Scheme = "http"
|
|
case "wss":
|
|
u.Scheme = "https"
|
|
case "http", "https":
|
|
default:
|
|
return "", fmt.Errorf("MULTICA_SERVER_URL must use ws, wss, http, or https")
|
|
}
|
|
if u.Path == "/ws" {
|
|
u.Path = ""
|
|
}
|
|
u.RawPath = ""
|
|
u.RawQuery = ""
|
|
u.Fragment = ""
|
|
return strings.TrimRight(u.String(), "/"), nil
|
|
}
|
|
|
|
func resolveDaemonConfigPath(raw string) (string, error) {
|
|
if raw != "" {
|
|
return filepath.Abs(raw)
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve daemon config path: %w", err)
|
|
}
|
|
return filepath.Join(home, DefaultDaemonConfigPath), nil
|
|
}
|
|
|
|
// LoadPersistedConfig reads the daemon config from disk.
|
|
func LoadPersistedConfig(path string) (PersistedConfig, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return PersistedConfig{}, nil
|
|
}
|
|
return PersistedConfig{}, fmt.Errorf("read daemon config: %w", err)
|
|
}
|
|
var cfg PersistedConfig
|
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
|
return PersistedConfig{}, fmt.Errorf("parse daemon config: %w", err)
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
// SavePersistedConfig writes the daemon config to disk.
|
|
func SavePersistedConfig(path string, cfg PersistedConfig) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return fmt.Errorf("create daemon config directory: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(cfg, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encode daemon config: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil {
|
|
return fmt.Errorf("write daemon config: %w", err)
|
|
}
|
|
return nil
|
|
}
|