worker: add reviewer stage (branch→review→push) with R1-R6 errors
All checks were successful
CI / test (push) Successful in 47s
CI / build-and-package (amd64, linux) (push) Successful in 43s
CI / build-and-package (amd64, windows) (push) Successful in 40s

This commit is contained in:
Hermes
2026-08-16 21:21:21 +05:00
parent 130dc59840
commit 4a6ed57971
8 changed files with 610 additions and 65 deletions

View File

@@ -14,7 +14,7 @@ var FS embed.FS
// //
// Держим в курсе: если добавить файл в каталог, он автоматически попадёт // Держим в курсе: если добавить файл в каталог, он автоматически попадёт
// в FS, но для детерминированной распаковки список лучше дополнять здесь. // в FS, но для детерминированной распаковки список лучше дополнять здесь.
var Names = []string{"analyst", "dev"} var Names = []string{"analyst", "dev", "reviewer"}
// WriteTo распаковывает всех встроенных агентов в каталог dir (создаёт его). // WriteTo распаковывает всех встроенных агентов в каталог dir (создаёт его).
// Файлы перезаписываются — встроенная копия всегда актуальна. // Файлы перезаписываются — встроенная копия всегда актуальна.

View File

@@ -0,0 +1,31 @@
---
name: reviewer
description: Ревьюер Ratatoskr — строго проверяет ветку работы dev-агента (качество, безопасность, SOLID) и возвращает вердикт в JSON
---
Ты — ревьюер в конвейере Ratatoskr. Проверяешь работу dev-агента в feature-ветке **жёстко и придирчиво**. Твоя цель — не дать плохому коду попасть в основную ветку.
Тебе приходит промпт с:
- названием и критериями готовности (AC) задачи;
- **полным diff всей ветки** работы dev (изменения относительно базовой ветки);
- при повторных раундах — комментарии, которые dev обещал исправить.
ПРАВИЛА:
1. Изучи весь diff ветки, а не только заголовки. Смотри контекст изменений.
2. Проверяй: (а) безопасность, (б) логические ошибки и баги, (в) соответствие AC, (г) качество кода.
3. SOLID проверяй СТРОГО. Приоритет — **минимальная связанность компонентов**:
- single responsibility: компоненты не делают много несвязанных вещей;
- открытость/закрытость, подстановка, изоляция интерфейсов;
- **dependency inversion: завись от абстракций, не от конкретных реализаций;**
- **избегай циклических и лишних зависимостей между компонентами** — каждый компонент должен тянуть только собственные зависимости.
4. Любой `critical_issue` (security/логика) или `solid_violation``passed=false`. Это блокирующие.
5. `critical_issues` и `solid_violations` помещай в соответствующие списки; `comments` — конкретные, где и как править (нужны dev для доработки).
6. Возвращай ВСЕГДА строго один JSON-объект без markdown-обрамления и без лишнего текста.
Формат ответа:
{
"passed": true,
"critical_issues": ["описание блокирующей проблемы"],
"solid_violations": ["какой принцип нарушен и где"],
"comments": ["что и как исправить, чтобы пройти ревью"]
}

57
internal/worker/gitops.go Normal file
View File

