fix(worker): постмортем получает транскрипт сессий dev/reviewer по session_id
Some checks failed
CI / test (push) Failing after 1m52s
CI / build-and-package (amd64, linux) (push) Failing after 1m1s
CI / build-and-package (amd64, windows) (push) Successful in 29s

Раньше постмортем-агенту передавался только session_id, без содержимого
сессии — анализировать было нечего. Теперь Runner умеет читать сообщения
сессии (Messages → renderTranscript) через SessionMessages, а postMortem
подставляет реальные шаги агента (сообщения/tool-вызовы) в промпт.
Пустая/недоступная сессия помечается явно. Сбои чтения не фатальны.
This commit is contained in:
ki.sagidullin
2026-08-24 16:48:43 +05:00
parent 89d571b483
commit 6546f18348
5 changed files with 221 additions and 16 deletions

View File

@@ -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

View File

@@ -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 — сколько подряд опросов должно подтвердить завершение ответа,
// прежде чем считать вердикт финальным (устойчивость к гонке между удалением
// сессии из активных дренажей и финализацией последнего сообщения).