255 lines
10 KiB
Go
255 lines
10 KiB
Go
package worker
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"text/template"
|
||
|
||
"github.com/kamelion/ratatoskr-go/internal/events"
|
||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||
)
|
||
|
||
// postMortemAgent — имя постмортем-агента (файл agents/postmortem.md).
|
||
const postMortemAgent = "postmortem"
|
||
|
||
// traceOutputMax — обрезка вывода сессии в постмортем-промпте (чтобы промпт
|
||
// не превращался в полные транскрипты и не переполнял контекст модели).
|
||
const traceOutputMax = 6000
|
||
|
||
// sessionTranscriptMax — обрезка транскрипта сессии dev/reviewer в промпте.
|
||
const sessionTranscriptMax = 20000
|
||
|
||
// postMortemPromptTemplate — промпт для постмортем-агента после failed/timeout:
|
||
// задача + сессии dev/reviewer. Ожидается резюме простым текстом на русском.
|
||
var postMortemPromptTemplate = template.Must(template.New("postmortem").Parse(`Ты — постмортем-аналитик в конвейере Ratatoskr. Задача завершилась неудачей ({{.Status}}). Проанализируй сессии агентов dev/reviewer и дай резюме: почему так случилось и что сделать, чтобы не повторялось.
|
||
|
||
**Задача:**
|
||
{{if .Title}}Название: {{.Title}}{{end}}
|
||
{{if .Goal}}Цель: {{.Goal}}{{end}}
|
||
{{if .Repos}}
|
||
Репозитории:
|
||
{{- range .Repos}}
|
||
- {{.}}
|
||
{{- end}}
|
||
{{end}}
|
||
{{if .Why}}Зачем: {{.Why}}{{end}}
|
||
{{if .AC}}Критерии готовности (AC):
|
||
{{.AC}}{{end}}
|
||
|
||
**Итоговый статус задачи:** {{.Status}}
|
||
|
||
**Сессии субагентов:**
|
||
{{.Sessions}}
|
||
|
||
Ответь ПРОСТЫМ ТЕКСТОМ на русском, без JSON и разметки. Формат:
|
||
|
||
Почему так случилось:
|
||
- <причина 1>
|
||
- <причина 2>
|
||
|
||
Что сделать, чтобы это не повторялось:
|
||
- <рекомендация 1>
|
||
- <рекомендация 2>`))
|
||
|
||
// PostMortemPromptData — данные для рендера постмортем-промпта.
|
||
type PostMortemPromptData struct {
|
||
Title string
|
||
Goal string
|
||
Repos []string
|
||
Why string
|
||
AC string
|
||
Status storage.Status
|
||
Sessions string
|
||
}
|
||
|
||
// RenderPostMortemPrompt собирает промпт для постмортем-агента.
|
||
func RenderPostMortemPrompt(data PostMortemPromptData) (string, error) {
|
||
var buf strings.Builder
|
||
if err := postMortemPromptTemplate.Execute(&buf, data); err != nil {
|
||
return "", err
|
||
}
|
||
return buf.String(), nil
|
||
}
|
||
|
||
// postMortemsText форматирует сессии dev/reviewer в секцию промпта: поля
|
||
// трассы (session_id, промпт, вывод) + транскрипт сессии (шаги агента).
|
||
// transcripts — sessionID → транскрипт (пустая строка — в сессии нет сообщений);
|
||
// отсутствие ключа — транскрипт недоступен (сбой чтения).
|
||
func postMortemsText(traces []storage.Trace, transcripts map[string]string) string {
|
||
var b strings.Builder
|
||
for _, tr := range traces {
|
||
b.WriteString("\n=== Агент: " + tr.Agent + " (статус " + string(tr.Status) + ") ===\n")
|
||
if tr.SessionID != "" {
|
||
b.WriteString("session_id: " + tr.SessionID + "\n")
|
||
}
|
||
if strings.TrimSpace(tr.Prompt) != "" {
|
||
b.WriteString("-- Промпт агента --\n")
|
||
b.WriteString(tr.Prompt)
|
||
b.WriteString("\n")
|
||
}
|
||
if strings.TrimSpace(tr.Output) != "" {
|
||
b.WriteString("-- Вывод агента --\n")
|
||
b.WriteString(truncateTrace(tr.Output, traceOutputMax))
|
||
b.WriteString("\n")
|
||
}
|
||
if tr.SessionID != "" {
|
||
tx, ok := transcripts[tr.SessionID]
|
||
b.WriteString("-- Транскрипт сессии (шаги агента) --\n")
|
||
switch {
|
||
case !ok:
|
||
b.WriteString("(транскрипт сессии недоступен)\n")
|
||
case strings.TrimSpace(tx) == "":
|
||
b.WriteString("(в сессии нет сообщений — агент не сделал ни одного шага)\n")
|
||
default:
|
||
b.WriteString(truncateTrace(tx, sessionTranscriptMax))
|
||
b.WriteString("\n")
|
||
}
|
||
}
|
||
}
|
||
if b.Len() == 0 {
|
||
return "(сессии dev/reviewer не найдены — вероятна инфраструктурная ошибка до запуска агентов)"
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// truncateTrace обрезает длинный текст до последних n символов (релевантен
|
||
// хвост: вердикт/ошибка агента в конце вывода).
|
||
func truncateTrace(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
if n <= 0 {
|
||
return ""
|
||
}
|
||
return "(вывод обрезан)\n" + s[len(s)-n:]
|
||
}
|
||
|
||
// hasPostMortemTrace возвращает true, если у задачи уже есть постмортем-trace
|
||
// (защита от повторного запуска при повторных прогонах/retry).
|
||
func (w *Worker) hasPostMortemTrace(ctx context.Context, taskID int64) bool {
|
||
if w.Store == nil {
|
||
return false
|
||
}
|
||
_, err := w.Store.GetLatestTrace(ctx, taskID, postMortemAgent)
|
||
return err == nil
|
||
}
|
||
|
||
// postMortem запускает постмортем-агент для задачи, завершившейся failed/timeout:
|
||
// собирает сессии dev/reviewer из трасс, даёт агенту анализ, сохраняет результат
|
||
// как trace agent=postmortem и уведомляет владельца задачи резюме.
|
||
//
|
||
// Статус задачи НЕ меняется (failed/timeout остаётся достигнутым); собственные
|
||
// сбои постмортема не влияют на исход задачи — только логируются.
|
||
// postMortem запускает постмортем-агент для задачи, завершившейся failed/timeout:
|
||
// собирает сессии dev/reviewer из трасс, даёт агенту анализ, сохраняет результат
|
||
// как trace agent=postmortem и уведомляет владельца задачи резюме.
|
||
//
|
||
// projectDir — каталог основного репозитория (тот же serve, где жили сессии
|
||
// dev/reviewer); fallback на w.Worktree, если папка репо недоступна.
|
||
//
|
||
// Статус задачи НЕ меняется (failed/timeout остаётся достигнутым); собственные
|
||
// сбои постмортема не влияют на исход задачи — только логируются.
|
||
func (w *Worker) postMortem(ctx context.Context, task *storage.Task, projectDir string) {
|
||
if w.Store == nil || w.Runner == nil {
|
||
return
|
||
}
|
||
if w.hasPostMortemTrace(ctx, task.ID) {
|
||
return
|
||
}
|
||
if projectDir == "" {
|
||
projectDir = w.Worktree
|
||
}
|
||
|
||
traces, err := w.Store.GetTraces(ctx, task.ID)
|
||
if err != nil {
|
||
log.Printf("worker: task %d: постмортем: трассы: %v", task.ID, err)
|
||
return
|
||
}
|
||
// Анализируем только сессии агентов конвейера (dev/reviewer).
|
||
var sessions []storage.Trace
|
||
for _, tr := range traces {
|
||
if tr.Agent == "dev" || tr.Agent == "reviewer" {
|
||
sessions = append(sessions, *tr)
|
||
}
|
||
}
|
||
|
||
// Достаём транскрипты сессий dev/reviewer (по session_id) — реальные шаги
|
||
// агента (сообщения/tool-вызовы), которые не попадают в трассу (там только
|
||
// финальный вывод). Сбои чтения не фатальны: промпт соберётся без них.
|
||
transcripts := make(map[string]string, len(sessions))
|
||
for i := range sessions {
|
||
tr := &sessions[i]
|
||
if tr.SessionID == "" {
|
||
continue
|
||
}
|
||
tx, tErr := w.Runner.SessionMessages(w.runCtx(ctx, task.ID), projectDir, tr.SessionID)
|
||
if tErr != nil {
|
||
log.Printf("worker: task %d: постмортем: транскрипт %s (%s): %v", task.ID, tr.Agent, tr.SessionID, tErr)
|
||
continue
|
||
}
|
||
transcripts[tr.SessionID] = tx
|
||
}
|
||
|
||
prompt, pErr := RenderPostMortemPrompt(PostMortemPromptData{
|
||
Title: task.Title,
|
||
Goal: task.Goal,
|
||
Repos: task.EffectiveRepos(),
|
||
Why: task.Why,
|
||
AC: task.AC,
|
||
Status: task.Status,
|
||
Sessions: postMortemsText(sessions, transcripts),
|
||
})
|
||
if pErr != nil {
|
||
log.Printf("worker: task %d: постмортем: рендер промпта: %v", task.ID, pErr)
|
||
return
|
||
}
|
||
|
||
// лог-событие для UI-панели «Состояние».
|
||
w.publish(events.AgentActivity{TaskID: task.ID, Agent: postMortemAgent, Stage: "postmortem"})
|
||
|
||
tr := &storage.Trace{TaskID: task.ID, Agent: postMortemAgent, Prompt: prompt}
|
||
traceID, aErr := w.Store.AppendTrace(ctx, tr)
|
||
if aErr != nil {
|
||
log.Printf("worker: task %d: постмортем: create trace: %v", task.ID, aErr)
|
||
return
|
||
}
|
||
|
||
res, rErr := w.Runner.Run(w.runCtx(ctx, task.ID), prompt, projectDir, postMortemAgent, "")
|
||
if rErr != nil {
|
||
log.Printf("worker: task %d: постмортем: запуск: %v", task.ID, rErr)
|
||
w.finalizeTrace(ctx, traceID, storage.TraceFailed, rErr.Error())
|
||
return
|
||
}
|
||
if res.SessionID != "" {
|
||
_ = w.Store.UpdateTraceSessionID(ctx, traceID, res.SessionID)
|
||
}
|
||
|
||
output := strings.TrimSpace(res.Stdout)
|
||
status := storage.TraceSuccess
|
||
if res.RC != 0 || output == "" {
|
||
status = storage.TraceFailed
|
||
if output == "" {
|
||
output = "(постмортем-агент не вернул текст)"
|
||
}
|
||
}
|
||
log.Printf("worker: task %d: постмортем: %s", task.ID, truncateTrace(output, 2000))
|
||
w.finalizeTrace(ctx, traceID, status, output)
|
||
|
||
if res.RC == 0 && output != "" {
|
||
text := fmt.Sprintf("Задача #%d: 🔍 анализ (после %s)\n%s", task.ID, task.Status, output)
|
||
w.notify(ctx, task, text)
|
||
}
|
||
}
|
||
|
||
// postMortemAfter — defer-хук из runTask: запускает постмортем, если задача
|
||
// завершилась failed/timeout. Собственные ошибки постмортема не мешают
|
||
// исходному результату задачи (возвращаемый *error только читается).
|
||
func (w *Worker) postMortemAfter(ctx context.Context, task *storage.Task, projectDir string, _ *error) {
|
||
if task.Status != storage.StatusFailed && task.Status != storage.StatusTimeout {
|
||
return
|
||
}
|
||
w.postMortem(ctx, task, projectDir)
|
||
}
|