summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--internal/executor/claude.go27
-rw-r--r--internal/executor/claude_test.go79
-rw-r--r--internal/executor/executor.go68
-rw-r--r--internal/executor/preamble.go23
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
+}