fix: ревьюер не извлекал вердикт из NDJSON-потока → всегда двойной запуск и failed
All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, linux) (push) Successful in 41s
CI / build-and-package (amd64, windows) (push) Successful in 53s

parseReviewVerdict брал первую { и последнюю } из всего stdout и парсил
как один JSON, а stdout ревьювера — это NDJSON-поток opencode (несколько
событий). JSON никогда не парсился → v==nil → reviewWithRetry делал
повтор (вторая сессия ревьюера) → verdict nil → StatusFailed.

Приведено к пути аналитика: opencode.ExtractVerdict достаёт текст
вердикта из NDJSON, затем parseReviewVerdict парсит чистый JSON.
+ регрессионный тест TestWorkerReviewNDJSONPass (verdict внутри
text-парта, проверка single run без retry).
This commit is contained in:
Hermes
2026-08-17 14:54:55 +05:00
parent 262c3a0c1c
commit bb9f0bdc43
2 changed files with 63 additions and 1 deletions

View File

@@ -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")