From bb9f0bdc4367dc17ef947208b85937f4ba18c1be Mon Sep 17 00:00:00 2001 From: Hermes Date: Mon, 17 Aug 2026 14:54:55 +0500 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=D0=B5?= =?UTF-8?q?=D1=80=20=D0=BD=D0=B5=20=D0=B8=D0=B7=D0=B2=D0=BB=D0=B5=D0=BA?= =?UTF-8?q?=D0=B0=D0=BB=20=D0=B2=D0=B5=D1=80=D0=B4=D0=B8=D0=BA=D1=82=20?= =?UTF-8?q?=D0=B8=D0=B7=20NDJSON-=D0=BF=D0=BE=D1=82=D0=BE=D0=BA=D0=B0=20?= =?UTF-8?q?=E2=86=92=20=D0=B2=D1=81=D0=B5=D0=B3=D0=B4=D0=B0=20=D0=B4=D0=B2?= =?UTF-8?q?=D0=BE=D0=B9=D0=BD=D0=BE=D0=B9=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=20=D0=B8=20failed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseReviewVerdict брал первую { и последнюю } из всего stdout и парсил как один JSON, а stdout ревьювера — это NDJSON-поток opencode (несколько событий). JSON никогда не парсился → v==nil → reviewWithRetry делал повтор (вторая сессия ревьюера) → verdict nil → StatusFailed. Приведено к пути аналитика: opencode.ExtractVerdict достаёт текст вердикта из NDJSON, затем parseReviewVerdict парсит чистый JSON. + регрессионный тест TestWorkerReviewNDJSONPass (verdict внутри text-парта, проверка single run без retry). --- internal/worker/review.go | 6 +++- internal/worker/worker_test.go | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) 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")