diff options
| -rw-r--r-- | internal/executor/claude.go | 27 | ||||
| -rw-r--r-- | internal/executor/claude_test.go | 79 | ||||
| -rw-r--r-- | internal/executor/executor.go | 68 | ||||
| -rw-r--r-- | internal/executor/preamble.go | 23 |
4 files changed, 190 insertions, 7 deletions
diff --git a/internal/executor/claude.go b/internal/executor/claude.go index 8901d35..8486427 100644 --- a/internal/executor/claude.go +++ b/internal/executor/claude.go @@ -20,6 +20,7 @@ type ClaudeRunner struct { BinaryPath string // defaults to "claude" Logger *slog.Logger LogDir string // base directory for execution logs + APIURL string // base URL of the Claudomator API, passed to subprocesses } func (r *ClaudeRunner) binaryPath() string { @@ -34,6 +35,10 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi args := r.buildArgs(t) cmd := exec.CommandContext(ctx, r.binaryPath(), args...) + cmd.Env = append(os.Environ(), + "CLAUDOMATOR_API_URL="+r.APIURL, + "CLAUDOMATOR_TASK_ID="+t.ID, + ) if t.Claude.WorkingDir != "" { cmd.Dir = t.Claude.WorkingDir } @@ -96,8 +101,26 @@ func (r *ClaudeRunner) Run(ctx context.Context, t *task.Task, e *storage.Executi } func (r *ClaudeRunner) buildArgs(t *task.Task) []string { + instructions := t.Claude.Instructions + allowedTools := t.Claude.AllowedTools + + if !t.Claude.SkipPlanning { + instructions = withPlanningPreamble(instructions) + // Ensure Bash is available so the agent can POST subtasks. + hasBash := false + for _, tool := range allowedTools { + if tool == "Bash" { + hasBash = true + break + } + } + if !hasBash { + allowedTools = append(allowedTools, "Bash") + } + } + args := []string{ - "-p", t.Claude.Instructions, + "-p", instructions, "--output-format", "stream-json", "--verbose", } @@ -114,7 +137,7 @@ func (r *ClaudeRunner) buildArgs(t *task.Task) []string { if t.Claude.SystemPromptAppend != "" { args = append(args, "--append-system-prompt", t.Claude.SystemPromptAppend) } - for _, tool := range t.Claude.AllowedTools { + for _, tool := range allowedTools { args = append(args, "--allowedTools", tool) } for _, tool := range t.Claude.DisallowedTools { diff --git a/internal/executor/claude_test.go b/internal/executor/claude_test.go index 6745957..fa81b09 100644 --- a/internal/executor/claude_test.go +++ b/internal/executor/claude_test.go @@ -1,6 +1,7 @@ package executor import ( + "strings" "testing" "github.com/thepeterstone/claudomator/internal/task" @@ -12,6 +13,7 @@ func TestClaudeRunner_BuildArgs_BasicTask(t *testing.T) { Claude: task.ClaudeConfig{ Instructions: "fix the bug", Model: "sonnet", + SkipPlanning: true, }, } @@ -41,6 +43,7 @@ func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) { DisallowedTools: []string{"Write"}, ContextFiles: []string{"/src"}, AdditionalArgs: []string{"--verbose"}, + SkipPlanning: true, }, } @@ -72,7 +75,10 @@ func TestClaudeRunner_BuildArgs_FullConfig(t *testing.T) { func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) { r := &ClaudeRunner{} tk := &task.Task{ - Claude: task.ClaudeConfig{Instructions: "do something"}, + Claude: task.ClaudeConfig{ + Instructions: "do something", + SkipPlanning: true, + }, } args := r.buildArgs(tk) @@ -89,6 +95,77 @@ func TestClaudeRunner_BuildArgs_AlwaysIncludesVerbose(t *testing.T) { } } +func TestClaudeRunner_BuildArgs_PreamblePrepended(t *testing.T) { + r := &ClaudeRunner{} + tk := &task.Task{ + Claude: task.ClaudeConfig{ + Instructions: "fix the bug", + SkipPlanning: false, + }, + } + + args := r.buildArgs(tk) + + // The -p value should start with the preamble and end with the original instructions. + if len(args) < 2 || args[0] != "-p" { + t.Fatalf("expected -p as first arg, got: %v", args) + } + if !strings.HasPrefix(args[1], planningPreamble) { + t.Errorf("instructions should start with planning preamble") + } + if !strings.HasSuffix(args[1], "fix the bug") { + t.Errorf("instructions should end with original instructions") + } +} + +func TestClaudeRunner_BuildArgs_PreambleAddsBash(t *testing.T) { + r := &ClaudeRunner{} + tk := &task.Task{ + Claude: task.ClaudeConfig{ + Instructions: "do work", + AllowedTools: []string{"Read"}, + SkipPlanning: false, + }, + } + + args := r.buildArgs(tk) + + // Bash should be appended to allowed tools. + foundBash := false + for i, a := range args { + if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { + foundBash = true + } + } + if !foundBash { + t.Errorf("Bash should be added to --allowedTools when preamble is active: %v", args) + } +} + +func TestClaudeRunner_BuildArgs_PreambleBashNotDuplicated(t *testing.T) { + r := &ClaudeRunner{} + tk := &task.Task{ + Claude: task.ClaudeConfig{ + Instructions: "do work", + AllowedTools: []string{"Bash", "Read"}, + SkipPlanning: false, + }, + } + + args := r.buildArgs(tk) + + // Count Bash occurrences in --allowedTools values. + bashCount := 0 + for i, a := range args { + if a == "--allowedTools" && i+1 < len(args) && args[i+1] == "Bash" { + bashCount++ + } + } + if bashCount != 1 { + t.Errorf("Bash should appear exactly once in --allowedTools, got %d: %v", bashCount, args) + } +} + func TestClaudeRunner_BinaryPath_Default(t *testing.T) { r := &ClaudeRunner{} if r.binaryPath() != "claude" { diff --git a/internal/executor/executor.go b/internal/executor/executor.go index c8130cc..68ebdf3 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -78,6 +78,33 @@ func (p *Pool) ActiveCount() int { } func (p *Pool) execute(ctx context.Context, t *task.Task) { + defer func() { + p.mu.Lock() + p.active-- + p.mu.Unlock() + }() + + // Wait for all dependencies to complete before starting execution. + if len(t.DependsOn) > 0 { + if err := p.waitForDependencies(ctx, t); err != nil { + now := time.Now().UTC() + exec := &storage.Execution{ + ID: uuid.New().String(), + TaskID: t.ID, + StartTime: now, + EndTime: now, + Status: "FAILED", + ErrorMsg: err.Error(), + } + if createErr := p.store.CreateExecution(exec); createErr != nil { + p.logger.Error("failed to create execution record", "error", createErr) + } + p.store.UpdateTaskState(t.ID, task.StateFailed) + p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: err} + return + } + } + execID := uuid.New().String() exec := &storage.Execution{ ID: execID, @@ -130,9 +157,42 @@ func (p *Pool) execute(ctx context.Context, t *task.Task) { p.logger.Error("failed to update execution", "error", updateErr) } - p.mu.Lock() - p.active-- - p.mu.Unlock() - p.resultCh <- &Result{TaskID: t.ID, Execution: exec, Err: err} } + +// terminalFailureStates are dependency states that cause the waiting task to fail immediately. +var terminalFailureStates = map[task.State]bool{ + task.StateFailed: true, + task.StateTimedOut: true, + task.StateCancelled: true, + task.StateBudgetExceeded: true, +} + +// waitForDependencies polls storage until all tasks in t.DependsOn reach COMPLETED, +// or until a dependency enters a terminal failure state or the context is cancelled. +func (p *Pool) waitForDependencies(ctx context.Context, t *task.Task) error { + for { + allDone := true + for _, depID := range t.DependsOn { + dep, err := p.store.GetTask(depID) + if err != nil { + return fmt.Errorf("dependency %q not found: %w", depID, err) + } + if dep.State == task.StateCompleted { + continue + } + if terminalFailureStates[dep.State] { + return fmt.Errorf("dependency %q ended in state %s", depID, dep.State) + } + allDone = false + } + if allDone { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + } + } +} diff --git a/internal/executor/preamble.go b/internal/executor/preamble.go new file mode 100644 index 0000000..cd1a2cc --- /dev/null +++ b/internal/executor/preamble.go @@ -0,0 +1,23 @@ +package executor + +const planningPreamble = `## Planning Step (do this first) + +Before doing any implementation work: + +1. Estimate: will this task take more than 5 minutes of implementation effort? + +2. If YES — break it down: + - Create 3–7 discrete subtasks by POSTing to $CLAUDOMATOR_API_URL/api/tasks + - Each subtask POST body should be JSON with: name, claude.instructions, claude.working_dir (copy from current task), claude.model, claude.allowed_tools, and claude.skip_planning set to true + - Set parent_task_id to $CLAUDOMATOR_TASK_ID in each POST body + - After creating all subtasks, output a brief summary and STOP. Do not implement anything. + +3. If NO — proceed with the task instructions below. + +--- + +` + +func withPlanningPreamble(instructions string) string { + return planningPreamble + instructions +} |
