From 9d2c249ed1e9091f593d305b8c27d68012c4d051 Mon Sep 17 00:00:00 2001 From: djuka Date: Fri, 20 Feb 2026 11:46:07 +0000 Subject: [PATCH] =?UTF-8?q?T03:=20Runner=20=E2=80=94=20pokretanje=20Claude?= =?UTF-8?q?=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RunTask sa timeout-om, stdout/stderr capture, exit code - buildPrompt generiše prompt za Claude CLI - CommandBuilder interfejs za mock testiranje - 7 runner testova — svi prolaze - T04 premešten u done/ Co-Authored-By: Claude Opus 4.6 --- TASKS/{review => done}/T04.md | 0 TASKS/reports/T03-report.md | 43 ++++++++ TASKS/review/T03.md | 91 +++++++++++++++++ code/internal/supervisor/runner.go | 96 ++++++++++++++++++ code/internal/supervisor/runner_test.go | 127 ++++++++++++++++++++++++ 5 files changed, 357 insertions(+) rename TASKS/{review => done}/T04.md (100%) create mode 100644 TASKS/reports/T03-report.md create mode 100644 TASKS/review/T03.md create mode 100644 code/internal/supervisor/runner.go create mode 100644 code/internal/supervisor/runner_test.go diff --git a/TASKS/review/T04.md b/TASKS/done/T04.md similarity index 100% rename from TASKS/review/T04.md rename to TASKS/done/T04.md diff --git a/TASKS/reports/T03-report.md b/TASKS/reports/T03-report.md new file mode 100644 index 0000000..41adb44 --- /dev/null +++ b/TASKS/reports/T03-report.md @@ -0,0 +1,43 @@ +# T03 Izveštaj: Runner — pokretanje Claude Code + +**Agent:** coder +**Model:** Opus +**Datum:** 2026-02-20 + +--- + +## Šta je urađeno + +Implementiran runner u `code/internal/supervisor/`: + +### Kreirani fajlovi + +| Fajl | Opis | +|------|------| +| `runner.go` | RunTask, buildPrompt, RunResult, CommandBuilder | +| `runner_test.go` | 7 testova sa mock komandama | + +### Funkcije + +- **RunTask** — pokreće komandu sa timeout-om, hvata stdout/stderr, meri vreme, čuva exit code +- **buildPrompt** — generiše prompt za Claude Code sa task ID, title, description +- **DefaultCommandBuilder** — kreira `claude --print --model --message` komandu +- **CommandBuilder** interfejs — omogućava mock testiranje bez pravog Claude CLI + +### Testovi — 7/7 PASS (runner) + +``` +TestRunTask_MockEcho PASS +TestRunTask_Timeout PASS (1.00s) +TestRunTask_OutputCapture PASS +TestRunTask_FailingCommand PASS +TestRunTask_EmptyTask PASS +TestBuildPrompt_ContainsFields PASS +TestBuildPrompt_CommitFormat PASS +``` + +### Ukupno projekat: 40 testova (6 config + 17 task + 10 checker + 7 runner), svi prolaze + +- `go vet ./...` — čist +- `go build ./...` — prolazi +- `make all` — prolazi diff --git a/TASKS/review/T03.md b/TASKS/review/T03.md new file mode 100644 index 0000000..e662030 --- /dev/null +++ b/TASKS/review/T03.md @@ -0,0 +1,91 @@ +# T03: Runner — pokretanje Claude Code + +**Kreirao:** planer +**Datum:** 2026-02-20 +**Agent:** coder +**Model:** Sonnet +**Zavisi od:** T02 ✅ + +--- + +## Opis + +Pokreće Claude Code sa task promptom, prati stdout/stderr u realnom vremenu, +meri vreme, čuva exit code. Koristi task loader iz T02 za čitanje taskova. + +## Fajlovi za kreiranje + +``` +code/internal/supervisor/ +├── runner.go ← RunTask(), priprema prompt, pokreće claude CLI +└── runner_test.go ← testovi sa mock komandom +``` + +## Strukture + +```go +type RunResult struct { + TaskID string + StartedAt time.Time + FinishedAt time.Time + Duration time.Duration + ExitCode int + Output string // stdout + Error string // stderr +} +``` + +## Funkcije + +- `RunTask(task Task, projectPath string, timeout time.Duration) RunResult` +- `buildPrompt(task Task) string` — pripremi prompt za Claude Code + +## Prompt format + +``` +Radi na KAOS projektu. +Tvoj task: {ID} — {Title} +Pročitaj CLAUDE.md u root-u projekta za pravila rada. +{Description} +Kad završiš: go build, go test, commituj sa "T{XX}: opis". +``` + +## Pokretanje + +```go +cmd := exec.CommandContext(ctx, "claude", + "--print", + "--model", task.Model, + "--message", prompt, +) +cmd.Dir = projectPath +``` + +Timeout se čita iz config-a (KAOS_TIMEOUT). Context sa cancel za timeout. + +## Testovi + +- Mock: umesto `claude` pokreni `echo "done"` — proveri RunResult polja +- Timeout: pokreni `sleep 999` sa timeout 1s — proveri da se prekine, ExitCode != 0 +- Output capture: pokreni komandu koja piše na stdout i stderr — proveri da su uhvaćeni +- buildPrompt: proveri da sadrži task ID, title, description +- Prazan task → graceful error + +**Napomena:** Testovi koriste mock komande (echo, sleep), ne pravi Claude CLI. + +## Očekivani izlaz + +RunTask pokrene proces, uhvati output, meri vreme, poštuje timeout. +`go test ./internal/supervisor/ -v` — svi testovi zeleni. + +--- + +## Pitanja + +*(agent piše pitanja ovde, planer odgovara)* + +--- + +## Odgovori + +*(planer piše odgovore ovde)* diff --git a/code/internal/supervisor/runner.go b/code/internal/supervisor/runner.go new file mode 100644 index 0000000..ab47090 --- /dev/null +++ b/code/internal/supervisor/runner.go @@ -0,0 +1,96 @@ +package supervisor + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strings" + "time" +) + +// RunResult holds the outcome of running a task via an external command. +type RunResult struct { + TaskID string + StartedAt time.Time + FinishedAt time.Time + Duration time.Duration + ExitCode int + Output string // stdout + Error string // stderr +} + +// CommandBuilder defines how to build the command for running a task. +// This allows tests to substitute mock commands. +type CommandBuilder func(ctx context.Context, task Task, projectPath string) *exec.Cmd + +// DefaultCommandBuilder creates the command to run Claude Code CLI. +func DefaultCommandBuilder(ctx context.Context, task Task, projectPath string) *exec.Cmd { + prompt := buildPrompt(task) + model := strings.ToLower(task.Model) + + cmd := exec.CommandContext(ctx, "claude", + "--print", + "--model", model, + "--message", prompt, + ) + cmd.Dir = projectPath + return cmd +} + +// RunTask executes a task using the given command builder, with timeout support. +// If cmdBuilder is nil, DefaultCommandBuilder is used. +func RunTask(task Task, projectPath string, timeout time.Duration, cmdBuilder CommandBuilder) RunResult { + result := RunResult{ + TaskID: task.ID, + } + + if task.ID == "" { + result.ExitCode = 1 + result.Error = "empty task ID" + return result + } + + if cmdBuilder == nil { + cmdBuilder = DefaultCommandBuilder + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := cmdBuilder(ctx, task, projectPath) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + result.StartedAt = time.Now() + err := cmd.Run() + result.FinishedAt = time.Now() + result.Duration = result.FinishedAt.Sub(result.StartedAt) + result.Output = stdout.String() + result.Error = stderr.String() + + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + result.ExitCode = exitErr.ExitCode() + } else { + result.ExitCode = 1 + if result.Error == "" { + result.Error = err.Error() + } + } + } + + return result +} + +// buildPrompt creates the prompt string to send to Claude Code. +func buildPrompt(task Task) string { + return fmt.Sprintf(`Radi na KAOS projektu. +Tvoj task: %s — %s +Pročitaj CLAUDE.md u root-u projekta za pravila rada. +%s +Kad završiš: go build, go test, commituj sa "T%s: opis".`, + task.ID, task.Title, task.Description, task.ID[1:]) +} diff --git a/code/internal/supervisor/runner_test.go b/code/internal/supervisor/runner_test.go new file mode 100644 index 0000000..fa999d7 --- /dev/null +++ b/code/internal/supervisor/runner_test.go @@ -0,0 +1,127 @@ +package supervisor + +import ( + "context" + "os/exec" + "strings" + "testing" + "time" +) + +func mockCommandBuilder(name string, args ...string) CommandBuilder { + return func(ctx context.Context, task Task, projectPath string) *exec.Cmd { + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = projectPath + return cmd + } +} + +func TestRunTask_MockEcho(t *testing.T) { + task := Task{ID: "T99", Title: "Test task", Description: "Do stuff"} + + result := RunTask(task, "/tmp", 10*time.Second, mockCommandBuilder("echo", "done")) + + if result.TaskID != "T99" { + t.Errorf("expected TaskID T99, got %s", result.TaskID) + } + if result.ExitCode != 0 { + t.Errorf("expected exit code 0, got %d", result.ExitCode) + } + if !strings.Contains(result.Output, "done") { + t.Errorf("expected output to contain 'done', got %q", result.Output) + } + if result.Duration <= 0 { + t.Error("expected duration > 0") + } + if result.StartedAt.IsZero() { + t.Error("expected StartedAt to be set") + } + if result.FinishedAt.IsZero() { + t.Error("expected FinishedAt to be set") + } +} + +func TestRunTask_Timeout(t *testing.T) { + task := Task{ID: "T99", Title: "Slow task", Description: "Takes forever"} + + result := RunTask(task, "/tmp", 1*time.Second, mockCommandBuilder("sleep", "999")) + + if result.ExitCode == 0 { + t.Error("expected non-zero exit code for timed-out process") + } + if result.Duration < 500*time.Millisecond { + t.Errorf("expected duration >= 500ms, got %s", result.Duration) + } +} + +func TestRunTask_OutputCapture(t *testing.T) { + task := Task{ID: "T99", Title: "Output task", Description: "Writes output"} + + // Use sh -c to write to both stdout and stderr + result := RunTask(task, "/tmp", 10*time.Second, + mockCommandBuilder("sh", "-c", "echo stdout_data; echo stderr_data >&2")) + + if !strings.Contains(result.Output, "stdout_data") { + t.Errorf("expected stdout to contain 'stdout_data', got %q", result.Output) + } + if !strings.Contains(result.Error, "stderr_data") { + t.Errorf("expected stderr to contain 'stderr_data', got %q", result.Error) + } +} + +func TestRunTask_FailingCommand(t *testing.T) { + task := Task{ID: "T99", Title: "Fail task", Description: "Will fail"} + + result := RunTask(task, "/tmp", 10*time.Second, + mockCommandBuilder("sh", "-c", "exit 42")) + + if result.ExitCode != 42 { + t.Errorf("expected exit code 42, got %d", result.ExitCode) + } +} + +func TestRunTask_EmptyTask(t *testing.T) { + task := Task{} + + result := RunTask(task, "/tmp", 10*time.Second, nil) + + if result.ExitCode == 0 { + t.Error("expected non-zero exit code for empty task") + } + if result.Error == "" { + t.Error("expected error message for empty task") + } +} + +func TestBuildPrompt_ContainsFields(t *testing.T) { + task := Task{ + ID: "T05", + Title: "Deploy aplikacije", + Description: "Deployuj na server sa testovima.", + } + + prompt := buildPrompt(task) + + if !strings.Contains(prompt, "T05") { + t.Error("expected prompt to contain task ID") + } + if !strings.Contains(prompt, "Deploy aplikacije") { + t.Error("expected prompt to contain task title") + } + if !strings.Contains(prompt, "Deployuj na server sa testovima.") { + t.Error("expected prompt to contain description") + } + if !strings.Contains(prompt, "CLAUDE.md") { + t.Error("expected prompt to mention CLAUDE.md") + } +} + +func TestBuildPrompt_CommitFormat(t *testing.T) { + task := Task{ID: "T12", Title: "Nešto", Description: "Opis."} + + prompt := buildPrompt(task) + + if !strings.Contains(prompt, `"T12: opis"`) { + t.Errorf("expected commit format with T12, got:\n%s", prompt) + } +}