Files
ratatoskr-go/internal/worker/worker_test.go
Hermes e055d1937c
All checks were successful
CI / test (push) Successful in 44s
CI / build-and-package (amd64, linux) (push) Successful in 43s
CI / build-and-package (amd64, windows) (push) Successful in 42s
fix: создавать каталог worktree в prepareRepos (decider: не могу сменить папку на ./worktrees)
2026-08-16 22:04:16 +05:00

571 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package worker
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
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, _, _, 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()
f := filepath.Join(t.TempDir(), "test.db")
s, err := storage.Open(ctx, f)
if err != nil {
t.Fatalf("open storage: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Task {
t.Helper()
ctx := context.Background()
task := &storage.Task{
ChatID: "tg://worker-test",
Title: title,
Goal: "сделать " + title,
Repos: []string{title},
Why: "для теста",
AC: "работает",
TaskTag: "test-" + title,
}
id, err := s.CreateTask(ctx, task)
if err != nil {
t.Fatalf("create task: %v", err)
}
task.ID = id
task.Status = storage.StatusCollecting
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("set collecting: %v", err)
}
task.Status = storage.StatusReady
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("set ready: %v", err)
}
task, _ = s.GetTask(ctx, id)
return task
}
// seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
// bare-origin + клон с начальным коммитом на main, чтобы воркер мог
// выполнять fetch origin, создавать ветку и пушить.
func seedFakeRepo(t *testing.T, worktree, repo string) {
t.Helper()
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) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "calc")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}},
Worktree: t.TempDir(),
Agent: "dev",
}
seedFakeRepo(t, w.Worktree, "calc")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
t.Fatalf("runTask: %v", err)
}
task, err := s.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
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)
}
if len(traces) != 2 {
t.Fatalf("got %d traces, want 2 (dev + reviewer)", len(traces))
}
if traces[0].Status != storage.TraceSuccess {
t.Errorf("trace[0] status = %q, want success", traces[0].Status)
}
if traces[0].Agent != "dev" {
t.Errorf("trace[0] agent = %q, want dev", traces[0].Agent)
}
if traces[0].SessionID != "sess-1" {
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)
}
}
func TestWorkerTimeout(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "slow")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "slow")
ctx := context.Background()
_ = w.runTask(ctx, task)
task, err := s.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusTimeout {
t.Errorf("status = %q, want timeout", task.Status)
}
traces, err := s.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
if len(traces) != 1 {
t.Fatalf("got %d traces, want 1", len(traces))
}
if traces[0].Status != storage.TraceTimeout {
t.Errorf("trace status = %q, want timeout", traces[0].Status)
}
}
func TestWorkerSpawnError(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "spawn-fail")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "spawn-fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
task, err := s.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusFailed {
t.Errorf("status = %q, want failed", task.Status)
}
traces, err := s.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
if len(traces) != 1 {
t.Fatalf("got %d traces, want 1", len(traces))
}
if traces[0].Status != storage.TraceFailed {
t.Errorf("trace status = %q, want failed", traces[0].Status)
}
}
func TestWorkerNonZeroExit(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "fail")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 7, Stdout: "error"}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
task, err := s.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusFailed {
t.Errorf("status = %q, want failed", task.Status)
}
traces, err := s.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
if len(traces) != 1 {
t.Fatalf("got %d traces, want 1", len(traces))
}
if traces[0].Status != storage.TraceFailed {
t.Errorf("trace status = %q, want failed", traces[0].Status)
}
if traces[0].Output != "error" {
t.Errorf("output = %q, want error", traces[0].Output)
}
}
func TestWorkerBadStatus(t *testing.T) {
s := setupWorkerDB(t)
ctx := context.Background()
task := &storage.Task{ChatID: "tg://bad", Title: "bad-status", TaskTag: "bad"}
id, err := s.CreateTask(ctx, task)
if err != nil {
t.Fatalf("create task: %v", err)
}
task, _ = s.GetTask(ctx, id)
err = (&Worker{Store: s}).runTask(ctx, task)
if err == nil {
t.Fatal("expected error for non-ready task")
}
if !errors.Is(err, ErrLaunch) {
t.Errorf("err = %v, want W2", err)
}
}
func TestWorkerPromptRendered(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "prompt-test")
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok", SessionID: "s"}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "prompt-test")
ctx := context.Background()
_ = w.runTask(ctx, task)
traces, err := s.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
if len(traces) == 0 {
t.Fatal("no traces")
}
tr := traces[0]
if tr.Prompt == "" {
t.Error("prompt empty — шаблон не срендерился")
}
if !strings.Contains(tr.Prompt, "prompt-test") {
t.Error("prompt не содержит название задачи")
}
if !strings.Contains(tr.Prompt, " - prompt-test") {
t.Error("prompt не содержит репозиторий prompt-test")
}
}
func TestValidateRepoName(t *testing.T) {
valid := []string{"calc", "proj-a", "my.repo", "node_2"}
for _, r := range valid {
if err := validateRepoName(r); err != nil {
t.Errorf("validateRepoName(%q) = %v, want nil", r, err)
}
}
invalid := []string{"", "../etc", "a/b", "a/../b", ".."}
for _, r := range invalid {
if err := validateRepoName(r); err == nil {
t.Errorf("validateRepoName(%q) = nil, want E3", r)
} else if !errors.Is(err, ErrRepoPathHint) {
t.Errorf("validateRepoName(%q) err = %v, want E3", r, err)
}
}
}
func TestBuildCloneURL(t *testing.T) {
if got := buildCloneURL("http://gitea.hal9000.home", "proj-a"); got != "http://gitea.hal9000.home/proj-a.git" {
t.Errorf("buildCloneURL = %q", got)
}
if got := buildCloneURL("http://gitea.hal9000.home/", "proj-b"); got != "http://gitea.hal9000.home/proj-b.git" {
t.Errorf("buildCloneURL trailing slash = %q", got)
}
}
func TestPrepareRepos(t *testing.T) {
s := setupWorkerDB(t)
_ = s
wt := t.TempDir()
w := &Worker{Worktree: wt, GitBaseURL: "http://gitea.hal9000.home"}
// seedFakeRepo уже создал .git — prepareRepos должен пройти без клона.
seedFakeRepo(t, wt, "proj-a")
if err := w.prepareRepos(context.Background(), []string{"proj-a"}); err != nil {
t.Fatalf("prepareRepos existing: %v", err)
}
// отсутствующий репо без git в PATH → E2 (clone упал), но не паника.
err := w.prepareRepos(context.Background(), []string{"missing"})
if err == nil {
t.Fatal("prepareRepos missing: expected error")
}
if !errors.Is(err, ErrClone) {
t.Errorf("prepareRepos missing err = %v, want E2", err)
}
}
// TestPrepareRepos_CreatesWorktreeDir проверяет, что prepareRepos создаёт сам
// каталог worktree на свежей машине (иначе git clone не может создать родителя,
// а decider падает с «не могу сменить папку на ./worktrees»).
func TestPrepareRepos_CreatesWorktreeDir(t *testing.T) {
// worktree ещё НЕ существует (родитель TempDir есть, сам каталог — нет).
base := t.TempDir()
wt := filepath.Join(base, "nested", "worktrees")
if _, err := os.Stat(wt); !os.IsNotExist(err) {
t.Fatalf("предусловие: %s должен отсутствовать, %v", wt, err)
}
w := &Worker{Worktree: wt, GitBaseURL: "http://gitea.hal9000.home"}
// Пустой список репо → prepareRepos всё равно создаёт каталог worktree.
if err := w.prepareRepos(context.Background(), nil); err != nil {
t.Fatalf("prepareRepos создал каталог: %v", err)
}
st, err := os.Stat(wt)
if err != nil {
t.Fatalf("worktree должен существовать после prepareRepos: %v", err)
}
if !st.IsDir() {
t.Fatalf("worktree не директория: %v", st.Mode())
}
}
func TestPrepareReposNonGitDir(t *testing.T) {
wt := t.TempDir()
w := &Worker{Worktree: wt}
// папка есть, но без .git → E4.
if err := os.MkdirAll(filepath.Join(wt, "plain"), 0o755); err != nil {
t.Fatal(err)
}
err := w.prepareRepos(context.Background(), []string{"plain"})
if err == nil {
t.Fatal("expected E4 error")
}
if !errors.Is(err, ErrRepoNotGit) {
t.Errorf("err = %v, want E4", err)
}
}
func TestWorkerStartStop(t *testing.T) {
s := setupWorkerDB(t)
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0}},
Worktree: t.TempDir(),
MaxJobs: 1,
Interval: 50 * time.Millisecond,
}
ctx := context.Background()
w.Start(ctx)
time.Sleep(150 * time.Millisecond)
w.Stop()
}
func TestWorkerSemaphore(t *testing.T) {
s := setupWorkerDB(t)
// создаём 2 ready-задачи
for i := 0; i < 2; i++ {
createReadyTask(t, s, fmt.Sprintf("task-%d", i))
}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "ok"}},
Worktree: t.TempDir(),
MaxJobs: 1,
Interval: 50 * time.Millisecond,
}
seedFakeRepo(t, w.Worktree, "task-0")
seedFakeRepo(t, w.Worktree, "task-1")
w.sem = make(chan struct{}, 1)
w.sem <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// первый poll — запустит 1 задачу (макс. 1)
w.pollAndDispatch(ctx)
time.Sleep(200 * time.Millisecond)
// 1 должна быть success, 1 — всё ещё ready
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
ready, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusReady})
if len(success) != 1 {
t.Errorf("success = %d, want 1 (ready=%d)", len(success), len(ready))
}
if len(ready) != 1 {
t.Errorf("ready = %d, want 1", len(ready))
}
// первая завершилась и вернула токен в сем — можем диспатчить вторую
w.pollAndDispatch(ctx)
time.Sleep(200 * time.Millisecond)
success, _ = s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
if len(success) != 2 {
t.Errorf("после освобождения слота success = %d, want 2", len(success))
}
}