3 Commits

Author SHA1 Message Date
Hermes
43266ea04f feat: воркер берёт задачи только после одобрения (approved)
Some checks failed
CI / test (push) Failing after 49s
CI / build-and-package (amd64, linux) (push) Successful in 42s
CI / build-and-package (amd64, windows) (push) Successful in 45s
Исправляет баг: воркер захватывал задачу на выполнение по статусу ready
ещё до «создавай» (consent был заглушкой). Теперь:

- новый статус approved: «создавай» → ready→approved;
- воркер (pollAndDispatch + runTask) берёт ТОЛЬКО approved,
  ready = черновик готов, ждёт одобрения;
- правка/текст в approved запрещены (финальное одобрение);
- e2e-тест TestE2EWorkerDoesNotTakeUnconfirmed: в ready воркер задачу
  не трогает, запускает только после «создавай» → success;
- обновлены все затронутые тесты (models/core/worker) и retry-фикстуры.
2026-08-17 19:03:42 +05:00
Hermes
f12ff04966 test: E2E перезапуск задачи /retry из различных состояний
Some checks failed
CI / test (push) Failing after 35s
CI / build-and-package (amd64, linux) (push) Successful in 44s
CI / build-and-package (amd64, windows) (push) Successful in 41s
Позитивный блок (задача перезапускается и доходит до success):
  collecting, ready, failed, timeout
Негативный блок (завершённые состояния нельзя перезапускать — только /start):
  success, cancelled → /retry отклоняется понятным сообщением

