worker: add reviewer stage (branch→review→push) with R1-R6 errors
This commit is contained in:
@@ -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 (создаёт его).
|
||||||
// Файлы перезаписываются — встроенная копия всегда актуальна.
|
// Файлы перезаписываются — встроенная копия всегда актуальна.
|
||||||
|
|||||||
31
internal/agents/reviewer.md
Normal file
31
internal/agents/reviewer.md
Normal 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
57
internal/worker/gitops.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
117
internal/worker/review.go
Normal 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
|
||||||
|
}
|
||||||
24
internal/worker/review_errors.go
Normal file
24
internal/worker/review_errors.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
// Классы ошибок R1–R5 (этап ревью).
|
||||||
|
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")
|
||||||
|
)
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user