feat: множественные репозитории (Repos) и клонирование в воркере
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, darwin) (push) Successful in 36s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 44s
CI / build-and-package (arm64, darwin) (push) Successful in 35s
CI / build-and-package (arm64, linux) (push) Successful in 36s

- Task.Repos []string (XML-колонка repos, обратная совместимость с repo)
- config: блок git {base_url, token}
- аналитик: ответ repos[], шаблон показывает список
- core: propose без repos → возврат в сбор (E1)
- worker вариант A: один dev из общего cwd, prepareRepos клонирует
  недостающие репо (git clone), validateRepoName (E3), ErrRepoNotGit (E4)
- ошибки E1-E4 в worker/errors.go
This commit is contained in:
Hermes
2026-08-16 09:18:15 +05:00
parent 7be75fe14f
commit bd3d825738
14 changed files with 346 additions and 71 deletions

View File

@@ -2,7 +2,7 @@ package worker
import "errors"
// Классы ошибок W1W5.
// Классы ошибок W1W5 (планировщик) и E1E4 (репозитории).
var (
// W1 — ошибка опроса БД.
ErrPoll = errors.New("W1: poll error")
@@ -18,4 +18,16 @@ var (
// W5 — превышена параллельность, задача пропущена.
ErrConcurrencyLimit = errors.New("W5: concurrency limit")
// E1 — задача без репозиториев (нечего клонировать/править).
ErrNoRepos = errors.New("E1: no repositories")
// E2 — git clone упал.
ErrClone = errors.New("E2: clone failed")
// E3 — имя репозитория содержит путь-эскейп (../, / и т.п.).
ErrRepoPathHint = errors.New("E3: repo path escape")
// E4 — папка существует, но не является git-репозиторием.
ErrRepoNotGit = errors.New("E4: existing dir is not a git repo")
)

View File

@@ -6,28 +6,35 @@ import (
)
// devPromptTemplate — промпт для dev-агента при запуске задачи.
var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-агент, реализуешь задачу в репозитории.
var devPromptTemplate = template.Must(template.New("dev").Parse(`Ты — dev-агент, реализуешь задачу в репозитории(ях).
**Задача:**
{{if .Title}}Название: {{.Title}}{{end}}
{{if .Goal}}Цель: {{.Goal}}{{end}}
{{if .Repos}}
Репозитории (доступны как подпапки текущего каталога):
{{- range .Repos}}
- {{.}}
{{- end}}
{{end}}
{{if .Why}}Зачем: {{.Why}}{{end}}
{{if .Repo}}Репозиторий: {{.Repo}}{{end}}
{{if .AC}}Критерии готовности:
{{.AC}}{{end}}
**Инструкции:**
1. Напиши код, реализующий задачу.
2. Убедись, что все acceptance criteria выполнены.
3. В процессе работы пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование).
4. По окончании работы верни краткий отчёт о том, что сделано.
1. Рабочий каталог — общий корень, в котором лежат все репозитории по именам.
Правь файлы внутри нужного репозитория (./имя_репо/...). Связанные репозитории меняй согласованно.
2. Напиши код, реализующий задачу.
3. Убедись, что все acceptance criteria выполнены.
4. Пользуйся встроенными инструментами opencode (чтение файлов, поиск, редактирование).
5. По окончании верни краткий отчёт о том, что сделано.
`))
// DevPromptData — данные для рендера dev-промпта.
type DevPromptData struct {
Title string
Goal string
Repo string
Repos []string
Why string
AC string
}

View File

@@ -4,7 +4,10 @@ import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/kamelion/ratatoskr-go/internal/opencode"
@@ -23,12 +26,16 @@ type PollTaskFunc func(ctx context.Context) error
type Worker struct {
Store *storage.Storage
Runner OpenCodeRunner
Worktree string // базовый путь, task.Repo — относительно него
Worktree string // общий каталог, репозитории вкладываются в него по имени
Agent string // default "dev"
Interval time.Duration // интервал опроса БД
MaxJobs int // макс. параллельных задач
sem chan struct{} // семафор
// Git — источник репозиториев для клонирования.
GitBaseURL string
GitToken string
sem chan struct{} // семафор
cancel context.CancelFunc
// подменяемый poll для тестов
@@ -117,24 +124,42 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
return nil
}
// runTask выполняет одну задачу: dev-агент через opencode.
// runTask выполняет одну задачу: готовит репозитории, затем dev-агент через opencode.
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
// 1. проверяем статус
if task.Status != storage.StatusReady {
return fmt.Errorf("%w: task %d status=%q", ErrLaunch, task.ID, task.Status)
}
// 2. ставим running
repos := task.EffectiveRepos()
if len(repos) == 0 {
return fmt.Errorf("%w: task %d: %v", ErrLaunch, task.ID, ErrNoRepos)
}
// 1b. проверяем имена репо (E3): не допускаем путь-escape.
for _, r := range repos {
if err := validateRepoName(r); err != nil {
return fmt.Errorf("%w: task %d repo %q: %w", ErrLaunch, task.ID, r, err)
}
}
// 2. ставим running (после валидации — чтобы плохие имена не жгли состояние)
task.Status = storage.StatusRunning
if err := w.Store.UpdateTask(ctx, task); err != nil {
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
}
// 2b. клонируем недостающие репозитории в общий каталог.
if err := w.prepareRepos(ctx, repos); err != nil {
w.failTask(ctx, task)
return fmt.Errorf("%w: %v", ErrClone, err)
}
// 3. рендерим промпт
prompt, err := RenderDevPrompt(DevPromptData{
Title: task.Title,
Goal: task.Goal,
Repo: task.Repo,
Repos: repos,
Why: task.Why,
AC: task.AC,
})
@@ -153,21 +178,16 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
return fmt.Errorf("%w: create: %v", ErrTrace, err)
}
// 5. вычисляем cwd
cwd := w.resolveCwd(task.Repo)
// 5. cwd — общий каталог (вариант A: один dev видит все репозитории).
cwd := w.Worktree
// 6. запускаем dev-агент
res, resErr := w.Runner.Run(ctx, prompt, cwd, w.Agent, "")
if resErr != nil {
// O1 ErrSpawn — не смог запустить бинарь
task.Status = storage.StatusFailed
if e := w.Store.UpdateTask(ctx, task); e != nil {
err = fmt.Errorf("%w: set failed: %v", ErrUpdate, e)
return
}
w.failTask(ctx, task)
w.finalizeTrace(ctx, traceID, storage.TraceFailed, resErr.Error())
err = fmt.Errorf("%w: spawn: %v", ErrLaunch, resErr)
return
return fmt.Errorf("%w: spawn: %v", ErrLaunch, resErr)
}
// 6b. сохраняем session_id из результата
@@ -193,13 +213,20 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
// 8. сохраняем результат
if e := w.Store.UpdateTask(ctx, task); e != nil {
err = fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
return
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.finalizeTrace(ctx, traceID, traceStatus, output)
return nil
}
// failTask помечает задачу failed.
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
task.Status = storage.StatusFailed
if e := w.Store.UpdateTask(ctx, task); e != nil {
log.Printf("worker: task %d: set failed: %v", task.ID, e)
}
}
// finalizeTrace обновляет output и статус трассы.
func (w *Worker) finalizeTrace(ctx context.Context, traceID int64, status storage.TraceStatus, output string) {
if e := w.Store.UpdateTraceOutput(ctx, traceID, output); e != nil {
@@ -210,9 +237,63 @@ func (w *Worker) finalizeTrace(ctx context.Context, traceID int64, status storag
}
}
func (w *Worker) resolveCwd(repo string) string {
if repo == "" {
return w.Worktree
// prepareRepos гарантирует наличие всех репозиториев в общем каталоге:
// папка есть → используем как есть; нет → git clone.
func (w *Worker) prepareRepos(ctx context.Context, repos []string) error {
for _, r := range repos {
dst := filepath.Join(w.Worktree, r)
if _, err := os.Stat(dst); err == nil {
gitdir := filepath.Join(dst, ".git")
if _, e := os.Stat(gitdir); e != nil {
// E4: папка есть, но не git-репо — клонировать поверх нельзя.
return fmt.Errorf("%w: %s существует, но не git-репозиторий", ErrRepoNotGit, r)
}
continue // уже готово
}
if err := w.clone(ctx, r); err != nil {
return fmt.Errorf("%w: %v", ErrClone, err)
}
}
return filepath.Join(w.Worktree, repo)
return nil
}
// clone клонирует репозиторий r в ./worktrees/<r>.
func (w *Worker) clone(ctx context.Context, repo string) error {
base := strings.TrimRight(w.GitBaseURL, "/")
if base == "" {
return fmt.Errorf("git.base_url не задан в конфиге")
}
url := buildCloneURL(base, repo)
dst := filepath.Join(w.Worktree, repo)
args := []string{"clone"}
if w.GitToken != "" {
// https-базовый URL: кладём токен внутрь URL (для приватных репозиториев).
args = append(args, "--config", "http.extraHeader=Authorization: Bearer "+w.GitToken)
}
args = append(args, url, dst)
cmd := exec.CommandContext(ctx, "git", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: git clone %s: %s", ErrClone, repo, strings.TrimSpace(string(out)))
}
return nil
}
// buildCloneURL собирает URL клона из base_url и имени репозитория.
func buildCloneURL(base, repo string) string {
return strings.TrimRight(base, "/") + "/" + repo + ".git"
}
// validateRepoName отклоняет имена с пути-эскейпом (E3).
func validateRepoName(repo string) error {
if repo == "" {
return fmt.Errorf("%w: пустое имя", ErrRepoPathHint)
}
if strings.Contains(repo, "/") || strings.Contains(repo, "..") {
return fmt.Errorf("%w: %q", ErrRepoPathHint, repo)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
@@ -41,7 +42,7 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
ChatID: "tg://worker-test",
Title: title,
Goal: "сделать " + title,
Repo: "test/" + title,
Repos: []string{title},
Why: "для теста",
AC: "работает",
TaskTag: "test-" + title,
@@ -63,6 +64,16 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
return task
}
// seedFakeRepo создаёт в worktree/<repo>/ папку с .git, чтобы prepareRepos
// прошёл без реального git clone.
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 {
t.Fatalf("seed repo %s: %v", repo, err)
}
}
func TestWorkerHappyPath(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "calc")
@@ -73,6 +84,7 @@ func TestWorkerHappyPath(t *testing.T) {
Worktree: t.TempDir(),
Agent: "dev",
}
seedFakeRepo(t, w.Worktree, "calc")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
@@ -114,6 +126,7 @@ func TestWorkerTimeout(t *testing.T) {
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "slow")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -147,6 +160,7 @@ func TestWorkerSpawnError(t *testing.T) {
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "spawn-fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -180,6 +194,7 @@ func TestWorkerNonZeroExit(t *testing.T) {
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 7, Stdout: "error"}},
Worktree: t.TempDir(),
}
seedFakeRepo(t, w.Worktree, "fail")
ctx := context.Background()
_ = w.runTask(ctx, task)
@@ -236,6 +251,7 @@ func TestWorkerPromptRendered(t *testing.T) {
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)
@@ -254,20 +270,74 @@ func TestWorkerPromptRendered(t *testing.T) {
if !strings.Contains(tr.Prompt, "prompt-test") {
t.Error("prompt не содержит название задачи")
}
if !strings.Contains(tr.Prompt, "test/prompt-test") {
t.Error("prompt не содержит repo")
if !strings.Contains(tr.Prompt, " - prompt-test") {
t.Error("prompt не содержит репозиторий prompt-test")
}
}
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)
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)
}
}
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"))
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)
}
}
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)
}
}
@@ -303,6 +373,8 @@ func TestWorkerSemaphore(t *testing.T) {
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{}{}