fix(opencode): x-opencode-directory, Content-Length, warmup /api/model; serve без явного Env
Some checks failed
CI / test (push) Failing after 2m13s
CI / build-and-package (amd64, linux) (push) Failing after 1m3s
CI / build-and-package (amd64, windows) (push) Successful in 29s

This commit is contained in:
ki.sagidullin
2026-08-24 08:38:59 +05:00
parent 88b455203f
commit c9ab753c1e
6 changed files with 32 additions and 55 deletions

View File

@@ -24,7 +24,7 @@ var Names = []string{"analyst", "dev", "reviewer", "chat", "postmortem"}
// WriteTo распаковывает всех встроенных агентов в каталог dir/agent // WriteTo распаковывает всех встроенных агентов в каталог dir/agent
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна. // (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
func WriteTo(dir string) error { func WriteTo(dir string) error {
agentsDir := filepath.Join(dir, "agent") agentsDir := filepath.Join(dir, "agents")
for _, name := range Names { for _, name := range Names {
data, err := FS.ReadFile(name + ".md") data, err := FS.ReadFile(name + ".md")
if err != nil { if err != nil {

View File

@@ -6,7 +6,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"time" "time"
) )
@@ -26,6 +25,7 @@ import (
type Client struct { type Client struct {
BaseURL string // http://host:port (без завершающего слеша) BaseURL string // http://host:port (без завершающего слеша)
Password string // basic auth (username "opencode") Password string // basic auth (username "opencode")
Directory string // каталог сервера — идёт заголовком x-opencode-directory
Debug bool // включать отладочные логи API-вызовов (log.level=debug) Debug bool // включать отладочные логи API-вызовов (log.level=debug)
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send) http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
} }
@@ -56,14 +56,15 @@ func (c *Client) do(ctx context.Context, method, path, op string, body []byte) (
if err != nil { if err != nil {
return nil, &ClientErr{Op: "connect", Err: err} return nil, &ClientErr{Op: "connect", Err: err}
} }
if body != nil {
req.ContentLength = int64(len(body))
}
if c.Password != "" { if c.Password != "" {
req.SetBasicAuth("opencode", c.Password) req.SetBasicAuth("opencode", c.Password)
} }
if body != nil {
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
} if c.Directory != "" {
if c.Debug { req.Header.Set("x-opencode-directory", c.Directory)
log.Printf("opencode api debug: %s -> %s %s%s", op, method, c.BaseURL, path)
} }
resp, err := c.http.Do(req) resp, err := c.http.Do(req)
if err != nil { if err != nil {
@@ -75,14 +76,8 @@ func (c *Client) do(ctx context.Context, method, path, op string, body []byte) (
return nil, &ClientErr{Op: "connect", Err: err} return nil, &ClientErr{Op: "connect", Err: err}
} }
if resp.StatusCode < 200 || resp.StatusCode > 299 { if resp.StatusCode < 200 || resp.StatusCode > 299 {
if c.Debug {
log.Printf("opencode api debug: %s response: status %d", op, resp.StatusCode)
}
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))} return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
} }
if c.Debug {
log.Printf("opencode api debug: %s response (%d bytes)", op, len(b))
}
return b, nil return b, nil
} }
@@ -130,6 +125,12 @@ func (c *Client) CreateSession(ctx context.Context, agent string) (string, error
if out.Data.ID == "" { if out.Data.ID == "" {
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")} return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
} }
time.Sleep(2 * time.Second)
// Холостой вызов /api/model: прогревает сервер (модель/провайдеры),
// чтобы первый промпт не спотыкался о «тёплый» старт.
_, _ = c.do(ctx, http.MethodGet, "/api/model", "models-warmup", nil)
time.Sleep(2 * time.Second)
_, _ = c.do(ctx, http.MethodGet, "/api/model", "models-warmup", nil)
return out.Data.ID, nil return out.Data.ID, nil
} }

View File

