diff --git a/internal/worker/review.go b/internal/worker/review.go index 7022aef..f7dfe0c 100644 --- a/internal/worker/review.go +++ b/internal/worker/review.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" + "github.com/kamelion/ratatoskr-go/internal/opencode" "github.com/kamelion/ratatoskr-go/internal/storage" ) @@ -80,7 +81,10 @@ func (w *Worker) runReviewer(ctx context.Context, taskID int64, cwd, prompt stri output := res.Stdout var v *reviewVerdict if res.RC == 0 { - pv, perr := parseReviewVerdict(output) + // Как у аналитика: сначала достаём текст вердикта из NDJSON-потока opencode + // (ExtractVerdict), затем парсим уже чистый JSON-объект. + verdict := opencode.ExtractVerdict(output) + pv, perr := parseReviewVerdict(verdict) if perr == nil { v = pv } diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index c65ed3d..e88165f 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -2,11 +2,13 @@ package worker import ( "context" + "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" @@ -257,6 +259,62 @@ func TestWorkerReviewMaxIterations(t *testing.T) { } } +// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode, +// где JSON находится внутри последнего text-парта. +func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result { + inner, err := json.Marshal(v) + if err != nil { + panic(err) + } + // text-парт содержит вердикт как JSON-строку (экранированную внутри NDJSON). + return &opencode.Result{RC: 0, Stdout: "{\"type\":\"step_start\"}\n{\"type\":\"text\",\"part\":{\"text\":" + strconv.Quote(string(inner)) + "}}"} +} + +// TestWorkerReviewNDJSONPass: ревьюер вернул passed:true внутри NDJSON-потока — +// извлечение должно достать вердикт, а не зафейлить задачу (регрессия двойного ревью). +func TestWorkerReviewNDJSONPass(t *testing.T) { + s := setupWorkerDB(t) + task := createReadyTask(t, s, "calcnd") + + w := &Worker{ + Store: s, + Runner: &mockRunnerWorker{ + result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}, + reviewResult: reviewNDJSONRunner(&reviewVerdict{ + Passed: true, + Comments: []string{"ок"}, + }), + }, + Worktree: t.TempDir(), + Agent: "dev", + } + seedFakeRepo(t, w.Worktree, "calcnd") + + ctx := context.Background() + if err := w.runTask(ctx, task); err != nil { + t.Fatalf("runTask: %v", err) + } + task, _ = s.GetTask(ctx, task.ID) + if task.Status != storage.StatusSuccess { + t.Errorf("status = %q, want success (вердикт из NDJSON должен распарситься)", task.Status) + } + + // ревью должен был запуститься ровно 1 раз (никакого retry на «невалидный»). + traces, err := s.GetTraces(ctx, task.ID) + if err != nil { + t.Fatalf("get traces: %v", err) + } + var reviewTraces int + for _, tr := range traces { + if tr.Agent == "reviewer" { + reviewTraces++ + } + } + if reviewTraces != 1 { + t.Errorf("reviewer запускался %d раз, want 1 (не должно быть retry второй сессии)", reviewTraces) + } +} + func TestWorkerTimeout(t *testing.T) { s := setupWorkerDB(t) task := createReadyTask(t, s, "slow")