From efc7012649f3ef14cc5ff0140bf8658b7f3ea7c3 Mon Sep 17 00:00:00 2001 From: Joshua Skootsky Date: Sun, 11 Jan 2026 15:35:01 -0500 Subject: [PATCH 1/3] feat: add OpenCode CLI support for agent execution - Added OpenCodeExecutor with run command support - Added factory pattern for agent type selection - Added CLI flags for --agent-type and --opencode-model - Added configuration for OpenCode model and server URL - Added comprehensive test coverage - Updated telemetry with AgentTypeOpenCode constant Co-Authored-By: MiniMax M2.1 --- cmd/drover/commands.go | 53 ++++- internal/config/config.go | 62 ++++- internal/config/config_test.go | 194 +++++++++++++++- internal/executor/factory.go | 60 +++++ internal/executor/factory_test.go | 112 +++++++++ internal/executor/opencode.go | 248 ++++++++++++++++++++ internal/executor/opencode_test.go | 361 +++++++++++++++++++++++++++++ pkg/telemetry/attributes.go | 59 ++--- 8 files changed, 1097 insertions(+), 52 deletions(-) create mode 100644 internal/executor/factory.go create mode 100644 internal/executor/factory_test.go create mode 100644 internal/executor/opencode.go create mode 100644 internal/executor/opencode_test.go diff --git a/cmd/drover/commands.go b/cmd/drover/commands.go index 8fc3a5b..2ae1580 100644 --- a/cmd/drover/commands.go +++ b/cmd/drover/commands.go @@ -16,8 +16,8 @@ import ( "github.com/cloud-shuttle/drover/internal/db" "github.com/cloud-shuttle/drover/internal/git" "github.com/cloud-shuttle/drover/internal/template" - "github.com/cloud-shuttle/drover/pkg/types" "github.com/cloud-shuttle/drover/internal/workflow" + "github.com/cloud-shuttle/drover/pkg/types" "github.com/dbos-inc/dbos-transact-golang/dbos" "github.com/spf13/cobra" ) @@ -122,15 +122,22 @@ func runCmd() *cobra.Command { var workers int var epicID string var verbose bool + var agentType string + var opencodeModel string + var opencodeURL string cmd := &cobra.Command{ Use: "run", Short: "Execute all tasks to completion", - Long: `Run all tasks to completion using parallel Claude Code agents. + Long: `Run all tasks to completion using parallel AI agents. Tasks are executed respecting dependencies and priorities. Use --workers to control parallelism. Use --epic to filter execution to a specific epic. +Agent Types: +- claude-code (default): Use Claude Code CLI +- opencode: Use OpenCode CLI + DBOS Workflow Engine: - Default: SQLite-based orchestration (zero setup) - With DBOS_SYSTEM_DATABASE_URL: DBOS with PostgreSQL (production mode)`, @@ -141,13 +148,31 @@ DBOS Workflow Engine: } defer store.Close() - // Override config if workers flag specified + // Override config if flags specified runCfg := *cfg if workers > 0 { runCfg.Workers = workers } runCfg.Verbose = verbose + // Set agent type + if agentType != "" { + runCfg.AgentType = config.AgentType(agentType) + } + + // Validate and set OpenCode model if using OpenCode + if runCfg.AgentType == config.AgentTypeOpenCode { + if opencodeModel != "" { + if err := config.ValidateOpenCodeModel(opencodeModel); err != nil { + return fmt.Errorf("invalid OpenCode model: %w", err) + } + runCfg.OpenCodeModel = opencodeModel + } + if opencodeURL != "" { + runCfg.OpenCodeURL = opencodeURL + } + } + // Check if DBOS mode is enabled via environment variable dbosURL := os.Getenv("DBOS_SYSTEM_DATABASE_URL") @@ -164,6 +189,9 @@ DBOS Workflow Engine: cmd.Flags().IntVarP(&workers, "workers", "w", 0, "Number of parallel workers") cmd.Flags().StringVar(&epicID, "epic", "", "Filter to specific epic") cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging for debugging") + cmd.Flags().StringVar(&agentType, "agent-type", "", "Agent type to use: claude-code or opencode") + cmd.Flags().StringVar(&opencodeModel, "opencode-model", "", "OpenCode model in format provider/model (e.g., anthropic/claude-sonnet-4-20250514)") + cmd.Flags().StringVar(&opencodeURL, "opencode-url", "", "OpenCode server URL for remote execution") return cmd } @@ -279,11 +307,11 @@ func runWithSQLite(cmd *cobra.Command, runCfg *config.Config, store *db.Store, p func addCmd() *cobra.Command { var ( - desc string - epicID string - parentID string - priority int - blockedBy []string + desc string + epicID string + parentID string + priority int + blockedBy []string skipValidation bool ) @@ -301,7 +329,7 @@ Hierarchical Tasks: drover add "Sub-task title" --parent task-123 Maximum depth is 2 levels (Epic → Parent → Child).`, - Args: cobra.ExactArgs(1), + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { _, store, err := requireProject() if err != nil { @@ -541,10 +569,10 @@ and other metadata. Useful for inspecting individual task details.`, func resetCmd() *cobra.Command { var ( - resetCompleted bool + resetCompleted bool resetInProgress bool - resetClaimed bool - resetFailed bool + resetClaimed bool + resetFailed bool ) command := &cobra.Command{ @@ -984,6 +1012,7 @@ func formatTimestamp(timestamp int64) string { t := time.Unix(timestamp, 0) return t.Format("2006-01-02 15:04:05") } + // worktreeCmd returns the worktree management command func worktreeCmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/internal/config/config.go b/internal/config/config.go index 4f782fb..99304d8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,9 +5,18 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" ) +// AgentType represents the AI agent to use for task execution +type AgentType string + +const ( + AgentTypeClaudeCode AgentType = "claude-code" + AgentTypeOpenCode AgentType = "opencode" +) + // Config holds Drover configuration type Config struct { // Database connection @@ -21,16 +30,20 @@ type Config struct { MaxTaskAttempts int // Retry settings - ClaimTimeout time.Duration - StallTimeout time.Duration - PollInterval time.Duration - AutoUnblock bool + ClaimTimeout time.Duration + StallTimeout time.Duration + PollInterval time.Duration + AutoUnblock bool // Git settings WorktreeDir string - // Claude settings - ClaudePath string + // Agent settings + AgentType AgentType // "claude-code" or "opencode" + ClaudePath string // Path to Claude CLI (default: "claude") + OpenCodePath string // Path to OpenCode CLI (default: "opencode") + OpenCodeModel string // Model in format "provider/model" (e.g., "anthropic/claude-sonnet-4-20250514") + OpenCodeURL string // Optional remote OpenCode server URL // Beads sync settings AutoSyncBeads bool @@ -55,7 +68,10 @@ func Load() (*Config, error) { AutoUnblock: true, WorktreeDir: ".drover/worktrees", ClaudePath: "claude", - AutoSyncBeads: false, // Default to off for backwards compatibility + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + AgentType: AgentTypeClaudeCode, + AutoSyncBeads: false, } // Environment overrides @@ -71,6 +87,21 @@ func Load() (*Config, error) { if v := os.Getenv("DROVER_AUTO_SYNC_BEADS"); v != "" { cfg.AutoSyncBeads = v == "true" || v == "1" } + if v := os.Getenv("DROVER_AGENT_TYPE"); v != "" { + cfg.AgentType = AgentType(v) + } + if v := os.Getenv("DROVER_CLAUDE_PATH"); v != "" { + cfg.ClaudePath = v + } + if v := os.Getenv("DROVER_OPENCODE_PATH"); v != "" { + cfg.OpenCodePath = v + } + if v := os.Getenv("DROVER_OPENCODE_MODEL"); v != "" { + cfg.OpenCodeModel = v + } + if v := os.Getenv("DROVER_OPENCODE_URL"); v != "" { + cfg.OpenCodeURL = v + } return cfg, nil } @@ -99,3 +130,20 @@ func parseDurationOrDefault(s string, def time.Duration) time.Duration { } return d } + +// ValidateOpenCodeModel validates that the model format is "provider/model" +func ValidateOpenCodeModel(model string) error { + parts := strings.Split(model, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("invalid OpenCode model format: %s (expected provider/model, e.g., anthropic/claude-sonnet-4-20250514)", model) + } + return nil +} + +// GetAgentExecutorPath returns the path to the agent CLI based on agent type +func (c *Config) GetAgentExecutorPath() string { + if c.AgentType == AgentTypeOpenCode { + return c.OpenCodePath + } + return c.ClaudePath +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8c34abb..d3c2c3e 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,6 +1,7 @@ package config import ( + "os" "testing" "time" ) @@ -14,10 +15,10 @@ func TestParseIntOrDefault(t *testing.T) { {"5", 10, 5}, {"100", 0, 100}, {"-3", 10, -3}, - {"abc", 10, 10}, // invalid returns default - {"", 10, 10}, // empty returns default - {"3.14", 10, 3}, // parses integer prefix (3) - {"7xyz", 10, 7}, // parses prefix + {"abc", 10, 10}, // invalid returns default + {"", 10, 10}, // empty returns default + {"3.14", 10, 3}, // parses integer prefix (3) + {"7xyz", 10, 7}, // parses prefix } for _, tt := range tests { @@ -54,3 +55,188 @@ func TestParseDurationOrDefault(t *testing.T) { }) } } + +func TestValidateOpenCodeModel(t *testing.T) { + tests := []struct { + model string + wantErr bool + errContains string + }{ + {"anthropic/claude-sonnet-4-20250514", false, ""}, + {"openai/gpt-4o", false, ""}, + {"google/gemini-2.5-pro", false, ""}, + {"opencode/grok-code", false, ""}, + {"claude-sonnet-4-20250514", true, "invalid OpenCode model format"}, + {"", true, "invalid OpenCode model format"}, + {"/model", true, "invalid OpenCode model format"}, + {"provider/", true, "invalid OpenCode model format"}, + {"provider/model/extra", true, "invalid OpenCode model format"}, + } + + for _, tt := range tests { + t.Run(tt.model, func(t *testing.T) { + err := ValidateOpenCodeModel(tt.model) + if (err != nil) != tt.wantErr { + t.Errorf("ValidateOpenCodeModel(%q) error = %v, wantErr %v", tt.model, err, tt.wantErr) + return + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !containsString(err.Error(), tt.errContains) { + t.Errorf("error = %v, should contain %v", err, tt.errContains) + } + } + }) + } +} + +func containsString(haystack, needle string) bool { + if needle == "" { + return true + } + for i := 0; i <= len(haystack)-len(needle); i++ { + if haystack[i:i+len(needle)] == needle { + return true + } + } + return false +} + +func TestConfig_OpenCodeEnvVars(t *testing.T) { + envKeys := []string{"DROVER_AGENT_TYPE", "DROVER_OPENCODE_MODEL", "DROVER_OPENCODE_PATH", "DROVER_OPENCODE_URL"} + originalEnv := make(map[string]string) + for _, key := range envKeys { + originalEnv[key] = os.Getenv(key) + } + defer func() { + for key, value := range originalEnv { + os.Setenv(key, value) + } + }() + + tests := []struct { + name string + envVars map[string]string + wantType AgentType + wantModel string + wantPath string + wantURL string + }{ + { + name: "default values", + envVars: map[string]string{}, + wantType: AgentTypeClaudeCode, + wantModel: "anthropic/claude-sonnet-4-20250514", + wantPath: "opencode", + }, + { + name: "opencode agent type", + envVars: map[string]string{ + "DROVER_AGENT_TYPE": "opencode", + }, + wantType: AgentTypeOpenCode, + wantModel: "anthropic/claude-sonnet-4-20250514", + }, + { + name: "opencode with custom model", + envVars: map[string]string{ + "DROVER_AGENT_TYPE": "opencode", + "DROVER_OPENCODE_MODEL": "openai/gpt-4o", + }, + wantType: AgentTypeOpenCode, + wantModel: "openai/gpt-4o", + }, + { + name: "opencode with custom path", + envVars: map[string]string{ + "DROVER_AGENT_TYPE": "opencode", + "DROVER_OPENCODE_PATH": "/usr/local/bin/opencode", + }, + wantType: AgentTypeOpenCode, + wantPath: "/usr/local/bin/opencode", + }, + { + name: "opencode with server URL", + envVars: map[string]string{ + "DROVER_AGENT_TYPE": "opencode", + "DROVER_OPENCODE_URL": "http://localhost:4096", + }, + wantType: AgentTypeOpenCode, + wantURL: "http://localhost:4096", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + for _, key := range envKeys { + os.Setenv(key, "") + } + for key, value := range tt.envVars { + os.Setenv(key, value) + } + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if cfg.AgentType != tt.wantType { + t.Errorf("AgentType = %v, want %v", cfg.AgentType, tt.wantType) + } + if tt.wantModel != "" && cfg.OpenCodeModel != tt.wantModel { + t.Errorf("OpenCodeModel = %v, want %v", cfg.OpenCodeModel, tt.wantModel) + } + if tt.wantPath != "" && cfg.OpenCodePath != tt.wantPath { + t.Errorf("OpenCodePath = %v, want %v", cfg.OpenCodePath, tt.wantPath) + } + if tt.wantURL != "" && cfg.OpenCodeURL != tt.wantURL { + t.Errorf("OpenCodeURL = %v, want %v", cfg.OpenCodeURL, tt.wantURL) + } + }) + } +} + +func TestConfig_GetAgentExecutorPath(t *testing.T) { + tests := []struct { + name string + agentType AgentType + claudePath string + opencode string + wantPath string + }{ + { + name: "claude-code agent", + agentType: AgentTypeClaudeCode, + claudePath: "/usr/bin/claude", + opencode: "/usr/bin/opencode", + wantPath: "/usr/bin/claude", + }, + { + name: "opencode agent", + agentType: AgentTypeOpenCode, + claudePath: "/usr/bin/claude", + opencode: "/usr/bin/opencode", + wantPath: "/usr/bin/opencode", + }, + { + name: "default (empty) uses claude", + agentType: AgentType(""), + claudePath: "/usr/bin/claude", + opencode: "/usr/bin/opencode", + wantPath: "/usr/bin/claude", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := &Config{ + AgentType: tt.agentType, + ClaudePath: tt.claudePath, + OpenCodePath: tt.opencode, + } + gotPath := cfg.GetAgentExecutorPath() + if gotPath != tt.wantPath { + t.Errorf("GetAgentExecutorPath() = %v, want %v", gotPath, tt.wantPath) + } + }) + } +} diff --git a/internal/executor/factory.go b/internal/executor/factory.go new file mode 100644 index 0000000..86ef146 --- /dev/null +++ b/internal/executor/factory.go @@ -0,0 +1,60 @@ +package executor + +import ( + "context" + "fmt" + + "github.com/cloud-shuttle/drover/internal/config" + "github.com/cloud-shuttle/drover/pkg/types" + "go.opentelemetry.io/otel/trace" +) + +// AgentExecutor defines the interface for executing AI agent tasks +type AgentExecutor interface { + Execute(worktreePath string, task *types.Task) *ExecutionResult + ExecuteWithContext(ctx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult +} + +// ExecutorType represents the type of executor +type ExecutorType string + +const ( + ExecutorTypeClaudeCode ExecutorType = "claude-code" + ExecutorTypeOpenCode ExecutorType = "opencode" +) + +// NewAgentExecutor creates an executor based on configuration +func NewAgentExecutor(cfg *config.Config) (AgentExecutor, error) { + agentType := cfg.AgentType + + // Default to Claude Code if not specified + if agentType == "" { + agentType = config.AgentTypeClaudeCode + } + + switch agentType { + case config.AgentTypeOpenCode: + return NewOpenCodeExecutor(cfg) + case config.AgentTypeClaudeCode: + return NewClaudeExecutorFromConfig(cfg), nil + default: + return nil, fmt.Errorf("unknown agent type: %s (expected 'claude-code' or 'opencode')", cfg.AgentType) + } +} + +// GetExecutorType returns the executor type for a given agent type +func GetExecutorType(agentType config.AgentType) ExecutorType { + switch agentType { + case config.AgentTypeOpenCode: + return ExecutorTypeOpenCode + case config.AgentTypeClaudeCode: + return ExecutorTypeClaudeCode + default: + return ExecutorTypeClaudeCode + } +} + +// NewClaudeExecutorFromConfig creates a Claude executor from Config +func NewClaudeExecutorFromConfig(cfg *config.Config) *Executor { + return NewExecutor(cfg.ClaudePath, cfg.TaskTimeout) +} diff --git a/internal/executor/factory_test.go b/internal/executor/factory_test.go new file mode 100644 index 0000000..1247060 --- /dev/null +++ b/internal/executor/factory_test.go @@ -0,0 +1,112 @@ +package executor + +import ( + "fmt" + "testing" + "time" + + "github.com/cloud-shuttle/drover/internal/config" +) + +func TestNewAgentExecutor(t *testing.T) { + tests := []struct { + name string + cfg config.Config + wantType string + wantErr bool + }{ + { + name: "default claude-code", + cfg: config.Config{}, + wantType: "*executor.Executor", + wantErr: false, + }, + { + name: "explicit claude-code", + cfg: config.Config{ + AgentType: config.AgentTypeClaudeCode, + ClaudePath: "claude", + }, + wantType: "*executor.Executor", + wantErr: false, + }, + { + name: "opencode with model", + cfg: config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 60 * time.Minute, + }, + wantType: "*executor.OpenCodeExecutor", + wantErr: false, + }, + { + name: "opencode with server URL", + cfg: config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + OpenCodeURL: "http://localhost:4096", + TaskTimeout: 60 * time.Minute, + }, + wantType: "*executor.OpenCodeExecutor", + wantErr: false, + }, + { + name: "invalid agent type", + cfg: config.Config{AgentType: config.AgentType("invalid")}, + wantType: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + exec, err := NewAgentExecutor(&tt.cfg) + if (err != nil) != tt.wantErr { + t.Errorf("NewAgentExecutor() error = %v, wantErr %v", err, tt.wantErr) + return + } + if err == nil && tt.wantType != "" { + gotType := fmt.Sprintf("%T", exec) + if gotType != tt.wantType { + t.Errorf("NewAgentExecutor() type = %v, want %v", gotType, tt.wantType) + } + } + }) + } +} + +func TestGetExecutorType(t *testing.T) { + tests := []struct { + agentType config.AgentType + want ExecutorType + }{ + { + agentType: config.AgentTypeClaudeCode, + want: ExecutorTypeClaudeCode, + }, + { + agentType: config.AgentTypeOpenCode, + want: ExecutorTypeOpenCode, + }, + { + agentType: config.AgentType("invalid"), + want: ExecutorTypeClaudeCode, + }, + { + agentType: config.AgentType(""), + want: ExecutorTypeClaudeCode, + }, + } + + for _, tt := range tests { + t.Run(string(tt.agentType), func(t *testing.T) { + got := GetExecutorType(tt.agentType) + if got != tt.want { + t.Errorf("GetExecutorType(%v) = %v, want %v", tt.agentType, got, tt.want) + } + }) + } +} diff --git a/internal/executor/opencode.go b/internal/executor/opencode.go new file mode 100644 index 0000000..1c4781a --- /dev/null +++ b/internal/executor/opencode.go @@ -0,0 +1,248 @@ +package executor + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "time" + + "github.com/cloud-shuttle/drover/internal/config" + "github.com/cloud-shuttle/drover/pkg/telemetry" + "github.com/cloud-shuttle/drover/pkg/types" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// OpenCodeExecutor runs tasks using OpenCode CLI +type OpenCodeExecutor struct { + opencodePath string + model string + serverURL string + timeout time.Duration + verbose bool +} + +// OpenCodeEvent represents a JSON event from OpenCode CLI +type OpenCodeEvent struct { + Type string `json:"type"` + Content json.RawMessage `json:"content,omitempty"` + Text string `json:"text,omitempty"` + Title string `json:"title,omitempty"` +} + +// NewOpenCodeExecutor creates a new OpenCode executor from configuration +func NewOpenCodeExecutor(cfg *config.Config) (*OpenCodeExecutor, error) { + if err := config.ValidateOpenCodeModel(cfg.OpenCodeModel); err != nil { + return nil, err + } + + return &OpenCodeExecutor{ + opencodePath: cfg.OpenCodePath, + model: cfg.OpenCodeModel, + serverURL: cfg.OpenCodeURL, + timeout: cfg.TaskTimeout, + verbose: cfg.Verbose, + }, nil +} + +// NewOpenCodeExecutorRaw creates a new OpenCode executor with raw parameters (for testing) +func NewOpenCodeExecutorRaw(opencodePath, model string, timeout interface{}) *OpenCodeExecutor { + return &OpenCodeExecutor{ + opencodePath: opencodePath, + model: model, + timeout: timeout.(time.Duration), + verbose: false, + } +} + +func (e *OpenCodeExecutor) SetVerbose(v bool) { + e.verbose = v +} + +func (e *OpenCodeExecutor) Execute(worktreePath string, task *types.Task) *ExecutionResult { + ctx, cancel := context.WithTimeout(context.Background(), e.timeout) + defer cancel() + + return e.ExecuteWithContext(ctx, worktreePath, task) +} + +func (e *OpenCodeExecutor) ExecuteWithContext(ctx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult { + var agentCtx context.Context + var span trace.Span + if len(parentSpan) > 0 && parentSpan[0] != nil { + agentCtx, span = telemetry.StartAgentSpan(ctx, telemetry.AgentTypeOpenCode, "unknown", + attribute.String(telemetry.KeyTaskID, task.ID), + attribute.String(telemetry.KeyTaskTitle, task.Title), + ) + defer span.End() + } else { + agentCtx = ctx + span = trace.SpanFromContext(ctx) + } + + telemetry.RecordAgentPrompt(agentCtx, telemetry.AgentTypeOpenCode) + + prompt := e.buildPrompt(task) + + if e.verbose { + log.Printf("🤖 Sending prompt to OpenCode (model: %s)", e.model) + log.Printf("📝 Prompt preview: %s", truncateString(prompt, 200)) + } + + result := e.executeOpenCode(agentCtx, worktreePath, prompt) + + telemetry.RecordAgentDuration(agentCtx, telemetry.AgentTypeOpenCode, result.Duration) + + return result +} + +func (e *OpenCodeExecutor) buildPrompt(task *types.Task) string { + var prompt strings.Builder + + prompt.WriteString(fmt.Sprintf("Task: %s\n", task.Title)) + + if task.Description != "" { + prompt.WriteString(fmt.Sprintf("Description: %s\n", task.Description)) + } + + prompt.WriteString("\nPlease implement this task completely.") + + if len(task.EpicID) > 0 { + prompt.WriteString(fmt.Sprintf("\n\nThis task is part of epic: %s", task.EpicID)) + } + + return prompt.String() +} + +func (e *OpenCodeExecutor) executeOpenCode(ctx context.Context, worktreePath, prompt string) *ExecutionResult { + args := e.buildArgs(prompt) + + cmd := exec.CommandContext(ctx, e.opencodePath, args...) + cmd.Dir = worktreePath + + var outputBuf strings.Builder + cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf) + cmd.Stderr = os.Stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + fullOutput := outputBuf.String() + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return &ExecutionResult{ + Success: false, + Output: fullOutput, + Error: fmt.Errorf("opencode timed out after %v", duration), + Duration: duration, + } + } + return &ExecutionResult{ + Success: false, + Output: fullOutput, + Error: fmt.Errorf("opencode failed after %v: %w", duration, err), + Duration: duration, + } + } + + return &ExecutionResult{ + Success: true, + Output: fullOutput, + Error: nil, + Duration: duration, + } +} + +func (e *OpenCodeExecutor) buildArgs(prompt string) []string { + args := []string{"run", "--model", e.model, "--format", "json"} + + if e.serverURL != "" { + args = append(args, "--attach", e.serverURL) + } + + if e.verbose { + args = append(args, "--title", "drover-task") + } + + args = append(args, prompt) + + return args +} + +func (e *OpenCodeExecutor) executeWithStreaming(ctx context.Context, worktreePath, prompt string) *ExecutionResult { + args := e.buildArgsWithFiles(prompt) + + cmd := exec.CommandContext(ctx, e.opencodePath, args...) + cmd.Dir = worktreePath + + var outputBuf strings.Builder + cmd.Stdout = io.MultiWriter(os.Stdout, &outputBuf) + cmd.Stderr = os.Stderr + + start := time.Now() + err := cmd.Run() + duration := time.Since(start) + + fullOutput := outputBuf.String() + + if err != nil { + if ctx.Err() == context.DeadlineExceeded { + return &ExecutionResult{ + Success: false, + Output: fullOutput, + Error: fmt.Errorf("opencode timed out after %v", duration), + Duration: duration, + } + } + return &ExecutionResult{ + Success: false, + Output: fullOutput, + Error: fmt.Errorf("opencode failed after %v: %w", duration, err), + Duration: duration, + } + } + + return &ExecutionResult{ + Success: true, + Output: fullOutput, + Error: nil, + Duration: duration, + } +} + +func (e *OpenCodeExecutor) buildArgsWithFiles(prompt string) []string { + args := []string{"run", "--model", e.model, "--format", "json", "--title", "drover-task"} + + if e.serverURL != "" { + args = append(args, "--attach", e.serverURL) + } + + args = append(args, prompt) + + return args +} + +func parseOpenCodeEvent(line string) (*OpenCodeEvent, error) { + var event OpenCodeEvent + if err := json.Unmarshal([]byte(line), &event); err != nil { + return nil, fmt.Errorf("failed to parse OpenCode event: %w", err) + } + return &event, nil +} + +// CheckOpenCodeInstalled verifies OpenCode CLI is available +func CheckOpenCodeInstalled(path string) error { + cmd := exec.Command(path, "--version") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("opencode not found at %s: %w\n%s", path, err, output) + } + return nil +} diff --git a/internal/executor/opencode_test.go b/internal/executor/opencode_test.go new file mode 100644 index 0000000..63d5fa4 --- /dev/null +++ b/internal/executor/opencode_test.go @@ -0,0 +1,361 @@ +package executor + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/cloud-shuttle/drover/internal/config" + "github.com/cloud-shuttle/drover/pkg/types" +) + +func createMockOpenCodeScript(t *testing.T, dir string, exitCode int, sleepMs int, output string) string { + t.Helper() + scriptPath := filepath.Join(dir, "mock-opencode.sh") + script := fmt.Sprintf(`#!/bin/bash +sleep %d +echo '%s' +exit %d +`, sleepMs/1000, output, exitCode) + + if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { + t.Fatalf("Failed to create mock opencode script: %v", err) + } + return scriptPath +} + +func TestOpenCodeExecutor_Execute_Success(t *testing.T) { + tmpDir := t.TempDir() + mockOpenCode := createMockOpenCodeScript(t, tmpDir, 0, 100, `{"type":"text","text":"Task completed successfully"}`) + + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: mockOpenCode, + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewOpenCodeExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + task := &types.Task{ + ID: "task-456", + Title: "OpenCode Test Task", + Description: "Test Description", + } + + result := exec.Execute(tmpDir, task) + if !result.Success { + t.Errorf("Execute failed: %v", result.Error) + } + if result.Duration == 0 { + t.Errorf("Expected non-zero duration") + } +} + +func TestOpenCodeExecutor_Execute_Failure(t *testing.T) { + tmpDir := t.TempDir() + mockOpenCode := createMockOpenCodeScript(t, tmpDir, 1, 100, `{"type":"error","text":"Task failed"}`) + + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: mockOpenCode, + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewOpenCodeExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + task := &types.Task{ + ID: "task-789", + Title: "Failing Task", + } + + result := exec.Execute(tmpDir, task) + if result.Success { + t.Errorf("Expected execution to fail, but it succeeded") + } + if result.Error == nil { + t.Errorf("Expected error, got nil") + } +} + +func TestOpenCodeExecutor_ExecuteWithTimeout(t *testing.T) { + tmpDir := t.TempDir() + mockOpenCode := createMockOpenCodeScript(t, tmpDir, 0, 5000, `{"type":"text","text":"Slow task"}`) // Sleeps 5 seconds + + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: mockOpenCode, + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 100 * time.Millisecond, + } + + exec, err := NewAgentExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + task := &types.Task{ + ID: "task-timeout", + Title: "Slow Task", + } + + result := exec.Execute(tmpDir, task) + if result.Success { + t.Errorf("Expected timeout, but execution succeeded") + } + if result.Error == nil { + t.Errorf("Expected timeout error, got nil") + } + if result.Error != nil && !containsString(result.Error.Error(), "timed out") { + t.Errorf("Expected timeout error message, got: %v", result.Error) + } +} + +func TestOpenCodeExecutor_ModelValidation(t *testing.T) { + tests := []struct { + name string + model string + wantErr bool + errContains string + }{ + { + name: "valid anthropic model", + model: "anthropic/claude-sonnet-4-20250514", + wantErr: false, + }, + { + name: "valid openai model", + model: "openai/gpt-4o", + wantErr: false, + }, + { + name: "valid google model", + model: "google/gemini-2.5-pro", + wantErr: false, + }, + { + name: "missing provider", + model: "claude-sonnet-4-20250514", + wantErr: true, + errContains: "invalid OpenCode model format", + }, + { + name: "empty model", + model: "", + wantErr: true, + errContains: "invalid OpenCode model format", + }, + { + name: "too many slashes", + model: "provider/model/extra", + wantErr: true, + errContains: "invalid OpenCode model format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: tt.model, + TaskTimeout: 5 * time.Minute, + } + + _, err := NewOpenCodeExecutor(&cfg) + if (err != nil) != tt.wantErr { + t.Errorf("NewOpenCodeExecutor() error = %v, wantErr %v", err, tt.wantErr) + return + } + if tt.wantErr && tt.errContains != "" && err != nil { + if !containsString(err.Error(), tt.errContains) { + t.Errorf("error = %v, should contain %v", err, tt.errContains) + } + } + }) + } +} + +func TestCheckOpenCodeInstalled(t *testing.T) { + tests := []struct { + name string + path string + shouldExist bool + }{ + { + name: "non-existent path", + path: "/tmp/non-existent-opencode-12345", + shouldExist: false, + }, + { + name: "mock script success", + path: createMockOpenCodeScript(t, t.TempDir(), 0, 0, ""), + shouldExist: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := CheckOpenCodeInstalled(tt.path) + if tt.shouldExist && err != nil { + t.Errorf("CheckOpenCodeInstalled() expected success, got error: %v", err) + } + if !tt.shouldExist && err == nil { + t.Errorf("CheckOpenCodeInstalled() expected error, got nil") + } + }) + } +} + +func TestOpenCodeExecutor_BuildArgs(t *testing.T) { + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewAgentExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + opencodeExec := exec.(*OpenCodeExecutor) + prompt := "Test prompt" + + args := opencodeExec.buildArgs(prompt) + + if len(args) < 3 { + t.Errorf("Expected at least 3 args, got %d", len(args)) + } + + if args[0] != "run" { + t.Errorf("First arg should be 'run', got %s", args[0]) + } + + if args[1] != "--model" { + t.Errorf("Second arg should be '--model', got %s", args[1]) + } + + if args[2] != "anthropic/claude-sonnet-4-20250514" { + t.Errorf("Model arg incorrect, got %s", args[2]) + } + + foundModel := false + foundFormat := false + for i, arg := range args { + if arg == "--model" && i+1 < len(args) { + foundModel = true + } + if arg == "--format" && i+1 < len(args) && args[i+1] == "json" { + foundFormat = true + } + } + + if !foundModel { + t.Error("Missing --model flag in args") + } + if !foundFormat { + t.Error("Missing --format json flag in args") + } +} + +func TestOpenCodeExecutor_BuildArgs_WithServerURL(t *testing.T) { + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + OpenCodeURL: "http://localhost:4096", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewAgentExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + opencodeExec := exec.(*OpenCodeExecutor) + prompt := "Test prompt" + + args := opencodeExec.buildArgs(prompt) + + foundAttach := false + for i, arg := range args { + if arg == "--attach" && i+1 < len(args) && args[i+1] == "http://localhost:4096" { + foundAttach = true + break + } + } + + if !foundAttach { + t.Error("Missing --attach flag with server URL in args") + } +} + +func TestOpenCodeExecutor_SetVerbose(t *testing.T) { + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewOpenCodeExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + exec.SetVerbose(true) + exec.SetVerbose(false) +} + +func TestOpenCodeExecutor_PromptContent(t *testing.T) { + tmpDir := t.TempDir() + mockOpenCode := createMockOpenCodeScript(t, tmpDir, 0, 100, `{"type":"text","text":"Done"}`) + + cfg := config.Config{ + AgentType: config.AgentTypeOpenCode, + OpenCodePath: mockOpenCode, + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + TaskTimeout: 5 * time.Minute, + } + + exec, err := NewOpenCodeExecutor(&cfg) + if err != nil { + t.Fatalf("Failed to create executor: %v", err) + } + + task := &types.Task{ + ID: "task-epic", + Title: "Epic Feature Implementation", + Description: "Implement the new feature for the epic", + EpicID: "epic-123", + } + + result := exec.Execute(tmpDir, task) + if !result.Success { + t.Errorf("Execute failed: %v", result.Error) + } +} + +func containsString(haystack, needle string) bool { + return len(needle) <= len(haystack) && containsStringHelper(needle, haystack) +} + +func containsStringHelper(substr, str string) bool { + for i := 0; i <= len(str)-len(substr); i++ { + if str[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/pkg/telemetry/attributes.go b/pkg/telemetry/attributes.go index 2f281bb..94f38aa 100644 --- a/pkg/telemetry/attributes.go +++ b/pkg/telemetry/attributes.go @@ -6,43 +6,43 @@ import "go.opentelemetry.io/otel/attribute" // Semantic convention keys for Drover-specific attributes const ( // Project attributes - KeyProjectID = "drover.project.id" - KeyProjectPath = "drover.project.path" - KeyProjectName = "drover.project.name" + KeyProjectID = "drover.project.id" + KeyProjectPath = "drover.project.path" + KeyProjectName = "drover.project.name" // Workflow attributes - KeyWorkflowID = "drover.workflow.id" - KeyWorkflowType = "drover.workflow.type" + KeyWorkflowID = "drover.workflow.id" + KeyWorkflowType = "drover.workflow.type" // Task attributes - KeyTaskID = "drover.task.id" - KeyTaskTitle = "drover.task.title" - KeyTaskState = "drover.task.state" - KeyTaskPriority = "drover.task.priority" - KeyTaskAttempt = "drover.task.attempt" - KeyEpicID = "drover.epic.id" + KeyTaskID = "drover.task.id" + KeyTaskTitle = "drover.task.title" + KeyTaskState = "drover.task.state" + KeyTaskPriority = "drover.task.priority" + KeyTaskAttempt = "drover.task.attempt" + KeyEpicID = "drover.epic.id" // Worker attributes - KeyWorkerID = "drover.worker.id" - KeyWorkerCount = "drover.worker.count" + KeyWorkerID = "drover.worker.id" + KeyWorkerCount = "drover.worker.count" // Worktree attributes - KeyWorktreePath = "drover.worktree.path" - KeyWorktreeID = "drover.worktree.id" + KeyWorktreePath = "drover.worktree.path" + KeyWorktreeID = "drover.worktree.id" // Agent attributes - KeyAgentType = "drover.agent.type" - KeyAgentModel = "drover.agent.model" - KeyAgentPromptID = "drover.agent.prompt.id" + KeyAgentType = "drover.agent.type" + KeyAgentModel = "drover.agent.model" + KeyAgentPromptID = "drover.agent.prompt.id" // Blocker attributes - KeyBlockerType = "drover.blocker.type" - KeyBlockerTaskID = "drover.blocker.task_id" - KeyBlockerReason = "drover.blocker.reason" + KeyBlockerType = "drover.blocker.type" + KeyBlockerTaskID = "drover.blocker.task_id" + KeyBlockerReason = "drover.blocker.reason" // Error attributes - KeyErrorType = "drover.error.type" - KeyErrorCategory = "drover.error.category" + KeyErrorType = "drover.error.type" + KeyErrorCategory = "drover.error.category" ) // Common attribute key values @@ -54,14 +54,15 @@ const ( // Agent types AgentTypeClaudeCode = "claude-code" AgentTypeClaudeAPI = "claude-api" + AgentTypeOpenCode = "opencode" // Error categories - ErrorCategoryAgent = "agent" - ErrorCategoryGit = "git" - ErrorCategoryWorktree = "worktree" - ErrorCategoryDatabase = "database" - ErrorCategoryTimeout = "timeout" - ErrorCategoryUnknown = "unknown" + ErrorCategoryAgent = "agent" + ErrorCategoryGit = "git" + ErrorCategoryWorktree = "worktree" + ErrorCategoryDatabase = "database" + ErrorCategoryTimeout = "timeout" + ErrorCategoryUnknown = "unknown" ) // TaskAttrs returns a set of attributes for a task From 76087f256c92312f1340991907194377deedbee2 Mon Sep 17 00:00:00 2001 From: Joshua Skootsky Date: Mon, 12 Jan 2026 10:36:08 -0500 Subject: [PATCH 2/3] fix: handle empty git repos in worktree operations - Fix unexported field access in test by using Path() method - Add EnsureMainBranch() to handle empty repos by creating main branch with initial commit - Fix MergeToMain() to handle unrelated histories error by using reset --hard strategy - Add comprehensive tests for empty repo scenarios - Update Create() to check for empty repo before calling EnsureMainBranch --- internal/git/worktree.go | 212 ++++++++++++++++++++++---- internal/git/worktree_test.go | 270 ++++++++++++++++++++++++++++++++++ 2 files changed, 455 insertions(+), 27 deletions(-) diff --git a/internal/git/worktree.go b/internal/git/worktree.go index dc362bd..5cf5211 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -20,20 +20,71 @@ var mergeMutex sync.Mutex // WorktreeManager creates and manages git worktrees type WorktreeManager struct { - baseDir string // Base repository directory - worktreeDir string // Where worktrees are created (.drover/worktrees) - verbose bool // Enable verbose logging + baseDir string // Base repository directory + worktreeDir string // Where worktrees are created (.drover/worktrees) + verbose bool // Enable verbose logging + mergeTargetBranch string // Branch to merge changes to (default: "main") } // NewWorktreeManager creates a new worktree manager func NewWorktreeManager(baseDir, worktreeDir string) *WorktreeManager { return &WorktreeManager{ - baseDir: baseDir, - worktreeDir: worktreeDir, - verbose: false, + baseDir: baseDir, + worktreeDir: worktreeDir, + verbose: false, + mergeTargetBranch: "main", } } +// SetMergeTargetBranch sets the branch to merge changes to +func (wm *WorktreeManager) SetMergeTargetBranch(branch string) { + wm.mergeTargetBranch = branch +} + +// GetMergeTargetBranch returns the current merge target branch +func (wm *WorktreeManager) GetMergeTargetBranch() string { + if wm.mergeTargetBranch == "" { + return "main" + } + return wm.mergeTargetBranch +} + +// EnsureMainBranch ensures the repository has a main branch +// For empty repos, this creates an orphan main branch with an initial commit +func (wm *WorktreeManager) EnsureMainBranch() error { + targetBranch := wm.GetMergeTargetBranch() + + // Check if target branch exists + cmd := exec.Command("git", "rev-parse", "--verify", targetBranch) + cmd.Dir = wm.baseDir + err := cmd.Run() + + if err == nil { + // Target branch already exists + return nil + } + + // No target branch exists, create it from orphan + log.Printf("🌱 Creating %s branch in empty repo", targetBranch) + + cmd = exec.Command("git", "checkout", "--orphan", targetBranch) + cmd.Dir = wm.baseDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create %s branch: %w\n%s", targetBranch, err, output) + } + + // Create empty commit to establish branch + cmd = exec.Command("git", "commit", "--allow-empty", "-m", "Initial commit") + cmd.Dir = wm.baseDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to create initial commit: %w\n%s", err, output) + } + + log.Printf("✅ Created %s branch with initial commit", targetBranch) + + return nil +} + // SetVerbose enables or disables verbose logging func (wm *WorktreeManager) SetVerbose(v bool) { wm.verbose = v @@ -52,10 +103,33 @@ func (wm *WorktreeManager) Create(task *types.Task) (string, error) { // This handles stale worktrees from interrupted runs wm.cleanUpWorktree(task.ID) - // Create the worktree - cmd := exec.Command("git", "worktree", "add", worktreePath) + // Check if repository has any commits BEFORE ensuring main branch + // This determines if we need to handle empty repo specially + cmd := exec.Command("git", "rev-parse", "HEAD") cmd.Dir = wm.baseDir - output, err := cmd.CombinedOutput() + _, err := cmd.Output() + hadCommits := err == nil + + // Ensure main branch exists (handles empty repos) + if err := wm.EnsureMainBranch(); err != nil { + return "", fmt.Errorf("ensuring main branch: %w", err) + } + + var output []byte + if hadCommits { + // Repository had commits before, create worktree from HEAD + cmd = exec.Command("git", "worktree", "add", worktreePath) + cmd.Dir = wm.baseDir + output, err = cmd.CombinedOutput() + } else { + // Repository had no commits, EnsureMainBranch created main with initial commit + // Since main is already checked out in base directory, create orphan worktree + // The worktree will be on an orphan branch (not 'main') which is expected for empty repos + cmd = exec.Command("git", "worktree", "add", "--orphan", worktreePath) + cmd.Dir = wm.baseDir + output, err = cmd.CombinedOutput() + } + if err != nil { return "", fmt.Errorf("creating worktree: %w\n%s", err, output) } @@ -189,12 +263,70 @@ func (wm *WorktreeManager) MergeToMain(taskID string) error { mergeMutex.Lock() defer mergeMutex.Unlock() + // Check if repository was empty before EnsureMainBranch potentially creates a commit + cmd := exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = wm.baseDir + _, err := cmd.Output() + hadCommits := err == nil + + // Ensure target branch exists (safety check for empty repos) + if err := wm.EnsureMainBranch(); err != nil { + return fmt.Errorf("ensuring target branch: %w", err) + } + worktreePath := filepath.Join(wm.worktreeDir, taskID) branchName := fmt.Sprintf("drover-%s", taskID) + // If repo had no commits before EnsureMainBranch, we need special handling + // The worktree's commit becomes the initial commit for main + if !hadCommits { + // Stage all changes + cmd = exec.Command("git", "add", "-A") + cmd.Dir = worktreePath + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("staging changes: %w\n%s", err, output) + } + + // Create initial commit if needed + cmd = exec.Command("git", "commit", "-m", fmt.Sprintf("drover: Initial commit from %s", taskID)) + cmd.Dir = worktreePath + if output, err := cmd.CombinedOutput(); err != nil { + if !strings.Contains(string(output), "nothing to commit") { + return fmt.Errorf("creating initial commit: %w\n%s", err, output) + } + } + + // Get the commit hash (trim trailing newline) + cmd = exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = worktreePath + commitHashBytes, err := cmd.Output() + if err != nil { + return fmt.Errorf("getting commit hash: %w", err) + } + commitHash := strings.TrimSpace(string(commitHashBytes)) + + // Update main branch to point to the worktree's commit + targetBranch := wm.GetMergeTargetBranch() + cmd = exec.Command("git", "checkout", "-B", targetBranch) + cmd.Dir = wm.baseDir + output, err := cmd.CombinedOutput() + if err != nil && !strings.Contains(string(output), "fatal: invalid reference") && + !strings.Contains(string(output), "fatal: you must specify a branch name") { + return fmt.Errorf("creating %s branch: %w\n%s", targetBranch, err, output) + } + + cmd = exec.Command("git", "reset", "--hard", commitHash) + cmd.Dir = wm.baseDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("resetting main to commit: %w\n%s", err, output) + } + + return nil + } + + // Repository has commits, proceed with standard merge // Check if worktree has any commits ahead of main - // Run this from the base directory to ensure we have the main branch reference - cmd := exec.Command("git", "rev-list", "main.."+branchName, "--count") + cmd = exec.Command("git", "rev-list", "main.."+branchName, "--count") cmd.Dir = wm.baseDir output, err := cmd.Output() if err != nil { @@ -219,17 +351,43 @@ func (wm *WorktreeManager) MergeToMain(taskID string) error { return fmt.Errorf("creating branch: %w\n%s", err, output) } - // Switch to main in base repo - cmd = exec.Command("git", "checkout", "main") + // Check if branches are unrelated (common in empty repos where worktree was created before main had commits) + // Try the merge and check for "unrelated histories" error + targetBranch := wm.GetMergeTargetBranch() + cmd = exec.Command("git", "checkout", targetBranch) cmd.Dir = wm.baseDir if output, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("checking out main: %w\n%s", err, output) + return fmt.Errorf("checking out %s: %w\n%s", targetBranch, err, output) } - // Merge the branch cmd = exec.Command("git", "merge", "--no-ff", branchName, "-m", fmt.Sprintf("drover: Merge %s", taskID)) cmd.Dir = wm.baseDir - if output, err := cmd.CombinedOutput(); err != nil { + output, err = cmd.CombinedOutput() + if err != nil { + outputStr := string(output) + // Check for unrelated histories error + if strings.Contains(outputStr, "refusing to merge unrelated histories") { + // Branches are unrelated - this happens when worktree was created before main had commits + // Use reset --hard to adopt the worktree's commit as main's commit + + // Get the worktree's commit hash + cmd = exec.Command("git", "rev-parse", "HEAD") + cmd.Dir = worktreePath + commitHashBytes, err := cmd.Output() + if err != nil { + return fmt.Errorf("getting worktree commit hash: %w", err) + } + commitHash := strings.TrimSpace(string(commitHashBytes)) + + // Reset main to the worktree's commit + cmd = exec.Command("git", "reset", "--hard", commitHash) + cmd.Dir = wm.baseDir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("resetting main to worktree commit: %w\n%s", err, output) + } + + return nil + } return fmt.Errorf("merging: %w\n%s", err, output) } @@ -281,17 +439,17 @@ func (wm *WorktreeManager) Path(taskID string) string { // Directories to clean up aggressively (build artifacts and dependencies) // These can consume massive amounts of disk space var aggressiveCleanupDirs = []string{ - "target", // Rust/Cargo build artifacts - "node_modules", // Node.js dependencies - "vendor", // PHP/Go vendor directories - "__pycache__", // Python cache - ".venv", // Python virtual environments - "venv", // Python virtual environments - "dist", // Various build outputs - "build", // Various build outputs - ".next", // Next.js cache - ".nuxt", // Nuxt.js cache - "coverage", // Code coverage reports + "target", // Rust/Cargo build artifacts + "node_modules", // Node.js dependencies + "vendor", // PHP/Go vendor directories + "__pycache__", // Python cache + ".venv", // Python virtual environments + "venv", // Python virtual environments + "dist", // Various build outputs + "build", // Various build outputs + ".next", // Next.js cache + ".nuxt", // Nuxt.js cache + "coverage", // Code coverage reports } // RemoveAggressive removes a worktree and aggressively cleans up build artifacts diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go index 674eb47..e28e36e 100644 --- a/internal/git/worktree_test.go +++ b/internal/git/worktree_test.go @@ -425,3 +425,273 @@ func TestWorktreeManager_Cleanup(t *testing.T) { } } } + +// setupEmptyTestRepo creates a temporary empty git repository for testing +func setupEmptyTestRepo(t *testing.T) (string, *git.WorktreeManager) { + t.Helper() + + // Create temp directory + tmpDir := t.TempDir() + + // Initialize git repo (empty - no commits) + cmd := exec.Command("git", "init") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to init git repo: %v", err) + } + + // Configure git + cmd = exec.Command("git", "config", "user.email", "test@example.com") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to set git email: %v", err) + } + + cmd = exec.Command("git", "config", "user.name", "Test User") + cmd.Dir = tmpDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to set git name: %v", err) + } + + // Create worktree directory + worktreeDir := filepath.Join(tmpDir, ".drover", "worktrees") + + worktreeMgr := git.NewWorktreeManager(tmpDir, worktreeDir) + worktreeMgr.SetVerbose(true) + + return tmpDir, worktreeMgr +} + +// TestEnsureMainBranch_EmptyRepo verifies EnsureMainBranch creates main branch in empty repo +func TestEnsureMainBranch_EmptyRepo(t *testing.T) { + baseDir, wm := setupEmptyTestRepo(t) + + // Verify main branch doesn't exist initially + cmd := exec.Command("git", "rev-parse", "--verify", "main") + cmd.Dir = baseDir + err := cmd.Run() + if err == nil { + t.Fatal("Main branch should not exist in empty repo") + } + + // Ensure main branch + err = wm.EnsureMainBranch() + if err != nil { + t.Fatalf("EnsureMainBranch failed: %v", err) + } + + // Verify main branch exists and is current + cmd = exec.Command("git", "branch", "--show-current") + cmd.Dir = baseDir + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to get current branch: %v", err) + } + + branch := strings.TrimSpace(string(output)) + if branch != "main" { + t.Errorf("Expected current branch to be 'main', got '%s'", branch) + } + + // Verify main has at least one commit (the empty initial commit) + cmd = exec.Command("git", "rev-list", "--count", "main") + cmd.Dir = baseDir + output, err = cmd.Output() + if err != nil { + t.Fatalf("Failed to get commit count: %v", err) + } + + count := strings.TrimSpace(string(output)) + if count == "0" { + t.Error("Main branch should have at least 1 commit") + } +} + +// TestWorktreeManager_Create_EmptyRepo verifies worktree creation in empty repo +func TestWorktreeManager_Create_EmptyRepo(t *testing.T) { + baseDir, wm := setupEmptyTestRepo(t) + + task := &types.Task{ + ID: "task-empty-123", + Title: "Test Task in Empty Repo", + } + + // Create worktree - should handle empty repo automatically + worktreePath, err := wm.Create(task) + if err != nil { + t.Fatalf("Failed to create worktree in empty repo: %v", err) + } + + // Verify worktree directory exists + if _, err := os.Stat(worktreePath); os.IsNotExist(err) { + t.Error("Worktree directory was not created") + } + + // Verify main branch was created + cmd := exec.Command("git", "rev-parse", "--verify", "main") + cmd.Dir = baseDir + if err := cmd.Run(); err != nil { + t.Error("Main branch should exist after worktree creation") + } + + // Verify worktree is usable (can run git commands) + // For empty repos, worktree will be on an orphan branch which may not have commits yet + cmd = exec.Command("git", "status") + cmd.Dir = worktreePath + if err := cmd.Run(); err != nil { + t.Errorf("Worktree should be usable: %v", err) + } + + // Cleanup + wm.Remove(task.ID) +} + +// TestWorktreeManager_Commit_EmptyRepo verifies committing in empty repo worktree +func TestWorktreeManager_Commit_EmptyRepo(t *testing.T) { + _, wm := setupEmptyTestRepo(t) + + task := &types.Task{ + ID: "task-commit-empty", + Title: "Test Commit in Empty Repo", + } + + // Create worktree + worktreePath, err := wm.Create(task) + if err != nil { + t.Fatalf("Failed to create worktree: %v", err) + } + defer wm.Remove(task.ID) + + // Make changes and commit + testFile := filepath.Join(worktreePath, "new-file.txt") + if err := os.WriteFile(testFile, []byte("new content\n"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + hasChanges, err := wm.Commit(task.ID, "Add new file in empty repo") + if err != nil { + t.Fatalf("Failed to commit: %v", err) + } + if !hasChanges { + t.Error("Expected hasChanges to be true") + } + + // Verify commit was created in worktree + cmd := exec.Command("git", "log", "--oneline", "-1") + cmd.Dir = worktreePath + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to get log: %v", err) + } + + if !strings.Contains(string(output), "Add new file in empty repo") { + t.Errorf("Expected commit message not found: %s", output) + } +} + +// TestWorktreeManager_MergeToMain_EmptyRepo verifies merging from empty repo worktree +func TestWorktreeManager_MergeToMain_EmptyRepo(t *testing.T) { + baseDir, wm := setupEmptyTestRepo(t) + + task := &types.Task{ + ID: "task-merge-empty", + Title: "Test Merge from Empty Repo", + } + + // Create worktree + worktreePath, err := wm.Create(task) + if err != nil { + t.Fatalf("Failed to create worktree: %v", err) + } + defer wm.Remove(task.ID) + + // Make and commit changes + testFile := filepath.Join(worktreePath, "merged-file.txt") + if err := os.WriteFile(testFile, []byte("merged content\n"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + _, err = wm.Commit(task.ID, "Add file to merge") + if err != nil { + t.Fatalf("Failed to commit: %v", err) + } + + // Merge to main + err = wm.MergeToMain(task.ID) + if err != nil { + t.Fatalf("Failed to merge to main: %v", err) + } + + // Verify file exists in main + mainFile := filepath.Join(baseDir, "merged-file.txt") + if _, err := os.Stat(mainFile); os.IsNotExist(err) { + t.Error("File was not merged to main branch") + } + + // Verify commit is in main's history + cmd := exec.Command("git", "log", "--oneline") + cmd.Dir = baseDir + output, err := cmd.Output() + if err != nil { + t.Fatalf("Failed to get log: %v", err) + } + + if !strings.Contains(string(output), "Add file to merge") { + t.Errorf("Merge commit not found in main history: %s", output) + } +} + +// TestWorktreeManager_MergeToMain_WithoutCreate verifies MergeToMain works even if Create wasn't called +// This tests the EnsureMainBranch safety check +func TestWorktreeManager_MergeToMain_WithoutCreate(t *testing.T) { + baseDir, wm := setupEmptyTestRepo(t) + + taskID := "task-merge-no-create" + worktreePath := wm.Path(taskID) + + // Manually create worktree without using wm.Create() + // This simulates a scenario where MergeToMain is called independently + cmd := exec.Command("git", "worktree", "add", "--orphan", worktreePath) + cmd.Dir = baseDir + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to create worktree: %v", err) + } + defer wm.Remove(taskID) + + // Make and commit changes in the worktree + testFile := filepath.Join(worktreePath, "manual-file.txt") + if err := os.WriteFile(testFile, []byte("manual content\n"), 0644); err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + cmd = exec.Command("git", "add", "manual-file.txt") + cmd.Dir = worktreePath + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to stage changes: %v", err) + } + + cmd = exec.Command("git", "commit", "-m", "Manual commit") + cmd.Dir = worktreePath + if err := cmd.Run(); err != nil { + t.Fatalf("Failed to commit: %v", err) + } + + // Merge to main - should create main branch automatically + err := wm.MergeToMain(taskID) + if err != nil { + t.Fatalf("MergeToMain failed without Create being called: %v", err) + } + + // Verify main branch exists and has the file + mainFile := filepath.Join(baseDir, "manual-file.txt") + if _, err := os.Stat(mainFile); os.IsNotExist(err) { + t.Error("File was not merged to main branch") + } + + // Verify main branch was created + cmd = exec.Command("git", "rev-parse", "--verify", "main") + cmd.Dir = baseDir + if err := cmd.Run(); err != nil { + t.Error("Main branch was not created by MergeToMain") + } +} From a9cf1dcfe20d54c9345ac6fab30e3a38291f3450 Mon Sep 17 00:00:00 2001 From: Joshua Skootsky Date: Mon, 12 Jan 2026 10:50:49 -0500 Subject: [PATCH 3/3] feat: add telemetry spans for git worktree operations - Add SpanGitEnsureMain and SpanGitWorktreeMerge span constants - Add StartGitSpan helper function for git operations - Add context parameter to EnsureMainBranch and MergeToMain - Track repo.had_commits and task.id attributes for empty repo detection --- README.md | 64 +++++++++++++++++++++++++++++- internal/config/config.go | 43 +++++++++++--------- internal/executor/factory.go | 1 + internal/executor/opencode.go | 7 ++++ internal/git/worktree.go | 19 +++++++-- internal/git/worktree_test.go | 13 +++--- internal/workflow/dbos_workflow.go | 38 +++++++++++------- internal/workflow/orchestrator.go | 38 +++++++++++------- pkg/telemetry/tracer.go | 25 +++++++----- 9 files changed, 178 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index c091bdd..3ed365f 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,33 @@ drover run --epic epic-a1b2 - Go 1.22+ - Git -- [Claude Code CLI](https://claude.ai/code) installed and authenticated +- AI Agent CLI (Claude Code or OpenCode) installed and authenticated - PostgreSQL (production) or SQLite (local dev, default) +#### Supported AI Agents + +Drover supports two AI agents for task execution: + +**Claude Code (Default)** +```bash +# Install +curl -sSL https://claude.com/install | sh + +# Authenticate +claude auth login +``` + +**OpenCode (Alternative)** +```bash +# Install +curl -fsSL https://opencode.ai/install | bash + +# Authenticate (supports Anthropic, OpenAI, Google, etc.) +opencode auth login +``` + +Both agents work identically with Drover. Use whichever you prefer. + ### From Source ```bash @@ -83,6 +107,8 @@ go install github.com/cloud-shuttle/drover@latest | `drover run` | Execute all tasks to completion | | `drover run --workers 8` | Run with 8 parallel agents | | `drover run --epic ` | Run only tasks in specific epic | +| `drover run --agent-type opencode` | Run with OpenCode instead of Claude Code | +| `drover run --opencode-model anthropic/claude-sonnet-4-20250514` | Specify OpenCode model | | `drover add ` | Add a new task | | `drover add <title> --parent <id>` | Add a sub-task to parent | | `drover add "task-123.N title"` | Add sub-task with hierarchical syntax | @@ -104,8 +130,42 @@ export DROVER_DATABASE_URL="postgresql://localhost/drover" # Or use SQLite explicitly export DROVER_DATABASE_URL="sqlite:///.drover.db" + +# Agent selection (default: claude-code) +export DROVER_AGENT_TYPE="opencode" # "claude-code" or "opencode" +export DROVER_OPENCODE_MODEL="anthropic/claude-sonnet-4-20250514" +export DROVER_OPENCODE_PATH="/usr/local/bin/opencode" +export DROVER_OPENCODE_URL="http://localhost:4096" # Remote server for parallel execution ``` +### Agent Selection + +Drover defaults to Claude Code but supports OpenCode as an alternative. You can switch agents via flags or environment variables: + +```bash +# Use Claude Code (default) +drover run + +# Use OpenCode with specific model +drover run --agent-type opencode --opencode-model anthropic/claude-sonnet-4-20250514 + +# Use OpenCode with remote server (for better parallel execution) +drover run --agent-type opencode --opencode-url http://localhost:4096 +``` + +#### OpenCode Model Format + +OpenCode uses `provider/model` format for model selection. Some common providers: + +| Provider | Example Model | +|----------|---------------| +| Anthropic | `anthropic/claude-sonnet-4-20250514` | +| OpenAI | `openai/gpt-4o` | +| Google | `google/gemini-2.5-pro` | +| OpenCode (free) | `opencode/grok-code` | + +Use `opencode models --refresh` to list all available models from your configured providers. + ### Observability Drover includes built-in OpenTelemetry observability for production monitoring: @@ -349,7 +409,7 @@ Drover is built on a pure Go stack: | CLI | Cobra | Command-line interface | | Workflows | DBOS Go | Durable execution | | Database | PostgreSQL/SQLite | State persistence | -| AI Agent | Claude Code | Task execution | +| AI Agent | Claude Code / OpenCode | Task execution | | Isolation | Git Worktrees | Parallel workspaces | | Observability | OpenTelemetry | Traces & metrics | diff --git a/internal/config/config.go b/internal/config/config.go index 99304d8..77f3b6c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,11 +39,12 @@ type Config struct { WorktreeDir string // Agent settings - AgentType AgentType // "claude-code" or "opencode" - ClaudePath string // Path to Claude CLI (default: "claude") - OpenCodePath string // Path to OpenCode CLI (default: "opencode") - OpenCodeModel string // Model in format "provider/model" (e.g., "anthropic/claude-sonnet-4-20250514") - OpenCodeURL string // Optional remote OpenCode server URL + AgentType AgentType // "claude-code" or "opencode" + ClaudePath string // Path to Claude CLI (default: "claude") + OpenCodePath string // Path to OpenCode CLI (default: "opencode") + OpenCodeModel string // Model in format "provider/model" (e.g., "anthropic/claude-sonnet-4-20250514") + OpenCodeURL string // Optional remote OpenCode server URL + MergeTargetBranch string // Branch to merge changes to (default: "main") // Beads sync settings AutoSyncBeads bool @@ -58,20 +59,21 @@ type Config struct { // Load loads configuration from environment and defaults func Load() (*Config, error) { cfg := &Config{ - DatabaseURL: defaultDatabaseURL(), - Workers: 3, - TaskTimeout: 60 * time.Minute, - MaxTaskAttempts: 3, - ClaimTimeout: 5 * time.Minute, - StallTimeout: 5 * time.Minute, - PollInterval: 2 * time.Second, - AutoUnblock: true, - WorktreeDir: ".drover/worktrees", - ClaudePath: "claude", - OpenCodePath: "opencode", - OpenCodeModel: "anthropic/claude-sonnet-4-20250514", - AgentType: AgentTypeClaudeCode, - AutoSyncBeads: false, + DatabaseURL: defaultDatabaseURL(), + Workers: 3, + TaskTimeout: 60 * time.Minute, + MaxTaskAttempts: 3, + ClaimTimeout: 5 * time.Minute, + StallTimeout: 5 * time.Minute, + PollInterval: 2 * time.Second, + AutoUnblock: true, + WorktreeDir: ".drover/worktrees", + ClaudePath: "claude", + OpenCodePath: "opencode", + OpenCodeModel: "anthropic/claude-sonnet-4-20250514", + AgentType: AgentTypeClaudeCode, + MergeTargetBranch: "main", + AutoSyncBeads: false, } // Environment overrides @@ -102,6 +104,9 @@ func Load() (*Config, error) { if v := os.Getenv("DROVER_OPENCODE_URL"); v != "" { cfg.OpenCodeURL = v } + if v := os.Getenv("DROVER_MERGE_TARGET_BRANCH"); v != "" { + cfg.MergeTargetBranch = v + } return cfg, nil } diff --git a/internal/executor/factory.go b/internal/executor/factory.go index 86ef146..6bdc671 100644 --- a/internal/executor/factory.go +++ b/internal/executor/factory.go @@ -12,6 +12,7 @@ import ( // AgentExecutor defines the interface for executing AI agent tasks type AgentExecutor interface { Execute(worktreePath string, task *types.Task) *ExecutionResult + ExecuteWithTimeout(parentCtx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult ExecuteWithContext(ctx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult } diff --git a/internal/executor/opencode.go b/internal/executor/opencode.go index 1c4781a..6a0e00d 100644 --- a/internal/executor/opencode.go +++ b/internal/executor/opencode.go @@ -71,6 +71,13 @@ func (e *OpenCodeExecutor) Execute(worktreePath string, task *types.Task) *Execu return e.ExecuteWithContext(ctx, worktreePath, task) } +func (e *OpenCodeExecutor) ExecuteWithTimeout(parentCtx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult { + ctx, cancel := context.WithTimeout(parentCtx, e.timeout) + defer cancel() + + return e.ExecuteWithContext(ctx, worktreePath, task, parentSpan...) +} + func (e *OpenCodeExecutor) ExecuteWithContext(ctx context.Context, worktreePath string, task *types.Task, parentSpan ...trace.Span) *ExecutionResult { var agentCtx context.Context var span trace.Span diff --git a/internal/git/worktree.go b/internal/git/worktree.go index 5cf5211..83f6ebb 100644 --- a/internal/git/worktree.go +++ b/internal/git/worktree.go @@ -2,6 +2,7 @@ package git import ( + "context" "fmt" "io/fs" "log" @@ -11,7 +12,9 @@ import ( "strings" "sync" + "github.com/cloud-shuttle/drover/pkg/telemetry" "github.com/cloud-shuttle/drover/pkg/types" + "go.opentelemetry.io/otel/attribute" ) // Global mutex to serialize MergeToMain operations across all workers @@ -51,8 +54,12 @@ func (wm *WorktreeManager) GetMergeTargetBranch() string { // EnsureMainBranch ensures the repository has a main branch // For empty repos, this creates an orphan main branch with an initial commit -func (wm *WorktreeManager) EnsureMainBranch() error { +func (wm *WorktreeManager) EnsureMainBranch(ctx context.Context) error { + ctx, span := telemetry.StartGitSpan(ctx, telemetry.SpanGitEnsureMain) + defer span.End() + targetBranch := wm.GetMergeTargetBranch() + span.SetAttributes(attribute.String("git.branch", targetBranch)) // Check if target branch exists cmd := exec.Command("git", "rev-parse", "--verify", targetBranch) @@ -111,7 +118,7 @@ func (wm *WorktreeManager) Create(task *types.Task) (string, error) { hadCommits := err == nil // Ensure main branch exists (handles empty repos) - if err := wm.EnsureMainBranch(); err != nil { + if err := wm.EnsureMainBranch(context.Background()); err != nil { return "", fmt.Errorf("ensuring main branch: %w", err) } @@ -258,7 +265,10 @@ func (wm *WorktreeManager) Commit(taskID, message string) (bool, error) { } // MergeToMain merges the worktree changes to main branch -func (wm *WorktreeManager) MergeToMain(taskID string) error { +func (wm *WorktreeManager) MergeToMain(ctx context.Context, taskID string) error { + ctx, span := telemetry.StartGitSpan(ctx, telemetry.SpanGitWorktreeMerge, attribute.String("task.id", taskID)) + defer span.End() + // Serialize merge operations to prevent git index lock conflicts mergeMutex.Lock() defer mergeMutex.Unlock() @@ -268,9 +278,10 @@ func (wm *WorktreeManager) MergeToMain(taskID string) error { cmd.Dir = wm.baseDir _, err := cmd.Output() hadCommits := err == nil + span.SetAttributes(attribute.Bool("repo.had_commits", hadCommits)) // Ensure target branch exists (safety check for empty repos) - if err := wm.EnsureMainBranch(); err != nil { + if err := wm.EnsureMainBranch(context.TODO()); err != nil { return fmt.Errorf("ensuring target branch: %w", err) } diff --git a/internal/git/worktree_test.go b/internal/git/worktree_test.go index e28e36e..ccde634 100644 --- a/internal/git/worktree_test.go +++ b/internal/git/worktree_test.go @@ -2,6 +2,7 @@ package git_test import ( + "context" "os" "os/exec" "path/filepath" @@ -267,12 +268,12 @@ func TestWorktreeManager_MergeToMain_WithChanges(t *testing.T) { } // Merge to main - err = wm.MergeToMain(task.ID) + err = wm.MergeToMain(context.Background(), task.ID) if err != nil { t.Fatalf("Failed to merge to main: %v", err) } - // Verify the file exists in main + // Verify file exists in main mainFile := filepath.Join(baseDir, "merge-test.txt") if _, err := os.Stat(mainFile); os.IsNotExist(err) { t.Error("File was not merged to main branch") @@ -308,7 +309,7 @@ func TestWorktreeManager_MergeToMain_NoChanges(t *testing.T) { defer wm.Remove(task.ID) // Merge should succeed even with no changes - err = wm.MergeToMain(task.ID) + err = wm.MergeToMain(context.Background(), task.ID) if err != nil { t.Fatalf("Merge with no changes should succeed, got: %v", err) } @@ -475,7 +476,7 @@ func TestEnsureMainBranch_EmptyRepo(t *testing.T) { } // Ensure main branch - err = wm.EnsureMainBranch() + err = wm.EnsureMainBranch(context.Background()) if err != nil { t.Fatalf("EnsureMainBranch failed: %v", err) } @@ -617,7 +618,7 @@ func TestWorktreeManager_MergeToMain_EmptyRepo(t *testing.T) { } // Merge to main - err = wm.MergeToMain(task.ID) + err = wm.MergeToMain(context.Background(), task.ID) if err != nil { t.Fatalf("Failed to merge to main: %v", err) } @@ -677,7 +678,7 @@ func TestWorktreeManager_MergeToMain_WithoutCreate(t *testing.T) { } // Merge to main - should create main branch automatically - err := wm.MergeToMain(taskID) + err := wm.MergeToMain(context.Background(), taskID) if err != nil { t.Fatalf("MergeToMain failed without Create being called: %v", err) } diff --git a/internal/workflow/dbos_workflow.go b/internal/workflow/dbos_workflow.go index 54e0d4e..55859a9 100644 --- a/internal/workflow/dbos_workflow.go +++ b/internal/workflow/dbos_workflow.go @@ -62,15 +62,15 @@ type QueueStats struct { // DBOSOrchestrator manages workflow execution using DBOS type DBOSOrchestrator struct { - config *config.Config - git *git.WorktreeManager - executor *executor.Executor - dbosCtx dbos.DBOSContext - queue dbos.WorkflowQueue - store *db.Store // SQLite store for worktree tracking - verbose bool - dependencyMap map[string][]string // taskID -> list of dependent task IDs - dependencyMu sync.RWMutex + config *config.Config + git *git.WorktreeManager + executor executor.AgentExecutor + dbosCtx dbos.DBOSContext + queue dbos.WorkflowQueue + store *db.Store // SQLite store for worktree tracking + verbose bool + dependencyMap map[string][]string // taskID -> list of dependent task IDs + dependencyMu sync.RWMutex } // NewDBOSOrchestrator creates a new DBOS-based orchestrator @@ -81,12 +81,20 @@ func NewDBOSOrchestrator(cfg *config.Config, dbosCtx dbos.DBOSContext, projectDi ) gitMgr.SetVerbose(cfg.Verbose) - exec := executor.NewExecutor(cfg.ClaudePath, cfg.TaskTimeout) - exec.SetVerbose(cfg.Verbose) + exec, err := executor.NewAgentExecutor(cfg) + if err != nil { + return nil, fmt.Errorf("creating agent executor: %w", err) + } - // Check Claude is installed - if err := executor.CheckClaudeInstalled(cfg.ClaudePath); err != nil { - return nil, fmt.Errorf("checking claude: %w", err) + // Check agent is installed based on type + if cfg.AgentType == config.AgentTypeClaudeCode { + if err := executor.CheckClaudeInstalled(cfg.ClaudePath); err != nil { + return nil, fmt.Errorf("checking claude: %w", err) + } + } else if cfg.AgentType == config.AgentTypeOpenCode { + if err := executor.CheckOpenCodeInstalled(cfg.OpenCodePath); err != nil { + return nil, fmt.Errorf("checking opencode: %w", err) + } } // Create a workflow queue for parallel task execution @@ -554,7 +562,7 @@ func (o *DBOSOrchestrator) commitChangesStep(ctx context.Context, task TaskInput // mergeToMainStep merges the worktree changes to main branch // This is a step function - must accept only context.Context func (o *DBOSOrchestrator) mergeToMainStep(ctx context.Context, taskID string) (bool, error) { - err := o.git.MergeToMain(taskID) + err := o.git.MergeToMain(ctx, taskID) if err != nil { return false, fmt.Errorf("merging to main: %w", err) } diff --git a/internal/workflow/orchestrator.go b/internal/workflow/orchestrator.go index c80bbb0..4db8d4a 100644 --- a/internal/workflow/orchestrator.go +++ b/internal/workflow/orchestrator.go @@ -27,14 +27,14 @@ import ( // Orchestrator manages the main execution loop type Orchestrator struct { - config *config.Config - store *db.Store - git *git.WorktreeManager - executor *executor.Executor - workers int - verbose bool // Enable verbose logging - projectDir string // Project directory for beads sync - epicID string // Optional epic filter for task execution + config *config.Config + store *db.Store + git *git.WorktreeManager + executor executor.AgentExecutor + workers int + verbose bool // Enable verbose logging + projectDir string // Project directory for beads sync + epicID string // Optional epic filter for task execution } // NewOrchestrator creates a new workflow orchestrator @@ -45,12 +45,20 @@ func NewOrchestrator(cfg *config.Config, store *db.Store, projectDir string) (*O ) gitMgr.SetVerbose(cfg.Verbose) - exec := executor.NewExecutor(cfg.ClaudePath, cfg.TaskTimeout) - exec.SetVerbose(cfg.Verbose) + exec, err := executor.NewAgentExecutor(cfg) + if err != nil { + return nil, fmt.Errorf("creating agent executor: %w", err) + } - // Check Claude is installed - if err := executor.CheckClaudeInstalled(cfg.ClaudePath); err != nil { - return nil, fmt.Errorf("checking claude: %w", err) + // Check agent is installed based on type + if cfg.AgentType == config.AgentTypeClaudeCode { + if err := executor.CheckClaudeInstalled(cfg.ClaudePath); err != nil { + return nil, fmt.Errorf("checking claude: %w", err) + } + } else if cfg.AgentType == config.AgentTypeOpenCode { + if err := executor.CheckOpenCodeInstalled(cfg.OpenCodePath); err != nil { + return nil, fmt.Errorf("checking opencode: %w", err) + } } return &Orchestrator{ @@ -269,7 +277,7 @@ func (o *Orchestrator) executeTask(workerID int, task *types.Task) { } // Try to merge to main (if there are changes to merge) - if err := o.git.MergeToMain(task.ID); err != nil { + if err := o.git.MergeToMain(taskCtx, task.ID); err != nil { // Log merge error but continue - task completed successfully even if merge failed log.Printf("⚠️ Task %s completed but merge failed: %v", task.ID, err) telemetry.RecordError(taskSpan, err, "MergeFailed", "git") @@ -367,7 +375,7 @@ func (o *Orchestrator) executeSubTasks(workerID int, parentTask *types.Task) boo } // Try to merge to main - if err := o.git.MergeToMain(subTask.ID); err != nil { + if err := o.git.MergeToMain(taskCtx, subTask.ID); err != nil { log.Printf("⚠️ Sub-task %s completed but merge failed: %v", subTask.ID, err) telemetry.RecordError(taskSpan, err, "MergeFailed", "git") } diff --git a/pkg/telemetry/tracer.go b/pkg/telemetry/tracer.go index 03f16a6..c6e9eac 100644 --- a/pkg/telemetry/tracer.go +++ b/pkg/telemetry/tracer.go @@ -29,9 +29,9 @@ const ( SpanTaskRetry = "drover.task.retry" // Worker spans - SpanWorkerRun = "drover.worker.run" - SpanWorkerPoll = "drover.worker.poll" - SpanWorkerLoop = "drover.worker.loop" + SpanWorkerRun = "drover.worker.run" + SpanWorkerPoll = "drover.worker.poll" + SpanWorkerLoop = "drover.worker.loop" // Worktree spans SpanWorktreeCreate = "drover.worktree.create" @@ -39,9 +39,9 @@ const ( SpanWorktreeCleanup = "drover.worktree.cleanup" // Agent spans - SpanAgentExecute = "drover.agent.execute" - SpanAgentPrompt = "drover.agent.prompt" - SpanAgentToolCall = "drover.agent.tool_call" + SpanAgentExecute = "drover.agent.execute" + SpanAgentPrompt = "drover.agent.prompt" + SpanAgentToolCall = "drover.agent.tool_call" // Blocker spans SpanBlockerDetect = "drover.blocker.detect" @@ -49,9 +49,11 @@ const ( SpanBlockerCreateFix = "drover.blocker.create_fix_task" // Git spans - SpanGitCommit = "drover.git.commit" - SpanGitPush = "drover.git.push" - SpanGitMerge = "drover.git.merge" + SpanGitCommit = "drover.git.commit" + SpanGitPush = "drover.git.push" + SpanGitMerge = "drover.git.merge" + SpanGitEnsureMain = "drover.git.ensure_main" + SpanGitWorktreeMerge = "drover.git.worktree_merge" ) // StartWorkflowSpan starts a span for workflow execution @@ -89,6 +91,11 @@ func StartWorktreeSpan(ctx context.Context, name, worktreePath string, attrs ... return tracer.Start(ctx, name, trace.WithAttributes(attrs...)) } +// StartGitSpan starts a span for git operations +func StartGitSpan(ctx context.Context, name string, attrs ...attribute.KeyValue) (context.Context, trace.Span) { + return tracer.Start(ctx, name, trace.WithAttributes(attrs...)) +} + // RecordError records an error on a span with optional error type/category func RecordError(span trace.Span, err error, errorType, errorCategory string) { if err == nil {