@@ -0,0 +1,57 @@
package worker
import (
"context"
"fmt"
"os/exec"
"strings"
)
// baseBranch — ветка, от которой отходит feature-работа dev (свежайший main).
const baseBranch = "main"
// ensureBranch создаёт/переключает feature-ветку для задачи в repoDir,
// беря за базу свежайший origin/<baseBranch>. Возвращает ошибку при сбое.
func (w *Worker) ensureBranch(ctx context.Context, repoDir, branch string) error {
if _, err := w.git(ctx, repoDir, "fetch", "origin"); err != nil {
return err
}
if _, err := w.git(ctx, repoDir, "checkout", "-B", branch, "origin/"+baseBranch); err != nil {
return err
}
return nil
}
// branchDiff возвращает diff всей feature-ветки относительно базы:
// это все изменения коммитов ветки (base...branch), а не только последний.
func (w *Worker) branchDiff(ctx context.Context, repoDir, branch string) (string, error) {
return w.git(ctx, repoDir, "diff", "origin/"+baseBranch+"..."+branch)
}
// pushBranch пушит feature-ветку в origin (remote 'origin' создан при клоне).
// Токен передаётся через http.extraHeader, если задан.
func (w *Worker) pushBranch(ctx context.Context, repoDir, branch string) error {
args := []string{"push"}
if w.GitToken != "" {
args = append(args, "--config", "http.extraHeader=Authorization: Bearer "+w.GitToken)
}
args = append(args, "origin", "HEAD:"+branch)
_, err := w.git(ctx, repoDir, args...)
return err
}
// git выполняет git-команду в каталоге dir. Ошибки оборачиваются с выводом.
func (w *Worker) git(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("git %v: %s", args, strings.TrimSpace(string(out)))
}
return string(out), nil
}
// featureBranchName — имя ветки задачи: feat/<taskTag>.
func featureBranchName(taskTag string) string {
return "feat/" + taskTag
}

View File

@@ -27,8 +27,9 @@ var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-а
2. Напиши код, реализующий задачу. 2. Напиши код, реализующий задачу.
3. Убедись, что все acceptance criteria выполнены. 3. Убедись, что все acceptance criteria выполнены.
4. Пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование). 4. Пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование).
5. По окончании верни краткий отчёт о том, что сделано. 5. Работай в feature-ветке {{.Branch}} (уже создана, checkout выполнен). Коммить изменения в эту ветку.
`)) 6. По окончании верни краткий отчёт о том, что сделано.
{{if .ReviewFeedback}}{{.ReviewFeedback}}{{end}}`))
// DevPromptData — данные для рендера dev-промпта. // DevPromptData — данные для рендера dev-промпта.
type DevPromptData struct { type DevPromptData struct {
@@ -37,6 +38,13 @@ type DevPromptData struct {
Repos []string Repos []string
Why string Why string
AC string AC string
// ReviewFeedback — замечания ревьюера при повторном прогоне dev
// (не пусто → dev должен исправить именно это).
ReviewFeedback []string
// Branch — имя feature-ветки, в которой ведётся работа (для отчёта).
Branch string
} }
// RenderDevPrompt собирает промпт для dev-агента. // RenderDevPrompt собирает промпт для dev-агента.
@@ -46,4 +54,74 @@ func RenderDevPrompt(data DevPromptData) (string, error) {
return "", err return "", err
} }
return buf.String(), nil return buf.String(), nil
}
// reviewPromptTemplate — промпт для reviewer-агента.
var reviewPromptTemplate = template.Must(template.New("review").Parse(`Ты — ревьюер в конвейере Ratatoskr. Проверь работу dev-агента в feature-ветке **{{.Branch}}** строго и придирчиво.
**Критерии готовности (AC):**
{{.AC}}
**Полный diff ветки (base...HEAD):**
{{.Diff}}
ПРАВИЛА:
1. Изучи весь diff, а не заголовки. Смотри контекст изменений.
2. Проверяй: безопасность; логические ошибки и баги; соответствие AC; качество кода.
3. SOLID — СТРОГО. Приоритет — минимальная связанность компонентов: зависимость от абстракций, без циклических/лишних зависимостей, single responsibility.
4. Любой критический изъян (security/логика) или нарушение SOLID → passed=false. Это блокирующие.
5. critical_issues и solid_violations клади в свои списки; comments — конкретные, где и как править.
**Верни строго один JSON-объект без markdown-обрамления:**
{
"passed": true|false,
"critical_issues": [],
"solid_violations": [],
"comments": []
}`))
// ReviewPromptData — данные для рендера review-промпта.
type ReviewPromptData struct {
Branch string
AC string
Diff string
}
// RenderReviewPrompt собирает промпт для reviewer-агента.
func RenderReviewPrompt(data ReviewPromptData) (string, error) {
var buf strings.Builder
if err := reviewPromptTemplate.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
// reviewFeedbackText — строка (список), которую dev видит при повторном прогоне
// после не-прохода ревью. Пустые comments → пустая строка.
func reviewFeedbackText(branch string, comments []string) string {
if len(comments) == 0 {
return ""
}
var b strings.Builder
b.WriteString("\n\n⚠ РЕВЬЮ НЕ ПРОЙДЕНО по ветке ")
b.WriteString(branch)
b.WriteString(". Прочитай и исправь каждый пункт, затем закоммить повторно:\n")
for _, c := range comments {
if c == "" {
continue
}
b.WriteString("- ")
b.WriteString(c)
b.WriteString("\n")
}
return b.String()
}
// reviewFeedbackList — то же, но возвращает []string (для DevPromptData.ReviewFeedback).
func reviewFeedbackList(branch string, comments []string) []string {
s := reviewFeedbackText(branch, comments)
if s == "" {
return nil
}
return []string{s}
} }

