Files
ratatoskr-go/internal/worker/review.go
Hermes bb9f0bdc43
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
fix: ревьюер не извлекал вердикт из NDJSON-потока → всегда двойной запуск и failed
parseReviewVerdict брал первую { и последнюю } из всего stdout и парсил
как один JSON, а stdout ревьювера — это NDJSON-поток opencode (несколько
событий). JSON никогда не парсился → v==nil → reviewWithRetry делал
повтор (вторая сессия ревьюера) → verdict nil → StatusFailed.

Приведено к пути аналитика: opencode.ExtractVerdict достаёт текст
вердикта из NDJSON, затем parseReviewVerdict парсит чистый JSON.
+ регрессионный тест TestWorkerReviewNDJSONPass (verdict внутри
text-парта, проверка single run без retry).
2026-08-17 14:54:55 +05:00

122 lines
4.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// maxReviewIterations — максимальное число раундов dev↔review.
const maxReviewIterations = 3
// reviewVerdict — результат ревьюера (JSON из stdout).
type reviewVerdict struct {
Passed bool `json:"passed"`
CriticalIssues []string `json:"critical_issues"`
SolidViolations []string `json:"solid_violations"`
Comments []string `json:"comments"`
}
// repoDirOf возвращает абсолютный каталог репозитория в общем worktree.
func (w *Worker) repoDirOf(repo string) string {
return filepath.Join(w.Worktree, repo)
}
// branchDiffAll собирает diff всей feature-ветки по каждому репо и возвращает
// единый блок для ревьюера (с заголовками, чтобы reviewer понимал структуру).
func (w *Worker) branchDiffAll(ctx context.Context, repos []string, branch string) (string, error) {
var b strings.Builder
for _, r := range repos {
d, err := w.branchDiff(ctx, w.repoDirOf(r), branch)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrReviewDiff, err)
}
b.WriteString("\n===== DIFF repo: ")
b.WriteString(r)
b.WriteString(" (branch ")
b.WriteString(branch)
b.WriteString(") =====\n")
b.WriteString(d)
if strings.TrimSpace(d) == "" {
b.WriteString("(нет изменений в этой ветке)\n")
}
}
return b.String(), nil
}
// pushBranches пушит feature-ветку во все затронутые репозитории.
func (w *Worker) pushBranches(ctx context.Context, repos []string, branch string) error {
for _, r := range repos {
if err := w.pushBranch(ctx, w.repoDirOf(r), branch); err != nil {
return fmt.Errorf("%w: repo %s: %v", ErrReviewPush, r, err)
}
}
return nil
}
// runReviewer разово запускает reviewer-агента и парсит JSON-вердикт.
// Возвращает (verdict, output, traceID, err). err — только классы R2/R5;
// непарсируемый JSON даёт (nil, output, traceID, nil) — вызывающий решает про retry.
func (w *Worker) runReviewer(ctx context.Context, taskID int64, cwd, prompt string) (*reviewVerdict, string, int64, error) {
tr := &storage.Trace{TaskID: taskID, Agent: "reviewer", Prompt: prompt}
traceID, err := w.Store.AppendTrace(ctx, tr)
if err != nil {
return nil, "", 0, fmt.Errorf("%w: %v", ErrReviewTrace, err)
}
res, resErr := w.Runner.Run(w.runCtx(ctx, taskID), prompt, cwd, "reviewer", "")
if resErr != nil {
w.finalizeTrace(ctx, traceID, storage.TraceFailed, resErr.Error())
return nil, "", traceID, fmt.Errorf("%w: %v", ErrReviewSpawn, resErr)
}
if res.SessionID != "" {
_ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID)
}
output := res.Stdout
var v *reviewVerdict
if res.RC == 0 {
// Как у аналитика: сначала достаём текст вердикта из NDJSON-потока opencode
// (ExtractVerdict), затем парсим уже чистый JSON-объект.
verdict := opencode.ExtractVerdict(output)
pv, perr := parseReviewVerdict(verdict)
if perr == nil {
v = pv
}
}
ts := storage.TraceFailed
if v != nil && v.Passed {
ts = storage.TraceSuccess
}
w.finalizeTrace(ctx, traceID, ts, output)
if v == nil {
// Невалидный/пустой вывод или RC!=0: не парсится — сигнал на retry.
return nil, output, traceID, nil
}
return v, output, traceID, nil
}
// parseReviewVerdict извлекает JSON-объект вердикта из вывода reviewer,
// устойчивый к случайному markdown-обрамлению.
func parseReviewVerdict(out string) (*reviewVerdict, error) {
s := strings.TrimSpace(out)
if i := strings.Index(s, "{"); i >= 0 {
s = s[i:]
}
if j := strings.LastIndex(s, "}"); j >= 0 {
s = s[:j+1]
}
var v reviewVerdict
if err := json.Unmarshal([]byte(s), &v); err != nil {
return nil, err
}
return &v, nil
}