fix: handleRetry валидирует терминальные статусы (success/cancelled/aborted/
closed) и отвечает «нельзя перезапустить», вместо падения ErrInvalidStatus
2026-08-17 18:24:24 +05:00
a8a2de12c3 Merge pull request 'feat/200acbbe17dab7c1' (#1) from feat/200acbbe17dab7c1 into main
All checks were successful
CI / test (push) Successful in 45s
CI / build-and-package (amd64, linux) (push) Successful in 44s
CI / build-and-package (amd64, windows) (push) Successful in 42s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/1
2026-08-17 16:05:54 +05:00
7 changed files with 785 additions and 23 deletions

722
internal/app/e2e_test.go Normal file
View File

@@ -0,0 +1,722 @@
package app
// Интеграционный (сквозной) тест «всё приложение от постановки задачи».
//
// Закрывает оба слоя конвейера одним прогоном, как в проде:
//
// handleIncoming (/start) → Core.ProcessTurn → Analyst (opencode) → задача ready
// Worker.runTask: dev → reviewer → настоящий git push → success
//
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
// возвращая NDJSON-вердикты нужного формата для каждого.
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/analyst"
"github.com/kamelion/ratatoskr-go/internal/chat"
"github.com/kamelion/ratatoskr-go/internal/core"
"github.com/kamelion/ratatoskr-go/internal/opencode"
"github.com/kamelion/ratatoskr-go/internal/storage"
"github.com/kamelion/ratatoskr-go/internal/worker"
)
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
// представление JSON-вердикта агента (как это делает реальный opencode).
func ndjsonText(t *testing.T, payload string) string {
t.Helper()
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
if err != nil {
t.Fatalf("json.Marshal payload: %v", err)
}
return `{"type":"text","part":{"text":` + string(b) + `}}`
}
// e2eFakeOpenCode пишет shell-скрипт, имитирующий opencode run.
// Различает агента по argv ($3 = имя агента после "--agent").
//
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
// dev → простой NDJSON "done"
// reviewer→ NDJSON c {"passed":true} в text-парте
func e2eFakeOpenCode(t *testing.T, dir string) string {
t.Helper()
analystNDJSON := ndjsonText(t, `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`)
reviewerNDJSON := ndjsonText(t, `{"passed":true,"comments":[]}`)
// каждый вариант печатаем через printf '%s' с одинарными кавычками:
// NDJSON содержит двойные кавычки и бэкслеши, но не одинарные — безопасно.
analystLine := "printf '%s\\n' '" + analystNDJSON + "'"
reviewerLine := "printf '%s\\n' '" + reviewerNDJSON + "'"
script := `#!/bin/sh
agent="$3"
case "$agent" in
analyst)
` + analystLine + `
;;
reviewer)
` + reviewerLine + `
;;
dev)
printf '%%s\n' '{"type":"text","part":{"text":"done"}}'
;;
*)
printf '%%s\n' '{"type":"text","part":{"text":"unknown agent"}}'
;;
esac
exit 0
`
bin := filepath.Join(dir, "opencode")
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write e2e fake opencode: %v", err)
}
return bin
}
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
// каталог worktree и fake-канал (для проверки исходящих).
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
t.Helper()
dir := t.TempDir()
worktree := filepath.Join(dir, "worktrees")
ctx := context.Background()
store, err := storage.Open(ctx, filepath.Join(dir, "e2e.db"))
if err != nil {
t.Fatalf("storage.Open: %v", err)
}
t.Cleanup(func() { store.Close() })
bin := e2eFakeOpenCode(t, dir)
runner := &opencode.Runner{
Bin: bin,
PollInterval: 20 * time.Millisecond,
IdleTimeout: 5 * time.Second,
HardTimeout: 30 * time.Second,
}
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
coreCtx := core.New(store, an)
// Router с fake-каналом: ловим исходящие replies для проверки.
fake := &e2eChannel{sent: make([]chat.Message, 0)}
a := &App{
Store: store,
CoreCtx: coreCtx,
}
a.Router = chat.NewRouter(a.handleIncoming)
if err := a.Router.Attach(fake); err != nil {
t.Fatalf("Attach fake channel: %v", err)
}
w := &worker.Worker{
Store: store,
Runner: runner,
Worktree: worktree,
Agent: "dev",
Interval: 30 * time.Millisecond,
MaxJobs: 1,
Live: opencode.NewLiveRegistry(),
// GitToken зададим пустым: origin в seed-репо локальный (file path),
// http.extraHeader не нужен для локального пуша.
}
a.Worker = w
return a, worktree, fake
}
// e2eChannel — минимальный fake-канал для перехвата исходящих
// и доставки входящих через роутер (как реальный канал).
type e2eChannel struct {
onMsg func(chat.Incoming)
sent []chat.Message
}
func (c *e2eChannel) Run(_ context.Context) error { return nil }
func (c *e2eChannel) OnMessage(f func(chat.Incoming)) {
c.onMsg = f
}
func (c *e2eChannel) Send(_ context.Context, _ chat.Address, m chat.Message) error {
c.sent = append(c.sent, m)
return nil
}
func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
return nil
}
func (c *e2eChannel) Close() error { return nil }
// deliver отправляет входящее сообщение через роутер: ставит маршрут
// пользователя и вызывает app.handleIncoming (как в проде).
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
if c.onMsg != nil {
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
}
}
// seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
// bare-origin + клон с начальным коммитом на main, чтобы воркер мог
// выполнять fetch origin, создавать ветку и пушить. (Дубликат из worker_test,
// вынесен сюда, т.к. интеграционный тест живёт в пакете app, а не worker.)
func (a *App) 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)
}
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")
}
// TestE2EWholeAppFromTaskSetup — положительный сквозной путь «всё приложение»:
// постановка задачи через аналитика (до ready), затем воркер (dev→reviewer→push)
// до success, и проверка, что feature-ветка реально запушена в origin.
func TestE2EWholeAppFromTaskSetup(t *testing.T) {
a, worktree, fake := e2eAssemble(t)
a.seedFakeRepo(t, worktree, "calc")
ctx := context.Background()
uid := chat.UserID("u1")
// --- 1. Постановка: /start → задача collecting ---
fake.deliver(uid, "/start")
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
if err != nil {
t.Fatalf("get task after /start: %v", err)
}
if task.Status != storage.StatusCollecting {
t.Errorf("status после /start = %q, want collecting", task.Status)
}
// --- 2. Постановка: ответ пользователя → аналитик (opencode) → ready ---
// Фейк-analyst возвращает propose с repo=calc → задача должна стать ready.
fake.deliver(uid, "Сделай калькулятор в calc")
task, err = a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusReady {
t.Fatalf("status после analyst = %q, want ready", task.Status)
}
if len(task.EffectiveRepos()) == 0 {
t.Errorf("repos пусто, want [calc]")
}
// --- 3. Согласие «создавай» → задача одобрена (approved) ---
fake.deliver(uid, "создавай")
task, err = a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusApproved {
t.Errorf("status после создавай = %q, want approved", task.Status)
}
// --- 4. Исполнение: воркер (poll) dev → reviewer → push ---
// Запускаем реальный poll-диспетчер и ждём, пока задача доедет до success.
wkCtx, wkCancel := context.WithCancel(ctx)
defer wkCancel()
a.Worker.Start(wkCtx)
deadline := time.Now().Add(30 * time.Second)
var last storage.Status
for {
task, err = a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
last = task.Status
if task.Status == storage.StatusSuccess || task.Status == storage.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("таймаут ожидания success, последний статус %q", last)
}
time.Sleep(50 * time.Millisecond)
}
task, err = a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusSuccess {
t.Fatalf("status после воркера = %q, want success", task.Status)
}
// ревью вызывается ровно один раз (passed сразу) — 2 трассы: dev + reviewer
traces, err := a.Store.GetTraces(ctx, task.ID)
if err != nil {
t.Fatalf("get traces: %v", err)
}
if len(traces) != 2 {
t.Fatalf("traces = %d, want 2 (dev + reviewer)", len(traces))
}
if traces[0].Agent != "dev" || traces[0].Status != storage.TraceSuccess {
t.Errorf("trace[0] = %s/%s, want dev/success", traces[0].Agent, traces[0].Status)
}
if traces[1].Agent != "reviewer" || traces[1].Status != storage.TraceSuccess {
t.Errorf("trace[1] = %s/%s, want reviewer/success", traces[1].Agent, traces[1].Status)
}
// --- 5. Проверка настоящего push: feature-ветка есть в origin ---
branch := "feat/" + task.TaskTag
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
if err != nil {
t.Fatalf("ls-remote origin: %v\n%s", err, out)
}
if !strings.Contains(string(out), "refs/heads/"+branch) {
t.Errorf("feature-ветка %q не найдена в origin:\n%s", branch, out)
}
// --- 6. Бот подтвердил через канал (хотя бы одно исходящее было) ---
if len(fake.sent) == 0 {
t.Error("нет ни одного исходящего сообщения через Router")
}
t.Logf("E2E OK: задача #%d %s, ветка %s, traces=%d", task.ID, task.Status, branch, len(traces))
}
// ---------------------------------------------------------------------------
// TestE2ERetryFromStates — положительный сценарий «перезапуск задачи»:
// задача перезапускается /retry N из различных стартовых состояний и после
// повторного цикла (аналитик → ready → создавай → воркер) снова доходит до
// success с повторным реальным push feature-ветки.
//
// Матрица стартовых состояний:
// collecting / ready / cancelled — реальными командами через канал;
// success — реальным полным циклом воркера;
// failed / timeout — фикстурой через Store (валидной цепочкой переходов),
// т.к. механика их достижения в воркере уже покрыта worker_test.go, а
// timeout вообще нельзя сэмулировать без реального зависания (RC=-1
// ставит только kill по таймауту).
// ---------------------------------------------------------------------------
// e2eChainToState приводит задачу к заданному статусу валидной цепочкой
// переходов через Store.UpdateTask (фикстура для failed/timeout).
func e2eChainToState(t *testing.T, store *storage.Storage, id int64, target storage.Status) {
t.Helper()
ctx := context.Background()
// валидные цепочки draft → ... → target (проверяются UpdateTask'ом)
var chain []storage.Status
switch target {
case storage.StatusFailed:
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusFailed}
case storage.StatusTimeout:
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusTimeout}
default:
t.Fatalf("e2eChainToState: неподдерживаемый target %q", target)
}
for _, s := range chain {
tk, err := store.GetTask(ctx, id)
if err != nil {
t.Fatalf("e2eChainToState: get task %d: %v", id, err)
}
tk.Status = s
if err := store.UpdateTask(ctx, tk); err != nil {
t.Fatalf("e2eChainToState: %s → %s: %v", tk.Status, s, err)
}
}
}
// e2eWaitWorker запускает poll-диспетчер воркера и ждёт, пока задача дойдёт
// до success или failed (лимит 30s). Возвращает итоговый статус.
func e2eWaitWorker(t *testing.T, a *App, id int64) storage.Status {
t.Helper()
ctx := context.Background()
wkCtx, wkCancel := context.WithCancel(ctx)
defer wkCancel()
a.Worker.Start(wkCtx)
deadline := time.Now().Add(30 * time.Second)
var last storage.Status
for {
tk, err := a.Store.GetTask(ctx, id)
if err != nil {
t.Fatalf("e2eWaitWorker: get task %d: %v", id, err)
}
last = tk.Status
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
return tk.Status
}
if time.Now().After(deadline) {
t.Fatalf("e2eWaitWorker: таймаут, последний статус %q", last)
}
time.Sleep(50 * time.Millisecond)
}
}
// e2eRetryCommon — общий хвост после /retry N для любого стартового состояния.
// Проверяет: collecting → аналитик → ready → создавай → воркер → success,
// повторный push ветки, и что тег задачи переиспользован (не сменился).
func e2eRetryCommon(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, taskID int64) {
t.Helper()
ctx := context.Background()
// тег до retry — должен остаться прежним после повторного цикла
before, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get task %d: %v", taskID, err)
}
origTag := before.TaskTag
baseTraces, err := a.Store.GetTraces(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get traces (before): %v", err)
}
baseN := len(baseTraces)
// 1. /retry N → collecting, история очищена
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
tk, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get task after /retry: %v", err)
}
if tk.Status != storage.StatusCollecting {
t.Errorf("retry: status = %q, want collecting", tk.Status)
}
// 2. новый текст → аналитик → ready (repos не пуст)
fake.deliver(chat.UserID(uid), "Переделай: добавь деление")
tk, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get task after analyst: %v", err)
}
if tk.Status != storage.StatusReady {
t.Fatalf("retry: status после аналитика = %q, want ready", tk.Status)
}
if len(tk.EffectiveRepos()) == 0 {
t.Errorf("retry: repos пусто, want [calc]")
}
// 3. «создавай» → approved (одобрено, воркер заберёт)
fake.deliver(chat.UserID(uid), "создавай")
tk, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get task after создавай: %v", err)
}
if tk.Status != storage.StatusApproved {
t.Errorf("retry: status после создавай = %q, want approved", tk.Status)
}
// 4. воркер → success
if got := e2eWaitWorker(t, a, taskID); got != storage.StatusSuccess {
t.Fatalf("retry: итог = %q, want success", got)
}
// 5. проверки после повторного цикла
tk, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get task final: %v", err)
}
if tk.TaskTag != origTag {
t.Errorf("retry: task_tag сменился %q → %q, want неизменный", origTag, tk.TaskTag)
}
branch := "feat/" + tk.TaskTag
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
if err != nil {
t.Fatalf("retry: ls-remote origin: %v\n%s", err, out)
}
if !strings.Contains(string(out), "refs/heads/"+branch) {
t.Errorf("retry: feature-ветка %q не найдена в origin после повторного push:\n%s", branch, out)
}
// 6. появились ровно 2 новые трассы (dev + reviewer), обе успешные
traces, err := a.Store.GetTraces(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryCommon: get traces (after): %v", err)
}
if len(traces) != baseN+2 {
t.Fatalf("retry: трасс = %d, want %d (base %d + dev + reviewer)", len(traces), baseN+2, baseN)
}
if traces[baseN].Agent != "dev" || traces[baseN].Status != storage.TraceSuccess {
t.Errorf("retry: trace[%d] = %s/%s, want dev/success", baseN, traces[baseN].Agent, traces[baseN].Status)
}
if traces[baseN+1].Agent != "reviewer" || traces[baseN+1].Status != storage.TraceSuccess {
t.Errorf("retry: trace[%d] = %s/%s, want reviewer/success", baseN+1, traces[baseN+1].Agent, traces[baseN+1].Status)
}
// 7. бот ответил
if len(fake.sent) == 0 {
t.Error("retry: нет ни одного исходящего сообщения через Router")
}
t.Logf("RETRY OK: задача #%d %s (из %s), ветка %s, new traces=%d", taskID, tk.Status, before.Status, branch, len(traces)-baseN)
}
// e2eSetupState приводит задачу к стартовому состоянию S и возвращает её ID.
// Для состояний, достижимых командами — реальный путь через канал; для
// failed/timeout — фикстура через Store.
func e2eSetupState(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, state storage.Status) int64 {
t.Helper()
ctx := context.Background()
switch state {
case storage.StatusCollecting:
fake.deliver(chat.UserID(uid), "/start")
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
if err != nil {
t.Fatalf("setup collecting: %v", err)
}
return tk.ID
case storage.StatusReady:
fake.deliver(chat.UserID(uid), "/start")
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
if err != nil {
t.Fatalf("setup ready: %v", err)
}
if tk.Status != storage.StatusReady {
t.Fatalf("setup ready: status = %q, want ready", tk.Status)
}
return tk.ID
case storage.StatusCancelled:
fake.deliver(chat.UserID(uid), "/start")
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
if err != nil {
t.Fatalf("setup cancelled: get active: %v", err)
}
fake.deliver(chat.UserID(uid), "/cancel")
tk, err = a.Store.GetTask(ctx, tk.ID)
if err != nil {
t.Fatalf("setup cancelled: get task: %v", err)
}
if tk.Status != storage.StatusCancelled {
t.Fatalf("setup cancelled: status = %q, want cancelled", tk.Status)
}
return tk.ID
case storage.StatusSuccess:
// реальный полный цикл до success
fake.deliver(chat.UserID(uid), "/start")
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
fake.deliver(chat.UserID(uid), "создавай")
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
if err != nil {
t.Fatalf("setup success: get active: %v", err)
}
if got := e2eWaitWorker(t, a, tk.ID); got != storage.StatusSuccess {
t.Fatalf("setup success: итог = %q, want success", got)
}
return tk.ID
case storage.StatusFailed, storage.StatusTimeout:
// фикстура: создаём задачу и прогоняем валидную цепочку переходов
id, err := a.Store.CreateTask(ctx, &storage.Task{ChatID: uid})
if err != nil {
t.Fatalf("setup %s: create task: %v", state, err)
}
e2eChainToState(t, a.Store, id, state)
return id
default:
t.Fatalf("setup: неподдерживаемое состояние %q", state)
return 0
}
}
// TestE2ERetryFromStates — перезапуск задачи /retry N из различных состояний.
//
// Позитивный блок (задача реально перезапускается и доходит до success):
// collecting, ready, failed, timeout.
// Негативный блок (завершённые состояния нельзя перезапускать — только /start):
// success, cancelled → /retry отклоняется понятным сообщением, статус не меняется.
func TestE2ERetryFromStates(t *testing.T) {
a, worktree, fake := e2eAssemble(t)
a.seedFakeRepo(t, worktree, "calc")
// --- Позитивный блок: перезапуск и повторный успешный цикл ---
positive := []storage.Status{
storage.StatusCollecting,
storage.StatusReady,
storage.StatusFailed,
storage.StatusTimeout,
}
for _, st := range positive {
st := st
t.Run("positive/"+string(st), func(t *testing.T) {
uid := fmt.Sprintf("retry-pos-%s", st)
id := e2eSetupState(t, a, fake, worktree, uid, st)
e2eRetryCommon(t, a, fake, worktree, uid, id)
})
}
// --- Негативный блок: завершённые состояния перезапуску не подлежат ---
terminal := []storage.Status{
storage.StatusSuccess,
storage.StatusCancelled,
}
for _, st := range terminal {
st := st
t.Run("negative/"+string(st), func(t *testing.T) {
uid := fmt.Sprintf("retry-neg-%s", st)
id := e2eSetupState(t, a, fake, worktree, uid, st)
e2eRetryRejected(t, a, fake, uid, id)
})
}
}
// e2eRetryRejected проверяет, что /retry на завершённую задачу НЕ перезапускает:
// статус остаётся терминальным, а пользователю уходит понятное сообщение.
func e2eRetryRejected(t *testing.T, a *App, fake *e2eChannel, uid string, taskID int64) {
t.Helper()
ctx := context.Background()
before, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryRejected: get task %d: %v", taskID, err)
}
if !storage.IsTerminal(before.Status) {
t.Fatalf("e2eRetryRejected: предпосылка — статус %q должен быть терминальным", before.Status)
}
// сбрасываем перехваченные исходящие, чтобы ловить только ответ на /retry
fake.sent = nil
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
after, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("e2eRetryRejected: get task after /retry: %v", err)
}
if after.Status != before.Status {
t.Errorf("retry(neg): статус изменился %q → %q, want без изменений", before.Status, after.Status)
}
if after.TaskTag != before.TaskTag {
t.Errorf("retry(neg): task_tag сменился, want неизменный")
}
// понятное сообщение пользователю: говорим про невозможность перезапуска
found := false
for _, m := range fake.sent {
if strings.Contains(m.Text, "нельзя перезапустить") {
found = true
break
}
}
if !found {
t.Errorf("retry(neg): нет понятного ответа «нельзя перезапустить», отправлено: %d сообщений", len(fake.sent))
}
t.Logf("RETRY-REJECTED OK: задача #%d осталась %q", taskID, after.Status)
}
// TestE2EWorkerDoesNotTakeUnconfirmed — регрессия бага, когда воркер брал
// задачу на выполнение ещё до «создавай» (по статусу ready, без одобрения).
// Проверяет: пока задача в ready, воркер её НЕ трогает; она уходит в работу
// только после «создавай» → approved.
func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
a, worktree, fake := e2eAssemble(t)
a.seedFakeRepo(t, worktree, "calc")
ctx := context.Background()
uid := chat.UserID("unconf")
// --- 1. Постановка до ready, без «создавай» ---
fake.deliver(uid, "/start")
fake.deliver(uid, "Сделай калькулятор в calc")
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusReady {
t.Fatalf("status после analyst = %q, want ready (черновик готов, но НЕ одобрен)", task.Status)
}
beforeTraces, _ := a.Store.GetTraces(ctx, task.ID)
// --- 2. Запускаем воркер и даём ему время «промахнуться» ---
wkCtx, wkCancel := context.WithCancel(ctx)
defer wkCancel()
a.Worker.Start(wkCtx)
time.Sleep(600 * time.Millisecond) // несколько poll-итераций (Interval=30ms)
// --- 3. Проверяем, что воркер НЕ взял неодобренную задачу ---
after, err := a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task after worker: %v", err)
}
if after.Status != storage.StatusReady {
t.Fatalf("БАГ: воркер взял неодобренную задачу — status = %q, want ready. Задача должна ждать «создавай».", after.Status)
}
afterTraces, _ := a.Store.GetTraces(ctx, task.ID)
if len(afterTraces) != len(beforeTraces) {
t.Errorf("БАГ: появились трассы без одобрения (было %d, стало %d)", len(beforeTraces), len(afterTraces))
}
// ветка не должна была создаться
branch := "feat/" + after.TaskTag
out, _ := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
if strings.Contains(string(out), "refs/heads/"+branch) {
t.Errorf("БАГ: feature-ветка %q уже в origin до одобрения", branch)
}
// --- 4. «создавай» → approved, теперь воркер берёт и доезжает до success ---
fake.deliver(uid, "создавай")
tk, err := a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task after создавай: %v", err)
}
if tk.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %q, want approved", tk.Status)
}
deadline := time.Now().Add(30 * time.Second)
for {
tk, err = a.Store.GetTask(ctx, task.ID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
}
time.Sleep(50 * time.Millisecond)
}
if tk.Status != storage.StatusSuccess {
t.Fatalf("status после воркера = %q, want success", tk.Status)
}
// ветка теперь должна быть в origin
out, err = exec.Command("git", "-C", filepath.Join(worktree, "calc"),
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
if err != nil {
t.Fatalf("ls-remote origin: %v\n%s", err, out)
}
if !strings.Contains(string(out), "refs/heads/"+branch) {
t.Errorf("feature-ветка %q не найдена в origin после одобрения:\n%s", branch, out)
}
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
}

