feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
агент определяется по title сессии
This commit is contained in:
@@ -60,7 +60,7 @@ type App struct {
|
||||
Worker *worker.Worker
|
||||
Updater *update.Updater
|
||||
tg *telegram.Channel // сохранена для Run
|
||||
serve *opencode.Server // супервайзер opencode serve (режим --attach); nil, если выключен
|
||||
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
||||
}
|
||||
|
||||
// New читает конфиг и собирает все зависимости.
|
||||
@@ -103,37 +103,26 @@ func New(configPath, version, updateToken string) (*App, error) {
|
||||
}
|
||||
log.Printf("app: db opened %s", cfg.Paths.DB)
|
||||
|
||||
// OpenCode: пул serve-процессов (по одному на каталог) + API-runner.
|
||||
// Служебный root-сервер (worktree) живёт всё время app; остальные лениво.
|
||||
ocPool := opencode.NewPool(cfg.Paths.Worktree)
|
||||
ocPool.Bin = cfg.OpenCode.Bin
|
||||
ocPool.Config = cfg.OpenCode.Config
|
||||
ocPool.ConfigDir = cfg.OpenCode.ConfigDir
|
||||
ocPool.DBPath = cfg.OpenCode.DBPath
|
||||
ocPool.Host = cfg.OpenCode.Serve.Hostname
|
||||
ocPool.BasePort = cfg.OpenCode.Serve.Port
|
||||
ocPool.Password = cfg.OpenCode.Serve.Password
|
||||
|
||||
// OpenCode runner — один на аналитика и воркер
|
||||
ocRunner := &opencode.Runner{
|
||||
Bin: cfg.OpenCode.Bin,
|
||||
DBPath: cfg.OpenCode.DBPath,
|
||||
Config: cfg.OpenCode.Config,
|
||||
ConfigDir: cfg.OpenCode.ConfigDir,
|
||||
Pool: ocPool,
|
||||
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
||||
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
||||
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
||||
Stdout: os.Stderr,
|
||||
}
|
||||
|
||||
// Режим opencode serve (--attach): супервайзер держит постоянный сервер и
|
||||
// подключает Runner к нему. Включается serve.enabled (свой процесс) либо
|
||||
// serve.url (внешний). Выключено по умолчанию — прежняя spawn-модель.
|
||||
var serveSrv *opencode.Server
|
||||
if cfg.OpenCode.Serve.Enabled || cfg.OpenCode.Serve.URL != "" {
|
||||
serveSrv = &opencode.Server{
|
||||
Bin: cfg.OpenCode.Bin,
|
||||
DBPath: cfg.OpenCode.DBPath,
|
||||
Config: cfg.OpenCode.Config,
|
||||
ConfigDir: cfg.OpenCode.ConfigDir,
|
||||
Host: cfg.OpenCode.Serve.Hostname,
|
||||
Port: cfg.OpenCode.Serve.Port,
|
||||
Password: cfg.OpenCode.Serve.Password,
|
||||
URL: cfg.OpenCode.Serve.URL,
|
||||
Stdout: os.Stderr,
|
||||
}
|
||||
ocRunner.AttachURL = serveSrv.Addr()
|
||||
}
|
||||
|
||||
// Analyst (Decider)
|
||||
analystCtx := &analyst.Analyst{
|
||||
Runner: ocRunner,
|
||||
@@ -152,7 +141,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
||||
Config: cfg,
|
||||
Store: store,
|
||||
CoreCtx: coreCtx,
|
||||
serve: serveSrv,
|
||||
pool: ocPool,
|
||||
}
|
||||
router := chat.NewRouter(a.handleIncoming)
|
||||
|
||||
@@ -206,17 +195,19 @@ func (a *App) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// opencode serve: поднимаем супервайзер до старта воркера (иначе первый
|
||||
// вызов --attach упрётся в несуществующий сервер). При неудаче — не стартуем.
|
||||
if a.serve != nil {
|
||||
if err := a.serve.Start(ctx); err != nil {
|
||||
return fmt.Errorf("opencode serve: %w", err)
|
||||
}
|
||||
log.Printf("app: opencode serve up at %s (attach mode)", a.serve.Addr())
|
||||
go a.serve.Run(ctx)
|
||||
defer a.serve.Close()
|
||||
// Уже отменённый контекст — не поднимаем подсистемы, graceful shutdown сразу.
|
||||
if ctx.Err() != nil {
|
||||
log.Print("app: context already cancelled, skipped start")
|
||||
return nil
|
||||
}
|
||||
|
||||
// opencode serve: поднимаем служебный корневой сервер (worktree) до старта
|
||||
// воркера, остальные каталоги — лениво. При неудаче — не стартуем.
|
||||
if err := a.pool.EnsureRoot(ctx); err != nil {
|
||||
return fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
defer a.pool.Close()
|
||||
|
||||
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
||||
tgErr := make(chan error, 1)
|
||||
|
||||
|
||||
@@ -8,18 +8,22 @@ package app
|
||||
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||
//
|
||||
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
|
||||
// возвращая NDJSON-вердикты нужного формата для каждого.
|
||||
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
|
||||
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
|
||||
// вердикты возвращаются как text-части assistant-сообщений.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -31,63 +35,82 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||
)
|
||||
|
||||
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
|
||||
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
|
||||
// представление JSON-вердикта агента (как это делает реальный opencode).
|
||||
func ndjsonText(t *testing.T, payload string) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal payload: %v", err)
|
||||
// вердикты фейкового агента по имени.
|
||||
var (
|
||||
e2eAgentVerdicts = map[string]string{
|
||||
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||
"dev": `done`,
|
||||
"reviewer": `{"passed":true,"comments":[]}`,
|
||||
}
|
||||
return `{"type":"text","part":{"text":` + string(b) + `}}`
|
||||
)
|
||||
|
||||
// e2eFakeAPI поднимает фейковый opencode serve HTTP API v1.17 и возвращает URL.
|
||||
// По title сессии (ratatoskr-<agent>) определяет агента и возвращает его вердикт
|
||||
// как text-часть единственного assistant-сообщения.
|
||||
func e2eFakeAPI(t *testing.T) string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
sessions := map[string]string{} // id → agent
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/session":
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
agent := strings.TrimPrefix(req.Title, "ratatoskr-")
|
||||
mu.Lock()
|
||||
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
|
||||
sessions[id] = agent
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": id}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/wait"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/interrupt"):
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"):
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/session/"), "/message")
|
||||
mu.Lock()
|
||||
agent := sessions[id]
|
||||
mu.Unlock()
|
||||
verdict := ""
|
||||
if v, ok := e2eAgentVerdicts[agent]; ok {
|
||||
verdict = v
|
||||
} else {
|
||||
verdict = "unknown agent"
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": []map[string]any{{
|
||||
"type": "assistant",
|
||||
"content": []map[string]any{{"type": "text", "text": verdict}},
|
||||
}}})
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// e2eFakeOpenCode пишет shell-скрипт, имитирующий opencode run.
|
||||
// Различает агента по argv ($3 = имя агента после "--agent").
|
||||
//
|
||||
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
|
||||
// dev → простой NDJSON "done"
|
||||
// reviewer→ NDJSON c {"passed":true} в text-парте
|
||||
func e2eFakeOpenCode(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
|
||||
analystNDJSON := ndjsonText(t, `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`)
|
||||
reviewerNDJSON := ndjsonText(t, `{"passed":true,"comments":[]}`)
|
||||
|
||||
// каждый вариант печатаем через printf '%s' с одинарными кавычками:
|
||||
// NDJSON содержит двойные кавычки и бэкслеши, но не одинарные — безопасно.
|
||||
analystLine := "printf '%s\\n' '" + analystNDJSON + "'"
|
||||
reviewerLine := "printf '%s\\n' '" + reviewerNDJSON + "'"
|
||||
|
||||
script := `#!/bin/sh
|
||||
agent="$3"
|
||||
case "$agent" in
|
||||
analyst)
|
||||
` + analystLine + `
|
||||
;;
|
||||
reviewer)
|
||||
` + reviewerLine + `
|
||||
;;
|
||||
dev)
|
||||
printf '%%s\n' '{"type":"text","part":{"text":"done"}}'
|
||||
;;
|
||||
*)
|
||||
printf '%%s\n' '{"type":"text","part":{"text":"unknown agent"}}'
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
bin := filepath.Join(dir, "opencode")
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write e2e fake opencode: %v", err)
|
||||
}
|
||||
return bin
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
||||
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
|
||||
// каталог worktree и fake-канал (для проверки исходящих).
|
||||
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
|
||||
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
|
||||
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -100,13 +123,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
bin := e2eFakeOpenCode(t, dir)
|
||||
fakeURL := e2eFakeAPI(t)
|
||||
pool := opencode.NewPool(worktree)
|
||||
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
|
||||
runner := &opencode.Runner{
|
||||
Bin: bin,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
Pool: pool,
|
||||
PollInterval: 5 * time.Millisecond,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
HardTimeout: 30 * time.Second,
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
||||
coreCtx := core.New(store, an)
|
||||
|
||||
Reference in New Issue
Block a user