feat: наблюдение за живой opencode-сессией (/status N показывает что делает агент)
All checks were successful
CI / test (push) Successful in 54s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Successful in 43s

LiveRegistry собирает live-шаги из stdout процесса opencode по taskID
(через контекст, без изменения интерфейса Runner). /status N для running-задачи
прикладывает живое состояние: активный субагент, давность последнего шага,
последние text-шаги агента. Ничего не перезапускается и не убивается —
только наблюдение по запросу.
This commit is contained in:
Hermes
2026-08-17 08:05:15 +05:00
parent 2255917949
commit da775ac227
9 changed files with 329 additions and 26 deletions

View File

@@ -103,6 +103,9 @@ func New(configPath, version, updateToken string) (*App, error) {
// Core — ядро машины состояний
coreCtx := core.New(store, analystCtx)
// Live-журнал живых сессий: воркер пишет шаги агента, /status N их читает.
live := opencode.NewLiveRegistry()
// Router — единый диспетчер входящих из всех каналов
a := &App{
Config: cfg,
@@ -129,10 +132,14 @@ func New(configPath, version, updateToken string) (*App, error) {
MaxJobs: 2,
GitBaseURL: cfg.Git.BaseURL,
GitToken: cfg.Git.Token,
Live: live,
}
a.Router = router
a.Worker = w
// /status N читает живое состояние сессии через liveProbe-адаптер.
coreCtx.Live = &liveProbe{reg: live}
// Auto-обновление: .new/.old рядом с бинарником (Dir пуст → binDir() от os.Executable).
// Токен: вшитый updateToken приоритетнее update.token из конфига.
udToken := updateToken

46
internal/app/live.go Normal file
View File

@@ -0,0 +1,46 @@
package app
import (
"fmt"
"strings"
"time"
"github.com/kamelion/ratatoskr-go/internal/opencode"
)
// liveProbe адаптирует *opencode.LiveRegistry под интерфейс core.LiveProber.
// Формирует короткое человекочитаемое описание живой сессии задачи.
type liveProbe struct {
reg *opencode.LiveRegistry
}
func (p *liveProbe) Probe(taskID int64) (string, bool) {
if p == nil || p.reg == nil {
return "", false
}
snap, ok := p.reg.Snap(taskID)
if !ok {
return "", false
}
var b strings.Builder
b.WriteString(fmt.Sprintf("Агент **%s** активен", snap.Agent))
if idl := time.Since(snap.Last).Round(time.Second); idl > 0 {
b.WriteString(fmt.Sprintf(" (последний шаг %s назад)", idl))
}
// последние text-шаги — что агент делает прямо сейчас
n := 0
for i := len(snap.Steps) - 1; i >= 0 && n < 3; i-- {
st := snap.Steps[i]
if st.Type != "text" || strings.TrimSpace(st.Text) == "" {
continue
}
text := strings.TrimSpace(st.Text)
if len(text) > 140 {
text = text[:140] + "…"
}
text = strings.ReplaceAll(text, "\n", " ")
b.WriteString("\n▸ " + text)
n++
}
return b.String(), true
}

39
internal/app/live_test.go Normal file
View File

@@ -0,0 +1,39 @@
package app
import (
"strings"
"testing"
"time"
"github.com/kamelion/ratatoskr-go/internal/opencode"
)
func TestLiveProbe_Fmt(t *testing.T) {
reg := opencode.NewLiveRegistry()
p := &liveProbe{reg: reg}
// Нет сессии — Probe должен вернуть ok=false.
if _, ok := p.Probe(1); ok {
t.Error("Probe без сессии вернул ok=true")
}
reg.Start(7, "dev")
reg.Observe(7, opencode.LiveStep{Type: "text", Text: "анализирую код", At: time.Now()})
reg.Observe(7, opencode.LiveStep{Type: "tool", At: time.Now()})
reg.Observe(7, opencode.LiveStep{Type: "text", Text: "пишу фичу по задаче номер триста семь", At: time.Now()})
desc, ok := p.Probe(7)
if !ok {
t.Fatal("Probe с сессией вернул ok=false")
}
if !strings.Contains(desc, "dev") {
t.Errorf("описание не содержит агента: %q", desc)
}
if !strings.Contains(desc, "пишу фичу") {
t.Errorf("описание не содержит последний text-шаг: %q", desc)
}
// text-шаги: оба, tools пропущен
if n := strings.Count(desc, "▸"); n != 2 {
t.Errorf("ожидал 2 text-шага (▸), вижу %d: %q", n, desc)
}
}