From 1cb5a15ab2accc759ca19ddcab21b4b23b196e3a Mon Sep 17 00:00:00 2001 From: whitmo Date: Tue, 3 Mar 2026 11:48:09 -0800 Subject: [PATCH] fix: convert raw errors to structured errors with helpful suggestions Audit and improve error messages in cli.go and daemon.go for the P0 "Clear error messages" roadmap item. Every failure now tells the user what went wrong and how to fix it. Changes: - Add 7 new error constructors: ClaudeStartupFailed, DaemonAlreadyRunning, AgentRestartFailed, PromptWriteFailed, LogFileNotFound, WorkspaceAlreadyExists, RepoAlreadyInitialized - Convert ~70 raw fmt.Errorf calls in cli.go to use structured errors with categories, messages, and actionable suggestions - Improve daemon.go socket error responses with recovery hints - Focus on most common failure paths: daemon communication, agent/worker not found, worktree issues, tmux failures, Claude binary not found - Add tests for all new error constructors Co-Authored-By: Claude Opus 4.6 --- internal/cli/cli.go | 168 ++++++++++++++++----------------- internal/daemon/daemon.go | 30 +++--- internal/errors/errors.go | 66 +++++++++++++ internal/errors/errors_test.go | 124 ++++++++++++++++++++++++ 4 files changed, 289 insertions(+), 99 deletions(-) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 3e06628b..4df0862d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -793,7 +793,7 @@ func (c *CLI) daemonStatus(args []string) error { pidFile := daemon.NewPIDFile(c.paths.DaemonPID) running, pid, err := pidFile.IsRunning() if err != nil { - return fmt.Errorf("failed to check daemon status: %w", err) + return errors.Wrap(errors.CategoryConnection, "failed to check daemon status", err).WithSuggestion("multiclaude daemon start") } if !running { @@ -812,7 +812,7 @@ func (c *CLI) daemonStatus(args []string) error { } if !resp.Success { - return fmt.Errorf("status check failed: %s", resp.Error) + return errors.DaemonCommunicationFailed("checking status", fmt.Errorf("%s", resp.Error)) } // Pretty print status @@ -839,7 +839,7 @@ func (c *CLI) systemStatus(args []string) error { pidFile := daemon.NewPIDFile(c.paths.DaemonPID) running, pid, err := pidFile.IsRunning() if err != nil { - return fmt.Errorf("failed to check daemon status: %w", err) + return errors.Wrap(errors.CategoryConnection, "failed to check daemon status", err).WithSuggestion("multiclaude daemon start") } if !running { @@ -1200,7 +1200,7 @@ func (c *CLI) initRepo(args []string) error { case "assigned": mqTrackMode = state.TrackModeAssigned default: - return fmt.Errorf("invalid --mq-track value: %s (must be 'all', 'author', or 'assigned')", trackMode) + return errors.InvalidArgument("--mq-track", trackMode, "'all', 'author', or 'assigned'") } } @@ -1226,16 +1226,16 @@ func (c *CLI) initRepo(args []string) error { // Check if repository is already initialized st, err := state.Load(c.paths.StateFile) if err != nil { - return fmt.Errorf("failed to load state: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to load state file", err).WithSuggestion("multiclaude repair") } if _, exists := st.GetRepo(repoName); exists { - return fmt.Errorf("repository '%s' is already initialized\nUse 'multiclaude repo rm %s' to remove it first, or choose a different name", repoName, repoName) + return errors.RepoAlreadyInitialized(repoName) } // Check if tmux session already exists (stale session from previous incomplete init) tmuxSession := sanitizeTmuxSessionName(repoName) if tmuxSession == "mc-" { - return fmt.Errorf("invalid tmux session name: repository name cannot be empty") + return errors.InvalidArgument("repository name", "", "non-empty string") } tmuxClient := tmux.NewClient() if exists, err := tmuxClient.HasSession(context.Background(), tmuxSession); err == nil && exists { @@ -1243,7 +1243,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Printf("This may be from a previous incomplete initialization.\n") fmt.Printf("Auto-repairing: killing existing tmux session...\n") if err := tmuxClient.KillSession(context.Background(), tmuxSession); err != nil { - return fmt.Errorf("failed to clean up existing tmux session: %w\nPlease manually kill it with: tmux kill-session -t %s", err, tmuxSession) + return errors.TmuxOperationFailed("kill session", err).WithSuggestion(fmt.Sprintf("tmux kill-session -t %s", tmuxSession)) } fmt.Println("✓ Cleaned up stale tmux session") } @@ -1251,7 +1251,7 @@ func (c *CLI) initRepo(args []string) error { // Check if repository directory already exists repoPath := c.paths.RepoDir(repoName) if _, err := os.Stat(repoPath); err == nil { - return fmt.Errorf("directory already exists: %s\nRemove it manually or choose a different name", repoPath) + return errors.New(errors.CategoryRuntime, fmt.Sprintf("directory already exists: %s", repoPath)).WithSuggestion("remove it manually or choose a different name") } // Clone repository @@ -1331,38 +1331,38 @@ func (c *CLI) initRepo(args []string) error { // Generate session IDs for agents supervisorSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate supervisor session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate supervisor session ID", err) } var mergeQueueSessionID, prShepherdSessionID string if mqEnabled { mergeQueueSessionID, err = claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate merge-queue session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate merge-queue session ID", err) } } else if psEnabled { prShepherdSessionID, err = claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate pr-shepherd session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate pr-shepherd session ID", err) } } // Write prompt files supervisorPromptFile, err := c.writePromptFile(repoPath, state.AgentTypeSupervisor, "supervisor") if err != nil { - return fmt.Errorf("failed to write supervisor prompt: %w", err) + return errors.PromptWriteFailed("supervisor", err) } var mergeQueuePromptFile, prShepherdPromptFile string if mqEnabled { mergeQueuePromptFile, err = c.writeMergeQueuePromptFile(repoPath, "merge-queue", mqConfig) if err != nil { - return fmt.Errorf("failed to write merge-queue prompt: %w", err) + return errors.PromptWriteFailed("merge-queue", err) } } else if psEnabled { prShepherdPromptFile, err = c.writePRShepherdPromptFile(repoPath, "pr-shepherd", psConfig, forkConfig) if err != nil { - return fmt.Errorf("failed to write pr-shepherd prompt: %w", err) + return errors.PromptWriteFailed("pr-shepherd", err) } } @@ -1377,13 +1377,13 @@ func (c *CLI) initRepo(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in supervisor window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, "supervisor", repoPath, supervisorSessionID, supervisorPromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start supervisor Claude: %w", err) + return errors.ClaudeStartupFailed("supervisor", err) } supervisorPID = pid @@ -1397,7 +1397,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Println("Starting Claude Code in merge-queue window...") pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, "merge-queue", repoPath, mergeQueueSessionID, mergeQueuePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start merge-queue Claude: %w", err) + return errors.ClaudeStartupFailed("merge-queue", err) } mergeQueuePID = pid @@ -1409,7 +1409,7 @@ func (c *CLI) initRepo(args []string) error { fmt.Println("Starting Claude Code in pr-shepherd window...") pid, err = c.startClaudeInTmux(claudeBinary, tmuxSession, "pr-shepherd", repoPath, prShepherdSessionID, prShepherdPromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start pr-shepherd Claude: %w", err) + return errors.ClaudeStartupFailed("pr-shepherd", err) } prShepherdPID = pid @@ -1441,10 +1441,10 @@ func (c *CLI) initRepo(args []string) error { Args: addRepoArgs, }) if err != nil { - return fmt.Errorf("failed to register repository with daemon: %w", err) + return errors.DaemonCommunicationFailed("registering repository", err) } if !resp.Success { - return fmt.Errorf("failed to register repository: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register repository", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } // Add supervisor agent @@ -1461,10 +1461,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register supervisor: %w", err) + return errors.DaemonCommunicationFailed("registering supervisor", err) } if !resp.Success { - return fmt.Errorf("failed to register supervisor: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register supervisor", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } // Add merge-queue agent only if enabled (non-fork mode) @@ -1482,10 +1482,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register merge-queue: %w", err) + return errors.DaemonCommunicationFailed("registering merge-queue", err) } if !resp.Success { - return fmt.Errorf("failed to register merge-queue: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register merge-queue", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } } @@ -1504,10 +1504,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register pr-shepherd: %w", err) + return errors.DaemonCommunicationFailed("registering pr-shepherd", err) } if !resp.Success { - return fmt.Errorf("failed to register pr-shepherd: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register pr-shepherd", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } } @@ -1522,9 +1522,9 @@ func (c *CLI) initRepo(args []string) error { // Check if it's a conflict state that requires manual resolution hasConflict, suggestion, checkErr := wt.CheckWorkspaceBranchConflict() if checkErr == nil && hasConflict { - return fmt.Errorf("workspace branch conflict detected:\n%s", suggestion) + return errors.New(errors.CategoryRuntime, "workspace branch conflict detected").WithSuggestion(suggestion) } - return fmt.Errorf("failed to check workspace branch state: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to check workspace branch state", err).WithSuggestion("multiclaude cleanup") } if migrated { fmt.Println("Migrated legacy 'workspace' branch to 'workspace/default'") @@ -1533,25 +1533,25 @@ func (c *CLI) initRepo(args []string) error { fmt.Printf("Creating default workspace worktree at: %s\n", workspacePath) if err := wt.CreateNewBranch(workspacePath, workspaceBranch, "HEAD"); err != nil { - return fmt.Errorf("failed to create default workspace worktree: %w", err) + return errors.WorktreeCreationFailed(err) } // Create default workspace tmux window (detached so it doesn't switch focus) cmd = exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", "default", "-c", workspacePath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create workspace window: %w", err) + return errors.TmuxOperationFailed("create window", err) } // Generate session ID for workspace workspaceSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate workspace session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate workspace session ID", err) } // Write prompt file for default workspace workspacePromptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, "default") if err != nil { - return fmt.Errorf("failed to write default workspace prompt: %w", err) + return errors.PromptWriteFailed("workspace", err) } // Copy hooks configuration if it exists @@ -1565,13 +1565,13 @@ func (c *CLI) initRepo(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in default workspace window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, "default", workspacePath, workspaceSessionID, workspacePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start default workspace Claude: %w", err) + return errors.ClaudeStartupFailed("workspace", err) } workspacePID = pid @@ -1595,10 +1595,10 @@ func (c *CLI) initRepo(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register default workspace: %w", err) + return errors.DaemonCommunicationFailed("registering default workspace", err) } if !resp.Success { - return fmt.Errorf("failed to register default workspace: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register default workspace", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println() @@ -1913,7 +1913,7 @@ func (c *CLI) configRepo(args []string) error { if len(repos) == 1 { repoName = repos[0] } else { - return fmt.Errorf("please specify a repository name or run from within a tracked repository") + return errors.NotInRepo() } } } @@ -1942,17 +1942,17 @@ func (c *CLI) showRepoConfig(repoName string) error { }, }) if err != nil { - return fmt.Errorf("failed to get repo config: %w (is daemon running?)", err) + return errors.DaemonCommunicationFailed("getting repository configuration", err) } if !resp.Success { - return fmt.Errorf("failed to get repo config: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to get repository configuration", fmt.Errorf("%s", resp.Error)) } // Parse response configMap, ok := resp.Data.(map[string]interface{}) if !ok { - return fmt.Errorf("unexpected response format") + return errors.Wrap(errors.CategoryRuntime, "daemon returned unexpected response format", nil).WithSuggestion("multiclaude daemon status") } fmt.Printf("Configuration for repository: %s\n\n", repoName) @@ -2025,7 +2025,7 @@ func (c *CLI) updateRepoConfig(repoName string, flags map[string]string) error { case "false": updateArgs["mq_enabled"] = false default: - return fmt.Errorf("invalid --mq-enabled value: %s (must be 'true' or 'false')", mqEnabled) + return errors.InvalidArgument("--mq-enabled", mqEnabled, "'true' or 'false'") } } @@ -2034,7 +2034,7 @@ func (c *CLI) updateRepoConfig(repoName string, flags map[string]string) error { case "all", "author", "assigned": updateArgs["mq_track_mode"] = mqTrack default: - return fmt.Errorf("invalid --mq-track value: %s (must be 'all', 'author', or 'assigned')", mqTrack) + return errors.InvalidArgument("--mq-track", mqTrack, "'all', 'author', or 'assigned'") } } @@ -2046,7 +2046,7 @@ func (c *CLI) updateRepoConfig(repoName string, flags map[string]string) error { case "false": updateArgs["ps_enabled"] = false default: - return fmt.Errorf("invalid --ps-enabled value: %s (must be 'true' or 'false')", psEnabled) + return errors.InvalidArgument("--ps-enabled", psEnabled, "'true' or 'false'") } } @@ -2055,7 +2055,7 @@ func (c *CLI) updateRepoConfig(repoName string, flags map[string]string) error { case "all", "author", "assigned": updateArgs["ps_track_mode"] = psTrack default: - return fmt.Errorf("invalid --ps-track value: %s (must be 'all', 'author', or 'assigned')", psTrack) + return errors.InvalidArgument("--ps-track", psTrack, "'all', 'author', or 'assigned'") } } @@ -2065,11 +2065,11 @@ func (c *CLI) updateRepoConfig(repoName string, flags map[string]string) error { Args: updateArgs, }) if err != nil { - return fmt.Errorf("failed to update repo config: %w (is daemon running?)", err) + return errors.DaemonCommunicationFailed("updating repository configuration", err) } if !resp.Success { - return fmt.Errorf("failed to update repo config: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to update repository configuration", fmt.Errorf("%s", resp.Error)) } fmt.Printf("Configuration updated for repository: %s\n", repoName) @@ -2224,7 +2224,7 @@ func (c *CLI) createWorker(args []string) error { // Generate session ID for worker workerSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate worker session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate worker session ID", err) } // Get fork config from daemon to include in worker prompt @@ -2255,7 +2255,7 @@ func (c *CLI) createWorker(args []string) error { } workerPromptFile, err := c.writeWorkerPromptFile(repoPath, workerName, workerConfig) if err != nil { - return fmt.Errorf("failed to write worker prompt: %w", err) + return errors.PromptWriteFailed("worker", err) } // Copy hooks configuration if it exists @@ -2269,14 +2269,14 @@ func (c *CLI) createWorker(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in worker window...") initialMessage := fmt.Sprintf("Task: %s", task) pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, workerName, wtPath, workerSessionID, workerPromptFile, repoName, initialMessage) if err != nil { - return fmt.Errorf("failed to start worker Claude: %w", err) + return errors.ClaudeStartupFailed("worker", err) } workerPID = pid @@ -2301,10 +2301,10 @@ func (c *CLI) createWorker(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register worker: %w", err) + return errors.DaemonCommunicationFailed("registering worker", err) } if !resp.Success { - return fmt.Errorf("failed to register worker: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register worker", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println() @@ -2979,10 +2979,10 @@ func (c *CLI) removeWorker(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to unregister worker: %w", err) + return errors.DaemonCommunicationFailed("unregistering worker", err) } if !resp.Success { - return fmt.Errorf("failed to unregister worker: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to unregister worker", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println("✓ Worker removed successfully") @@ -3297,7 +3297,7 @@ func (c *CLI) addWorkspace(args []string) error { agentType, _ := agentMap["type"].(string) name, _ := agentMap["name"].(string) if agentType == "workspace" && name == workspaceName { - return fmt.Errorf("workspace '%s' already exists in repo '%s'", workspaceName, repoName) + return errors.WorkspaceAlreadyExists(workspaceName, repoName) } } } @@ -3316,7 +3316,7 @@ func (c *CLI) addWorkspace(args []string) error { fmt.Printf("This may be from a previous incomplete workspace creation.\n") fmt.Printf("Auto-repairing: removing existing worktree...\n") if err := wt.Remove(wtPath, true); err != nil { - return fmt.Errorf("failed to clean up existing worktree: %w\nPlease manually remove it with: git worktree remove %s", err, wtPath) + return errors.WorktreeCreationFailed(err).WithSuggestion(fmt.Sprintf("git worktree remove %s", wtPath)) } fmt.Println("✓ Cleaned up stale worktree") } @@ -3336,7 +3336,7 @@ func (c *CLI) addWorkspace(args []string) error { fmt.Printf("This may be from a previous incomplete workspace creation.\n") fmt.Printf("Auto-repairing: killing existing tmux window...\n") if err := tmuxClient.KillWindow(context.Background(), tmuxSession, workspaceName); err != nil { - return fmt.Errorf("failed to clean up existing tmux window: %w\nPlease manually kill it with: tmux kill-window -t %s:%s", err, tmuxSession, workspaceName) + return errors.TmuxOperationFailed("kill window", err).WithSuggestion(fmt.Sprintf("tmux kill-window -t %s:%s", tmuxSession, workspaceName)) } fmt.Println("✓ Cleaned up stale tmux window") } @@ -3351,13 +3351,13 @@ func (c *CLI) addWorkspace(args []string) error { // Generate session ID for workspace workspaceSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate workspace session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate workspace session ID", err) } // Write prompt file for workspace workspacePromptFile, err := c.writePromptFile(repoPath, state.AgentTypeWorkspace, workspaceName) if err != nil { - return fmt.Errorf("failed to write workspace prompt: %w", err) + return errors.PromptWriteFailed("workspace", err) } // Copy hooks configuration if it exists @@ -3371,13 +3371,13 @@ func (c *CLI) addWorkspace(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in workspace window...") pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, workspaceName, wtPath, workspaceSessionID, workspacePromptFile, repoName, "") if err != nil { - return fmt.Errorf("failed to start workspace Claude: %w", err) + return errors.ClaudeStartupFailed("workspace", err) } workspacePID = pid @@ -3401,10 +3401,10 @@ func (c *CLI) addWorkspace(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register workspace: %w", err) + return errors.DaemonCommunicationFailed("registering workspace", err) } if !resp.Success { - return fmt.Errorf("failed to register workspace: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register workspace", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println() @@ -3537,10 +3537,10 @@ func (c *CLI) removeWorkspace(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to unregister workspace: %w", err) + return errors.DaemonCommunicationFailed("unregistering workspace", err) } if !resp.Success { - return fmt.Errorf("failed to unregister workspace: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to unregister workspace", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println("✓ Workspace removed successfully") @@ -3643,10 +3643,10 @@ func (c *CLI) connectWorkspace(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to get workspace info: %w (is daemon running?)", err) + return errors.DaemonCommunicationFailed("getting workspace information", err) } if !resp.Success { - return fmt.Errorf("failed to get workspace info: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to get workspace information", fmt.Errorf("%s", resp.Error)) } agents, _ := resp.Data.([]interface{}) @@ -4205,7 +4205,7 @@ func (c *CLI) completeWorker(args []string) error { // Determine current agent and repo repoName, agentName, err := c.inferAgentContext() if err != nil { - return fmt.Errorf("failed to determine agent context: %w", err) + return errors.NotInAgentContext() } fmt.Printf("Marking agent '%s' as complete...\n", agentName) @@ -4376,7 +4376,7 @@ func (c *CLI) reviewPR(args []string) error { fmt.Printf("Creating worktree at: %s\n", wtPath) if err := wt.CreateNewBranch(wtPath, reviewBranch, localRef); err != nil { - return fmt.Errorf("failed to create worktree: %w", err) + return errors.WorktreeCreationFailed(err) } // Get tmux session name @@ -4386,19 +4386,19 @@ func (c *CLI) reviewPR(args []string) error { fmt.Printf("Creating tmux window: %s\n", reviewerName) cmd = exec.Command("tmux", "new-window", "-d", "-t", tmuxSession, "-n", reviewerName, "-c", wtPath) if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to create tmux window: %w", err) + return errors.TmuxOperationFailed("create window", err) } // Generate session ID for reviewer reviewerSessionID, err := claude.GenerateSessionID() if err != nil { - return fmt.Errorf("failed to generate reviewer session ID: %w", err) + return errors.Wrap(errors.CategoryRuntime, "failed to generate reviewer session ID", err) } // Write prompt file for reviewer reviewerPromptFile, err := c.writePromptFile(repoPath, state.AgentTypeReview, reviewerName) if err != nil { - return fmt.Errorf("failed to write reviewer prompt: %w", err) + return errors.PromptWriteFailed("reviewer", err) } // Copy hooks configuration if it exists @@ -4412,14 +4412,14 @@ func (c *CLI) reviewPR(args []string) error { // Resolve claude binary claudeBinary, err := c.getClaudeBinary() if err != nil { - return fmt.Errorf("failed to resolve claude binary: %w", err) + return errors.ClaudeNotFound(err) } fmt.Println("Starting Claude Code in reviewer window...") initialMessage := fmt.Sprintf("Review PR #%s: https://github.com/%s/%s/pull/%s", prNumber, parts[1], parts[2], prNumber) pid, err := c.startClaudeInTmux(claudeBinary, tmuxSession, reviewerName, wtPath, reviewerSessionID, reviewerPromptFile, repoName, initialMessage) if err != nil { - return fmt.Errorf("failed to start reviewer Claude: %w", err) + return errors.ClaudeStartupFailed("reviewer", err) } reviewerPID = pid @@ -4445,10 +4445,10 @@ func (c *CLI) reviewPR(args []string) error { }, }) if err != nil { - return fmt.Errorf("failed to register reviewer: %w", err) + return errors.DaemonCommunicationFailed("registering reviewer", err) } if !resp.Success { - return fmt.Errorf("failed to register reviewer: %s", resp.Error) + return errors.Wrap(errors.CategoryRuntime, "failed to register reviewer", fmt.Errorf("%s", resp.Error)).WithSuggestion("multiclaude daemon status") } fmt.Println() @@ -4466,7 +4466,7 @@ func (c *CLI) reviewPR(args []string) error { func (c *CLI) viewLogs(args []string) error { if len(args) < 1 { - return fmt.Errorf("usage: multiclaude logs [--lines N] [--follow]") + return errors.InvalidUsage("multiclaude logs [--lines N] [--follow]") } agentName := args[0] @@ -4479,12 +4479,12 @@ func (c *CLI) viewLogs(args []string) error { } else { repos := c.getReposList() if len(repos) == 0 { - return fmt.Errorf("no repositories tracked") + return errors.NoRepositoriesFound() } if len(repos) == 1 { repoName = repos[0] } else { - return fmt.Errorf("multiple repos exist. Use --repo flag to specify which one") + return errors.MultipleRepos() } } @@ -4498,7 +4498,7 @@ func (c *CLI) viewLogs(args []string) error { } else if _, err := os.Stat(systemLogFile); err == nil { logFile = systemLogFile } else { - return fmt.Errorf("no log file found for agent %s in repo %s", agentName, repoName) + return errors.LogFileNotFound(agentName, repoName) } // Check for --follow flag @@ -4609,7 +4609,7 @@ func (c *CLI) listLogsForRepo(repoName string) error { func (c *CLI) searchLogs(args []string) error { if len(args) < 1 { - return fmt.Errorf("usage: multiclaude logs search [--repo ]") + return errors.InvalidUsage("multiclaude logs search [--repo ]") } pattern := args[0] @@ -4666,13 +4666,13 @@ func (c *CLI) cleanLogs(args []string) error { olderThan, ok := flags["older-than"] if !ok { - return fmt.Errorf("usage: multiclaude logs clean --older-than (e.g., 7d, 24h)") + return errors.InvalidUsage("multiclaude logs clean --older-than (e.g., 7d, 24h)") } // Parse duration duration, err := parseDuration(olderThan) if err != nil { - return fmt.Errorf("invalid duration: %v", err) + return errors.InvalidArgument("--older-than", olderThan, "a valid duration (e.g., 7d, 24h)") } cutoff := time.Now().Add(-duration) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index c7554129..7f93cdd7 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -1125,15 +1125,15 @@ func (d *Daemon) handleRestartAgent(req socket.Request) socket.Response { // Check if tmux window exists repo, exists := d.state.GetRepo(repoName) if !exists { - return socket.ErrorResponse("repository '%s' not found in state", repoName) + return socket.ErrorResponse("repository '%s' not found - check tracked repos with: multiclaude repo list", repoName) } hasWindow, err := d.tmux.HasWindow(d.ctx, repo.TmuxSession, agentName) if err != nil { - return socket.ErrorResponse("failed to check tmux window: %v", err) + return socket.ErrorResponse("failed to check tmux window for '%s': %v - try: multiclaude daemon status", agentName, err) } if !hasWindow { - return socket.ErrorResponse("tmux window '%s' does not exist - the agent may need to be recreated", agentName) + return socket.ErrorResponse("tmux window '%s' does not exist - recreate the agent or run: multiclaude cleanup", agentName) } // Check if agent is already running @@ -1146,7 +1146,7 @@ func (d *Daemon) handleRestartAgent(req socket.Request) socket.Response { // Restart the agent if err := d.restartAgent(repoName, agentName, agent, repo); err != nil { - return socket.ErrorResponse("failed to restart agent: %v", err) + return socket.ErrorResponse("failed to restart agent '%s': %v - check logs: tail -f ~/.multiclaude/daemon.log", agentName, err) } // Get updated PID from state @@ -1262,7 +1262,7 @@ func (d *Daemon) handleGetRepoConfig(req socket.Request) socket.Response { repo, exists := d.state.GetRepo(name) if !exists { - return socket.ErrorResponse("repository %q not found", name) + return socket.ErrorResponse("repository %q not found - check tracked repos with: multiclaude repo list", name) } // Get merge queue config (use default if not set for backward compatibility) @@ -1564,12 +1564,12 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { // Get repository repo, exists := d.state.GetRepo(repoName) if !exists { - return socket.ErrorResponse("repository %q not found", repoName) + return socket.ErrorResponse("repository %q not found - check tracked repos with: multiclaude repo list", repoName) } // Check if agent already exists if _, exists := d.state.GetAgent(repoName, agentName); exists { - return socket.ErrorResponse("agent %q already exists in repository %q", agentName, repoName) + return socket.ErrorResponse("agent %q already exists in repository %q - try: multiclaude cleanup", agentName, repoName) } // Determine agent type based on class @@ -1607,7 +1607,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { // Ephemeral agents get their own worktree with a new branch branchName := fmt.Sprintf("work/%s", agentName) if err := wt.CreateNewBranch(worktreePath, branchName, "HEAD"); err != nil { - return socket.ErrorResponse("failed to create worktree: %v", err) + return socket.ErrorResponse("failed to create worktree for '%s': %v - try: multiclaude cleanup", agentName, err) } } @@ -1618,18 +1618,18 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { if agentClass != "persistent" { wt.Remove(worktreePath, true) } - return socket.ErrorResponse("failed to create tmux window: %v", err) + return socket.ErrorResponse("failed to create tmux window for '%s': %v - check tmux session with: tmux ls", agentName, err) } // Write prompt to file promptDir := filepath.Join(d.paths.Root, "prompts") if err := os.MkdirAll(promptDir, 0755); err != nil { - return socket.ErrorResponse("failed to create prompt directory: %v", err) + return socket.ErrorResponse("failed to create prompt directory: %v - check disk space and permissions in ~/.multiclaude/", err) } promptPath := filepath.Join(promptDir, fmt.Sprintf("%s.md", agentName)) if err := os.WriteFile(promptPath, []byte(promptText), 0644); err != nil { - return socket.ErrorResponse("failed to write prompt file: %v", err) + return socket.ErrorResponse("failed to write prompt file for '%s': %v - check disk space and permissions", agentName, err) } // Copy hooks config @@ -1651,7 +1651,7 @@ func (d *Daemon) handleSpawnAgent(req socket.Request) socket.Response { if agentClass != "persistent" { wt.Remove(worktreePath, true) } - return socket.ErrorResponse("failed to start agent: %v", err) + return socket.ErrorResponse("failed to start agent '%s': %v - check that 'claude' is in PATH and daemon logs: tail -f ~/.multiclaude/daemon.log", agentName, err) } // Update task if provided @@ -1819,7 +1819,7 @@ func (d *Daemon) restoreRepoAgents(repoName string, repo *state.Repository) erro // Verify the repo still exists on disk if _, err := os.Stat(repoPath); os.IsNotExist(err) { - return fmt.Errorf("repository path does not exist: %s", repoPath) + return fmt.Errorf("repository path does not exist: %s - try: multiclaude repo rm %s && multiclaude repo init ", repoPath, repoName) } // Clear any stale agents from state (their tmux session is gone) @@ -2022,7 +2022,7 @@ func (d *Daemon) sendAgentDefinitionsToSupervisor(repoName, repoPath string, mqC func (d *Daemon) getClaudeBinaryPath() (string, error) { binaryPath, err := exec.LookPath("claude") if err != nil { - return "", fmt.Errorf("claude binary not found in PATH: %w", err) + return "", fmt.Errorf("claude binary not found in PATH: %w - install Claude Code CLI: https://docs.anthropic.com/claude-code", err) } return binaryPath, nil } @@ -2248,7 +2248,7 @@ func RunDetached() error { // Check if already running pidFile := NewPIDFile(paths.DaemonPID) if running, pid, _ := pidFile.IsRunning(); running { - return fmt.Errorf("daemon already running (PID: %d)", pid) + return fmt.Errorf("daemon already running (PID: %d) - try: multiclaude daemon stop && multiclaude daemon start", pid) } // Ensure config directory exists diff --git a/internal/errors/errors.go b/internal/errors/errors.go index 6798ebdd..91cc77c7 100644 --- a/internal/errors/errors.go +++ b/internal/errors/errors.go @@ -391,3 +391,69 @@ func WorkspaceNotFound(name, repo string) *CLIError { Suggestion: fmt.Sprintf("multiclaude workspace list --repo %s", repo), } } + +// ClaudeStartupFailed creates an error for when Claude fails to start for an agent +func ClaudeStartupFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to start Claude for %s", agentType), + Cause: cause, + Suggestion: "check that 'claude' is installed and in PATH, then try: multiclaude daemon status", + } +} + +// DaemonAlreadyRunning creates an error for when the daemon is already running +func DaemonAlreadyRunning(pid int) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("daemon already running (PID: %d)", pid), + Suggestion: "multiclaude daemon stop && multiclaude daemon start", + } +} + +// AgentRestartFailed creates an error for when an agent fails to restart +func AgentRestartFailed(agentName string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to restart agent '%s'", agentName), + Cause: cause, + Suggestion: "check daemon logs: tail -f ~/.multiclaude/daemon.log", + } +} + +// PromptWriteFailed creates an error for when writing an agent prompt file fails +func PromptWriteFailed(agentType string, cause error) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("failed to write %s prompt file", agentType), + Cause: cause, + Suggestion: "check disk space and file permissions in ~/.multiclaude/prompts/", + } +} + +// LogFileNotFound creates an error for when an agent's log file is not found +func LogFileNotFound(agentName, repo string) *CLIError { + return &CLIError{ + Category: CategoryNotFound, + Message: fmt.Sprintf("no log file found for agent '%s' in repository '%s'", agentName, repo), + Suggestion: fmt.Sprintf("multiclaude worker list --repo %s", repo), + } +} + +// WorkspaceAlreadyExists creates an error for when a workspace already exists +func WorkspaceAlreadyExists(name, repo string) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("workspace '%s' already exists in repository '%s'", name, repo), + Suggestion: fmt.Sprintf("multiclaude workspace list --repo %s", repo), + } +} + +// RepoAlreadyInitialized creates an error for when a repository is already initialized +func RepoAlreadyInitialized(name string) *CLIError { + return &CLIError{ + Category: CategoryRuntime, + Message: fmt.Sprintf("repository '%s' is already initialized", name), + Suggestion: fmt.Sprintf("multiclaude repo rm %s", name), + } +} diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go index 36a41d45..9f9498ea 100644 --- a/internal/errors/errors_test.go +++ b/internal/errors/errors_test.go @@ -556,6 +556,130 @@ func TestNoAgentsFound(t *testing.T) { } } +func TestClaudeStartupFailed(t *testing.T) { + cause := errors.New("connection refused") + err := ClaudeStartupFailed("supervisor", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Cause != cause { + t.Error("should wrap cause") + } + if err.Suggestion == "" { + t.Error("should have a suggestion") + } + + formatted := Format(err) + if !strings.Contains(formatted, "supervisor") { + t.Errorf("expected agent type in message, got: %s", formatted) + } + if !strings.Contains(formatted, "claude") { + t.Errorf("expected claude in suggestion, got: %s", formatted) + } +} + +func TestDaemonAlreadyRunning(t *testing.T) { + err := DaemonAlreadyRunning(12345) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + if err.Suggestion == "" { + t.Error("should have a suggestion") + } + + formatted := Format(err) + if !strings.Contains(formatted, "12345") { + t.Errorf("expected PID in message, got: %s", formatted) + } + if !strings.Contains(formatted, "daemon stop") { + t.Errorf("expected stop suggestion, got: %s", formatted) + } +} + +func TestAgentRestartFailed(t *testing.T) { + cause := errors.New("tmux error") + err := AgentRestartFailed("my-worker", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + + formatted := Format(err) + if !strings.Contains(formatted, "my-worker") { + t.Errorf("expected agent name in message, got: %s", formatted) + } + if !strings.Contains(formatted, "daemon.log") { + t.Errorf("expected log suggestion, got: %s", formatted) + } +} + +func TestPromptWriteFailed(t *testing.T) { + cause := errors.New("permission denied") + err := PromptWriteFailed("worker", cause) + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + + formatted := Format(err) + if !strings.Contains(formatted, "worker") { + t.Errorf("expected agent type in message, got: %s", formatted) + } + if !strings.Contains(formatted, "disk space") { + t.Errorf("expected disk space suggestion, got: %s", formatted) + } +} + +func TestLogFileNotFound(t *testing.T) { + err := LogFileNotFound("my-agent", "my-repo") + + if err.Category != CategoryNotFound { + t.Errorf("expected CategoryNotFound, got %v", err.Category) + } + + formatted := Format(err) + if !strings.Contains(formatted, "my-agent") { + t.Errorf("expected agent name in message, got: %s", formatted) + } + if !strings.Contains(formatted, "my-repo") { + t.Errorf("expected repo name in message, got: %s", formatted) + } +} + +func TestWorkspaceAlreadyExists(t *testing.T) { + err := WorkspaceAlreadyExists("dev", "my-repo") + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + + formatted := Format(err) + if !strings.Contains(formatted, "dev") { + t.Errorf("expected workspace name in message, got: %s", formatted) + } + if !strings.Contains(formatted, "workspace list") { + t.Errorf("expected list suggestion, got: %s", formatted) + } +} + +func TestRepoAlreadyInitialized(t *testing.T) { + err := RepoAlreadyInitialized("my-repo") + + if err.Category != CategoryRuntime { + t.Errorf("expected CategoryRuntime, got %v", err.Category) + } + + formatted := Format(err) + if !strings.Contains(formatted, "my-repo") { + t.Errorf("expected repo name in message, got: %s", formatted) + } + if !strings.Contains(formatted, "repo rm") { + t.Errorf("expected repo rm suggestion, got: %s", formatted) + } +} + func TestWorkspaceNotFound(t *testing.T) { err := WorkspaceNotFound("my-workspace", "my-repo")