feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s

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:
Hermes
2026-08-18 13:36:54 +05:00
parent b60978121d
commit 733e63339a
12 changed files with 943 additions and 494 deletions

View File

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