feat: авто-уведомления о статусах и хендоффах задачи
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +18,32 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
// fakeNotifier — фейковый нотифаер, собирающий все авто-уведомления воркера.
|
||||
type fakeNotifier struct {
|
||||
notifs []notifCall
|
||||
}
|
||||
|
||||
// notifCall — одно перехваченное уведомление.
|
||||
type notifCall struct {
|
||||
taskID int64
|
||||
chatID string
|
||||
text string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) Notify(_ context.Context, taskID int64, chatID, text string) error {
|
||||
f.notifs = append(f.notifs, notifCall{taskID: taskID, chatID: chatID, text: text})
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifTexts возвращает тексты уведомлений в порядке отправки.
|
||||
func notifTexts(n *fakeNotifier) []string {
|
||||
texts := make([]string, len(n.notifs))
|
||||
for i, c := range n.notifs {
|
||||
texts[i] = c.text
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
type mockRunnerWorker struct {
|
||||
result *opencode.Result
|
||||
err error
|
||||
@@ -263,6 +290,186 @@ func TestWorkerReviewMaxIterations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerStatusNotifications — happy path: владелец получает уведомления
|
||||
// на каждый переход статуса со стороны воркера (running → success)
|
||||
// и на хендофф dev→reviewer.
|
||||
func TestWorkerStatusNotifications(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-ok")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-ok")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
if len(n.notifs) != 3 {
|
||||
t.Fatalf("уведомлений = %d, want 3 (running, dev→reviewer, success)", len(n.notifs))
|
||||
}
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{
|
||||
prefix + ": running",
|
||||
prefix + ": dev → reviewer (итерация 1)",
|
||||
prefix + ": success",
|
||||
}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
// все уведомления уходят владельцу задачи (task.ChatID)
|
||||
for _, c := range n.notifs {
|
||||
if c.chatID != task.ChatID {
|
||||
t.Errorf("уведомление ушло в %q, want %q", c.chatID, task.ChatID)
|
||||
}
|
||||
if c.taskID != task.ID {
|
||||
t.Errorf("уведомление для задачи %d, want %d", c.taskID, task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerHandoffNotifications — цикл dev↔review: уведомления на оба хендоффа
|
||||
// (dev→reviewer и reviewer→dev на доработку) с номером задачи и итерации.
|
||||
func TestWorkerHandoffNotifications(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-loop")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{
|
||||
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||
reviewSequence: []*opencode.Result{
|
||||
reviewFailedRunner(),
|
||||
{RC: 0, Stdout: `{"passed":true,"comments":[]}`},
|
||||
},
|
||||
},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-loop")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{
|
||||
prefix + ": running",
|
||||
prefix + ": dev → reviewer (итерация 1)",
|
||||
prefix + ": reviewer → dev на доработку (итерация 1)",
|
||||
prefix + ": dev → reviewer (итерация 2)",
|
||||
prefix + ": success",
|
||||
}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerIterationsLimitNotification — при исчерпании лимита итераций
|
||||
// владельцу уходит одно уведомление о failed (без дублей с running→failed).
|
||||
func TestWorkerIterationsLimitNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-lim")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{
|
||||
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||
reviewResult: reviewFailedRunner(),
|
||||
},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-lim")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
texts := notifTexts(n)
|
||||
if len(texts) == 0 {
|
||||
t.Fatal("нет уведомлений")
|
||||
}
|
||||
last := texts[len(texts)-1]
|
||||
if !strings.Contains(last, "failed") {
|
||||
t.Errorf("последнее уведомление = %q, want упоминание failed", last)
|
||||
}
|
||||
if !strings.Contains(last, "итераци") {
|
||||
t.Errorf("последнее уведомление = %q, want упоминание лимита итераций", last)
|
||||
}
|
||||
// ровно одно уведомление о failed (running→failed не задваивается)
|
||||
var failedCount int
|
||||
for _, txt := range texts {
|
||||
if strings.Contains(txt, ": failed") {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 1 {
|
||||
t.Errorf("уведомлений о failed = %d, want ровно 1: %#v", failedCount, texts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerTimeoutNotification — RC=-1 (таймаут dev) → уведомление о timeout.
|
||||
func TestWorkerTimeoutNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-timeout")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
|
||||
Worktree: t.TempDir(),
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-timeout")
|
||||
|
||||
ctx := context.Background()
|
||||
_ = w.runTask(ctx, task)
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{prefix + ": running", prefix + ": timeout"}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerSpawnErrorNotification — сбой запуска dev → уведомление о failed.
|
||||
func TestWorkerSpawnErrorNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-spawn")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
|
||||
Worktree: t.TempDir(),
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-spawn")
|
||||
|
||||
ctx := context.Background()
|
||||
_ = w.runTask(ctx, task)
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{prefix + ": running", prefix + ": failed"}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
|
||||
// где JSON находится внутри последнего text-парта.
|
||||
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
||||
|
||||
Reference in New Issue
Block a user