T03: Runner — pokretanje Claude Code
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
5d869f56ce
commit
9d2c249ed1
43
TASKS/reports/T03-report.md
Normal file
43
TASKS/reports/T03-report.md
Normal file
@ -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
|
||||||
91
TASKS/review/T03.md
Normal file
91
TASKS/review/T03.md
Normal file
@ -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)*
|
||||||
96
code/internal/supervisor/runner.go
Normal file
96
code/internal/supervisor/runner.go
Normal file
@ -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:])
|
||||||
|
}
|
||||||
127
code/internal/supervisor/runner_test.go
Normal file
127
code/internal/supervisor/runner_test.go
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user