Files
ratatoskr-go/internal/worker/postmortem.go
ki.sagidullin 94521488b6
Some checks failed
CI / test (push) Failing after 1m55s
CI / build-and-package (amd64, linux) (push) Failing after 1m7s
CI / build-and-package (amd64, windows) (push) Successful in 29s
feat(agents): человекочитаемые вердикты аналитика, ревьюера, dev и постмортема в панель «Логи»
2026-08-24 10:33:45 +05:00

207 lines
7.7 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 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
// 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 в секцию промпта.
func postMortemsText(traces []storage.Trace) 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 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 остаётся достигнутым); собственные
// сбои постмортема не влияют на исход задачи — только логируются.
func (w *Worker) postMortem(ctx context.Context, task *storage.Task) {
if w.Store == nil || w.Runner == nil {
return
}
if w.hasPostMortemTrace(ctx, task.ID) {
return
}
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)
}
}
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),
})
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, w.Worktree, 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, _ *error) {
if task.Status != storage.StatusFailed && task.Status != storage.StatusTimeout {
return
}
w.postMortem(ctx, task)
}