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
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:
@@ -2,7 +2,7 @@ package worker
|
||||
|
||||
import "errors"
|
||||
|
||||
// Классы ошибок W1–W5.
|
||||
// Классы ошибок W1–W5 (планировщик) и E1–E4 (репозитории).
|
||||
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")
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user