diff --git a/internal/opencode/client.go b/internal/opencode/client.go index 8e4e367..76df43f 100644 --- a/internal/opencode/client.go +++ b/internal/opencode/client.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) @@ -15,7 +16,7 @@ import ( // Пути v2 начинаются с префикса /api (см. README, минимальная версия opencode): // - POST /api/session создать сессию {model:{...}} → {data: Session.Info} // - POST /api/session/{id}/prompt отправить промпт {prompt:{text}} → -// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted} +// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted} // - GET /api/session/{id}/message?order=desc → {data:[Message,...]} // - POST /api/session/{id}/interrupt прервать активный ответ (204) // - GET /api/session/active активные дренажи → {data:{sessionID:...}} @@ -349,6 +350,59 @@ func assistantVerdict(msgs []v2Message, since int64) (texts []string, usedReason return reasoning, len(reasoning) > 0 } +// transcriptRole возвращает человекочитаемую подпись роли сообщения сессии. +func transcriptRole(t string) string { + switch t { + case "user": + return "Пользователь" + case "assistant": + return "Ассистент" + case "tool": + return "Инструмент" + case "system": + return "Система" + default: + return t + } +} + +// finishLabel — подпись для finish-reason (пусто/: опускаем). +func finishLabel(f string) string { + if f == "" || f == "stop" { + return "" + } + return f +} + +// renderTranscript форматирует сообщения сессии (API отдаёт новыми первыми) +// в хронологическом порядке: роль, текст (и tool-вызовы), с обрезкой. +func renderTranscript(msgs []v2Message) string { + var b strings.Builder + for i := len(msgs) - 1; i >= 0; i-- { + m := &msgs[i] + b.WriteString("\n== " + transcriptRole(m.Type)) + if m.Type == "assistant" { + if m.Model != nil { + b.WriteString(" (" + m.Model.String() + ")") + } + if fl := finishLabel(m.Finish); fl != "" { + b.WriteString(" finish=" + fl) + } + } + b.WriteString(" ==\n") + for _, p := range m.Content { + if p.Text == "" { + continue + } + b.WriteString(p.Text) + if !strings.HasSuffix(p.Text, "\n") { + b.WriteString("\n") + } + } + } + return b.String() +} + func truncateStr(s string, n int) string { if len(s) <= n { return s diff --git a/internal/opencode/runner.go b/internal/opencode/runner.go index 725c01f..55b7093 100644 --- a/internal/opencode/runner.go +++ b/internal/opencode/runner.go @@ -90,6 +90,30 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) return r.awaitVerdict(ctx, c, sid, agent, prompt) } +// SessionMessages возвращает транскрипт сессии (шаги агента: сообщения, +// тексты, tool-вызовы) в хронологическом порядке для постмортем-анализа. +// sessionID пустой → пустая строка без ошибки. Ошибки чтения возвращаются +// как есть — вызывающий (постмортем) решает, логировать и продолжить. +func (r *Runner) SessionMessages(ctx context.Context, cwd, sessionID string) (string, error) { + r.defaults() + if r.Pool == nil { + return "", fmt.Errorf("opencode: Pool не задан (API-режим обязателен)") + } + if sessionID == "" { + return "", nil + } + srv, err := r.Pool.Ensure(ctx, cwd) + if err != nil { + return "", err + } + c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Directory: srv.Dir, Debug: r.Debug} + msgs, err := c.Messages(ctx, sessionID) + if err != nil { + return "", err + } + return renderTranscript(msgs), nil +} + // settlePolls — сколько подряд опросов должно подтвердить завершение ответа, // прежде чем считать вердикт финальным (устойчивость к гонке между удалением // сессии из активных дренажей и финализацией последнего сообщения). diff --git a/internal/worker/postmortem.go b/internal/worker/postmortem.go index 18fb759..ae7e3ea 100644 --- a/internal/worker/postmortem.go +++ b/internal/worker/postmortem.go @@ -18,6 +18,9 @@ const postMortemAgent = "postmortem" // не превращался в полные транскрипты и не переполнял контекст модели). 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 и дай резюме: почему так случилось и что сделать, чтобы не повторялось. @@ -70,8 +73,11 @@ func RenderPostMortemPrompt(data PostMortemPromptData) (string, error) { return buf.String(), nil } -// postMortemsText форматирует сессии dev/reviewer в секцию промпта. -func postMortemsText(traces []storage.Trace) string { +// 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") @@ -88,6 +94,19 @@ func postMortemsText(traces []storage.Trace) string { 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 не найдены — вероятна инфраструктурная ошибка до запуска агентов)" @@ -144,6 +163,23 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { } } + // Достаём транскрипты сессий 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), w.Worktree, 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, @@ -151,7 +187,7 @@ func (w *Worker) postMortem(ctx context.Context, task *storage.Task) { Why: task.Why, AC: task.AC, Status: task.Status, - Sessions: postMortemsText(sessions), + Sessions: postMortemsText(sessions, transcripts), }) if pErr != nil { log.Printf("worker: task %d: постмортем: рендер промпта: %v", task.ID, pErr) diff --git a/internal/worker/worker.go b/internal/worker/worker.go index 69f6963..de6fdae 100644 --- a/internal/worker/worker.go +++ b/internal/worker/worker.go @@ -18,6 +18,9 @@ import ( // OpenCodeRunner — интерфейс для opencode (подменяемый в тестах). type OpenCodeRunner interface { Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*opencode.Result, error) + // SessionMessages возвращает транскрипт сессии (шаги агента) по sessionID + // для постмортем-анализа. cwd — каталог, где живёт сервер пула сессии. + SessionMessages(ctx context.Context, cwd, sessionID string) (string, error) } // PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах). @@ -350,11 +353,11 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) { return pErr } if e := w.setStatus(ctx, task, storage.StatusSuccess); e != nil { - return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) + return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e) + } + w.notifyStatus(ctx, task, storage.StatusSuccess) + return nil } - w.notifyStatus(ctx, task, storage.StatusSuccess) - return nil - } // Не пройдено: если есть итерации — dev дорабатывает. if iter+1 < maxReviewIterations { diff --git a/internal/worker/worker_test.go b/internal/worker/worker_test.go index 4812068..3411dc6 100644 --- a/internal/worker/worker_test.go +++ b/internal/worker/worker_test.go @@ -59,6 +59,11 @@ type mockRunnerWorker struct { // резюме. postMortemCount — сколько раз постмортем вызывался. postMortemResult *opencode.Result postMortemCount int + + // sessionTranscripts — sessionID → транскрипт для постмортем-агента; + // sessionMsgsErr — ошибка чтения транскрипта. + sessionTranscripts map[string]string + sessionMsgsErr error } func (m *mockRunnerWorker) Run(_ context.Context, _, _, agent, _ string) (*opencode.Result, error) { @@ -86,6 +91,13 @@ func (m *mockRunnerWorker) Run(_ context.Context, _, _, agent, _ string) (*openc return m.result, m.err } +func (m *mockRunnerWorker) SessionMessages(_ context.Context, _, sessionID string) (string, error) { + if m.sessionMsgsErr != nil { + return "", m.sessionMsgsErr + } + return m.sessionTranscripts[sessionID], nil +} + // reviewFailedRunner возвращает вердикт not-passed с комментариями. func reviewFailedRunner() *opencode.Result { return &opencode.Result{RC: 0, Stdout: `{"passed":false,"critical_issues":[],"solid_violations":["DIP: высокая связанность"],"comments":["исправь связанность"]}`} @@ -987,7 +999,7 @@ func TestWorkerPostMortemFailureDoesNotChangeStatus(t *testing.T) { runner := &mockRunnerWorker{ // dev падает при спавне → failed; постмортем тоже падает. - err: errors.New("opencode not found"), + err: errors.New("opencode not found"), postMortemResult: &opencode.Result{RC: 1, Stdout: ""}, } w := &Worker{ @@ -1065,13 +1077,66 @@ func TestWorkerPostMortemNotRepeated(t *testing.T) { } } -// TestRenderPostMortemPrompt — промпт постмортема включает задачу и сессии. +// TestWorkerPostMortemUsesTranscript — постмортем достаёт транскрипт dev-сессии +// (по session_id) и вставляет шаги агента в промпт постмортем-агента. +func TestWorkerPostMortemUsesTranscript(t *testing.T) { + s := setupWorkerDB(t) + task := createReadyTask(t, s, "pm-transcript") + + runner := &mockRunnerWorker{ + result: &opencode.Result{RC: -1, Stdout: "", SessionID: "sess-dev"}, + sessionTranscripts: map[string]string{ + "sess-dev": "[Инструмент]\nчитает requirements.md\n[Ассистент]\nправлю main.go", + }, + } + w := &Worker{ + Store: s, + Runner: runner, + Agent: "dev", + Worktree: t.TempDir(), + } + seedFakeRepo(t, w.Worktree, "pm-transcript") + + ctx := context.Background() + _ = w.runTask(ctx, task) + + task, _ = s.GetTask(ctx, task.ID) + if task.Status != storage.StatusTimeout { + t.Fatalf("status = %q, want timeout", task.Status) + } + + traces, err := s.GetTraces(ctx, task.ID) + if err != nil { + t.Fatalf("get traces: %v", err) + } + var pm *storage.Trace + for _, tr := range traces { + if tr.Agent == postMortemAgent { + pm = tr + } + } + if pm == nil { + t.Fatal("нет постмортем-трассы") + } + for _, want := range []string{"Транскрипт сессии", "читает requirements.md", "правлю main.go"} { + if !strings.Contains(pm.Prompt, want) { + t.Errorf("промпт постмортема не содержит %q", want) + } + } +} + +// TestRenderPostMortemPrompt — промпт постмортема включает задачу, сессии и +// транскрипт сессий (шаги агентов), а не только финальный вывод. func TestRenderPostMortemPrompt(t *testing.T) { tr := storage.Trace{ - Agent: "dev", - Status: storage.TraceTimeout, - Prompt: "промпт dev", - Output: "вывод dev", + Agent: "dev", + Status: storage.TraceTimeout, + SessionID: "sess-dev-1", + Prompt: "промпт dev", + Output: "вывод dev", + } + transcripts := map[string]string{ + "sess-dev-1": "[Инструмент]\nпрочитал файл a.go\n[Ассистент]\nправлю код", } prompt, err := RenderPostMortemPrompt(PostMortemPromptData{ Title: "Таймаут-задача", @@ -1079,18 +1144,41 @@ func TestRenderPostMortemPrompt(t *testing.T) { Repos: []string{"calc"}, AC: "работает", Status: storage.StatusTimeout, - Sessions: postMortemsText([]storage.Trace{tr}), + Sessions: postMortemsText([]storage.Trace{tr}, transcripts), }) if err != nil { t.Fatalf("render: %v", err) } - for _, want := range []string{"Таймаут-задача", "timeout", "=== Агент: dev", "промпт dev", "вывод dev"} { + for _, want := range []string{ + "Таймаут-задача", "timeout", "=== Агент: dev", "промпт dev", "вывод dev", + "Транскрипт сессии", "прочитал файл a.go", "правлю код", + } { if !strings.Contains(prompt, want) { t.Errorf("промпт не содержит %q", want) } } } +// TestPostMortemsTextTranscriptUnavailable — сессия с session_id, для которой +// транскрипт не загружен, помечается как недоступный, а не падает. +func TestPostMortemsTextTranscriptUnavailable(t *testing.T) { + tr := storage.Trace{Agent: "reviewer", Status: storage.TraceFailed, SessionID: "sess-r"} + text := postMortemsText([]storage.Trace{tr}, nil) + if !strings.Contains(text, "транскрипт сессии недоступен") { + t.Errorf("нет пометки о недоступном транскрипте: %q", text) + } +} + +// TestPostMortemsTextTranscriptEmpty — в сессии нет сообщений: агент не сделал +// ни одного шага — это пишется явно, чтобы постмортем не строил догадок. +func TestPostMortemsTextTranscriptEmpty(t *testing.T) { + tr := storage.Trace{Agent: "dev", Status: storage.TraceTimeout, SessionID: "sess-e"} + text := postMortemsText([]storage.Trace{tr}, map[string]string{"sess-e": ""}) + if !strings.Contains(text, "не сделал ни одного шага") { + t.Errorf("нет пометки о пустой сессии: %q", text) + } +} + // TestFormatReviewVerdict — человекочитаемое описание вердикта ревьюера. func TestFormatReviewVerdict(t *testing.T) { tests := []struct {