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) }