feat: opencode serve — постоянный сервер вместо разовых subprocess (вариант A)
Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
serve.enabled=false — историческая spawn-модель сохранена; наличие
serve.url переключает на внешний сервер.
- config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер
научился int/bool (раньше только string/Duration/struct).
- opencode: Server (Start/Run/Close, /global/health, рестарт упавшего,
reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL).
- Runner.AttachURL: run --attach <url> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
This commit is contained in:
271
internal/opencode/server.go
Normal file
271
internal/opencode/server.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server — супервайзер постоянного opencode serve (режим --attach).
|
||||
//
|
||||
// Вариант A интеграции: один headless-сервер живёт долго (тёплые модели и MCP),
|
||||
// а Runner ходит к нему через `opencode run --attach <url> --dir cwd ...`.
|
||||
//
|
||||
// Два режима владения процессом:
|
||||
// - URL == "": супервайзер сам spawn'ит `opencode serve`, следит через
|
||||
// /global/health, рестартует при падении, гасит при Close.
|
||||
// - URL != "": внешний сервер — супервайзер только проверяет доступность и
|
||||
// отдаёт URL, процессом не владеет.
|
||||
type Server struct {
|
||||
Bin string // путь к opencode (по умолчанию "opencode")
|
||||
Config string // OPENCODE_CONFIG
|
||||
ConfigDir string // OPENCODE_CONFIG_DIR
|
||||
DBPath string // рабочая БД сервера (передам env, если задана)
|
||||
|
||||
Host string // hostname для прослушивания
|
||||
Port int // порт сервера
|
||||
Password string // basic auth (если непустой — сервер защищён)
|
||||
|
||||
// URL задаёт внешний сервер. Пусто — супервайзер владеет процессом.
|
||||
URL string
|
||||
PollInterval time.Duration // как часто проверять /global/health
|
||||
|
||||
// Заменяемые для тестов:
|
||||
Stdout io.Writer
|
||||
|
||||
mu sync.Mutex
|
||||
proc *exec.Cmd
|
||||
done chan struct{} // закрывается reaper'ом при выходе процесса
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *Server) defaults() {
|
||||
if s.Bin == "" {
|
||||
s.Bin = "opencode"
|
||||
}
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Port == 0 {
|
||||
s.Port = 4096
|
||||
}
|
||||
if s.PollInterval == 0 {
|
||||
s.PollInterval = 5 * time.Second
|
||||
}
|
||||
if s.Stdout == nil {
|
||||
s.Stdout = os.Stderr
|
||||
}
|
||||
}
|
||||
|
||||
// baseURL собирает полный адрес сервера (http://host:port).
|
||||
func (s *Server) baseURL() string {
|
||||
s.defaults()
|
||||
return fmt.Sprintf("http://%s:%d", s.Host, s.Port)
|
||||
}
|
||||
|
||||
// Addr возвращает URL, по которому Runner должен ходить через --attach.
|
||||
func (s *Server) Addr() string {
|
||||
if s.URL != "" {
|
||||
return s.URL
|
||||
}
|
||||
return s.baseURL()
|
||||
}
|
||||
|
||||
// Start запускает сервер (внешний — просто проверку) в фоне.
|
||||
// Возвращает ошибку, если процесс не удалось поднять или первый healthcheck
|
||||
// не прошёл (serve доступен, но ещё «тёплый»).
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.defaults()
|
||||
if s.URL != "" {
|
||||
// внешний сервер — не владеем процессом, только ждём доступность
|
||||
return s.waitHealthy(ctx, s.URL)
|
||||
}
|
||||
addr := s.baseURL()
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("opencode serve: server closed")
|
||||
}
|
||||
cmd := s.serveCmd(ctx)
|
||||
cmd.Stdout = s.Stdout
|
||||
cmd.Stderr = s.Stdout
|
||||
done := make(chan struct{})
|
||||
s.proc = cmd
|
||||
s.done = done
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err)
|
||||
}
|
||||
// reaper: ждём выход процесса и закрываем done — единственный владелец
|
||||
// Wait (Close его не трогает, Run поллит ProcessState).
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
return s.waitHealthy(ctx, addr)
|
||||
}
|
||||
|
||||
// serveCmd собирает команду запуска сервера.
|
||||
func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
|
||||
args := []string{"serve", "--hostname", s.Host, "--port", fmt.Sprintf("%d", s.Port)}
|
||||
cmd := exec.CommandContext(ctx, s.Bin, args...)
|
||||
cmd.Dir = "."
|
||||
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
|
||||
// дочерние процессы, а не чужой процесс (например, тест-реннер).
|
||||
setpgid(cmd)
|
||||
env := append(os.Environ(),
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DISABLE_MODELS_FETCH=1")
|
||||
if s.Config != "" {
|
||||
env = append(env, "OPENCODE_CONFIG="+s.Config)
|
||||
}
|
||||
if s.ConfigDir != "" {
|
||||
env = append(env, "OPENCODE_CONFIG_DIR="+s.ConfigDir)
|
||||
}
|
||||
if s.DBPath != "" {
|
||||
env = append(env, "OPENCODE_DB="+s.DBPath)
|
||||
}
|
||||
if s.Password != "" {
|
||||
env = append(env, "OPENCODE_SERVER_PASSWORD="+s.Password)
|
||||
}
|
||||
cmd.Env = env
|
||||
return cmd
|
||||
}
|
||||
|
||||
// waitHealthy опрашивает /global/health сервера до первого успеха или Connect.
|
||||
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
|
||||
// жив, но может требовать авторизации).
|
||||
func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||||
deadline := time.Now().Add(60 * time.Second)
|
||||
poll := s.PollInterval
|
||||
for {
|
||||
if healthy := s.health(ctx, addr); healthy {
|
||||
return nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("opencode serve %s: не стал доступным (healthcheck)", addr)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(poll):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// healthGET делает GET на адрес и возвращает true, если сервер ответил.
|
||||
// 401 (basic auth требуется) тоже считается «жив» — сервер доступен.
|
||||
func (s *Server) healthGET(ctx context.Context, addr string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/global/health", nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if s.Password != "" {
|
||||
req.SetBasicAuth("opencode", s.Password)
|
||||
}
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
// 200/403/401 — сервер жив (остальное считаем недоступным)
|
||||
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
|
||||
}
|
||||
|
||||
// Run следит за сервером, пока ctx не отменён.
|
||||
//
|
||||
// В режиме владения (URL=="") перезапускает owned-процесс, если тот вышел
|
||||
// (cmd.ProcessState указывает на завершение). Внешний сервер (URL!="") просто
|
||||
// поллится на доступность и логирует сбои — процессом не владеем.
|
||||
func (s *Server) Run(ctx context.Context) {
|
||||
s.defaults()
|
||||
addr := s.Addr()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.Close()
|
||||
return
|
||||
case <-time.After(s.PollInterval):
|
||||
}
|
||||
s.mu.Lock()
|
||||
proc := s.proc
|
||||
closed := s.closed
|
||||
s.mu.Unlock()
|
||||
if closed {
|
||||
return
|
||||
}
|
||||
if s.URL != "" {
|
||||
// внешний сервер упал — не наша работа перезапускать, но логируем
|
||||
if !s.health(ctx, addr) {
|
||||
log.Printf("opencode serve: внешний сервер %s недоступен", addr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// наш процесс: перезапускаем, если он вышел. Признак выхода —
|
||||
// cmd.ProcessState != nil: его выставляет reaper-горутина (cmd.Wait)
|
||||
// только когда процесс завершился любым способом (exit, сигнал, OOM).
|
||||
// .Exited() использовать нельзя — для SIGKILL он false.
|
||||
exited := proc == nil || proc.ProcessState != nil
|
||||
if !exited {
|
||||
continue
|
||||
}
|
||||
log.Printf("opencode serve: процесс упал — перезапускаю")
|
||||
s.mu.Lock()
|
||||
cmd := s.serveCmd(ctx)
|
||||
cmd.Stdout = s.Stdout
|
||||
cmd.Stderr = s.Stdout
|
||||
done := make(chan struct{})
|
||||
s.proc = cmd
|
||||
s.done = done
|
||||
s.mu.Unlock()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Printf("opencode serve: перезапуск не удался: %v", err)
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
_ = cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// health оборачивает healthGET, игнорируя нерелевантные ошибки.
|
||||
func (s *Server) health(ctx context.Context, addr string) bool {
|
||||
hctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
return s.healthGET(hctx, addr)
|
||||
}
|
||||
|
||||
// Close гасит процесс, которым владеет супервайзер. Идемпотентен.
|
||||
// Wait НЕ вызываем — reaper-горутина единственный владелец Wait; Close лишь
|
||||
// убивает процесс и ждёт, когда reaper закроет канал done.
|
||||
func (s *Server) Close() {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
proc := s.proc
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
if proc != nil && proc.Process != nil {
|
||||
killGroup(proc)
|
||||
}
|
||||
if done != nil {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user