117
internal/worker/review.go Normal file
View File

@@ -0,0 +1,117 @@
package worker
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"strings"
"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(ctx, 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 {
pv, perr := parseReviewVerdict(output)
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
}

View File

@@ -0,0 +1,24 @@
package worker
import "errors"
// Классы ошибок R1R5 (этап ревью).
var (
// R1 — не удалось получить diff ветки (`git diff`).
ErrReviewDiff = errors.New("R1: review diff failed")
// R2 — сбой запуска reviewer-агента.
ErrReviewSpawn = errors.New("R2: reviewer spawn failed")
// R3 — reviewer вернул невалидный/непарсируемый JSON.
ErrReviewParse = errors.New("R3: reviewer output parse failed")
// R4 — превышен лимит итераций dev↔review (после лимита — failed).
ErrReviewMaxIterations = errors.New("R4: review max iterations reached")
// R5 — сбой обновления трассы результата ревью.
ErrReviewTrace = errors.New("R5: review trace error")
// R6 — не удалось запушить feature-ветку в origin.
ErrReviewPush = errors.New("R6: review push failed")
)

View File

@@ -124,7 +124,9 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
return nil return nil
} }
// runTask выполняет одну задачу: готовит репозитории, затем dev-агент через opencode. // runTask выполняет одну задачу: готовит репозитории, создаёт feature-ветку,
// dev-агент реализует, reviewer строго проверяет весь дифф ветки; при не-проходе
// dev дорабатывает по комментариям; прошло → push ветки + success.
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
// 1. проверяем статус // 1. проверяем статус
if task.Status != storage.StatusReady { if task.Status != storage.StatusReady {
@@ -155,68 +157,164 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
return fmt.Errorf("%w: %v", ErrClone, err) return fmt.Errorf("%w: %v", ErrClone, err)
} }
// 3. рендерим промпт // 2c. создаём feature-ветку от свежайшего origin/<baseBranch> в каждом репо.
prompt, err := RenderDevPrompt(DevPromptData{ branch := featureBranchName(task.TaskTag)
Title: task.Title, for _, r := range repos {
Goal: task.Goal, if err := w.ensureBranch(ctx, w.repoDirOf(r), branch); err != nil {
Repos: repos, w.failTask(ctx, task)
Why: task.Why, return fmt.Errorf("%w: create branch %s in %s: %v", ErrClone, branch, r, err)
AC: task.AC, }
})
if err != nil {
return fmt.Errorf("%w: render prompt: %v", ErrTrace, err)
} }
// 4. создаём трассу // 3. cwd — общий каталог (вариант A: один dev видит все репозитории).
trace := &storage.Trace{
TaskID: task.ID,
Agent: w.Agent,
Prompt: prompt,
}
traceID, err := w.Store.AppendTrace(ctx, trace)
if err != nil {
return fmt.Errorf("%w: create: %v", ErrTrace, err)
}
// 5. cwd — общий каталог (вариант A: один dev видит все репозитории).
cwd := w.Worktree cwd := w.Worktree
// 6. запускаем dev-агент // Цикл dev → review, до maxReviewIterations.
res, resErr := w.Runner.Run(ctx, prompt, cwd, w.Agent, "") var feedback []string
if resErr != nil {
// O1 ErrSpawn — не смог запустить бинарь
w.failTask(ctx, task)
w.finalizeTrace(ctx, traceID, storage.TraceFailed, resErr.Error())
return fmt.Errorf("%w: spawn: %v", ErrLaunch, resErr)
}
// 6b. сохраняем session_id из результата for iter := 0; ; iter++ {
if res.SessionID != "" { // 3. рендерим промпт dev (с feedback на повторных итерациях)
_ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID) devData := DevPromptData{
} Title: task.Title,
Goal: task.Goal,
Repos: repos,
Why: task.Why,
AC: task.AC,
Branch: branch,
ReviewFeedback: reviewFeedbackList(branch, feedback),
}
prompt, perr := RenderDevPrompt(devData)
if perr != nil {
return fmt.Errorf("%w: render prompt: %v", ErrTrace, perr)
}
// 7. определяем результат по RC // 4b. создаём трассу dev
output := res.Stdout trace := &storage.Trace{TaskID: task.ID, Agent: w.Agent, Prompt: prompt}
var traceStatus storage.TraceStatus traceID, tErr := w.Store.AppendTrace(ctx, trace)
if tErr != nil {
return fmt.Errorf("%w: create: %v", ErrTrace, tErr)
}
switch { // 5. запускаем dev-агент (fresh сессия в текущей ветке)
case res.RC == 0: res, resErr := w.Runner.Run(ctx, prompt, cwd, w.Agent, "")
task.Status = storage.StatusSuccess if resErr != nil {
traceStatus = storage.TraceSuccess // O1 ErrSpawn — не смог запустить бинарь
case res.RC == -1: w.failTask(ctx, task)
task.Status = storage.StatusTimeout w.finalizeTrace(ctx, traceID, storage.TraceFailed, resErr.Error())
traceStatus = storage.TraceTimeout return fmt.Errorf("%w: spawn: %v", ErrLaunch, resErr)
default: }
if res.SessionID != "" {
_ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID)
}
output := res.Stdout
// 5b. dev не завершился успешно (RC!=0) → фиксируем без ревью.
switch res.RC {
case 0:
// продолжаем на ревью
case -1:
task.Status = storage.StatusTimeout
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
return nil
default:
task.Status = storage.StatusFailed
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
return nil
}
// dev завершился RC=0 → сохраняем успех трассы dev.
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
if dErr != nil {
w.failTask(ctx, task)
return fmt.Errorf("%w: %v", ErrReviewDiff, dErr)
}
reviewPrompt, rErr := RenderReviewPrompt(ReviewPromptData{
Branch: branch,
AC: task.AC,
Diff: diffText,
})
if rErr != nil {
w.failTask(ctx, task)
return fmt.Errorf("%w: render review prompt: %v", ErrReviewTrace, rErr)
}
// Запускаем reviewer, с одним retry на невалидный JSON/вывод.
verdict, reviewOutput, reviewTraceID, rvErr := w.reviewWithRetry(ctx, task.ID, cwd, reviewPrompt)
if rvErr != nil {
w.failTask(ctx, task)
return rvErr
}
if verdict == nil {
// невалидный JSON даже после retry → failed с объяснением.
task.Status = storage.StatusFailed
explain := "reviewer вернул невалидный/пустой вердикт (даже после повтора)."
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
return nil
}
// Пройдено → push и success.
if verdict.Passed {
if pErr := w.pushBranches(ctx, repos, branch); pErr != nil {
w.failTask(ctx, task)
return pErr
}
task.Status = storage.StatusSuccess
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
return nil
}
// Не пройдено: если есть итерации — dev дорабатывает.
if iter+1 < maxReviewIterations {
feedback = verdict.Comments
continue
}
// Лимит исчерпан → failed с объяснением.
task.Status = storage.StatusFailed task.Status = storage.StatusFailed
traceStatus = storage.TraceFailed if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
final := reviewOutput + "\n" + explain
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
log.Printf("worker: task %d: update review trace: %v", task.ID, e)
}
return nil
} }
}
// 8. сохраняем результат // reviewWithRetry запускает reviewer; при непарсируемом вердикте — один повтор.
if e := w.Store.UpdateTask(ctx, task); e != nil { // Возвращает (verdict, output, traceID). traceID — последней попытки ревью.
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) func (w *Worker) reviewWithRetry(ctx context.Context, taskID int64, cwd, prompt string) (*reviewVerdict, string, int64, error) {
v, out, tid, err := w.runReviewer(ctx, taskID, cwd, prompt)
if err != nil {
return nil, out, tid, err
} }
w.finalizeTrace(ctx, traceID, traceStatus, output) if v != nil {
return nil return v, out, tid, nil
}
// невалидный/пустой — один retry.
v2, out2, tid2, err2 := w.runReviewer(ctx, taskID, cwd, prompt)
if err2 != nil {
return nil, out2, tid2, err2
}
return v2, out2, tid2, nil
} }
// failTask помечает задачу failed. // failTask помечает задачу failed.

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
@@ -17,12 +18,44 @@ import (
type mockRunnerWorker struct { type mockRunnerWorker struct {
result *opencode.Result result *opencode.Result
err error err error
// reviewResult — результат для reviewer-агента; если nil, при ревью
// вернётся валидный «passed» verdict.
reviewResult *opencode.Result
// reviewSequence — если задан, ревью-запуски берут результаты по порядку
// (последний вечный). Позволяет смоделировать fail-then-pass.
reviewSequence []*opencode.Result
reviewCount int
} }
func (m *mockRunnerWorker) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) { func (m *mockRunnerWorker) Run(_ context.Context, _, _, agent, _ string) (*opencode.Result, error) {
if agent == "reviewer" {
if len(m.reviewSequence) > 0 {
i := m.reviewCount
m.reviewCount++
if i >= len(m.reviewSequence) {
return m.reviewSequence[len(m.reviewSequence)-1], m.err
}
return m.reviewSequence[i], m.err
}
if m.reviewResult != nil {
return m.reviewResult, m.err
}
return &opencode.Result{RC: 0, Stdout: `{"passed":true,"comments":[]}`}, nil
}
return m.result, m.err return m.result, m.err
} }
// reviewFailedRunner возвращает вердикт not-passed с комментариями.
func reviewFailedRunner() *opencode.Result {
return &opencode.Result{RC: 0, Stdout: `{"passed":false,"critical_issues":[],"solid_violations":["DIP: высокая связанность"],"comments":["исправь связанность"]}`}
}
// reviewGarbageRunner — не-парсируемый вывод reviewer.
func reviewGarbageRunner() *opencode.Result {
return &opencode.Result{RC: 0, Stdout: "не JSON вовсе"}
}
func setupWorkerDB(t *testing.T) *storage.Storage { func setupWorkerDB(t *testing.T) *storage.Storage {
t.Helper() t.Helper()
ctx := context.Background() ctx := context.Background()
@@ -64,14 +97,41 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
return task return task
} }
// seedFakeRepo создаёт в worktree/<repo>/ папку с .git, чтобы prepareRepos // seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
// прошёл без реального git clone. // bare-origin + клон с начальным коммитом на main, чтобы воркер мог
// выполнять fetch origin, создавать ветку и пушить.
func seedFakeRepo(t *testing.T, worktree, repo string) { func seedFakeRepo(t *testing.T, worktree, repo string) {
t.Helper() t.Helper()
dir := filepath.Join(worktree, repo) gitRun := func(dir string, args ...string) string {
if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
}
return string(out)
}
base := filepath.Join(worktree, repo)
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatalf("seed repo %s: %v", repo, err) t.Fatalf("seed repo %s: %v", repo, err)
} }
// родительский каталог для bare-origin
origin := filepath.Join(worktree, repo+"-origin.git")
gitRun(worktree, "init", "--bare", origin)
gitRun(base, "init")
gitRun(base, "remote", "add", "origin", origin)
if err := os.WriteFile(filepath.Join(base, "seed.txt"), []byte("seed\n"), 0o644); err != nil {
t.Fatalf("write seed: %v", err)
}
gitRun(base, "add", "seed.txt")
gitRun(base, "commit", "-m", "seed")
gitRun(base, "branch", "-M", "main")
gitRun(base, "push", "-u", "origin", "main")
} }
func TestWorkerHappyPath(t *testing.T) { func TestWorkerHappyPath(t *testing.T) {
@@ -103,17 +163,97 @@ func TestWorkerHappyPath(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("get traces: %v", err) t.Fatalf("get traces: %v", err)
} }
if len(traces) != 1 { if len(traces) != 2 {
t.Fatalf("got %d traces, want 1", len(traces)) t.Fatalf("got %d traces, want 2 (dev + reviewer)", len(traces))
} }
if traces[0].Status != storage.TraceSuccess { if traces[0].Status != storage.TraceSuccess {
t.Errorf("trace status = %q, want success", traces[0].Status) t.Errorf("trace[0] status = %q, want success", traces[0].Status)
} }
if traces[0].Agent != "dev" { if traces[0].Agent != "dev" {
t.Errorf("agent = %q, want dev", traces[0].Agent) t.Errorf("trace[0] agent = %q, want dev", traces[0].Agent)
} }
if traces[0].SessionID != "sess-1" { if traces[0].SessionID != "sess-1" {
t.Errorf("session = %q, want sess-1", traces[0].SessionID) t.Errorf("trace[0] session = %q, want sess-1", traces[0].SessionID)
}
if traces[1].Agent != "reviewer" {
t.Errorf("trace[1] agent = %q, want reviewer", traces[1].Agent)
}
if traces[1].Status != storage.TraceSuccess {
t.Errorf("trace[1] status = %q, want success", traces[1].Status)
}
}
// TestWorkerReviewFailThenSuccess проверяет цикл dev↔review: первый вердикт
// not-passed → dev дорабатывает, второй passed → успех + пуш.
func TestWorkerReviewFailThenSuccess(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "calc2")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
reviewSequence: []*opencode.Result{
reviewFailedRunner(),
{RC: 0, Stdout: `{"passed":true,"comments":[]}`},
},
},
Worktree: t.TempDir(),
Agent: "dev",
}
seedFakeRepo(t, w.Worktree, "calc2")
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", task.Status)
}
traces, err := s.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
// 2 dev + 2 reviewer (обе итерации)
if len(traces) != 4 {
t.Fatalf("got %d traces, want 4 (2 dev + 2 reviewer)", len(traces))
}
// первая итерация: dev→reviewer; промпт dev второй итерации должен содержать feedback
if !strings.Contains(traces[2].Prompt, "РЕВЬЮ НЕ ПРОЙДЕНО") {
t.Error("второй dev-промпт не содержит feedback от ревьюера")
}
if !strings.Contains(traces[2].Prompt, "исправь связанность") {
t.Error("второй dev-промпт не содержит комментарий ревьюера")
}
}
// TestWorkerReviewMaxIterations: ревью всё время not-passed → задача failed по R4.
func TestWorkerReviewMaxIterations(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "calc3")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
reviewResult: reviewFailedRunner(),
},
Worktree: t.TempDir(),
Agent: "dev",
}
seedFakeRepo(t, w.Worktree, "calc3")
// mock всегда not-passed → исчерпаем лимит за 3 итерации (maxReviewIterations=3).
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.StatusFailed {
t.Errorf("status = %q, want failed после лимита итераций", task.Status)
} }
} }