fix(opencode): агенты — в <worktree>/.opencode/agent, OPENCODE_CONFIG_DIR больше не выставляем
Some checks failed
CI / test (push) Failing after 1m20s
CI / build-and-package (amd64, linux) (push) Failing after 1m4s
CI / build-and-package (amd64, windows) (push) Successful in 37s

OPENCODE_CONFIG_DIR в opencode v1.18.18 перенаправляет Global.Path.config
(global.ts: config = OPENCODE_CONFIG_DIR ?? ~/.config/opencode), из-за чего
глобальный конфиг (модель/провайдеры, напр. tokentool) не загружался и
opencode уходил в fallback-модель. Агенты открывались, т.к. OPENCODE_CONFIG_DIR
дополнительно сканируется как каталог для agent/*.md.

Теперь агенты распаковываются в <worktree>/.opencode/agent/*.md, где opencode
находит их через project-каталог .opencode (paths.ts, cwd=worktree). Глобальный
конфиг не трогаем вовсе.

- internal/opencode: удалены Server.ConfigDir/Pool.ConfigDir и env OPENCODE_CONFIG_DIR
- internal/config: удалено поле OpenCodeCfg.ConfigDir (config_dir)
- internal/app: ensureAgentsDir пишет в <worktree>/.opencode/agent
- internal/agents: WriteTo(dir) → dir/agent/*.md
- README/config.yaml.example/memory обновлены
This commit is contained in:
ki.sagidullin
2026-08-23 17:54:35 +05:00
parent d731a7429d
commit 8c91c83024
10 changed files with 44 additions and 78 deletions

View File

@@ -46,7 +46,7 @@ docs/ ui-spec.md — спека Fyne UI (слои, event-bus, fyne.Do
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18. - Интеграция с субагентами — через headless `opencode serve`, **v2 API** (`/api/*`). Версия opencode >= 1.18.18.
- **Модель — только глобальный конфиг opencode.** Ratatoskr модель не выбирает и про неё не знает: opencode сам берёт модель по умолчанию из своего глобального конфига. Код opencode-конфиг не читает (config.go удалён). - **Модель — только глобальный конфиг opencode.** Ratatoskr модель не выбирает и про неё не знает: opencode сам берёт модель по умолчанию из своего глобального конфига. Код opencode-конфиг не читает (config.go удалён).
- **Свой агент:** при создании сессии в `POST /api/session` передаётся `agent` (analyst/dev/reviewer/chat/postmortem) из встроенных определений (`internal/agents`, распаковка в `OPENCODE_CONFIG_DIR`). - **Свой агент:** при создании сессии в `POST /api/session` передаётся `agent` (analyst/dev/reviewer/chat/postmortem) из встроенных определений. Агенты распаковываются в `<worktree>/.opencode/agent/*.md` (opencode находит через project-каталог `.opencode`); `OPENCODE_CONFIG_DIR` НЕ выставлять — он перенаправляет Global.Path.config и ломает глобальный конфиг (модель/провайдеры) → падение в fallback.
- **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit) → `GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в `content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort. - **Поллинг вердикта:** `POST /api/session/:id/prompt` (durable admit) → `GET /api/session/:id/message?order=desc&limit=200` (новые assistant-сообщения, текст в `content[].type=="text"`) → завершение = `GET /api/session/active` без сессии + финальное assistant-сообщение, стабильное `settlePolls=2` опроса. `POST .../interrupt` вместо abort.
## Контракты (не ломать) ## Контракты (не ломать)

View File

@@ -100,7 +100,6 @@ telegram:
chat_id: "..." # TG_CHAT_ID (обязательно, если enabled) chat_id: "..." # TG_CHAT_ID (обязательно, если enabled)
opencode: opencode:
bin: "opencode" bin: "opencode"
config_dir: "./agents" # каталог, куда распаковываются встроенные агенты
paths: paths:
db: "./ratatoskr.db" # дефолт; резолвится от каталога бинаря (не от cwd) db: "./ratatoskr.db" # дефолт; резолвится от каталога бинаря (не от cwd)
worktree: "./worktrees" # то же правило «всё рядом с .exe» worktree: "./worktrees" # то же правило «всё рядом с .exe»
@@ -144,8 +143,10 @@ update:
глобального конфига (`~/.config/opencode/opencode.jsonc`). Наш код конфиг глобального конфига (`~/.config/opencode/opencode.jsonc`). Наш код конфиг
opencode не читает. opencode не читает.
- **Свой агент.** При создании сессии в `POST /api/session` передаётся имя - **Свой агент.** При создании сессии в `POST /api/session` передаётся имя
встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem), который встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem). Агенты
распаковывается в каталог `OPENCODE_CONFIG_DIR` (см. `internal/agents`). распаковываются в `<worktree>/.opencode/agent/*.md`, где opencode находит
их через project-каталог `.opencode` (см. `internal/agents`); глобальный
конфиг при этом не трогается.
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` - **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt`
durable admit), вердикт собирается из новых assistant-сообщений durable admit), вердикт собирается из новых assistant-сообщений
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей (`GET .../message`); завершение ответа — сессия ушла из активных дренажей

View File

@@ -10,10 +10,12 @@ telegram:
# Всё ниже — опционально, показаны дефолты: # Всё ниже — опционально, показаны дефолты:
# opencode: # opencode:
# bin: "opencode" # bin: "opencode"
# config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG) # config: "/путь/к/opencode.json" # файл-конфиг модели (OPENCODE_CONFIG); пусто = глобальный конфиг opencode
# config_dir: "./agents" # каталог с агентами (OPENCODE_CONFIG_DIR)
# hard_timeout: "20m" # hard_timeout: "20m"
# idle_timeout: "5m" # idle_timeout: "5m"
#
# Агенты (analyst/dev/reviewer/...) распаковываются автоматически в
# <worktree>/.opencode/agent/*.md; конфиг opencode для этого не нужен.
# git (источник репозиториев для git clone) # git (источник репозиториев для git clone)
# base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования) # base_url: "http://gitea.hal9000.home" # базовый URL git-хоста (обязательно для клонирования)

View File

@@ -1,12 +1,10 @@
// Package agents — встроенные агенты opencode (analyst, dev, ...). // Package agents — встроенные агенты opencode (analyst, dev, ...).
// //
// Агенты зашиты в бинарь через go:embed и распаковываются при запуске // Агенты зашиты в бинарь через go:embed и распаковываются при запуске
// в каталог OPENCODE_CONFIG_DIR (по умолчанию ./agents рядом с бинарём), // в project-каталог <worktree>/.opencode/agent/*.md. opencode при serve
// чтобы opencode находил их независимо от того, где запущен ratatoskr. // (cwd = worktree) обнаруживает .opencode поднятием вверх (ConfigPaths) и
// // находит агентов по шаблону {agent,agents}/**/*.md. Глобальный конфиг
// opencode сканирует OPENCODE_CONFIG_DIR как каталог .opencode: агенты // opencode остаётся нетронутым (OPENCODE_CONFIG_DIR не выставляем).
// ищутся в поддиректории agents/ (мн.ч.), а не в корне. Поэтому распаковка
// идёт в <dir>/agents/*.md.
package agents package agents
import ( import (
@@ -23,10 +21,10 @@ var FS embed.FS
// в FS, но для детерминированной распаковки список лучше дополнять здесь. // в FS, но для детерминированной распаковки список лучше дополнять здесь.
var Names = []string{"analyst", "dev", "reviewer", "chat", "postmortem"} var Names = []string{"analyst", "dev", "reviewer", "chat", "postmortem"}
// WriteTo распаковывает всех встроенных агентов в каталог dir/agents // WriteTo распаковывает всех встроенных агентов в каталог dir/agent
// (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна. // (создаёт его). Файлы перезаписываются — встроенная копия всегда актуальна.
func WriteTo(dir string) error { func WriteTo(dir string) error {
agentsDir := filepath.Join(dir, "agents") agentsDir := filepath.Join(dir, "agent")
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

@@ -13,8 +13,8 @@ func TestWriteTo(t *testing.T) {
t.Fatalf("WriteTo: %v", err) t.Fatalf("WriteTo: %v", err)
} }
for _, name := range Names { for _, name := range Names {
// opencode ищет агентов в поддиректории agents/, как у .opencode // opencode ищет агентов в поддиректории agent/ (или agents/) внутри .opencode
p := filepath.Join(dir, "agents", name+".md") p := filepath.Join(dir, "agent", name+".md")
data, err := os.ReadFile(p) data, err := os.ReadFile(p)
if err != nil { if err != nil {
t.Fatalf("read %s: %v", p, err) t.Fatalf("read %s: %v", p, err)

View File

@@ -157,7 +157,6 @@ func New(configPath, version, updateToken string, noUI bool) (*App, error) {
ocPool := opencode.NewPool(cfg.Paths.Worktree) ocPool := opencode.NewPool(cfg.Paths.Worktree)
ocPool.Bin = cfg.OpenCode.Bin ocPool.Bin = cfg.OpenCode.Bin
ocPool.Config = cfg.OpenCode.Config ocPool.Config = cfg.OpenCode.Config
ocPool.ConfigDir = cfg.OpenCode.ConfigDir
ocPool.DBPath = cfg.OpenCode.DBPath ocPool.DBPath = cfg.OpenCode.DBPath
ocPool.Host = cfg.OpenCode.Serve.Hostname ocPool.Host = cfg.OpenCode.Serve.Hostname
ocPool.BasePort = cfg.OpenCode.Serve.Port ocPool.BasePort = cfg.OpenCode.Serve.Port
@@ -665,23 +664,21 @@ func (a *App) handleUpdateCmd(ctx context.Context, uid chat.UserID) {
// сюда не возвращаемся — SwapAndRestart завершил процесс (os.Exit) // сюда не возвращаемся — SwapAndRestart завершил процесс (os.Exit)
} }
// ensureAgentsDir определяет каталог с агентами opencode и распаковывает // ensureAgentsDir распаковывает встроенных агентов opencode (go:embed) в
// туда встроенных агентов (go:embed). Если config_dir не задан — использует // project-каталог <worktree>/.opencode/agent/*.md. opencode при serve (cwd =
// ./agents рядом с бинарём. Встроенные агенты перезаписываются (всегда актуальны). // worktree) обнаруживает .opencode поднятием вверх (paths.ts) и сам находит
// агентов. Глобальный конфиг opencode (модель/провайдеры) не трогаем и
// OPENCODE_CONFIG_DIR не выставляем.
// //
// idempotent: вызывается только из New. // idempotent: вызывается только из New.
func ensureAgentsDir(cfg *config.Config) error { func ensureAgentsDir(cfg *config.Config) error {
if cfg.OpenCode.ConfigDir == "" { if cfg.Paths.Worktree == "" {
exe, err := os.Executable() return fmt.Errorf("paths.worktree не задан — некуда раскладывать агентов")
if err != nil {
return fmt.Errorf("resolve executable: %w", err)
}
// ./agents рядом с бинарём
cfg.OpenCode.ConfigDir = filepath.Join(filepath.Dir(exe), "agents")
} }
if err := agents.WriteTo(cfg.OpenCode.ConfigDir); err != nil { base := filepath.Join(cfg.Paths.Worktree, ".opencode")
return fmt.Errorf("write agents to %s: %w", cfg.OpenCode.ConfigDir, err) if err := agents.WriteTo(base); err != nil {
return fmt.Errorf("write agents to %s: %w", base, err)
} }
log.Printf("app: agents ensured in %s", cfg.OpenCode.ConfigDir) log.Printf("app: agents ensured in %s", base)
return nil return nil
} }

View File

@@ -146,34 +146,6 @@ log:
} }
} }
func TestLoad_OpenCodeConfigDir(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
// без config_dir — пусто
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenCode.ConfigDir != "" {
t.Errorf("config_dir = %q, want пусто", cfg.OpenCode.ConfigDir)
}
// через env OPENCODE_CONFIG_DIR
t.Setenv("OPENCODE_CONFIG_DIR", "/opt/agents")
cfg, err = Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.OpenCode.ConfigDir != "/opt/agents" {
t.Errorf("config_dir = %q, want /opt/agents", cfg.OpenCode.ConfigDir)
}
}
func TestLoad_EnvOverride(t *testing.T) { func TestLoad_EnvOverride(t *testing.T) {
t.Setenv("TG_TOKEN", "tok") t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42") t.Setenv("TG_CHAT_ID", "42")

View File

@@ -89,7 +89,6 @@ type OpenCodeCfg struct {
Bin string `yaml:"bin" default:"opencode"` Bin string `yaml:"bin" default:"opencode"`
DBPath string `yaml:"db_path" default:""` DBPath string `yaml:"db_path" default:""`
Config string `yaml:"config" env:"OPENCODE_CONFIG"` Config string `yaml:"config" env:"OPENCODE_CONFIG"`
ConfigDir string `yaml:"config_dir" env:"OPENCODE_CONFIG_DIR"`
HardTimeout Duration `yaml:"hard_timeout" default:"20m"` HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"` IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
PollMs Duration `yaml:"poll_ms" default:"2s"` PollMs Duration `yaml:"poll_ms" default:"2s"`

View File

@@ -20,7 +20,6 @@ import (
type Pool struct { type Pool struct {
Bin string Bin string
Config string Config string
ConfigDir string
DBPath string DBPath string
Host string Host string
BasePort int BasePort int
@@ -72,13 +71,12 @@ func (p *Pool) EnsureRoot(ctx context.Context) error {
return nil return nil
} }
s := &Server{ s := &Server{
Bin: p.Bin, Bin: p.Bin,
Config: p.Config, Config: p.Config,
ConfigDir: p.ConfigDir, DBPath: p.DBPath,
DBPath: p.DBPath, Host: p.Host,
Host: p.Host, Password: p.Password,
Password: p.Password, Dir: p.rootDir,
Dir: p.rootDir,
} }
if err := p.assign(s); err != nil { if err := p.assign(s); err != nil {
return err return err
@@ -102,13 +100,12 @@ func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) {
} }
abs := filepath.Clean(dir) abs := filepath.Clean(dir)
s := &Server{ s := &Server{
Bin: p.Bin, Bin: p.Bin,
Config: p.Config, Config: p.Config,
ConfigDir: p.ConfigDir, DBPath: p.DBPath,
DBPath: p.DBPath, Host: p.Host,
Host: p.Host, Password: p.Password,
Password: p.Password, Dir: abs,
Dir: abs,
} }
if err := p.assign(s); err != nil { if err := p.assign(s); err != nil {
p.mu.Unlock() p.mu.Unlock()

View File

@@ -25,7 +25,6 @@ import (
type Server struct { type Server struct {
Bin string // путь к opencode (по умолчанию "opencode") Bin string // путь к opencode (по умолчанию "opencode")
Config string // OPENCODE_CONFIG Config string // OPENCODE_CONFIG
ConfigDir string // OPENCODE_CONFIG_DIR
DBPath string // рабочая БД сервера (передам env, если задана) DBPath string // рабочая БД сервера (передам env, если задана)
Host string // hostname для прослушивания Host string // hostname для прослушивания
@@ -127,12 +126,13 @@ func (s *Server) serveCmd(ctx context.Context) *exec.Cmd {
env := append(os.Environ(), env := append(os.Environ(),
"OPENCODE_DISABLE_AUTOUPDATE=1", "OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DISABLE_MODELS_FETCH=1") "OPENCODE_DISABLE_MODELS_FETCH=1")
// Агенты (analyst/dev/...) opencode находит сам через project-каталог
// .opencode (см. internal/agents); OPENCODE_CONFIG_DIR не выставляем,
// чтобы не перенаправлять Global.Path.config и сохранить глобальный
// конфиг opencode (модель/провайдеры).
if s.Config != "" { if s.Config != "" {
env = append(env, "OPENCODE_CONFIG="+s.Config) env = append(env, "OPENCODE_CONFIG="+s.Config)
} }
if s.ConfigDir != "" {
env = append(env, "OPENCODE_CONFIG_DIR="+s.ConfigDir)
}
if s.DBPath != "" { if s.DBPath != "" {
env = append(env, "OPENCODE_DB="+s.DBPath) env = append(env, "OPENCODE_DB="+s.DBPath)
} }