@@ -73,7 +73,7 @@ func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string)
if err != nil { if err != nil {
return nil, err return nil, err
} }
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug} c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Directory: srv.Dir, Debug: r.Debug}
// Сессия: заданная (resume) или новая. Модель НЕ выбираем — opencode сам // Сессия: заданная (resume) или новая. Модель НЕ выбираем — opencode сам
// берёт её по умолчанию из своего глобального конфига. // берёт её по умолчанию из своего глобального конфига.

View File

@@ -146,7 +146,7 @@ func TestRun_ContextCancel(t *testing.T) {
done <- res done <- res
errCh <- err errCh <- err
}() }()
time.Sleep(30 * time.Millisecond) time.Sleep(6000 * time.Millisecond)
cancel() cancel()
res := <-done res := <-done
if err := <-errCh; err != nil { if err := <-errCh; err != nil {

View File

@@ -120,6 +120,8 @@ func (s *Server) Start(ctx context.Context) error {
s.done = done s.done = done
s.mu.Unlock() s.mu.Unlock()
log.Printf("opencode serve: spawn dir=%s HOME=%q USERPROFILE=%q OPENCODE_CONFIG=%q (serve наследует окружение родителя)", cmd.Dir, os.Getenv("HOME"), os.Getenv("USERPROFILE"), s.ConfigPath)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err) return fmt.Errorf("opencode serve: start %v: %w", s.Bin, err)
} }
@@ -143,24 +145,8 @@ func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
// Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его // Своя process-group: чтобы killGroup (по pgid) убивал только сервер и его
// дочерние процессы, а не чужой процесс (например, тест-реннер). // дочерние процессы, а не чужой процесс (например, тест-реннер).
setpgid(cmd) setpgid(cmd)
env := append(os.Environ(), // Env не задаём: serve наследует окружение родителя и сам резолвит свой
"OPENCODE_DISABLE_AUTOUPDATE=1", // глобальный/локальный конфиг (модель/провайдеры, агентов, БД) как обычно.
"OPENCODE_DISABLE_MODELS_FETCH=1")
// Гарантированно указываем глобальный конфиг opencode: serve обязан
// подхватить модель/провайдеров из него (иначе возможен фоллбэк на
// случайную модель из каталога).
if s.ConfigPath != "" {
env = append(env, "OPENCODE_CONFIG="+s.ConfigPath)
}
// Агенты (analyst/dev/...) opencode находит сам через project-каталог
// .opencode (см. internal/agents).
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 return cmd
} }

View File

@@ -200,28 +200,18 @@ func atoiOrZero(s string) int {
return n return n
} }
func TestServeCmd_SetsOpenCodeConfig(t *testing.T) { func TestServeCmd_NoCustomEnv(t *testing.T) {
// serveCmd должен всегда передавать OPENCODE_CONFIG: без него serve может // serveCmd не должен задавать свой Env: serve наследует окружение родителя
// не подхватить модель/провайдеров из глобального конфига. // и сам резолвит конфиг/БД/пароль как обычно.
s := &Server{} s := &Server{}
s.defaults() s.defaults()
cmd := s.serveCmd(context.Background()) cmd := s.serveCmd(context.Background())
if cmd.Env == nil { if cmd.Env != nil {
t.Fatal("serveCmd: Env не задан")
}
prefix := "OPENCODE_CONFIG="
path := ""
for _, kv := range cmd.Env { for _, kv := range cmd.Env {
if strings.HasPrefix(kv, prefix) { if strings.HasPrefix(kv, "OPENCODE_") {
path = strings.TrimPrefix(kv, prefix) t.Errorf("serveCmd: не ожидал env %q", kv)
break
} }
} }
if path == "" {
t.Fatal("serveCmd: OPENCODE_CONFIG не выставлен")
}
if want := s.ConfigPath; path != want {
t.Errorf("OPENCODE_CONFIG = %q, want %q", path, want)
} }
} }