worker: добавлен пакет worker (W1-W5, poll-цикл, runTask, промпт, тесты) + SetMaxOpenConns(1) для SQLITE_BUSY
All checks were successful
build-test / build (push) Successful in 42s

This commit is contained in:
Hermes
2026-08-15 09:14:34 +05:00
parent dfe374dd55
commit 4641090398
6 changed files with 627 additions and 0 deletions

View File

@@ -0,0 +1,334 @@
package worker
import (
"context"
"errors"
"fmt"
"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
}
func (m *mockRunnerWorker) Run(_ context.Context, _, _, _, _ string) (*opencode.Result, error) {
return m.result, m.err
}
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,
Repo: "test/" + 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
}
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",
}
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) != 1 {
t.Fatalf("got %d traces, want 1", len(traces))
}
if traces[0].Status != storage.TraceSuccess {
t.Errorf("trace status = %q, want success", traces[0].Status)
}
if traces[0].Agent != "dev" {
t.Errorf("agent = %q, want dev", traces[0].Agent)
}
if traces[0].SessionID != "sess-1" {
t.Errorf("session = %q, want sess-1", traces[0].SessionID)
}
}
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(),
}
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(),
}
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(),
}
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(),
}
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, "test/prompt-test") {
t.Error("prompt не содержит repo")
}
}
func TestWorkerResolveCwd(t *testing.T) {
base := "/opt/data/src"
w := &Worker{Worktree: base}
if got := w.resolveCwd(""); got != base {
t.Errorf("empty repo: got %q, want %q", got, base)
}
if got := w.resolveCwd("tools/calc"); got != filepath.Join(base, "tools/calc") {
t.Errorf("repo: got %q, want %q", got, filepath.Join(base, "tools/calc"))
}
}
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,
}
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))
}
}