diff --git a/orchestrator/internal/service/github/fork.go b/orchestrator/internal/service/github/fork.go new file mode 100644 index 00000000..b462c4fb --- /dev/null +++ b/orchestrator/internal/service/github/fork.go @@ -0,0 +1,100 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "os/exec" + "strings" + "time" +) + +// ForkResponse — минимальные поля форка из GitHub API. +type ForkResponse struct { + FullName string `json:"full_name"` + CloneURL string `json:"clone_url"` + HTMLURL string `json:"html_url"` + DefaultBranch string `json:"default_branch"` + Owner struct { + Login string `json:"login"` + } `json:"owner"` +} + +// IsPermissionError — распознаёт ошибку отсутствия прав на запись в репозиторий +// (push возвращает HTTP 403 / "Permission to ... denied"). Используется, чтобы +// переключиться на создание PR из форка (issue #85). +func IsPermissionError(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "403") || + strings.Contains(msg, "permission to") || + strings.Contains(msg, "permission denied") || + strings.Contains(msg, "denied to") || + strings.Contains(msg, "write access to repository not granted") +} + +// ForkRepository — создаёт форк upstream-репозитория в аккаунт бота. +// GitHub отвечает 202 Accepted; форк создаётся асинхронно (см. WaitForRepository). +func (c *Client) ForkRepository(ctx context.Context, owner, repo string) (*ForkResponse, error) { + var fork ForkResponse + path := fmt.Sprintf("/repos/%s/%s/forks", owner, repo) + if err := c.doJSON(ctx, "POST", path, struct{}{}, &fork, http.StatusAccepted); err != nil { + return nil, fmt.Errorf("failed to fork repository: %w", err) + } + log.Printf("Forked %s/%s -> %s", owner, repo, fork.FullName) + return &fork, nil +} + +// WaitForRepository — ждёт, пока репозиторий (форк) станет доступен через API. +// Форк создаётся асинхронно, поэтому push сразу после fork может упасть. +func (c *Client) WaitForRepository(ctx context.Context, owner, repo string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for attempt := 0; ; attempt++ { + if _, err := c.GetRepository(ctx, owner, repo); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("repository %s/%s not ready after %s", owner, repo, timeout) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +// PushBranchToRemote — пушит ветку в произвольный remote URL (например, форк), +// используя временный remote. Токен встраивается в URL для авторизации. +func (c *Client) PushBranchToRemote(ctx context.Context, dir, branch, remoteURL, remoteName string) error { + if branch == "" { + return fmt.Errorf("branch is required") + } + if remoteName == "" { + remoteName = "octra-fork" + } + // Снимаем старый remote с тем же именем, если остался от прошлого прогона. + rm := exec.CommandContext(ctx, "git", "remote", "remove", remoteName) + rm.Dir = dir + rm.Run() // ошибку игнорируем — remote мог не существовать + + auth := c.authenticatedGitURL(remoteURL) + add := exec.CommandContext(ctx, "git", "remote", "add", remoteName, auth) + add.Dir = dir + if out, err := add.CombinedOutput(); err != nil { + return fmt.Errorf("git remote add failed: %w - %s", err, c.sanitize(string(out))) + } + + push := exec.CommandContext(ctx, "git", "push", "-u", remoteName, branch, "--force") + push.Dir = dir + out, err := push.CombinedOutput() + if err != nil { + log.Printf("git push to fork output: %s", c.sanitize(string(out))) + return fmt.Errorf("git push to fork failed: %w - %s", err, c.sanitize(string(out))) + } + log.Printf("git push to fork output: %s", c.sanitize(string(out))) + return nil +} diff --git a/orchestrator/internal/service/github/fork_test.go b/orchestrator/internal/service/github/fork_test.go new file mode 100644 index 00000000..358c6aaf --- /dev/null +++ b/orchestrator/internal/service/github/fork_test.go @@ -0,0 +1,27 @@ +package github + +import ( + "errors" + "testing" +) + +func TestIsPermissionError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"403 status", errors.New("git push branch failed: exit status 128 - The requested URL returned error: 403"), true}, + {"permission denied phrase", errors.New("remote: Permission to Payel-git-ol/Octra.git denied to Octra-git."), true}, + {"write access not granted", errors.New("remote: Write access to repository not granted."), true}, + {"unrelated error", errors.New("fatal: could not resolve host"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsPermissionError(tc.err); got != tc.want { + t.Fatalf("IsPermissionError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/orchestrator/internal/service/groupchat/orchestrator.go b/orchestrator/internal/service/groupchat/orchestrator.go index 5a57413a..364af4b7 100644 --- a/orchestrator/internal/service/groupchat/orchestrator.go +++ b/orchestrator/internal/service/groupchat/orchestrator.go @@ -16,6 +16,9 @@ type Orchestrator struct { terminateFn TerminationCondition mu sync.RWMutex eventCh chan Event + + errsMu sync.Mutex + errs []error } func NewOrchestrator(maxRounds int) *Orchestrator { @@ -29,6 +32,25 @@ func NewOrchestrator(maxRounds int) *Orchestrator { } } +// recordError — сохраняет ошибку агента, чтобы вызывающий код мог понять, +// что воркер реально провалился (раньше ошибки только эмитились в events и +// молча терялись, из-за чего менеджер репортил success при пустом результате — issue #85). +func (o *Orchestrator) recordError(err error) { + if err == nil { + return + } + o.errsMu.Lock() + o.errs = append(o.errs, err) + o.errsMu.Unlock() +} + +// Errors — возвращает ошибки агентов, накопленные за время прогона. +func (o *Orchestrator) Errors() []error { + o.errsMu.Lock() + defer o.errsMu.Unlock() + return append([]error(nil), o.errs...) +} + func (o *Orchestrator) SetSelector(s SpeakerSelector) { o.selector = s } @@ -197,6 +219,7 @@ func (o *Orchestrator) runSingle(ctx context.Context, agentID string, round int) conv := o.SnapshotConversation() messages, err := agent.Process(ctx, conv) if err != nil { + o.recordError(fmt.Errorf("agent %s: %w", agentID, err)) o.setAgentStatus(agentID, AgentError) o.emit(Event{Type: EventError, AgentID: agentID, Error: err.Error(), Round: round}) return @@ -237,6 +260,7 @@ func (o *Orchestrator) runConcurrentRound(ctx context.Context, round int) { messages, err := a.Process(ctx, conv) if err != nil { + o.recordError(fmt.Errorf("agent %s: %w", agentID, err)) o.setAgentStatus(agentID, AgentError) o.emit(Event{Type: EventError, AgentID: agentID, Error: err.Error(), Round: round}) return diff --git a/orchestrator/internal/service/rules/boss/github.go b/orchestrator/internal/service/rules/boss/github.go index abd0dcba..fe648d1d 100644 --- a/orchestrator/internal/service/rules/boss/github.go +++ b/orchestrator/internal/service/rules/boss/github.go @@ -7,6 +7,7 @@ import ( "os" "os/exec" "strings" + "time" "orchestrator/internal/service/git" gh "orchestrator/internal/service/github" @@ -95,16 +96,28 @@ func (s *Service) createPullRequest(ctx context.Context, task *models.Task, proj log.Printf("Failed to checkout pull request branch: %v", err) return "", fmt.Sprintf("Failed to prepare pull request branch %q: %v", target.BranchName, err) } + headRef := target.BranchName if err := s.githubClient.PushBranch(ctx, projectPath, target.BranchName); err != nil { - log.Printf("Failed to push pull request branch: %v", err) - return "", fmt.Sprintf("Failed to push pull request branch %q: %v", target.BranchName, err) + // 403 → нет прав на запись в upstream. Раньше пайплайн просто падал и PR + // не создавался (issue #85). Теперь форкаем репозиторий и пушим в форк. + if gh.IsPermissionError(err) { + log.Printf("Push denied (no write access to %s/%s) — falling back to fork-based pull request", target.Owner, target.Repo) + forkHead, forkErr := s.pushBranchToFork(ctx, projectPath, target) + if forkHead == "" { + return "", forkErr + } + headRef = forkHead + } else { + log.Printf("Failed to push pull request branch: %v", err) + return "", fmt.Sprintf("Failed to push pull request branch %q: %v", target.BranchName, err) + } } prTitle := pullRequestTitle(task, target) pr, err := s.githubClient.CreatePullRequest(ctx, gh.PullRequestRequest{ Owner: target.Owner, Repo: target.Repo, Title: prTitle, - Head: target.BranchName, + Head: headRef, Base: firstNonEmpty(target.BaseBranch, "main"), Body: pullRequestBody(task, target), }) @@ -132,6 +145,32 @@ func (s *Service) createPullRequest(ctx context.Context, task *models.Task, proj return pr.HTMLURL, "" } +// pushBranchToFork — форкает upstream-репозиторий и пушит ветку в форк. +// Возвращает head-ссылку вида "forkOwner:branch" для кросс-репозиторного PR +// и пустую причину при успехе, либо ("", reason) при ошибке. Вызывается, когда +// у бота нет прав на запись в upstream. +func (s *Service) pushBranchToFork(ctx context.Context, projectPath string, target *gh.IssueTarget) (string, string) { + fork, err := s.githubClient.ForkRepository(ctx, target.Owner, target.Repo) + if err != nil { + log.Printf("Fork fallback failed: %v", err) + return "", fmt.Sprintf("Failed to fork %s/%s for pull request: %v", target.Owner, target.Repo, err) + } + if err := s.githubClient.WaitForRepository(ctx, fork.Owner.Login, target.Repo, 60*time.Second); err != nil { + log.Printf("Fork not ready: %v", err) + return "", fmt.Sprintf("Fork %s/%s was not ready in time: %v", fork.Owner.Login, target.Repo, err) + } + forkURL := fork.CloneURL + if forkURL == "" { + forkURL = fmt.Sprintf("https://github.com/%s/%s.git", fork.Owner.Login, target.Repo) + } + if err := s.githubClient.PushBranchToRemote(ctx, projectPath, target.BranchName, forkURL, "octra-fork"); err != nil { + log.Printf("Failed to push branch to fork: %v", err) + return "", fmt.Sprintf("Failed to push branch %q to fork %s/%s: %v", target.BranchName, fork.Owner.Login, target.Repo, err) + } + log.Printf("Pushed branch to fork %s/%s, opening cross-repo pull request", fork.Owner.Login, target.Repo) + return fmt.Sprintf("%s:%s", fork.Owner.Login, target.BranchName), "" +} + func pullRequestTitle(task *models.Task, target *gh.IssueTarget) string { title := target.IssueTitle if title == "" { @@ -171,4 +210,3 @@ func extractRemoteURL(projectPath string) string { } return strings.TrimSpace(string(out)) } - diff --git a/orchestrator/internal/service/rules/boss/project.go b/orchestrator/internal/service/rules/boss/project.go index d8eb5c76..6e92e7e5 100644 --- a/orchestrator/internal/service/rules/boss/project.go +++ b/orchestrator/internal/service/rules/boss/project.go @@ -138,9 +138,9 @@ func (s *Service) mergeManagerBranches(repoPath string, roles []models.ManagerRo // Использует FlakeBuilder для генерации богатого flake.nix с зависимостями // на основе techStack, определённого AI на этапе планирования. // После записи flake.nix: -// 1. Генерирует flake.lock для закрепления версий зависимостей. -// 2. Коммитит flake.nix + flake.lock в git, чтобы они не потерялись -// при последующих git-операциях (ветвление/мерж воркеров). +// 1. Генерирует flake.lock для закрепления версий зависимостей. +// 2. Коммитит flake.nix + flake.lock в git, чтобы они не потерялись +// при последующих git-операциях (ветвление/мерж воркеров). func (s *Service) generateFlake(projectPath, taskID, title string, techStack []string, progress rules.ProgressFunc) { packages := NewFlakeBuilder().ResolveFromTechStacks(techStack) s.WriteFlake(projectPath, taskID, title, packages) @@ -228,7 +228,7 @@ func prepareSnapshotDir(projectPath string) (string, error) { } src := filepath.Join(projectPath, file) dst := filepath.Join(stagingDir, file) - if err := copyFile(src, dst); err != nil { + if _, err := copyFile(src, dst); err != nil { os.RemoveAll(stagingDir) return "", fmt.Errorf("failed to copy %s: %w", file, err) } @@ -236,7 +236,7 @@ func prepareSnapshotDir(projectPath string) (string, error) { flakeSrc := filepath.Join(projectPath, "flake.nix") if _, err := os.Stat(flakeSrc); err == nil { - if err := copyFile(flakeSrc, filepath.Join(stagingDir, "flake.nix")); err != nil { + if _, err := copyFile(flakeSrc, filepath.Join(stagingDir, "flake.nix")); err != nil { os.RemoveAll(stagingDir) return "", fmt.Errorf("failed to copy flake.nix: %w", err) } @@ -245,25 +245,62 @@ func prepareSnapshotDir(projectPath string) (string, error) { return stagingDir, nil } -// copyFile — копирует файл с созданием родительских директорий -func copyFile(src, dst string) error { +// copyFile — копирует файл с созданием родительских директорий. +// Возвращает (skipped=true, nil) для записей, которые нельзя/не нужно копировать +// как обычный файл: симлинки в Nix store (например, `result` от `nix build`), +// директории и битые симлинки. Раньше попытка скопировать такой `result` падала +// с `copy_file_range: is a directory` и срывала весь снапшот проекта (issue #85). +func copyFile(src, dst string) (skipped bool, err error) { + info, err := os.Lstat(src) + if err != nil { + return false, err + } + + // Симлинки: не идём по ссылке (это и вызывало падение на `result`, + // указывающем на директорию в /nix/store), а воссоздаём саму ссылку. + if info.Mode()&os.ModeSymlink != 0 { + target, rerr := os.Readlink(src) + if rerr != nil { + return false, rerr + } + // Артефакты сборки Nix (`result`, `result-*`) не должны попадать в снапшот. + if strings.HasPrefix(target, "/nix/store") { + return true, nil + } + // Битый симлинк или указывает на директорию — пропускаем, чтобы не падать. + if resolved, serr := os.Stat(src); serr != nil || resolved.IsDir() { + return true, nil + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return false, err + } + return false, os.Symlink(target, dst) + } + + // git ls-files не должен возвращать директории, но на всякий случай пропускаем. + if info.IsDir() { + return true, nil + } + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { - return err + return false, err } srcFile, err := os.Open(src) if err != nil { - return err + return false, err } defer srcFile.Close() dstFile, err := os.Create(dst) if err != nil { - return err + return false, err } defer dstFile.Close() - _, err = io.Copy(dstFile, srcFile) - return err + if _, err := io.Copy(dstFile, srcFile); err != nil { + return false, err + } + return false, nil } // registerGCRoot — регистрирует store path как GC root, чтобы Nix не удалил его @@ -459,4 +496,3 @@ func envOrDefault(key, def string) string { } return def } - diff --git a/orchestrator/internal/service/rules/boss/snapshot_test.go b/orchestrator/internal/service/rules/boss/snapshot_test.go new file mode 100644 index 00000000..72095350 --- /dev/null +++ b/orchestrator/internal/service/rules/boss/snapshot_test.go @@ -0,0 +1,119 @@ +package boss + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +// TestCopyFileSkipsNixStoreSymlink проверяет, что симлинк в /nix/store +// (например, `result` от `nix build`) пропускается, а не копируется по ссылке. +// Раньше это валило снапшот с `copy_file_range: is a directory` (issue #85). +func TestCopyFileSkipsNixStoreSymlink(t *testing.T) { + dir := t.TempDir() + link := filepath.Join(dir, "result") + if err := os.Symlink("/nix/store/abc-some-build", link); err != nil { + t.Fatalf("symlink: %v", err) + } + + dst := filepath.Join(dir, "out", "result") + skipped, err := copyFile(link, dst) + if err != nil { + t.Fatalf("copyFile returned error: %v", err) + } + if !skipped { + t.Fatalf("expected nix-store symlink to be skipped") + } + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("expected dst to not exist, got err=%v", err) + } +} + +// TestCopyFileSkipsDirectorySymlink проверяет, что симлинк на директорию +// пропускается (а не приводит к ошибке "is a directory"). +func TestCopyFileSkipsDirectorySymlink(t *testing.T) { + dir := t.TempDir() + realDir := filepath.Join(dir, "build-output") + if err := os.Mkdir(realDir, 0755); err != nil { + t.Fatalf("mkdir: %v", err) + } + link := filepath.Join(dir, "result") + if err := os.Symlink(realDir, link); err != nil { + t.Fatalf("symlink: %v", err) + } + + skipped, err := copyFile(link, filepath.Join(dir, "out", "result")) + if err != nil { + t.Fatalf("copyFile returned error: %v", err) + } + if !skipped { + t.Fatalf("expected directory symlink to be skipped") + } +} + +// TestCopyFileCopiesRegularFile проверяет, что обычные файлы копируются как раньше. +func TestCopyFileCopiesRegularFile(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "main.go") + if err := os.WriteFile(src, []byte("package main"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + dst := filepath.Join(dir, "out", "main.go") + skipped, err := copyFile(src, dst) + if err != nil { + t.Fatalf("copyFile error: %v", err) + } + if skipped { + t.Fatalf("expected regular file to be copied, not skipped") + } + got, err := os.ReadFile(dst) + if err != nil { + t.Fatalf("read dst: %v", err) + } + if string(got) != "package main" { + t.Fatalf("content mismatch: %q", string(got)) + } +} + +// TestPrepareSnapshotDirIgnoresNixResult — интеграционный тест: проект с git и +// `result` симлинком успешно снапшотится (не падает на симлинке в /nix/store). +func TestPrepareSnapshotDirIgnoresNixResult(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + dir := t.TempDir() + run := func(args ...string) { + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v - %s", args, err, string(out)) + } + } + run("init") + run("config", "user.email", "test@example.com") + run("config", "user.name", "test") + + if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main"), 0644); err != nil { + t.Fatalf("write: %v", err) + } + // Симулируем артефакт `nix build`: симлинк на директорию в /nix/store. + if err := os.Symlink("/nix/store/xxxx-build", filepath.Join(dir, "result")); err != nil { + t.Fatalf("symlink: %v", err) + } + run("add", "main.go") + run("commit", "-m", "init") + + staging, err := prepareSnapshotDir(dir) + if err != nil { + t.Fatalf("prepareSnapshotDir failed: %v", err) + } + defer os.RemoveAll(staging) + + if _, err := os.Stat(filepath.Join(staging, "main.go")); err != nil { + t.Fatalf("expected main.go in staging: %v", err) + } + if _, err := os.Lstat(filepath.Join(staging, "result")); !os.IsNotExist(err) { + t.Fatalf("expected `result` symlink to be excluded from snapshot, err=%v", err) + } +} diff --git a/orchestrator/internal/service/rules/boss/task.go b/orchestrator/internal/service/rules/boss/task.go index 85c65b5d..1195310b 100644 --- a/orchestrator/internal/service/rules/boss/task.go +++ b/orchestrator/internal/service/rules/boss/task.go @@ -154,20 +154,36 @@ func (s *Service) ExecuteTask(ctx context.Context, req *CreateTaskRequest, progr } tokens["title"] = req.Title validation := s.validateSolution(ctx, provider, model, tokens, decision, managerResults) - if !validation.Approved { + approved := validation == nil || validation.Approved + if !approved { log.Printf("Boss validation rejected: %s", validation.Feedback) } emit(progress, 90, "Packaging project", nil) + // Валидация как гейт (issue #85): если босс не аппрувнул решение, мы НЕ + // публикуем его в GitHub (не создаём PR с заведомо неверным кодом). Результат + // всё равно отдаётся во вкладку Solution с фидбеком босса, чтобы пользователь + // видел, что было сгенерировано и почему отклонено. Гейт можно отключить через + // VALIDATION_GATE=off (fail-open для отладки). + gateEnabled := strings.ToLower(os.Getenv("VALIDATION_GATE")) != "off" // Для не-кодовых задач (research/document/presentation) публикация в GitHub // не требуется (см. issue): результат отдаётся во вкладку Solution и в чат. // Исключение — задача привязана к конкретному GitHub issue. repoURL := "" githubErr := "" isCodeTask := isCodeLikeTask(decision.TaskType) - if isCodeTask || issueTarget != nil { + switch { + case gateEnabled && !approved: + // Валидация как гейт (issue #85): босс отклонил решение — не публикуем + // заведомо неверный код в GitHub. Результат остаётся во вкладке Solution. + log.Printf("Skipping GitHub publish: boss validation rejected the solution") + emit(progress, 90, "Boss rejected the solution — skipping GitHub publish", map[string]string{ + "status": "rejected", + "bossReview": validation.Feedback, + }) + case isCodeTask || issueTarget != nil: repoURL, githubErr = s.pushToGitHub(ctx, task, projectPath, issueTarget, req.Meta) - } else { + default: log.Printf("Skipping GitHub publish for %s task (delivered to Solution tab)", decision.TaskType) } @@ -177,7 +193,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req *CreateTaskRequest, progr // Reload task to get NixStorePath saved by cleanupProject. database.Db.First(task, "id = ?", taskID) - task.Status = "done" + if gateEnabled && !approved { + task.Status = "rejected" + } else { + task.Status = "done" + } database.Db.Save(task) data := map[string]string{ @@ -246,6 +266,11 @@ func (s *Service) ExecuteTask(ctx context.Context, req *CreateTaskRequest, progr } } } + if gateEnabled && !approved { + data["status"] = "rejected" + emit(progress, 100, "Solution rejected by boss validation — see review for required fixes", data) + return nil + } emit(progress, 100, "Project ready! "+task.Title+" created successfully", data) return nil } diff --git a/orchestrator/internal/service/rules/worker/assign.go b/orchestrator/internal/service/rules/worker/assign.go index ac0c196a..74e2f5d2 100644 --- a/orchestrator/internal/service/rules/worker/assign.go +++ b/orchestrator/internal/service/rules/worker/assign.go @@ -77,6 +77,14 @@ func (s *Service) AssignWorkersAndWait(ctx context.Context, req *rules.AssignWor } } + // Гейт результата (issue #85): для кодовых задач воркер обязан реально + // создать хотя бы один файл. Иначе менеджер раньше репортил success при + // пустом результате (например, при таймауте генерации ollama), и босс + // создавал пустой PR. + if isCodeTask(meta.taskType) && len(allFiles) == 0 { + return nil, fmt.Errorf("worker %s produced no files (generation likely failed)", wr.Role) + } + if progress != nil { progress(80, "Worker completed, writing files and committing", map[string]string{ "files": fmt.Sprintf("%d", len(allFiles)), @@ -155,6 +163,16 @@ func (s *Service) AssignWorkersAndWait(ctx context.Context, req *rules.AssignWor conv := orch.SnapshotConversation() allFiles := conv.AllFiles() + // Гейт результата (issue #85): ошибки агентов в group chat раньше молча + // терялись (orch.Run возвращал nil), и менеджер репортил success даже когда + // все воркеры упали. Если ничего не сгенерировано — это провал, а не успех. + if agentErrs := orch.Errors(); len(agentErrs) > 0 && len(allFiles) == 0 { + return nil, fmt.Errorf("all workers failed, no files generated: %w", agentErrs[0]) + } + if isCodeTask(meta.taskType) && len(allFiles) == 0 { + return nil, fmt.Errorf("workers produced no files (generation likely failed)") + } + if progress != nil { progress(80, "All agents completed, writing files and committing", map[string]string{ "files": fmt.Sprintf("%d", len(allFiles)), diff --git a/orchestrator/internal/service/rules/worker/assign_helpers.go b/orchestrator/internal/service/rules/worker/assign_helpers.go index f23a26a7..82680f18 100644 --- a/orchestrator/internal/service/rules/worker/assign_helpers.go +++ b/orchestrator/internal/service/rules/worker/assign_helpers.go @@ -51,6 +51,13 @@ func parseWorkerMetadata(metadata map[string]string) workerMeta { return m } +// isCodeTask — true для кодовых задач (включая дефолтный пустой тип). +// Для них пустой результат воркеров считается провалом (issue #85), тогда как +// для document/research пустой набор файлов кода допустим. +func isCodeTask(taskType string) bool { + return taskType == "" || taskType == "code" +} + // resolveBasePath — определяет директорию проекта для группы воркеров func resolveBasePath(req *rules.AssignWorkersRequest) string { if req.ProjectPath != "" { @@ -102,4 +109,3 @@ func writeContextFile(basePath, taskID, managerID, managerRole string, workerRol os.MkdirAll(octraDir, 0755) os.WriteFile(filepath.Join(octraDir, "context.json"), contextJSON, 0644) } - diff --git a/orchestrator/internal/service/rules/worker/result_gate_test.go b/orchestrator/internal/service/rules/worker/result_gate_test.go new file mode 100644 index 00000000..304aa3e5 --- /dev/null +++ b/orchestrator/internal/service/rules/worker/result_gate_test.go @@ -0,0 +1,21 @@ +package worker + +import "testing" + +// TestIsCodeTask проверяет, для каких типов задач пустой результат воркеров +// считается провалом (issue #85): код (включая дефолтный пустой тип) — да, +// документные/исследовательские — нет. +func TestIsCodeTask(t *testing.T) { + cases := map[string]bool{ + "": true, + "code": true, + "document": false, + "research": false, + "presentation": false, + } + for taskType, want := range cases { + if got := isCodeTask(taskType); got != want { + t.Errorf("isCodeTask(%q) = %v, want %v", taskType, got, want) + } + } +}