View File

@@ -52,7 +52,7 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
return Result{}, err
}
// 2. согласие в фазе ready → создание
// 2. согласие в фазе ready → одобрение
if task.Status == storage.StatusReady {
if isConsent(text) {
return c.handleConsent(ctx, task)
@@ -61,6 +61,17 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
return c.handleEdit(ctx, task, text)
}
// 2b. approved — финальное одобрение, правка запрещена.
// Воркер уже взял/заберёт задачу; текст не меняет статус.
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// 3. обычный ход: накопление + аналитик
return c.handleTurn(ctx, task, text)
}
@@ -188,6 +199,16 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if err != nil {
return c.notFoundReply(ctx, id, err)
}
// Завершённые задачи (success/cancelled/aborted/closed) перезапускать нельзя:
// переход → collecting для них невалиден. Только новая задача через /start.
if storage.IsTerminal(task.Status) {
return Result{
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
}
task.Status = storage.StatusCollecting
if err := c.Store.ClearHistory(ctx, id); err != nil {
return Result{}, err
@@ -261,14 +282,15 @@ func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result,
}, nil
}
// handleConsent создаёт задачу (статус ready → ...). Пока — подтверждение готовности.
// handleConsent одобряет задачу: ready → approved (финальное одобрение,
// после которого воркер забирает задачу на выполнение).
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
task.Status = storage.StatusReady
task.Status = storage.StatusApproved
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " готова к запуску.",
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,

