worker: add reviewer stage (branch→review→push) with R1-R6 errors
This commit is contained in:
@@ -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/<repo>/ папку с .git, чтобы prepareRepos
|
||||
// прошёл без реального git clone.
|
||||
// seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user