Files
ratatoskr-go/internal/core/core.go
ki.sagidullin ed13386612
Some checks failed
CI / test (push) Failing after 1m54s
CI / build-and-package (amd64, linux) (push) Failing after 1m21s
CI / build-and-package (amd64, windows) (push) Successful in 33s
fix(core): /retry чистит postmortem-трассы — на новом прогоне постмортем запускается заново
2026-08-24 14:56:39 +05:00

516 lines
17 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package core
import (
"context"
"fmt"
"strings"
"github.com/kamelion/ratatoskr-go/internal/events"
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// Core — ядро Ratatoskr: машина состояний поверх storage.
type Core struct {
// Store — хранилище задач и трасс.
Store *storage.Storage
Decide Decider
MaxTurns int // D3
MaxConfirmCycles int // D4
MaxQuestionsPerTurn int
// Live — опциональный просмотр живой сессии задачи (для /status N).
Live LiveProber
// Events — издатель доменных событий для UI. nil — события выключены.
Events events.Publisher
}
// publish отправляет доменное событие, если задан издатель.
func (c *Core) publish(e events.Event) {
if c.Events != nil {
c.Events.Publish(e)
}
}
// setStatus переводит задачу в новый статус: сохраняет в БД и публикует событие.
// from фиксируется до перехода (для TaskStatusChanged).
func (c *Core) setStatus(ctx context.Context, task *storage.Task, to storage.Status) error {
from := task.Status
task.Status = to
if err := c.Store.UpdateTask(ctx, task); err != nil {
return err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: to})
return nil
}
// LiveProber — абстракция за журналом живых сессий (реализация — *opencode.LiveRegistry).
// Возвращает текстовое описание текущего состояния сессии задачи и ok=была ли активна.
type LiveProber interface {
Probe(taskID int64) (description string, ok bool)
}
// New создаёт Core с дефолтными лимитами.
func New(store *storage.Storage, decide Decider) *Core {
return &Core{
Store: store,
Decide: decide,
MaxTurns: 15,
MaxConfirmCycles: 3,
MaxQuestionsPerTurn: 5,
}
}
// ProcessTurn обрабатывает один ход для задачи. Меняет состояние в БД.
func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Result, error) {
text = strings.TrimSpace(text)
// 1. команды — обрабатываются до выборки «текущей» задачи,
// т.к. /retry N /status N /continue N ссылаются на другую задачу
if strings.HasPrefix(text, "/") {
return c.handleCommand(ctx, taskID, text)
}
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
// 2. согласие в фазе ready → одобрение
if task.Status == storage.StatusReady {
if isConsent(text) {
return c.handleConsent(ctx, task)
}
// правка черновика → снова сбор
return c.handleEdit(ctx, task, text)
}
// 2b. approved — финальное одобрение, правка запрещена.
// Воркер уже взял/заберёт задачу; текст не меняет статус.
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
}
// 3. обычный ход: накопление + аналитик
return c.handleTurn(ctx, task, text)
}
// greeting — приветствие для /start.
const greeting = "⚡ Ratatoskr на связи. Опишите задачу — я помогу её продумать. " +
"(команды: /cancel — отмена, /skip — хватит вопросов, /retry N — перезапустить задачу, /status N — статус)"
// handleCommand обрабатывает "/"-команды. Минимальный набор.
// taskID — текущая задача пользователя (может не существовать для "чужих" команд).
func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Result, error) {
cmd := text
if i := strings.IndexByte(cmd, ' '); i > 0 {
cmd = cmd[:i]
}
cmd = strings.ToLower(cmd)
rest := strings.TrimSpace(text[len(cmd):])
switch cmd {
case "/start":
return c.handleStart(ctx, taskID)
case "/cancel":
return c.handleCancel(ctx, taskID)
case "/skip":
return c.handleSkip(ctx, taskID)
case "/retry":
return c.handleRetry(ctx, rest)
case "/status":
return c.handleStatus(ctx, rest)
case "/continue":
return c.handleContinue(ctx, rest)
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
TaskID: taskID,
}, nil
}
}
// handleStart переводит задачу в сбор (collecting) и чистит историю.
// Возвращает greeting. task_tag переиспользуется (стабильный UUID задачи).
func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
return Result{}, err
}
return Result{
Reply: greeting,
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleCancel отменяет задачу (терминальный статус cancelled).
func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
if err := c.setStatus(ctx, task, storage.StatusCancelled); err != nil {
return Result{}, err
}
return Result{
Reply: "🚫 Отменил.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleSkip принудительно предлагает черновик в фазе collecting, либо подсказывает.
func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
task, err := c.Store.GetTask(ctx, taskID)
if err != nil {
return Result{}, err
}
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// принудительный вызов аналитика
return c.runDecide(ctx, task, true)
}
// parseTaskID извлекает целое из строки после команды.
func parseTaskID(s string) (int64, bool) {
s = strings.TrimSpace(s)
if s == "" {
return 0, false
}
var id int64
for _, r := range s {
if r < '0' || r > '9' {
return 0, false
}
id = id*10 + int64(r-'0')
}
return id, id > 0
}
// handleRetry перезапускает задачу N: чистит историю и ставит collecting.
func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
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.",
TaskID: id,
Status: task.Status,
}, nil
}
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
if err := c.Store.ClearHistory(ctx, id); err != nil {
return Result{}, err
}
// Чистый перезапуск: сбрасываем маркер постмортем-анализа от прошлого
// прогона, чтобы на новом failed/timeout постмортем запустился заново.
if err := c.Store.DeleteTracesByAgent(ctx, id, "postmortem"); err != nil {
return Result{}, err
}
return Result{
Reply: "Задача перезапущена. Опишите, что меняем:",
TaskID: id,
Status: task.Status,
}, nil
}
// handleStatus возвращает статус задачи N.
func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
if err != nil {
return c.notFoundReply(ctx, id, err)
}
reply := "Задача #" + itoa(id) + ": " + string(task.Status)
// Для выполняемой задачи прикладываем живое состояние сессии (если настроено).
if task.Status == storage.StatusRunning && c.Live != nil {
if desc, ok := c.Live.Probe(id); ok && desc != "" {
reply += "\n" + desc
}
}
return Result{
Reply: reply,
TaskID: id,
Status: task.Status,
}, nil
}
// handleContinue — заглушка (резюм opencode-сессии), подключим позже.
func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error) {
id, ok := parseTaskID(rest)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
if err != nil {
return c.notFoundReply(ctx, id, err)
}
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
TaskID: id,
Status: task.Status,
}, nil
}
// notFoundReply формирует reply для ненайденной задачи.
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
}, nil
}
// handleConsent одобряет задачу: ready → approved (финальное одобрение,
// после которого воркер забирает задачу на выполнение).
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
if err := c.setStatus(ctx, task, storage.StatusApproved); err != nil {
return Result{}, err
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
// handleEdit — правка черновика в фазе ready → снова сбор + аналитик.
func (c *Core) handleEdit(ctx context.Context, task *storage.Task, text string) (Result, error) {
from := task.Status
task.Status = storage.StatusCollecting
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err
}
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
return c.runDecide(ctx, task, false)
}
// handleTurn — обычный ход в фазе collecting: накопление + аналитик.
func (c *Core) handleTurn(ctx context.Context, task *storage.Task, text string) (Result, error) {
// переход draft→collecting нужно персистить до вызова аналитика,
// иначе propose сделает draft→ready (невалидно)
if task.Status != storage.StatusCollecting {
from := task.Status
task.Status = storage.StatusCollecting
if err := c.Store.UpdateTask(ctx, task); err != nil {
return Result{}, err
}
c.publish(events.TaskStatusChanged{ID: task.ID, From: from, To: task.Status})
}
if text != "" {
if err := c.Store.AppendHistory(ctx, task.ID, "user", text); err != nil {
return Result{}, err
}
}
return c.runDecide(ctx, task, false)
}
// runDecide вызывает аналитика и применяет вердикт.
func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (Result, error) {
history, err := c.Store.GetHistory(ctx, task.ID)
if err != nil {
return Result{}, err
}
if !force && c.MaxTurns > 0 {
userTurns := 0
for _, h := range history {
if h.Role == "user" {
userTurns++
}
}
if userTurns > c.MaxTurns {
return Result{
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
}
decision, err := c.Decide.Decide(ctx, msgs, *task, force)
if err != nil {
return Result{}, fmt.Errorf("%w: %v", ErrDecideFailed, err)
}
switch decision.Phase {
case "abort":
if err := c.setStatus(ctx, task, storage.StatusAborted); err != nil {
return Result{}, err
}
reply := decision.ChatReply
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
applyDraft(task, decision.Draft)
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
if len(task.EffectiveRepos()) == 0 {
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
if err := c.setStatus(ctx, task, storage.StatusReady); err != nil {
return Result{}, err
}
return Result{
Reply: formatSummary(*task),
TaskID: task.ID,
Status: task.Status,
}, nil
default: // "ask"
applyDraft(task, decision.Draft)
if err := c.setStatus(ctx, task, storage.StatusCollecting); err != nil {
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
}
// applyDraft копирует непустые draft-поля из вердикта в задачу.
func applyDraft(task *storage.Task, dec storage.Task) {
if dec.Title != "" {
task.Title = dec.Title
}
if dec.Goal != "" {
task.Goal = dec.Goal
}
if dec.Repo != "" {
task.Repo = dec.Repo
}
if len(dec.Repos) > 0 {
task.Repos = dec.Repos
task.Repo = strings.Join(dec.Repos, ",")
}
if dec.Why != "" {
task.Why = dec.Why
}
if dec.AC != "" {
task.AC = dec.AC
}
}
// decChatReply возвращает ChatReply из вердикта или переданный дефолт.
func decChatReply(dec Decision, fallback string) string {
if dec.ChatReply != "" {
return dec.ChatReply
}
return fallback
}
// buildAskReply формирует ответ с вопросами.
func buildAskReply(dec Decision, max int) string {
reply := dec.ChatReply
if reply == "" {
reply = "Уточню детали."
}
if len(dec.Questions) == 0 {
return reply
}
qs := dec.Questions
if len(qs) > max {
qs = qs[:max]
}
lines := []string{reply}
for i, q := range qs {
lines = append(lines, itoa(int64(i+1))+". "+q)
}
if len(dec.Questions) > max {
lines = append(lines, "(и ещё вопросы по ходу)")
}
return strings.Join(lines, "\n")
}
// formatSummary возвращает сводку черновика в фазе ready.
func formatSummary(t storage.Task) string {
var b strings.Builder
b.WriteString("📋 **Черновик задачи #" + itoa(t.ID) + "** готов.\n\n")
if t.Title != "" {
b.WriteString("**Название:** " + t.Title + "\n")
}
if len(t.EffectiveRepos()) > 0 {
b.WriteString("**Репозитории:** " + strings.Join(t.EffectiveRepos(), ", ") + "\n")
}
if t.Goal != "" {
b.WriteString("**Цель:** " + t.Goal + "\n")
}
if t.Why != "" {
b.WriteString("**Зачем:** " + t.Why + "\n")
}
if t.AC != "" {
b.WriteString("**Критерии:**\n" + t.AC + "\n")
}
b.WriteString("\nНапишите «создавай» — или правьте текст.")
return b.String()
}
// itoa — мини int→string без strconv.
func itoa(n int64) string {
if n == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
neg := n < 0
if neg {
n = -n
}
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}