merge: resolve conflicts after merging main

Adapt runtime features (usage tracking, ping, heartbeat) to main's
multi-workspace architecture. Update frontend imports from @multica/types
to @/shared/types after the package consolidation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan 2026-03-26 18:37:56 +08:00
commit 6ee034c6e9
151 changed files with 3664 additions and 6579 deletions

View file

@ -8,7 +8,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
@ -40,6 +39,7 @@ func isWorkspaceNotFoundError(err error) bool {
// Client handles HTTP communication with the Multica server daemon API.
type Client struct {
baseURL string
token string
client *http.Client
}
@ -51,6 +51,11 @@ func NewClient(baseURL string) *Client {
}
}
// SetToken sets the auth token for authenticated requests.
func (c *Client) SetToken(token string) {
c.token = token
}
func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error) {
var resp struct {
Task *Task `json:"task"`
@ -61,30 +66,6 @@ func (c *Client) ClaimTask(ctx context.Context, runtimeID string) (*Task, error)
return resp.Task, nil
}
func (c *Client) CreatePairingSession(ctx context.Context, req map[string]string) (PairingSession, error) {
var resp PairingSession
if err := c.postJSON(ctx, "/api/daemon/pairing-sessions", req, &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) GetPairingSession(ctx context.Context, token string) (PairingSession, error) {
var resp PairingSession
if err := c.getJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s", url.PathEscape(token)), &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) ClaimPairingSession(ctx context.Context, token string) (PairingSession, error) {
var resp PairingSession
if err := c.postJSON(ctx, fmt.Sprintf("/api/daemon/pairing-sessions/%s/claim", url.PathEscape(token)), map[string]any{}, &resp); err != nil {
return PairingSession{}, err
}
return resp, nil
}
func (c *Client) StartTask(ctx context.Context, taskID string) error {
return c.postJSON(ctx, fmt.Sprintf("/api/daemon/tasks/%s/start", taskID), map[string]any{}, nil)
}
@ -167,6 +148,9 @@ func (c *Client) postJSON(ctx context.Context, path string, reqBody any, respBod
return err
}
req.Header.Set("Content-Type", "application/json")
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.client.Do(req)
if err != nil {
@ -190,6 +174,9 @@ func (c *Client) getJSON(ctx context.Context, path string, respBody any) error {
if err != nil {
return err
}
if c.token != "" {
req.Header.Set("Authorization", "Bearer "+c.token)
}
resp, err := c.client.Do(req)
if err != nil {

View file

@ -1,8 +1,6 @@
package daemon
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"os"
@ -14,23 +12,20 @@ import (
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"
DefaultConfigReloadInterval = 5 * time.Second
)
// 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
@ -42,10 +37,7 @@ type Config struct {
// 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
@ -54,8 +46,8 @@ type Overrides struct {
RuntimeName string
}
// LoadConfig builds the daemon configuration from environment variables,
// persisted config, and optional CLI flag overrides.
// LoadConfig builds the daemon configuration from environment variables
// and optional CLI flag overrides.
func LoadConfig(overrides Overrides) (Config, error) {
// Server URL: override > env > default
rawServerURL := envOrDefault("MULTICA_SERVER_URL", DefaultServerURL)
@ -67,31 +59,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
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")
@ -118,22 +85,6 @@ func LoadConfig(overrides Overrides) (Config, error) {
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 {
@ -181,12 +132,11 @@ func LoadConfig(overrides Overrides) (Config, error) {
workspacesRoot = overrides.WorkspacesRoot
}
if workspacesRoot == "" {
home, _ := os.UserHomeDir()
if home != "" {
workspacesRoot = filepath.Join(home, "multica_workspaces")
} else {
workspacesRoot = filepath.Join(reposRoot, "multica_workspaces")
home, err := os.UserHomeDir()
if err != nil {
return Config{}, fmt.Errorf("resolve home directory: %w (set MULTICA_WORKSPACES_ROOT to override)", err)
}
workspacesRoot = filepath.Join(home, "multica_workspaces")
}
workspacesRoot, err = filepath.Abs(workspacesRoot)
if err != nil {
@ -198,13 +148,10 @@ func LoadConfig(overrides Overrides) (Config, error) {
return Config{
ServerBaseURL: serverBaseURL,
ConfigPath: configPath,
WorkspaceID: workspaceID,
DaemonID: daemonID,
DeviceName: deviceName,
RuntimeName: runtimeName,
Agents: agents,
ReposRoot: reposRoot,
WorkspacesRoot: workspacesRoot,
KeepEnvAfterTask: keepEnv,
PollInterval: pollInterval,
@ -236,45 +183,3 @@ func NormalizeServerBaseURL(raw string) (string, error) {
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
}

View file

@ -4,63 +4,164 @@ import (
"context"
"fmt"
"log/slog"
"os"
"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/usage"
"github.com/multica-ai/multica/server/pkg/agent"
)
// workspaceState tracks registered runtimes for a single workspace.
type workspaceState struct {
workspaceID string
runtimeIDs []string
}
// Daemon is the local agent runtime that polls for and executes tasks.
type Daemon struct {
cfg Config
client *Client
logger *slog.Logger
mu sync.Mutex
workspaces map[string]*workspaceState
runtimeIndex map[string]Runtime // runtimeID -> Runtime for provider lookups
reloading sync.Mutex // prevents concurrent reloadWorkspaces
}
// New creates a new Daemon instance.
func New(cfg Config, logger *slog.Logger) *Daemon {
return &Daemon{
cfg: cfg,
client: NewClient(cfg.ServerBaseURL),
logger: logger,
cfg: cfg,
client: NewClient(cfg.ServerBaseURL),
logger: logger,
workspaces: make(map[string]*workspaceState),
runtimeIndex: make(map[string]Runtime),
}
}
// Run starts the daemon: pairs if needed, registers runtimes, then polls for tasks.
// Run starts the daemon: resolves auth, registers runtimes, then polls for tasks.
func (d *Daemon) Run(ctx context.Context) error {
agentNames := make([]string, 0, len(d.cfg.Agents))
for name := range d.cfg.Agents {
agentNames = append(agentNames, name)
}
d.logger.Info("starting daemon", "agents", agentNames, "workspace_id", d.cfg.WorkspaceID, "server", d.cfg.ServerBaseURL, "repos_root", d.cfg.ReposRoot)
d.logger.Info("starting daemon", "agents", agentNames, "server", d.cfg.ServerBaseURL)
if strings.TrimSpace(d.cfg.WorkspaceID) == "" {
workspaceID, err := d.ensurePaired(ctx)
if err != nil {
return err
}
d.cfg.WorkspaceID = workspaceID
d.logger.Info("pairing completed", "workspace_id", workspaceID)
}
runtimes, err := d.registerRuntimes(ctx)
if err != nil {
// Load auth token from CLI config.
if err := d.resolveAuth(); err != nil {
return err
}
runtimeIDs := make([]string, 0, len(runtimes))
for _, rt := range runtimes {
d.logger.Info("registered runtime", "id", rt.ID, "provider", rt.Provider, "status", rt.Status)
runtimeIDs = append(runtimeIDs, rt.ID)
// Load and register watched workspaces.
if err := d.loadWatchedWorkspaces(ctx); err != nil {
return err
}
go d.heartbeatLoop(ctx, runtimes)
go d.usageScanLoop(ctx, runtimes)
return d.pollLoop(ctx, runtimeIDs)
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
return fmt.Errorf("no runtimes registered")
}
// Start config watcher for hot-reload.
go d.configWatchLoop(ctx)
go d.heartbeatLoop(ctx)
go d.usageScanLoop(ctx)
return d.pollLoop(ctx)
}
func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
// resolveAuth loads the auth token from the CLI config.
func (d *Daemon) resolveAuth() error {
cfg, err := cli.LoadCLIConfig()
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if cfg.Token == "" {
d.logger.Warn("not authenticated — run 'multica auth login' to authenticate, then restart the daemon")
return fmt.Errorf("not authenticated: run 'multica auth login' first")
}
d.client.SetToken(cfg.Token)
d.logger.Info("authenticated")
return nil
}
// loadWatchedWorkspaces reads watched workspaces from CLI config and registers runtimes.
func (d *Daemon) loadWatchedWorkspaces(ctx context.Context) error {
cfg, err := cli.LoadCLIConfig()
if err != nil {
return fmt.Errorf("load CLI config: %w", err)
}
if len(cfg.WatchedWorkspaces) == 0 {
return fmt.Errorf("no watched workspaces configured: run 'multica watch <id>' to add one")
}
var registered int
for _, ws := range cfg.WatchedWorkspaces {
runtimes, 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[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 {
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
d.logger.Info("watching workspace", "workspace_id", ws.ID, "name", ws.Name, "runtimes", len(runtimes))
registered++
}
if registered == 0 {
return fmt.Errorf("failed to register runtimes for any of the %d watched workspace(s)", len(cfg.WatchedWorkspaces))
}
return nil
}
// allRuntimeIDs returns all runtime IDs across all watched workspaces.
func (d *Daemon) allRuntimeIDs() []string {
d.mu.Lock()
defer d.mu.Unlock()
var ids []string
for _, ws := range d.workspaces {
ids = append(ids, ws.runtimeIDs...)
}
return ids
}
// findRuntime looks up a Runtime by its ID.
func (d *Daemon) findRuntime(id string) *Runtime {
d.mu.Lock()
defer d.mu.Unlock()
if rt, ok := d.runtimeIndex[id]; ok {
return &rt
}
return nil
}
// providerToRuntimeMap returns a mapping from provider name to runtime ID.
func (d *Daemon) providerToRuntimeMap() map[string]string {
d.mu.Lock()
defer d.mu.Unlock()
m := make(map[string]string)
for id, rt := range d.runtimeIndex {
m[rt.Provider] = id
}
return m
}
func (d *Daemon) registerRuntimesForWorkspace(ctx context.Context, workspaceID string) ([]Runtime, error) {
var runtimes []map[string]string
for name, entry := range d.cfg.Agents {
version, err := agent.DetectVersion(ctx, entry.Path)
@ -80,7 +181,7 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
}
req := map[string]any{
"workspace_id": d.cfg.WorkspaceID,
"workspace_id": workspaceID,
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtimes": runtimes,
@ -96,77 +197,106 @@ func (d *Daemon) registerRuntimes(ctx context.Context) ([]Runtime, error) {
return rts, nil
}
func (d *Daemon) ensurePaired(ctx context.Context) (string, error) {
// Use a deterministic agent for the pairing session metadata (prefer codex for backward compat).
var firstName string
var firstEntry AgentEntry
for _, preferred := range []string{"codex", "claude"} {
if entry, ok := d.cfg.Agents[preferred]; ok {
firstName = preferred
firstEntry = entry
break
}
}
version, err := agent.DetectVersion(ctx, firstEntry.Path)
// configWatchLoop periodically checks for config file changes and reloads workspaces.
func (d *Daemon) configWatchLoop(ctx context.Context) {
configPath, err := cli.CLIConfigPath()
if err != nil {
return "", err
d.logger.Warn("cannot watch config file", "error", err)
return
}
session, err := d.client.CreatePairingSession(ctx, map[string]string{
"daemon_id": d.cfg.DaemonID,
"device_name": d.cfg.DeviceName,
"runtime_name": d.cfg.RuntimeName,
"runtime_type": firstName,
"runtime_version": version,
})
if err != nil {
return "", fmt.Errorf("create pairing session: %w", err)
}
if session.LinkURL != nil {
d.logger.Info("open this link to pair the daemon", "url", *session.LinkURL)
} else {
d.logger.Info("pairing session created", "token", session.Token)
var lastModTime time.Time
if info, err := os.Stat(configPath); err == nil {
lastModTime = info.ModTime()
}
ticker := time.NewTicker(DefaultConfigReloadInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
current, err := d.client.GetPairingSession(ctx, session.Token)
if err != nil {
return "", fmt.Errorf("poll pairing session: %w", err)
}
switch current.Status {
case "approved", "claimed":
if current.WorkspaceID == nil || strings.TrimSpace(*current.WorkspaceID) == "" {
return "", fmt.Errorf("pairing session approved without workspace")
return
case <-ticker.C:
info, err := os.Stat(configPath)
if err != nil {
continue
}
if err := SavePersistedConfig(d.cfg.ConfigPath, PersistedConfig{
WorkspaceID: strings.TrimSpace(*current.WorkspaceID),
}); err != nil {
return "", err
if !info.ModTime().After(lastModTime) {
continue
}
if current.Status != "claimed" {
if _, err := d.client.ClaimPairingSession(ctx, current.Token); err != nil {
return "", fmt.Errorf("claim pairing session: %w", err)
}
}
return strings.TrimSpace(*current.WorkspaceID), nil
case "expired":
return "", fmt.Errorf("pairing session expired before approval")
}
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return "", err
lastModTime = info.ModTime()
d.reloadWorkspaces(ctx)
}
}
}
func (d *Daemon) heartbeatLoop(ctx context.Context, runtimes []Runtime) {
// reloadWorkspaces reconciles the active workspace set with the config file.
// NOTE: Token changes (e.g. re-login as a different user) are not picked up;
// the daemon must be restarted for a new auth token to take effect.
func (d *Daemon) reloadWorkspaces(ctx context.Context) {
d.reloading.Lock()
defer d.reloading.Unlock()
cfg, err := cli.LoadCLIConfig()
if err != nil {
d.logger.Warn("reload config failed", "error", err)
return
}
newIDs := make(map[string]string) // id -> name
for _, ws := range cfg.WatchedWorkspaces {
newIDs[ws.ID] = ws.Name
}
d.mu.Lock()
currentIDs := make(map[string]bool)
for id := range d.workspaces {
currentIDs[id] = true
}
d.mu.Unlock()
// Register runtimes for newly added workspaces.
for id, name := range newIDs {
if !currentIDs[id] {
runtimes, 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[i] = rt.ID
}
d.mu.Lock()
d.workspaces[id] = &workspaceState{workspaceID: id, runtimeIDs: runtimeIDs}
for _, rt := range runtimes {
d.runtimeIndex[rt.ID] = rt
}
d.mu.Unlock()
d.logger.Info("now watching workspace", "workspace_id", id, "name", name)
}
}
// Remove workspaces no longer in config.
// NOTE: runtimes are not deregistered server-side; they will go offline
// after heartbeats stop arriving (within HeartbeatInterval).
for id := range currentIDs {
if _, ok := newIDs[id]; !ok {
d.mu.Lock()
if ws, exists := d.workspaces[id]; exists {
for _, rid := range ws.runtimeIDs {
delete(d.runtimeIndex, rid)
}
}
delete(d.workspaces, id)
d.mu.Unlock()
d.logger.Info("stopped watching workspace", "workspace_id", id)
}
}
}
func (d *Daemon) heartbeatLoop(ctx context.Context) {
ticker := time.NewTicker(d.cfg.HeartbeatInterval)
defer ticker.Stop()
@ -175,16 +305,19 @@ func (d *Daemon) heartbeatLoop(ctx context.Context, runtimes []Runtime) {
case <-ctx.Done():
return
case <-ticker.C:
for _, rt := range runtimes {
resp, err := d.client.SendHeartbeat(ctx, rt.ID)
for _, rid := range d.allRuntimeIDs() {
resp, err := d.client.SendHeartbeat(ctx, rid)
if err != nil {
d.logger.Warn("heartbeat failed", "runtime_id", rt.ID, "error", err)
d.logger.Warn("heartbeat failed", "runtime_id", rid, "error", err)
continue
}
// Handle pending ping requests.
if resp.PendingPing != nil {
go d.handlePing(ctx, rt, resp.PendingPing.ID)
rt := d.findRuntime(rid)
if rt != nil {
go d.handlePing(ctx, *rt, resp.PendingPing.ID)
}
}
}
}
@ -265,31 +398,28 @@ func (d *Daemon) handlePing(ctx context.Context, rt Runtime, pingID string) {
}
}
func (d *Daemon) usageScanLoop(ctx context.Context, runtimes []Runtime) {
func (d *Daemon) usageScanLoop(ctx context.Context) {
scanner := usage.NewScanner(d.logger)
// Build provider -> runtime ID mapping.
providerToRuntime := make(map[string]string)
for _, rt := range runtimes {
providerToRuntime[rt.Provider] = rt.ID
}
report := func() {
records := scanner.Scan()
if len(records) == 0 {
return
}
// Build provider -> runtime ID mapping from current state.
providerToRuntime := d.providerToRuntimeMap()
// Group records by provider to send to the correct runtime.
byProvider := make(map[string][]map[string]any)
for _, r := range records {
byProvider[r.Provider] = append(byProvider[r.Provider], map[string]any{
"date": r.Date,
"provider": r.Provider,
"model": r.Model,
"input_tokens": r.InputTokens,
"output_tokens": r.OutputTokens,
"cache_read_tokens": r.CacheReadTokens,
"date": r.Date,
"provider": r.Provider,
"model": r.Model,
"input_tokens": r.InputTokens,
"output_tokens": r.OutputTokens,
"cache_read_tokens": r.CacheReadTokens,
"cache_write_tokens": r.CacheWriteTokens,
})
}
@ -324,7 +454,7 @@ func (d *Daemon) usageScanLoop(ctx context.Context, runtimes []Runtime) {
}
}
func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
func (d *Daemon) pollLoop(ctx context.Context) error {
pollOffset := 0
pollCount := 0
for {
@ -334,6 +464,14 @@ func (d *Daemon) pollLoop(ctx context.Context, runtimeIDs []string) error {
default:
}
runtimeIDs := d.allRuntimeIDs()
if len(runtimeIDs) == 0 {
if err := sleepWithContext(ctx, d.cfg.PollInterval); err != nil {
return err
}
continue
}
claimed := false
n := len(runtimeIDs)
for i := 0; i < n; i++ {
@ -421,7 +559,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task) (TaskResult, error) {
}
env, err := execenv.Prepare(execenv.PrepareParams{
WorkspacesRoot: d.cfg.WorkspacesRoot,
ReposRoot: d.cfg.ReposRoot,
RepoPath: task.Context.RepoPath,
TaskID: task.ID,
AgentName: task.Context.Agent.Name,
Task: taskCtx,

View file

@ -21,7 +21,7 @@ const (
// PrepareParams holds all inputs needed to set up an execution environment.
type PrepareParams struct {
WorkspacesRoot string // base path for all envs (e.g., ~/multica_workspaces)
ReposRoot string // source git repo (for worktree creation)
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
Task TaskContextForEnv // context data for writing files
@ -100,8 +100,8 @@ func Prepare(params PrepareParams, logger *slog.Logger) (*Environment, error) {
}
// Detect git repo and set up worktree if available.
if params.ReposRoot != "" {
if gitRoot, ok := detectGitRepo(params.ReposRoot); ok {
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.

View file

@ -96,7 +96,7 @@ func TestPrepareDirectoryMode(t *testing.T) {
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
ReposRoot: reposRoot,
RepoPath: reposRoot,
TaskID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
AgentName: "Test Agent",
Task: TaskContextForEnv{
@ -176,7 +176,7 @@ func TestPrepareGitWorktreeMode(t *testing.T) {
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
ReposRoot: reposRoot,
RepoPath: reposRoot,
TaskID: "b2c3d4e5-f6a7-8901-bcde-f12345678901",
AgentName: "Code Reviewer",
Task: TaskContextForEnv{
@ -334,7 +334,7 @@ func TestCleanupGitWorktree(t *testing.T) {
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
ReposRoot: reposRoot,
RepoPath: reposRoot,
TaskID: "c3d4e5f6-a7b8-9012-cdef-123456789012",
AgentName: "Cleanup Test",
Task: TaskContextForEnv{IssueTitle: "Cleanup test"},
@ -477,7 +477,7 @@ func TestCleanupPreservesLogs(t *testing.T) {
env, err := Prepare(PrepareParams{
WorkspacesRoot: workspacesRoot,
ReposRoot: t.TempDir(), // not a git repo
RepoPath: t.TempDir(), // not a git repo
TaskID: "d4e5f6a7-b8c9-0123-defa-234567890123",
AgentName: "Preserve Test",
Task: TaskContextForEnv{IssueTitle: "Preserve test"},

View file

@ -14,27 +14,6 @@ type Runtime struct {
Status string `json:"status"`
}
// PairingSession represents a daemon pairing session.
type PairingSession struct {
Token string `json:"token"`
DaemonID string `json:"daemon_id"`
DeviceName string `json:"device_name"`
RuntimeName string `json:"runtime_name"`
RuntimeType string `json:"runtime_type"`
RuntimeVersion string `json:"runtime_version"`
WorkspaceID *string `json:"workspace_id"`
Status string `json:"status"`
ApprovedAt *string `json:"approved_at"`
ClaimedAt *string `json:"claimed_at"`
ExpiresAt string `json:"expires_at"`
LinkURL *string `json:"link_url"`
}
// PersistedConfig is the JSON structure saved to ~/.multica/daemon.json.
type PersistedConfig struct {
WorkspaceID string `json:"workspace_id"`
}
// Task represents a claimed task from the server.
type Task struct {
ID string `json:"id"`
@ -49,6 +28,7 @@ type TaskContext struct {
Agent AgentContext `json:"agent"`
Runtime RuntimeContext `json:"runtime"`
WorkspaceContext string `json:"workspace_context,omitempty"`
RepoPath string `json:"repo_path,omitempty"`
}
// IssueContext holds issue details for task execution.