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 --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 (если непустой — сервер защищён) Dir string // каталог, в котором запускается serve (project сервера) // 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 = s.Dir // project сервера — каталог, который обслуживает этот serve if cmd.Dir == "" { 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): } } }