From 4a6ed57971e455c9e2cdbff73a18cbac7420bc36 Mon Sep 17 00:00:00 2001 From: Hermes Date: Sun, 16 Aug 2026 21:21:21 +0500 Subject: [PATCH] =?UTF-8?q?worker:=20add=20reviewer=20stage=20(branch?= =?UTF-8?q?=E2=86=92review=E2=86=92push)=20with=20R1-R6=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/agents/agents.go | 2 +- internal/agents/reviewer.md | 31 +++++ internal/worker/gitops.go | 57 +++++++++ internal/worker/prompt.go | 82 ++++++++++++- internal/worker/review.go | 117 ++++++++++++++++++ internal/worker/review_errors.go | 24 ++++ internal/worker/worker.go | 202 +++++++++++++++++++++++-------- internal/worker/worker_test.go | 160 ++++++++++++++++++++++-- 8 files changed, 610 insertions(+), 65 deletions(-) create mode 100644 internal/agents/reviewer.md create mode 100644 internal/worker/gitops.go create mode 100644 internal/worker/review.go create mode 100644 internal/worker/review_errors.go diff --git a/internal/agents/agents.go b/internal/agents/agents.go index 19fec1e..0d544d4 100644 --- a/internal/agents/agents.go +++ b/internal/agents/agents.go @@ -14,7 +14,7 @@ var FS embed.FS // // Держим в курсе: если добавить файл в каталог, он автоматически попадёт // в FS, но для детерминированной распаковки список лучше дополнять здесь. -var Names = []string{"analyst", "dev"} +var Names = []string{"analyst", "dev", "reviewer"} // WriteTo распаковывает всех встроенных агентов в каталог dir (создаёт его). // Файлы перезаписываются — встроенная копия всегда актуальна. diff --git a/internal/agents/reviewer.md b/internal/agents/reviewer.md new file mode 100644 index 0000000..7019740 --- /dev/null +++ b/internal/agents/reviewer.md @@ -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": ["что и как исправить, чтобы пройти ревью"] +} \ No newline at end of file diff --git a/internal/worker/gitops.go b/internal/worker/gitops.go new file mode 100644 index 0000000..c427e5d --- /dev/null +++ b/internal/worker/gitops.go @@ -0,0 +1,57 @@ +package worker + +import ( + "context" + "fmt" + "os/exec" + "strings" +) + +// baseBranch — ветка, от которой отходит feature-работа dev (свежайший main). +const baseBranch = "main" + +// ensureBranch создаёт/переключает feature-ветку для задачи в repoDir, +// беря за базу свежайший origin/. Возвращает ошибку при сбое. +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/. +func featureBranchName(taskTag string) string { + return "feat/" + taskTag +} \ No newline at end of file diff --git a/internal/worker/prompt.go b/internal/worker/prompt.go index 28fc9c0..a518da9 100644 --- a/internal/worker/prompt.go +++ b/internal/worker/prompt.go @@ -27,8 +27,9 @@ var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-а 2. Напиши код, реализующий задачу. 3. Убедись, что все acceptance criteria выполнены. 4. Пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование). -5. По окончании верни краткий отчёт о том, что сделано. -`)) +5. Работай в feature-ветке {{.Branch}} (уже создана, checkout выполнен). Коммить изменения в эту ветку. +6. По окончании верни краткий отчёт о том, что сделано. +{{if .ReviewFeedback}}{{.ReviewFeedback}}{{end}}`)) // DevPromptData — данные для рендера dev-промпта. type DevPromptData struct { @@ -37,6 +38,13 @@ type DevPromptData struct { Repos []string Why string AC string + + // ReviewFeedback — замечания ревьюера при повторном прогоне dev + // (не пусто → dev должен исправить именно это). + ReviewFeedback []string + + // Branch — имя feature-ветки, в которой ведётся работа (для отчёта). + Branch string } // RenderDevPrompt собирает промпт для dev-агента. @@ -46,4 +54,74 @@ func RenderDevPrompt(data DevPromptData) (string, error) { return "", err } 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} } \ No newline at end of file diff --git a/internal/worker/review.go b/internal/worker/review.go new file mode 100644 index 0000000..fc20ce5 --- /dev/null +++ b/internal/worker/review.go @@ -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 +} \ No newline at end of file diff --git a/internal/worker/review_errors.go b/internal/worker/review_errors.go new file mode 100644 index 0000000..5ce882e --- /dev/null +++ b/internal/worker/review_errors.go @@ -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") +) \ No newline at end of file diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 95a09ed..c885c69 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -124,7 +124,9 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error { return nil } -// runTask выполняет одну задачу: готовит репозитории, затем dev-агент через opencode. +// runTask выполняет одну задачу: готовит репозитории, создаёт feature-ветку, +// dev-агент реализует, reviewer строго проверяет весь дифф ветки; при не-проходе +// dev дорабатывает по комментариям; прошло → push ветки + success. func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { // 1. проверяем статус 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) } - // 3. рендерим промпт - prompt, err := RenderDevPrompt(DevPromptData{ - Title: task.Title, - Goal: task.Goal, - Repos: repos, - Why: task.Why, - AC: task.AC, - }) - if err != nil { - return fmt.Errorf("%w: render prompt: %v", ErrTrace, err) + // 2c. создаём feature-ветку от свежайшего origin/ в каждом репо. + branch := featureBranchName(task.TaskTag) + for _, r := range repos { + if err := w.ensureBranch(ctx, w.repoDirOf(r), branch); err != nil { + w.failTask(ctx, task) + return fmt.Errorf("%w: create branch %s in %s: %v", ErrClone, branch, r, err) + } } - // 4. создаём трассу - 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 видит все репозитории). + // 3. cwd — общий каталог (вариант A: один dev видит все репозитории). cwd := w.Worktree - // 6. запускаем dev-агент - res, resErr := w.Runner.Run(ctx, prompt, cwd, w.Agent, "") - 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) - } + // Цикл dev → review, до maxReviewIterations. + var feedback []string - // 6b. сохраняем session_id из результата - if res.SessionID != "" { - _ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID) - } + for iter := 0; ; iter++ { + // 3. рендерим промпт dev (с feedback на повторных итерациях) + 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 - output := res.Stdout - var traceStatus storage.TraceStatus + // 4b. создаём трассу dev + trace := &storage.Trace{TaskID: task.ID, Agent: w.Agent, Prompt: prompt} + traceID, tErr := w.Store.AppendTrace(ctx, trace) + if tErr != nil { + return fmt.Errorf("%w: create: %v", ErrTrace, tErr) + } - switch { - case res.RC == 0: - task.Status = storage.StatusSuccess - traceStatus = storage.TraceSuccess - case res.RC == -1: - task.Status = storage.StatusTimeout - traceStatus = storage.TraceTimeout - default: + // 5. запускаем dev-агент (fresh сессия в текущей ветке) + res, resErr := w.Runner.Run(ctx, prompt, cwd, w.Agent, "") + 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) + } + 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 - 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. сохраняем результат - if e := w.Store.UpdateTask(ctx, task); e != nil { - return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) +// reviewWithRetry запускает reviewer; при непарсируемом вердикте — один повтор. +// Возвращает (verdict, output, traceID). traceID — последней попытки ревью. +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) - return nil + if v != 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. diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index dd48048..247180c 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -17,12 +18,44 @@ import ( type mockRunnerWorker struct { result *opencode.Result 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 } +// 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 { t.Helper() ctx := context.Background() @@ -64,14 +97,41 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta return task } -// seedFakeRepo создаёт в worktree// папку с .git, чтобы prepareRepos -// прошёл без реального git clone. +// seedFakeRepo создаёт реальный git-репозиторий для worktree/: +// bare-origin + клон с начальным коммитом на main, чтобы воркер мог +// выполнять fetch origin, создавать ветку и пушить. func seedFakeRepo(t *testing.T, worktree, repo string) { t.Helper() - dir := filepath.Join(worktree, repo) - if err := os.MkdirAll(filepath.Join(dir, ".git"), 0o755); err != nil { + gitRun := func(dir string, args ...string) string { + 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) } + // родительский каталог для 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) { @@ -103,17 +163,97 @@ func TestWorkerHappyPath(t *testing.T) { if err != nil { t.Fatalf("get traces: %v", err) } - if len(traces) != 1 { - t.Fatalf("got %d traces, want 1", len(traces)) + if len(traces) != 2 { + t.Fatalf("got %d traces, want 2 (dev + reviewer)", len(traces)) } 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" { - 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" { - 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) } }