View File

@@ -160,6 +160,10 @@ func TestConsentInReady(t *testing.T) {
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %s, want approved", task.Status)
}
}
func TestEditInReadyGoesCollecting(t *testing.T) {

View File

@@ -8,7 +8,8 @@ type Status string
const (
StatusDraft Status = "draft" // только что создана
StatusCollecting Status = "collecting" // аналитик собирает детали
StatusReady Status = "ready" // черновик готов, ждёт запуска
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу
StatusRunning Status = "running" // opencode работает
StatusSuccess Status = "success" // задача выполнена
StatusFailed Status = "failed" // ошибка выполнения
@@ -20,23 +21,26 @@ const (
// AllStatuses — все возможные статусы для валидации.
var AllStatuses = []Status{
StatusDraft, StatusCollecting, StatusReady,
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
StatusCancelled, StatusAborted, StatusClosed,
}
// validTransitions задаёт разрешённые переходы статусов.
var validTransitions = map[Status][]Status{
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting}, // правка готового
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusCancelled: {StatusClosed},
StatusAborted: {StatusClosed},
StatusClosed: {}, // терминальный
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
StatusSuccess: {StatusClosed},
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusTimeout: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
StatusCancelled: {StatusClosed},
StatusAborted: {StatusClosed},
StatusClosed: {}, // терминальный
}
// IsValidTransition проверяет, допустим ли переход from → to.

View File

@@ -25,6 +25,12 @@ func TestIsValidTransition(t *testing.T) {
{StatusTimeout, StatusReady, true}, // retry
{StatusTimeout, StatusCollecting, true}, // retry: перезапуск сбора
{StatusTimeout, StatusRunning, false},
{StatusReady, StatusApproved, true}, // «создавай» → одобрено
{StatusReady, StatusRunning, false}, // без одобрения воркер не запускает
{StatusApproved, StatusRunning, true}, // воркер берёт approved
{StatusApproved, StatusCancelled, true}, // отмена до запуска
{StatusApproved, StatusReady, false}, // финал: назад нельзя
{StatusApproved, StatusCollecting, false},
{StatusClosed, StatusDraft, false},
{StatusClosed, StatusRunning, false},
}

View File

@@ -113,7 +113,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
}
tasks, err := w.Store.ListTasks(ctx, storage.TaskFilter{
Status: storage.StatusReady,
Status: storage.StatusApproved,
Limit: slots,
})
if err != nil {
@@ -142,7 +142,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
// dev дорабатывает по комментариям; прошло → push ветки + success.
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
// 1. проверяем статус
if task.Status != storage.StatusReady {
if task.Status != storage.StatusApproved {
return fmt.Errorf("%w: task %d status=%q", ErrLaunch, task.ID, task.Status)
}

View File

@@ -95,6 +95,10 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("set ready: %v", err)
}
task.Status = storage.StatusApproved
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("set approved: %v", err)
}
task, _ = s.GetTask(ctx, id)
return task
}
@@ -625,14 +629,14 @@ func TestWorkerSemaphore(t *testing.T) {
w.pollAndDispatch(ctx)
time.Sleep(200 * time.Millisecond)
// 1 должна быть success, 1 — всё ещё ready
// 1 должна быть success, 1 — всё ещё approved
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
ready, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusReady})
approved, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusApproved})
if len(success) != 1 {
t.Errorf("success = %d, want 1 (ready=%d)", len(success), len(ready))
t.Errorf("success = %d, want 1 (approved=%d)", len(success), len(approved))
}
if len(ready) != 1 {
t.Errorf("ready = %d, want 1", len(ready))
if len(approved) != 1 {
t.Errorf("approved = %d, want 1", len(approved))
}
// первая завершилась и вернула токен в сем — можем диспатчить вторую