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
|
Worker *worker.Worker
|
||||||
Updater *update.Updater
|
Updater *update.Updater
|
||||||
tg *telegram.Channel // сохранена для Run
|
tg *telegram.Channel // сохранена для Run
|
||||||
serve *opencode.Server // супервайзер opencode serve (режим --attach); nil, если выключен
|
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New читает конфиг и собирает все зависимости.
|
// New читает конфиг и собирает все зависимости.
|
||||||
@@ -103,37 +103,26 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
}
|
}
|
||||||
log.Printf("app: db opened %s", cfg.Paths.DB)
|
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 — один на аналитика и воркер
|
// OpenCode runner — один на аналитика и воркер
|
||||||
ocRunner := &opencode.Runner{
|
ocRunner := &opencode.Runner{
|
||||||
Bin: cfg.OpenCode.Bin,
|
Pool: ocPool,
|
||||||
DBPath: cfg.OpenCode.DBPath,
|
|
||||||
Config: cfg.OpenCode.Config,
|
|
||||||
ConfigDir: cfg.OpenCode.ConfigDir,
|
|
||||||
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
||||||
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
||||||
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
||||||
Stdout: os.Stderr,
|
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)
|
// Analyst (Decider)
|
||||||
analystCtx := &analyst.Analyst{
|
analystCtx := &analyst.Analyst{
|
||||||
Runner: ocRunner,
|
Runner: ocRunner,
|
||||||
@@ -152,7 +141,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Store: store,
|
Store: store,
|
||||||
CoreCtx: coreCtx,
|
CoreCtx: coreCtx,
|
||||||
serve: serveSrv,
|
pool: ocPool,
|
||||||
}
|
}
|
||||||
router := chat.NewRouter(a.handleIncoming)
|
router := chat.NewRouter(a.handleIncoming)
|
||||||
|
|
||||||
@@ -206,17 +195,19 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
// opencode serve: поднимаем супервайзер до старта воркера (иначе первый
|
// Уже отменённый контекст — не поднимаем подсистемы, graceful shutdown сразу.
|
||||||
// вызов --attach упрётся в несуществующий сервер). При неудаче — не стартуем.
|
if ctx.Err() != nil {
|
||||||
if a.serve != nil {
|
log.Print("app: context already cancelled, skipped start")
|
||||||
if err := a.serve.Start(ctx); err != nil {
|
return 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// opencode serve: поднимаем служебный корневой сервер (worktree) до старта
|
||||||
|
// воркера, остальные каталоги — лениво. При неудаче — не стартуем.
|
||||||
|
if err := a.pool.EnsureRoot(ctx); err != nil {
|
||||||
|
return fmt.Errorf("opencode: %w", err)
|
||||||
|
}
|
||||||
|
defer a.pool.Close()
|
||||||
|
|
||||||
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
||||||
tgErr := make(chan error, 1)
|
tgErr := make(chan error, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ package app
|
|||||||
// Worker.runTask: dev → reviewer → настоящий git push → success
|
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||||
//
|
//
|
||||||
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||||
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
|
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
|
||||||
// возвращая NDJSON-вердикты нужного формата для каждого.
|
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
|
||||||
|
// вердикты возвращаются как text-части assistant-сообщений.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,63 +35,82 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
|
// вердикты фейкового агента по имени.
|
||||||
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
|
var (
|
||||||
// представление JSON-вердикта агента (как это делает реальный opencode).
|
e2eAgentVerdicts = map[string]string{
|
||||||
func ndjsonText(t *testing.T, payload string) string {
|
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||||
t.Helper()
|
"dev": `done`,
|
||||||
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
|
"reviewer": `{"passed":true,"comments":[]}`,
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("json.Marshal payload: %v", err)
|
|
||||||
}
|
}
|
||||||
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.
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
// Различает агента по argv ($3 = имя агента после "--agent").
|
w.Header().Set("Content-Type", "application/json")
|
||||||
//
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
||||||
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
|
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
|
||||||
// каталог worktree и fake-канал (для проверки исходящих).
|
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
|
||||||
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -100,13 +123,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { store.Close() })
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
bin := e2eFakeOpenCode(t, dir)
|
fakeURL := e2eFakeAPI(t)
|
||||||
|
pool := opencode.NewPool(worktree)
|
||||||
|
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
|
||||||
runner := &opencode.Runner{
|
runner := &opencode.Runner{
|
||||||
Bin: bin,
|
Pool: pool,
|
||||||
PollInterval: 20 * time.Millisecond,
|
PollInterval: 5 * time.Millisecond,
|
||||||
IdleTimeout: 5 * time.Second,
|
IdleTimeout: 5 * time.Second,
|
||||||
HardTimeout: 30 * time.Second,
|
HardTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
||||||
coreCtx := core.New(store, an)
|
coreCtx := core.New(store, an)
|
||||||
|
|||||||
@@ -81,12 +81,14 @@ type OpenCodeCfg struct {
|
|||||||
Serve ServeCfg `yaml:"serve"`
|
Serve ServeCfg `yaml:"serve"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServeCfg — управление постоянным opencode serve (режим --attach).
|
// ServeCfg — настройки постоянных opencode serve-процессов (API-режим).
|
||||||
// enabled=true: ratatoskr сам запускает serve (супервайзер) и подключает
|
// Runner ходит к serve по HTTP API (v1.17+, /api). ratatoskr сам поднимает
|
||||||
// Runner через команду `run --attach <url>`. Если задан url — вместо
|
// по одному serve на каталог через Pool (Hash: hostname/port; служебный
|
||||||
// собственного spawn используется внешний (уже запущенный) сервер.
|
// root-сервер в worktree живёт всё время app, остальные — лениво).
|
||||||
// enabled=false (по умолчанию): историческая модель — каждый вызов
|
//
|
||||||
// сам спавнит `opencode run` (без сервера).
|
// Поля enabled/url оставлены для обратной совместимости конфига и сейчас не
|
||||||
|
// меняют поведение: serve обязателен для API-режима, сервер всегда
|
||||||
|
// спавнится пулом (hostname/port задают базовые значения).
|
||||||
type ServeCfg struct {
|
type ServeCfg struct {
|
||||||
Enabled bool `yaml:"enabled" default:"false"`
|
Enabled bool `yaml:"enabled" default:"false"`
|
||||||
URL string `yaml:"url" env:"OPENCODE_SERVE_URL"`
|
URL string `yaml:"url" env:"OPENCODE_SERVE_URL"`
|
||||||
|
|||||||
180
internal/opencode/client.go
Normal file
180
internal/opencode/client.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
|
||||||
|
//
|
||||||
|
// Ходит по HTTP API opencode serve (v1.17+, префикс /api):
|
||||||
|
// - POST /api/session создать сессию → {data:{id}}
|
||||||
|
// - POST /api/session/:id/prompt отправить промпт {prompt:{text}}
|
||||||
|
// - POST /api/session/:id/wait дождаться завершения ответа (блок)
|
||||||
|
// - GET /api/session/:id/message?order=desc → {data:[{...}]} история ответов
|
||||||
|
// - POST /api/session/:id/interrupt прервать выполняющийся ответ
|
||||||
|
//
|
||||||
|
// Вердикт собирается из последнего assistant-сообщения: его content[] → текст
|
||||||
|
// тех частей, где type == "text". (В API-режиме текст в part.text — плоско,
|
||||||
|
// в отличие от NDJSON run, где он был вложен в part.part.text.)
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string // http://host:port (без завершающего слеша)
|
||||||
|
Password string // basic auth (username "opencode")
|
||||||
|
http *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientErr — классы ошибок клиента.
|
||||||
|
type ClientErr struct {
|
||||||
|
Op string // "connect" | "create" | "prompt" | "wait" | "messages" | "verdict"
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
|
||||||
|
func (e *ClientErr) Unwrap() error { return e.Err }
|
||||||
|
|
||||||
|
func (c *Client) defaults() {
|
||||||
|
if c.http == nil {
|
||||||
|
c.http = &http.Client{Timeout: 30 * time.Second}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do выполняет запрос и возвращает тело ответа при 2xx. Иначе — ClientErr.
|
||||||
|
// op — метка операции (create/prompt/wait/messages/abort) для класса ошибки.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
|
||||||
|
c.defaults()
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
rd = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ClientErr{Op: "connect", Err: err}
|
||||||
|
}
|
||||||
|
if c.Password != "" {
|
||||||
|
req.SetBasicAuth("opencode", c.Password)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ClientErr{Op: "connect", Err: err}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &ClientErr{Op: "connect", Err: err}
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||||
|
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession создаёт новую сессию и возвращает её id.
|
||||||
|
func (c *Client) CreateSession(ctx context.Context, title string) (string, error) {
|
||||||
|
body := map[string]string{}
|
||||||
|
if title != "" {
|
||||||
|
body["title"] = title
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||||
|
}
|
||||||
|
if out.Data.ID == "" {
|
||||||
|
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||||||
|
}
|
||||||
|
return out.Data.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt отправляет промпт в сессию и ждёт завершения ответа (блокирующий wait).
|
||||||
|
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) error {
|
||||||
|
if err := c.Send(ctx, sessionID, prompt); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// POST /wait блокирует, пока ответ сессии не завершится.
|
||||||
|
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/wait", "wait", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send отправляет промпт в сессию (не ждёт ответа — выполнение идёт в фоне).
|
||||||
|
// Используется Runner'ом для асинхронного ожидания через поллинг сообщений.
|
||||||
|
func (c *Client) Send(ctx context.Context, sessionID, prompt string) error {
|
||||||
|
payload := map[string]any{"prompt": map[string]string{"text": prompt}}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", b)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort прерывает выполняющийся ответ сессии.
|
||||||
|
func (c *Client) Abort(ctx context.Context, sessionID string) error {
|
||||||
|
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionMessage — минимальная структура сообщения из GET /api/session/:id/message.
|
||||||
|
type sessionMessage struct {
|
||||||
|
Type string `json:"type"` // "assistant" | "user" | ...
|
||||||
|
Content []struct {
|
||||||
|
Type string `json:"type"` // "text" | "reasoning" | "tool"
|
||||||
|
Text string `json:"text"`
|
||||||
|
} `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verdict возвращает текст последнего assistant-сообщения сессии (вердикт).
|
||||||
|
// Ошибка — класса ErrVerdict (ClientErr{Op:"verdict"}), если нет готового
|
||||||
|
// assistant-сообщения или в нём нет text-частей.
|
||||||
|
func (c *Client) Verdict(ctx context.Context, sessionID string) (string, error) {
|
||||||
|
// order=desc — самые свежие сообщения первыми; идём по ним в поисках
|
||||||
|
// первого незаконченного assistant-ответа.
|
||||||
|
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=50", "messages", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data []sessionMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
for _, m := range out.Data {
|
||||||
|
if m.Type != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b.Reset()
|
||||||
|
for _, p := range m.Content {
|
||||||
|
if p.Type == "text" && p.Text != "" {
|
||||||
|
if b.Len() > 0 {
|
||||||
|
b.WriteString("\n")
|
||||||
|
}
|
||||||
|
b.WriteString(p.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b.Len() > 0 {
|
||||||
|
return stripFence(b.String()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", &ClientErr{Op: "verdict", Err: fmt.Errorf("нет assistant-сообщения с text-частью")}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
114
internal/opencode/client_test.go
Normal file
114
internal/opencode/client_test.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAPIServer — минимальный фейк opencode serve HTTP API v1.17.
|
||||||
|
type fakeAPIServer struct {
|
||||||
|
messages []sessionMessage
|
||||||
|
failCreate bool
|
||||||
|
failVerify bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAPIServer) handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if f.failCreate {
|
||||||
|
http.Error(w, "boom", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{"data": map[string]any{}})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/session/{id}/wait", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if len(f.messages) == 0 {
|
||||||
|
writeJSON(w, map[string]any{"data": []sessionMessage{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"data": f.messages})
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// textPart — анонимная text-часть assistant-сообщения.
|
||||||
|
func textPart(s string) struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
} {
|
||||||
|
return struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{Type: "text", Text: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
|
||||||
|
t.Helper()
|
||||||
|
ts := httptest.NewServer(f.handler())
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
return &Client{BaseURL: ts.URL}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_CreateSession(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{})
|
||||||
|
id, err := c.CreateSession(context.Background(), "ratatoskr-analyst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession err: %v", err)
|
||||||
|
}
|
||||||
|
if id != "sess-fake" {
|
||||||
|
t.Errorf("id = %q, want sess-fake", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_CreateSessionFail(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{failCreate: true})
|
||||||
|
if _, err := c.CreateSession(context.Background(), "x"); err == nil {
|
||||||
|
t.Fatal("CreateSession должен упасть при 500, а не nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Verdict(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{
|
||||||
|
messages: []sessionMessage{{Type: "assistant", Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{textPart(`{"phase":"ready"}`)}}},
|
||||||
|
})
|
||||||
|
vd, err := c.Verdict(context.Background(), "sess-fake")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Verdict err: %v", err)
|
||||||
|
}
|
||||||
|
if vd != `{"phase":"ready"}` {
|
||||||
|
t.Errorf("verdict = %q, want вердикт модели", vd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_VerdictNoText(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{}) // нет assistant-сообщения с text
|
||||||
|
if _, err := c.Verdict(context.Background(), "sess-fake"); err == nil {
|
||||||
|
t.Fatal("Verdict должен упасть, когда нет text-части")
|
||||||
|
} else {
|
||||||
|
var ce *ClientErr
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Errorf("ожидался *ClientErr, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,4 +83,14 @@ func SessionIDFromOutput(out string) (string, bool) {
|
|||||||
return m[1], true
|
return m[1], true
|
||||||
}
|
}
|
||||||
return "", false
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
|
||||||
|
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
|
||||||
|
func stripFence(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
|
||||||
|
s = strings.TrimSpace(f[1])
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(s)
|
||||||
}
|
}
|
||||||
234
internal/opencode/pool.go
Normal file
234
internal/opencode/pool.go
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool — контроль над пулом opencode serve-процессов (по одному на каталог).
|
||||||
|
//
|
||||||
|
// Ленивый: сервер для каталога поднимается при первом запросе (Ensure), кроме
|
||||||
|
// служебного root-сервера (EnsureRoot), который живёт с момента старта app.
|
||||||
|
// Завершение задачи снимает поднятые серверы кроме root'a (ReleaseTask).
|
||||||
|
//
|
||||||
|
// Каждый Server слушает свой порт (basePort + сдвиг), запускается в своей
|
||||||
|
// директории → каждая сессия API привязана к правильному project-каталогу.
|
||||||
|
type Pool struct {
|
||||||
|
Bin string
|
||||||
|
Config string
|
||||||
|
ConfigDir string
|
||||||
|
DBPath string
|
||||||
|
Host string
|
||||||
|
BasePort int
|
||||||
|
Password string
|
||||||
|
|
||||||
|
rootDir string // каталог служебного сервера
|
||||||
|
root *Server
|
||||||
|
|
||||||
|
ctx context.Context // базовый ctx для всех serve; живёт, пока пул активен
|
||||||
|
cancel context.CancelFunc
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
segs map[string]*Server // dir → сервер (root тоже здесь)
|
||||||
|
used map[int]bool // занятые порты
|
||||||
|
next int // следующий кандидат порта
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPool создаёт пул. rootDir помечен как служебный (не снимается ReleaseTask).
|
||||||
|
func NewPool(rootDir string) *Pool {
|
||||||
|
return &Pool{
|
||||||
|
Host: "127.0.0.1",
|
||||||
|
BasePort: 4096,
|
||||||
|
rootDir: rootDir,
|
||||||
|
segs: make(map[string]*Server),
|
||||||
|
used: make(map[int]bool),
|
||||||
|
next: 4096,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startMonitored поднимает сервер и запускает его Run-перезапуск (reaper).
|
||||||
|
// Наследует базовый ctx пула: Serve живёт, пока жив пул.
|
||||||
|
func (p *Pool) startMonitored(ctx context.Context, s *Server) error {
|
||||||
|
if p.ctx == nil {
|
||||||
|
sctx, cancel := context.WithCancel(ctx)
|
||||||
|
p.ctx, p.cancel = sctx, cancel
|
||||||
|
}
|
||||||
|
if err := s.Start(p.ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go s.Run(p.ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureRoot поднимает служебный сервер в rootDir (идемпотентен).
|
||||||
|
func (p *Pool) EnsureRoot(ctx context.Context) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
if p.root != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := &Server{
|
||||||
|
Bin: p.Bin,
|
||||||
|
Config: p.Config,
|
||||||
|
ConfigDir: p.ConfigDir,
|
||||||
|
DBPath: p.DBPath,
|
||||||
|
Host: p.Host,
|
||||||
|
Password: p.Password,
|
||||||
|
Dir: p.rootDir,
|
||||||
|
}
|
||||||
|
if err := p.assign(s); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.startMonitored(ctx, s); err != nil {
|
||||||
|
return fmt.Errorf("opencode serve (root): %w", err)
|
||||||
|
}
|
||||||
|
p.root = s
|
||||||
|
p.segs[p.rootDir] = s
|
||||||
|
log.Printf("opencode: root serve up at %s (dir %s)", s.Addr(), p.rootDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure гарантирует наличие сервера для каталога dir (лениво).
|
||||||
|
// Возвращает сервер; root-сервер для rootDir возвращается как есть.
|
||||||
|
func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) {
|
||||||
|
p.mu.Lock()
|
||||||
|
if s, ok := p.segs[dir]; ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
abs := filepath.Clean(dir)
|
||||||
|
s := &Server{
|
||||||
|
Bin: p.Bin,
|
||||||
|
Config: p.Config,
|
||||||
|
ConfigDir: p.ConfigDir,
|
||||||
|
DBPath: p.DBPath,
|
||||||
|
Host: p.Host,
|
||||||
|
Password: p.Password,
|
||||||
|
Dir: abs,
|
||||||
|
}
|
||||||
|
if err := p.assign(s); err != nil {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p.segs[abs] = s
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if err := p.startMonitored(ctx, s); err != nil {
|
||||||
|
p.mu.Lock()
|
||||||
|
delete(p.segs, abs)
|
||||||
|
p.releasePort(s.Port)
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil, fmt.Errorf("opencode serve (%s): %w", abs, err)
|
||||||
|
}
|
||||||
|
log.Printf("opencode: serve up at %s (dir %s)", s.Addr(), abs)
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterExternal регистрирует внешний (уже запущенный) сервер для каталога
|
||||||
|
// dir. Полезно, когда serve поднят вне пула (в т.ч. в тестах): Ensure вернёт
|
||||||
|
// его без spawn'а. url — полный адрес, по которому Runner ходит через API.
|
||||||
|
func (p *Pool) RegisterExternal(dir, url string) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
abs := filepath.Clean(dir)
|
||||||
|
p.segs[abs] = &Server{URL: url, Host: p.Host, PollInterval: 0}
|
||||||
|
if p.rootDir != "" && abs == p.rootDir {
|
||||||
|
p.root = p.segs[abs]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerFor возвращает сервер для каталога (без поднятия). ok=false если нет.
|
||||||
|
func (p *Pool) ServerFor(dir string) (*Server, bool) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
s, ok := p.segs[filepath.Clean(dir)]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseTask закрывает все серверы пула, кроме служебного root. Вызывается
|
||||||
|
// при завершении задачи.
|
||||||
|
func (p *Pool) ReleaseTask() {
|
||||||
|
p.mu.Lock()
|
||||||
|
var toClose []*Server
|
||||||
|
for dir, s := range p.segs {
|
||||||
|
if p.root != nil && dir == p.rootDir {
|
||||||
|
continue // служебный не снимаем
|
||||||
|
}
|
||||||
|
toClose = append(toClose, s)
|
||||||
|
delete(p.segs, dir)
|
||||||
|
p.releasePort(s.Port)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
for _, s := range toClose {
|
||||||
|
log.Printf("opencode: closing serve %s (release task)", s.Addr())
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close закрывает все серверы пула, включая root. Идемпотентен.
|
||||||
|
func (p *Pool) Close() {
|
||||||
|
p.mu.Lock()
|
||||||
|
toClose := make([]*Server, 0, len(p.segs))
|
||||||
|
for dir, s := range p.segs {
|
||||||
|
toClose = append(toClose, s)
|
||||||
|
delete(p.segs, dir)
|
||||||
|
p.releasePort(s.Port)
|
||||||
|
}
|
||||||
|
p.root = nil
|
||||||
|
if p.cancel != nil {
|
||||||
|
p.cancel()
|
||||||
|
p.cancel = nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
for _, s := range toClose {
|
||||||
|
s.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assign выделяет свободный порт и проставляет его серверу.
|
||||||
|
func (p *Pool) assign(s *Server) error {
|
||||||
|
port, err := p.allocPort()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.Port = port
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// allocPort находит свободный порт начиная с next, коммитит его.
|
||||||
|
func (p *Pool) allocPort() (int, error) {
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
port := p.next
|
||||||
|
p.next++
|
||||||
|
if p.used[port] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !portFree(p.Host, port) {
|
||||||
|
p.used[port] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.used[port] = true
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("opencode: нет свободных портов в диапазоне")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) releasePort(port int) {
|
||||||
|
if port != 0 {
|
||||||
|
delete(p.used, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// portFree проверяет, свободен ли порт (bind probe).
|
||||||
|
func portFree(host string, port int) bool {
|
||||||
|
l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
l.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
75
internal/opencode/pool_test.go
Normal file
75
internal/opencode/pool_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPool_EnsureRoot_NoSpawn(t *testing.T) {
|
||||||
|
// без spawn: пул без Bin — EnsureRoot должен упасть (нет бинаря),
|
||||||
|
// но НЕ упасть на пустой карте. Проверяем, что повтор вызова не паникует.
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := NewPool(dir)
|
||||||
|
// не запускаем — просто проверяем кэш
|
||||||
|
p.mu.Lock()
|
||||||
|
p.segs[dir] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Millisecond}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
s, ok := p.ServerFor(dir)
|
||||||
|
if !ok || s == nil {
|
||||||
|
t.Fatal("ServerFor должен найти закэшированный сервер")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_ServerFor_ReleaseTask(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
a := filepath.Join(dir, "a")
|
||||||
|
b := filepath.Join(dir, "b")
|
||||||
|
p := NewPool(dir)
|
||||||
|
p.Bin = fakeServeBin(t, dir)
|
||||||
|
|
||||||
|
// root не запускаем; добавляем серверы в карту напрямую (как после Ensure).
|
||||||
|
p.mu.Lock()
|
||||||
|
p.segs[a] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
|
||||||
|
p.segs[b] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// ReleaseTask: rootDir в карте НЕ занят, значит оба закрываются.
|
||||||
|
p.ReleaseTask()
|
||||||
|
if _, ok := p.ServerFor(a); ok {
|
||||||
|
t.Error("каталог a должен быть снят после ReleaseTask")
|
||||||
|
}
|
||||||
|
if _, ok := p.ServerFor(b); ok {
|
||||||
|
t.Error("каталог b должен быть снят после ReleaseTask")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPool_AllocPort_Unique(t *testing.T) {
|
||||||
|
p := NewPool("/tmp/x")
|
||||||
|
p.Host = "127.0.0.1"
|
||||||
|
seen := map[int]bool{}
|
||||||
|
var ports []int
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
port, err := p.allocPort()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("allocPort err: %v", err)
|
||||||
|
}
|
||||||
|
if seen[port] {
|
||||||
|
t.Fatalf("дубль порта %d", port)
|
||||||
|
}
|
||||||
|
seen[port] = true
|
||||||
|
ports = append(ports, port)
|
||||||
|
}
|
||||||
|
// освобождаем и убеждаемся, что порт можно переиспользовать
|
||||||
|
p.releasePort(ports[0])
|
||||||
|
if !p.free(ports[0]) {
|
||||||
|
t.Errorf("порт %d должен быть свободен после releasePort", ports[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Pool) free(port int) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
return !p.used[port]
|
||||||
|
}
|
||||||
24
internal/opencode/procgroup_unix.go
Normal file
24
internal/opencode/procgroup_unix.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
//go:build linux || darwin || freebsd || netbsd || openbsd || aix || solaris
|
||||||
|
|
||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setpgid выделяет дочернему процессу собственную process-group, чтобы
|
||||||
|
// killGroup мог убить весь групповой процесс, а не чужой (тест-реннер и т.п.).
|
||||||
|
func setpgid(cmd *exec.Cmd) {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// killGroup шлёт SIGKILL всей process-group процесса (pgid == pid из-за
|
||||||
|
// Setpgid). Процесс уже завершён — возвращаем 0 и не отвлекаемся на ошибку
|
||||||
|
// ESRCH (группа могла сама разойтись).
|
||||||
|
func killGroup(proc *exec.Cmd) {
|
||||||
|
if proc == nil || proc.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = syscall.Kill(-proc.Process.Pid, syscall.SIGKILL)
|
||||||
|
}
|
||||||
@@ -1,53 +1,42 @@
|
|||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
|
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
|
||||||
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
|
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
|
||||||
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
|
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
|
||||||
|
// (класс O2 Timeout — результат, не ошибка).
|
||||||
type Result struct {
|
type Result struct {
|
||||||
RC int
|
RC int
|
||||||
Stdout string
|
Stdout string
|
||||||
SessionID string
|
SessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner — конфигурация запуска opencode-субагентов.
|
// Runner — запуск opencode-субагентов через HTTP API serve.
|
||||||
|
//
|
||||||
|
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
|
||||||
|
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
|
||||||
|
// (в нём запущен serve → он его project).
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
Bin string // путь к opencode (по умолчанию "opencode")
|
Pool *Pool // пул serve-серверов (обязательный)
|
||||||
DBPath string // путь к opencode.db (idle-детекция активности)
|
IdleTimeout time.Duration
|
||||||
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
HardTimeout time.Duration
|
||||||
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
|
||||||
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
|
|
||||||
HardTimeout time.Duration // общий лимит на запуск
|
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
|
|
||||||
// AttachURL — постоянный opencode serve (режим --attach). Непустое значение
|
// Заменяемый для тестов:
|
||||||
// переключает команду на `run --attach <url> ...`: тёплые модели/MCP, без
|
|
||||||
// холодного старта на каждый вызов. Пусто — историческая spawn-модель.
|
|
||||||
AttachURL string
|
|
||||||
|
|
||||||
// Заменяемые для тестов:
|
|
||||||
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) defaults() {
|
func (r *Runner) defaults() {
|
||||||
if r.Bin == "" {
|
|
||||||
r.Bin = "opencode"
|
|
||||||
}
|
|
||||||
if r.IdleTimeout == 0 {
|
if r.IdleTimeout == 0 {
|
||||||
r.IdleTimeout = 5 * time.Minute
|
r.IdleTimeout = 5 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -66,236 +55,157 @@ func (r *Runner) logf(format string, args ...any) {
|
|||||||
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
|
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
|
||||||
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
|
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
|
||||||
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
|
//
|
||||||
if r.DBPath == "" {
|
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
|
||||||
return 0, false
|
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
|
||||||
}
|
// Result, а не error (класс O2).
|
||||||
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
|
|
||||||
if err != nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
var ts sql.NullInt64
|
|
||||||
err = db.QueryRowContext(ctx,
|
|
||||||
"SELECT MAX(m.time_updated) FROM message m JOIN session s ON s.id = m.session_id WHERE s.directory = ?",
|
|
||||||
worktree).Scan(&ts)
|
|
||||||
if err != nil || !ts.Valid {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return ts.Int64, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (string, bool) {
|
|
||||||
if r.DBPath == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
q := "SELECT id FROM session WHERE directory = ?"
|
|
||||||
args := []any{worktree}
|
|
||||||
if agent != "" {
|
|
||||||
q += " AND agent = ?"
|
|
||||||
args = append(args, agent)
|
|
||||||
}
|
|
||||||
q += " ORDER BY time_created DESC LIMIT 1"
|
|
||||||
var id string
|
|
||||||
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return id, id != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run запускает opencode run. Возвращает *Result (rc, stdout, session_id).
|
|
||||||
// Ошибка — только класс O1 ErrSpawn (не смог запустить бинарь). Таймауты
|
|
||||||
// дают rc=-1 в Result, а не error (класс O2).
|
|
||||||
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
||||||
r.defaults()
|
r.defaults()
|
||||||
cmd := []string{r.Bin, "run"}
|
if r.Pool == nil {
|
||||||
if r.AttachURL != "" {
|
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
|
||||||
cmd = append(cmd, "--attach", r.AttachURL)
|
|
||||||
}
|
|
||||||
cmd = append(cmd, "--agent", agent, "--format", "json", "--dir", cwd)
|
|
||||||
if sessionID != "" {
|
|
||||||
cmd = append(cmd, "--session", sessionID)
|
|
||||||
}
|
|
||||||
cmd = append(cmd, prompt)
|
|
||||||
|
|
||||||
env := append(os.Environ(),
|
|
||||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
|
||||||
"OPENCODE_DISABLE_MODELS_FETCH=1")
|
|
||||||
if r.Config != "" {
|
|
||||||
env = append(env, "OPENCODE_CONFIG="+r.Config)
|
|
||||||
}
|
|
||||||
if r.ConfigDir != "" {
|
|
||||||
env = append(env, "OPENCODE_CONFIG_DIR="+r.ConfigDir)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
|
srv, err := r.Pool.Ensure(ctx, cwd)
|
||||||
proc.Env = env
|
|
||||||
proc.Dir = cwd
|
|
||||||
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
|
|
||||||
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
|
|
||||||
setpgid(proc)
|
|
||||||
stdout, err := proc.StdoutPipe()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
proc.Stderr = proc.Stdout
|
c := &Client{BaseURL: srv.Addr(), Password: srv.Password}
|
||||||
if err := proc.Start(); err != nil {
|
|
||||||
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
|
// Сессия: заданная (resume) или новая.
|
||||||
|
sid := sessionID
|
||||||
|
if sid == "" {
|
||||||
|
sid, err = c.CreateSession(ctx, "ratatoskr-"+agent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||||
|
}
|
||||||
|
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf []string
|
// Отправляем промпт (неблокирующий — сервер начинает выполнение).
|
||||||
|
if err := c.Send(ctx, sid, prompt); err != nil {
|
||||||
|
return nil, fmt.Errorf("opencode: prompt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.awaitVerdict(ctx, c, sid, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// awaitVerdict поллит сообщения сессии, пока не появится готовый text-вердикт
|
||||||
|
// от assistant, либо не истечёт idle/hard таймаут (тогда Abort + rc=-1).
|
||||||
|
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent string) (*Result, error) {
|
||||||
|
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
|
||||||
|
// сбрасывает idle-таймер (LLM стримит = жив).
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
done := make(chan struct{})
|
lastCount := -1
|
||||||
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
|
|
||||||
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
|
|
||||||
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
|
|
||||||
var liveSeq atomic.Uint64
|
|
||||||
prevLive := liveSeq.Load()
|
|
||||||
// Живое наблюдение сессии (если задано через WithLive в контексте).
|
|
||||||
liveReg, liveTask := liveFromContext(ctx)
|
|
||||||
if liveReg != nil && liveTask != 0 {
|
|
||||||
liveReg.Start(liveTask, agent)
|
|
||||||
defer liveReg.Finish(liveTask)
|
|
||||||
}
|
|
||||||
go func() {
|
|
||||||
defer close(done)
|
|
||||||
sc := bufio.NewScanner(stdout)
|
|
||||||
// NDJSON opencode пишет каждый объект одной строкой; большой text-парт с
|
|
||||||
// вердиктом легко превышает дефолтный лимит Scanner в 64КБ → ErrTooLong и
|
|
||||||
// потеря всего потока после первой строки. Поднимаем до 64МБ.
|
|
||||||
sc.Buffer(make([]byte, 64*1024), 64*1024*1024)
|
|
||||||
for sc.Scan() {
|
|
||||||
line := sc.Text()
|
|
||||||
mu.Lock()
|
|
||||||
buf = append(buf, line)
|
|
||||||
mu.Unlock()
|
|
||||||
if st := parseLiveStep(line); st != nil {
|
|
||||||
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
|
|
||||||
liveSeq.Add(1)
|
|
||||||
if liveReg != nil {
|
|
||||||
liveReg.Observe(liveTask, *st)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
scanErr := sc.Err()
|
|
||||||
if scanErr != nil {
|
|
||||||
r.logf("opencode(%s) scan err: %v", agent, scanErr)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
baseline, _ := r.maxDirMsgTS(ctx, cwd)
|
|
||||||
lastProgress := time.Now()
|
lastProgress := time.Now()
|
||||||
launch := time.Now()
|
launch := time.Now()
|
||||||
killed := false
|
|
||||||
|
|
||||||
pollLoop:
|
|
||||||
for {
|
for {
|
||||||
select {
|
if ctx.Err() != nil {
|
||||||
case <-done:
|
if err := c.Abort(ctx, sid); err != nil {
|
||||||
// процесс завершился (pipe EOF) — выходим, берём exit code
|
r.logf("opencode(%s) abort (ctx): %v", agent, err)
|
||||||
break pollLoop
|
}
|
||||||
case <-ctx.Done():
|
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
|
||||||
killGroup(proc)
|
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
if proc.ProcessState != nil && proc.ProcessState.Exited() {
|
|
||||||
break pollLoop
|
count, _ := c.textCount(ctx, sid)
|
||||||
|
mu.Lock()
|
||||||
|
if count != lastCount {
|
||||||
|
lastProgress = time.Now()
|
||||||
|
lastCount = count
|
||||||
}
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
// Пробуем вердикт (дешёвый GET). Если готов — выходим.
|
||||||
|
vd, vErr := c.Verdict(ctx, sid)
|
||||||
|
if vErr == nil && vd != "" {
|
||||||
|
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(vd))
|
||||||
|
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||||
|
}
|
||||||
|
// Сервер недоступен — фатально (не таймаут). Но если это следствие
|
||||||
|
// отмены контекста (cancel прилетел прямо во время запроса) — это обрыв
|
||||||
|
// по контексту, а не недоступность сервера: вернём RC=-1 на следующей
|
||||||
|
// итерации (проверка ctx.Err() вверху) и не подменяем класс ошибки.
|
||||||
|
var ce *ClientErr
|
||||||
|
if errors.As(vErr, &ce) && ce.Op == "connect" && ctx.Err() == nil {
|
||||||
|
return nil, fmt.Errorf("opencode: %w", vErr)
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// «Пульс» LLM: если с прошлого поллинга появились live-строки
|
|
||||||
// (text/tool/agent/reasoning) — LLM реально работает, сбрасываем idle.
|
|
||||||
if cur := liveSeq.Load(); cur != prevLive {
|
|
||||||
prevLive = cur
|
|
||||||
lastProgress = now
|
|
||||||
}
|
|
||||||
ts, ok := r.maxDirMsgTS(ctx, cwd)
|
|
||||||
if ok && ts > baseline {
|
|
||||||
lastProgress = now
|
|
||||||
}
|
|
||||||
if now.Sub(lastProgress) > r.IdleTimeout {
|
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||||
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
|
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||||
killGroup(proc)
|
if err := c.Abort(ctx, sid); err != nil {
|
||||||
killed = true
|
r.logf("opencode(%s) abort idle: %v", agent, err)
|
||||||
break pollLoop
|
}
|
||||||
|
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
|
||||||
}
|
}
|
||||||
|
// hard — общий бюджет от старта запуска.
|
||||||
if now.Sub(launch) > r.HardTimeout {
|
if now.Sub(launch) > r.HardTimeout {
|
||||||
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
|
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||||
killGroup(proc)
|
if err := c.Abort(ctx, sid); err != nil {
|
||||||
killed = true
|
r.logf("opencode(%s) abort hard: %v", agent, err)
|
||||||
break pollLoop
|
}
|
||||||
|
return &Result{RC: -1, Stdout: "", SessionID: sid}, nil
|
||||||
}
|
}
|
||||||
time.Sleep(r.PollInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-done
|
select {
|
||||||
procErr := proc.Wait()
|
case <-time.After(r.PollInterval):
|
||||||
rc := proc.ProcessState.ExitCode()
|
case <-ctx.Done():
|
||||||
if rc < 0 {
|
|
||||||
rc = 1
|
|
||||||
}
|
|
||||||
if killed {
|
|
||||||
rc = -1
|
|
||||||
}
|
|
||||||
_ = procErr
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
out := strings.Join(buf, "\n")
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
|
|
||||||
|
|
||||||
sid := sessionID
|
|
||||||
if s, ok := SessionIDFromOutput(out); ok {
|
|
||||||
sid = s
|
|
||||||
}
|
|
||||||
if rc == -1 && sid == "" {
|
|
||||||
if s, ok := r.latestSession(ctx, cwd, agent); ok {
|
|
||||||
sid = s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
r.logf("opencode(%s) rc=%d", agent, rc)
|
}
|
||||||
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
|
|
||||||
|
// textCount считает число text-частей в assistant-сообщениях (для progress).
|
||||||
|
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
|
||||||
|
msgs, err := c.messages(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Type != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range m.Content {
|
||||||
|
if p.Type == "text" && p.Text != "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
|
||||||
|
func (c *Client) messages(ctx context.Context, sessionID string) ([]sessionMessage, error) {
|
||||||
|
raw, err := c.do(ctx, "GET", "/api/session/"+sessionID+"/message?order=asc&limit=200", "messages", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data []sessionMessage `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
|
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
|
||||||
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
|
// падает с rc!=0 — повторяем ОДИН раз свежей сессией в том же каталоге.
|
||||||
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит fallback.
|
// rc=-1 (обрыв по таймауту) НЕ триггерит fallback.
|
||||||
// Возвращает (result, timedOut).
|
// Возвращает (result, timedOut).
|
||||||
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
|
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
|
||||||
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
|
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// spawn-ошибку не ретраим fallback'ом — она повторится
|
|
||||||
return res, false
|
return res, false
|
||||||
}
|
}
|
||||||
if res.RC != 0 && res.RC != -1 && sessionID != "" {
|
if res.RC != 0 && res.RC != -1 && sessionID != "" {
|
||||||
r.logf("dev resume rc=%d — запускаю заново без --session (worktree сохраняю)", res.RC)
|
r.logf("dev resume rc=%d — запускаю заново свежей сессией (каталог сохраняю)", res.RC)
|
||||||
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
|
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
|
||||||
}
|
}
|
||||||
return res, res.RC == -1
|
return res, res.RC == -1
|
||||||
}
|
}
|
||||||
|
|
||||||
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
|
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
|
||||||
|
|
||||||
// --- process-group helpers (Linux) ---
|
|
||||||
// Ставим процесс в собственную process-group, чтобы killGroup мог убить и
|
|
||||||
// дочерние процессы (иначе они держат унаследованные stdout-fd и <-done виснет).
|
|
||||||
|
|
||||||
func setpgid(proc *exec.Cmd) {
|
|
||||||
sysProcAttr(proc)
|
|
||||||
}
|
|
||||||
|
|
||||||
func killGroup(proc *exec.Cmd) {
|
|
||||||
if proc.Process != nil {
|
|
||||||
killProcGroup(proc.Process.Pid)
|
|
||||||
}
|
|
||||||
_ = proc.Process.Kill()
|
|
||||||
}
|
|
||||||
@@ -2,68 +2,42 @@ package opencode
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
|
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||||||
//
|
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||||||
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
|
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
|
||||||
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
|
|
||||||
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
|
|
||||||
func fakeOpenCode(t *testing.T, workdir string) string {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
bin := filepath.Join(workdir, "opencode")
|
ts := httptestURL(t, f)
|
||||||
script := `#!/bin/sh
|
p := NewPool(dir)
|
||||||
mode="${FAKE_MODE:-ok}"
|
p.mu.Lock()
|
||||||
case "$mode" in
|
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
|
||||||
ok)
|
p.mu.Unlock()
|
||||||
echo '{"type":"text","part":{"text":"done"}}'
|
return p, &Client{BaseURL: ts}
|
||||||
echo '{"session_id":"sess-123"}'
|
}
|
||||||
exit 0
|
|
||||||
;;
|
// httptestURL запускает фейк-API и возвращает его URL.
|
||||||
slow)
|
func httptestURL(t *testing.T, f *fakeAPIServer) string {
|
||||||
sleep 30
|
t.Helper()
|
||||||
;;
|
ts := httptest.NewServer(f.handler())
|
||||||
live-reset)
|
t.Cleanup(ts.Close)
|
||||||
# шлём live-строку каждые 30мс долго — почти до hard timeout,
|
return ts.URL
|
||||||
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
|
|
||||||
i=0
|
|
||||||
while [ $i -lt 20 ]; do
|
|
||||||
echo '{"type":"text","part":{"text":"tick"}}'
|
|
||||||
sleep 0.03
|
|
||||||
i=$((i+1))
|
|
||||||
done
|
|
||||||
sleep 30
|
|
||||||
;;
|
|
||||||
fail)
|
|
||||||
echo '{"type":"text","part":{"text":"boom"}}'
|
|
||||||
exit 7
|
|
||||||
;;
|
|
||||||
args)
|
|
||||||
# печатаем аргументы в $FAKE_ARGS_FILE (тест читает) и успешно завершаемся
|
|
||||||
printf '%s\n' "$@" > "${FAKE_ARGS_FILE:-/dev/null}"
|
|
||||||
echo '{"type":"text","part":{"text":"ok"}}'
|
|
||||||
echo '{"session_id":"sess-args"}'
|
|
||||||
exit 0
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
`
|
|
||||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
|
||||||
t.Fatalf("write fake opencode: %v", err)
|
|
||||||
}
|
|
||||||
return bin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_Success(t *testing.T) {
|
func TestRun_Success(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{
|
||||||
t.Setenv("FAKE_MODE", "ok")
|
messages: []sessionMessage{{Type: "assistant", Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{textPart("done")}}},
|
||||||
|
}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
@@ -71,86 +45,39 @@ func TestRun_Success(t *testing.T) {
|
|||||||
if res.RC != 0 {
|
if res.RC != 0 {
|
||||||
t.Errorf("RC = %d, want 0", res.RC)
|
t.Errorf("RC = %d, want 0", res.RC)
|
||||||
}
|
}
|
||||||
if res.SessionID != "sess-123" {
|
if res.SessionID != "sess-fake" {
|
||||||
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
|
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
|
||||||
}
|
}
|
||||||
if !contains(res.Stdout, "done") {
|
if !contains(res.Stdout, "done") {
|
||||||
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
|
t.Errorf("Stdout = %q, want contain done", res.Stdout)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRun_LiveRegistry(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
bin := fakeOpenCode(t, dir)
|
|
||||||
t.Setenv("FAKE_MODE", "ok")
|
|
||||||
|
|
||||||
reg := NewLiveRegistry()
|
|
||||||
ctx := WithLive(context.Background(), reg, 42)
|
|
||||||
|
|
||||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
|
||||||
res, err := r.Run(ctx, "task", dir, "dev", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run err: %v", err)
|
|
||||||
}
|
|
||||||
if res.RC != 0 {
|
|
||||||
t.Fatalf("RC = %d, want 0", res.RC)
|
|
||||||
}
|
|
||||||
// После завершения Finish удаляет сессию → Snap не найден.
|
|
||||||
if _, ok := reg.Snap(42); ok {
|
|
||||||
t.Error("сессия не удалена после Finish (должна быть, т.к. задача завершилась)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_IdleTimeout(t *testing.T) {
|
func TestRun_IdleTimeout(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
// сервер никогда не отдаёт text → всегда неготов, прогресс не растёт
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
f := &fakeAPIServer{}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond}
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
}
|
}
|
||||||
if res.RC != -1 {
|
if res.RC != -1 {
|
||||||
t.Errorf("RC = %d, want -1 (timeout kill)", res.RC)
|
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRun_LiveResetsIdle: пока LLM стримит live-строки, idle-таймер должен
|
|
||||||
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
|
|
||||||
func TestRun_LiveResetsIdle(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
bin := fakeOpenCode(t, dir)
|
|
||||||
t.Setenv("FAKE_MODE", "live-reset")
|
|
||||||
|
|
||||||
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
|
|
||||||
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
|
|
||||||
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
|
|
||||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
|
||||||
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
|
|
||||||
start := time.Now()
|
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run err: %v", err)
|
|
||||||
}
|
|
||||||
if res.RC != -1 {
|
|
||||||
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
|
|
||||||
}
|
|
||||||
if elapsed < 400*time.Millisecond {
|
|
||||||
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_ContextCancel(t *testing.T) {
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{}
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
r := &Runner{Bin: bin, HardTimeout: time.Minute,
|
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond}
|
||||||
done := make(chan *Result, 1)
|
done := make(chan *Result, 1)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -169,74 +96,26 @@ func TestRun_ContextCancel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResumeDev_Fallback(t *testing.T) {
|
// TestResumeDev_Fallback: resume (sessionID) "падает" rc!=0 только когда самого
|
||||||
|
// сервера нет; в фейке такого нет, поэтому проверяем, что при успехе
|
||||||
|
// fallback не срабатывает и таймаут не выставляется.
|
||||||
|
func TestResumeDev_NoFallbackOnSuccess(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{
|
||||||
t.Setenv("FAKE_MODE", "fail")
|
messages: []sessionMessage{{Type: "assistant", Content: []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}{textPart("ok")}}},
|
||||||
|
}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
|
||||||
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
|
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
|
||||||
if timedOut {
|
if timedOut {
|
||||||
t.Error("timedOut = true, want false")
|
t.Error("timedOut = true, want false (успех не должен считаться таймаутом)")
|
||||||
}
|
}
|
||||||
// fake fail всегда exit 7, fallback тоже 7 — проверяем что RC от fallback-вызова
|
if res.RC != 0 {
|
||||||
if res.RC != 7 {
|
t.Errorf("RC = %d, want 0", res.RC)
|
||||||
t.Errorf("RC = %d, want 7 (fallback повтор с тем же кодом)", res.RC)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRun_AttachMode проверяет, что при заданном AttachURL команда opencode run
|
|
||||||
// получает флаг `--attach <url>`, и что без AttachURL — не получает.
|
|
||||||
func TestRun_AttachMode(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
bin := fakeOpenCode(t, dir)
|
|
||||||
t.Setenv("FAKE_MODE", "args")
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
attach string
|
|
||||||
wantFlag bool
|
|
||||||
}{
|
|
||||||
{"attach задан", "http://127.0.0.1:4096", true},
|
|
||||||
{"attach пуст (spawn-модель)", "", false},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
argsFile := filepath.Join(dir, "args_"+strings.ReplaceAll(tc.name, " ", "_")+".txt")
|
|
||||||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
|
||||||
|
|
||||||
r := &Runner{Bin: bin, AttachURL: tc.attach, PollInterval: 20 * time.Millisecond}
|
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Run err: %v", err)
|
|
||||||
}
|
|
||||||
if res.RC != 0 {
|
|
||||||
t.Fatalf("RC = %d, want 0", res.RC)
|
|
||||||
}
|
|
||||||
data, err := os.ReadFile(argsFile)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("читать args-файл: %v", err)
|
|
||||||
}
|
|
||||||
args := strings.Fields(string(data))
|
|
||||||
hasAttach := false
|
|
||||||
for i, a := range args {
|
|
||||||
if a == "--attach" {
|
|
||||||
hasAttach = true
|
|
||||||
if i+1 >= len(args) || args[i+1] != tc.attach {
|
|
||||||
t.Fatalf("--attach URL = %q, want %q", args[min(i+1, len(args)-1)], tc.attach)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if hasAttach != tc.wantFlag {
|
|
||||||
t.Errorf("--attach присутствует = %v, want %v; args=%v", hasAttach, tc.wantFlag, args)
|
|
||||||
}
|
|
||||||
if hasAttach && tc.attach != "" {
|
|
||||||
// --dir должен идти следом за --attach
|
|
||||||
if !contains(string(data), "--dir") {
|
|
||||||
t.Errorf("ожидался --dir в args: %v", args)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,4 +130,4 @@ func indexOf(s, sub string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ type Server struct {
|
|||||||
Host string // hostname для прослушивания
|
Host string // hostname для прослушивания
|
||||||
Port int // порт сервера
|
Port int // порт сервера
|
||||||
Password string // basic auth (если непустой — сервер защищён)
|
Password string // basic auth (если непустой — сервер защищён)
|
||||||
|
Dir string // каталог, в котором запускается serve (project сервера)
|
||||||
|
|
||||||
// URL задаёт внешний сервер. Пусто — супервайзер владеет процессом.
|
// URL задаёт внешний сервер. Пусто — супервайзер владеет процессом.
|
||||||
URL string
|
URL string
|
||||||
@@ -116,7 +117,10 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
||||||
args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)}
|
args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)}
|
||||||
cmd := exec.CommandContext(ctx, s.Bin, args...)
|
cmd := exec.CommandContext(ctx, s.Bin, args...)
|
||||||
cmd.Dir = "."
|
cmd.Dir = s.Dir // project сервера — каталог, который обслуживает этот serve
|
||||||
|
if cmd.Dir == "" {
|
||||||
|
cmd.Dir = "."
|
||||||
|
}
|
||||||
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
|
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
|
||||||
// дочерние процессы, а не чужой процесс (например, тест-реннер).
|
// дочерние процессы, а не чужой процесс (например, тест-реннер).
|
||||||
setpgid(cmd)
|
setpgid(cmd)
|
||||||
|
|||||||
Reference in New Issue
Block a user