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

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