Позитивный блок (задача перезапускается и доходит до success): collecting, ready, failed, timeout Негативный блок (завершённые состояния нельзя перезапускать — только /start): success, cancelled → /retry отклоняется понятным сообщением fix: handleRetry валидирует терминальные статусы (success/cancelled/aborted/ closed) и отвечает «нельзя перезапустить», вместо падения ErrInvalidStatus
479 lines
15 KiB
Go
479 lines
15 KiB
Go
package core
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"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
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
|
||
// 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",
|
||
Action: "send",
|
||
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
|
||
}
|
||
task.Status = storage.StatusCollecting
|
||
if err := c.Store.ClearHistory(ctx, task.ID); err != nil {
|
||
return Result{}, err
|
||
}
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
return Result{
|
||
Reply: greeting,
|
||
Action: "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
|
||
}
|
||
task.Status = storage.StatusCancelled
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
return Result{
|
||
Reply: "🚫 Отменил.",
|
||
Action: "drop",
|
||
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: "Напишите «создавай» — или правьте текст.",
|
||
Action: "send",
|
||
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`.",
|
||
Action: "send",
|
||
}, 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.",
|
||
Action: "send",
|
||
TaskID: id,
|
||
Status: task.Status,
|
||
}, nil
|
||
}
|
||
task.Status = storage.StatusCollecting
|
||
if err := c.Store.ClearHistory(ctx, id); err != nil {
|
||
return Result{}, err
|
||
}
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
return Result{
|
||
Reply: "Задача перезапущена. Опишите, что меняем:",
|
||
Action: "send",
|
||
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`.",
|
||
Action: "send",
|
||
}, 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,
|
||
Action: "send",
|
||
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`.",
|
||
Action: "send",
|
||
}, 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) +
|
||
". Резюм сессии — пока не реализован.",
|
||
Action: "send",
|
||
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) + " не найдена.",
|
||
Action: "send",
|
||
}, nil
|
||
}
|
||
|
||
// handleConsent создаёт задачу (статус ready → ...). Пока — подтверждение готовности.
|
||
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
|
||
task.Status = storage.StatusReady
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
return Result{
|
||
Reply: "✅ Задача #" + itoa(task.ID) + " готова к запуску.",
|
||
Action: "created:" + 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) {
|
||
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
|
||
}
|
||
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 {
|
||
task.Status = storage.StatusCollecting
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
}
|
||
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
|
||
}
|
||
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":
|
||
task.Status = storage.StatusAborted
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
reply := decision.ChatReply
|
||
if reply == "" {
|
||
reply = "Недостаточно данных. Начните заново (/start)."
|
||
}
|
||
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
|
||
|
||
case "propose", "ready":
|
||
// применяем черновик (для ready — текущий, без изменений)
|
||
applyDraft(task, decision.Draft)
|
||
// E1: propose/ready без репозиториев → остаёмся в сборе, просим уточнить.
|
||
if len(task.EffectiveRepos()) == 0 {
|
||
task.Status = storage.StatusCollecting
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
|
||
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
|
||
}
|
||
task.Status = storage.StatusReady
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
return Result{
|
||
Reply: formatSummary(*task),
|
||
Action: "summary",
|
||
TaskID: task.ID,
|
||
Status: task.Status,
|
||
}, nil
|
||
|
||
default: // "ask"
|
||
applyDraft(task, decision.Draft)
|
||
task.Status = storage.StatusCollecting
|
||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||
return Result{}, err
|
||
}
|
||
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
|
||
return Result{Reply: reply, Action: "send", 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:])
|
||
}
|