feat(worker): постмортем-анализ после failed/timeout
После завершения задачи со статусом failed/timeout воркер запускает постмортем-анализ (агент postmortem): разбирает сессии dev/reviewer, оценивает причины сбоя и шлёт владельцу уведомление с анализом. Статус задачи не меняет; сбои анализа не влияют на исход.
This commit is contained in:
205
internal/worker/postmortem.go
Normal file
205
internal/worker/postmortem.go
Normal file
@@ -0,0 +1,205 @@
|
||||
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 = "(постмортем-агент не вернул текст)"
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user