Compare commits
20 Commits
18b48bfdc4
...
feat/3c852
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be4d749c45 | ||
|
|
b5fb583c90 | ||
| 3c8528dbd9 | |||
|
|
9f64be4ea5 | ||
| ad025c1668 | |||
|
|
1459670ce9 | ||
|
|
7eb3a0292c | ||
|
|
a64e3d6cc3 | ||
|
|
774ebf135a | ||
|
|
e1167c9537 | ||
|
|
ad9c2dd522 | ||
|
|
f77a6000c7 | ||
|
|
ae00556923 | ||
|
|
baf7179085 | ||
|
|
733e63339a | ||
|
|
b60978121d | ||
|
|
2f26b6ae88 | ||
| ed11879cbd | |||
|
|
631387fda7 | ||
|
|
07bae203c3 |
@@ -16,7 +16,7 @@ internal/
|
||||
analyst/ аналитик: промпт + разбор JSON-вердикта (opencode agent). Коды A1-A4
|
||||
worker/ polling-планировщик + dev/reviewer-конвейер + gitops. Коды W*, E*, R*
|
||||
agents/ встроенные opencode-агенты (analyst.md, dev.md, reviewer.md) через go:embed
|
||||
opencode/ обёртка запуска opencode, LiveRegistry, парсинг вердикта. Коды O1-O4
|
||||
opencode/ HTTP-клиент v2 API opencode serve, поллинг вердикта, LiveRegistry. Коды O1-O5
|
||||
storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5
|
||||
update/ автообновление из Gitea Packages. Коды U1-U6
|
||||
```
|
||||
@@ -42,6 +42,25 @@ internal/
|
||||
- **Автообновление:** авто = только Check+уведомление; замена — по /update. Версии в
|
||||
Gitea Packages `commit-<sha7>/` (не `latest/`). Вердикты агентов — строгий JSON.
|
||||
|
||||
## opencode (v2 HTTP API, >= 1.18.18)
|
||||
|
||||
- Интеграция с субагентами — через headless `opencode serve`, **v2 API** (префикс `/api/*`).
|
||||
Минимальная версия opencode **>= 1.18.18** (старый бинарь — только `/global/health`, не годится;
|
||||
healthcheck падает с понятной ошибкой, класс O1).
|
||||
- **Хардпин модели:** при создании сессии читается top-level `"model"` из конфига opencode
|
||||
(`internal/opencode/config.go`, JSONC-стрип `//`/`/* */`/trailing-запятых) и передаётся в
|
||||
`POST /api/session` как `{"model":{providerID,id}}` (разбор `provider/id` по первому `/`).
|
||||
- **Класс O5 WARN (устойчивость к v1-конфигу):** конфиг по старой v1-схеме
|
||||
(`provider.X.npm`/`options`) молча игнорируется v2 → провайдер без api → модель unsupported →
|
||||
fallback. Раtatoskr не чинит это сам, но логирует warning: конфиг не читается/нет `model`,
|
||||
и/или фактическая модель ответа (из assistant-сообщения) ≠ ожидаемой. Правильный v2-вид:
|
||||
`api:{type:"aisdk",package,url}` и `request.headers` вместо `options.headers`.
|
||||
- **Поллинг вердикта:** `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.
|
||||
- `ModelRef{ProviderID,ID,Variant}` — аналог v2 Model.Ref; `MinVersion="1.18.18"` в server.go.
|
||||
|
||||
## Контракты (не ломать)
|
||||
|
||||
- `App.New(configPath, version, updateToken string)` — сигнатура.
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- **opencode** (opencode.ai) — внешний процесс для субагентов (analyst/dev/reviewer).
|
||||
Управляется через internal/opencode (Runner, LiveRegistry). Настраивается в конфиге
|
||||
(opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms).
|
||||
Требуемая версия opencode: **>= 1.18.18** (v2 HTTP API `/api/*`; хардпин model из конфига —
|
||||
`internal/opencode/config.go`, классы O1/O5). Подробно — `mem:core`.
|
||||
|
||||
## Сборка / Makefile
|
||||
- `make build` — go build -ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/.
|
||||
|
||||
@@ -1,39 +1,6 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
# the name by which the project can be referenced within Serena/when chatting with the LLM.
|
||||
project_name: "ratatoskr-go"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al angular ansible bash clojure
|
||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
||||
# dart elixir elm erlang fortran
|
||||
# fsharp go groovy haskell haxe
|
||||
# hlsl html java json julia
|
||||
# kotlin lean4 lua luau markdown
|
||||
# matlab msl nix ocaml pascal
|
||||
# perl php php_phpactor powershell python
|
||||
# python_jedi python_ty r rego ruby
|
||||
# ruby_solargraph rust scala scss solidity
|
||||
# svelte swift systemverilog terraform toml
|
||||
# typescript typescript_vts vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
@@ -55,23 +22,19 @@ ignore_all_files_in_gitignore: true
|
||||
|
||||
# advanced configuration option allowing to configure language server-specific options.
|
||||
# Maps the language key to the options.
|
||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
||||
# No documentation on options means no options are available.
|
||||
# The settings are considered only if the project is trusted (see global configuration to define trusted projects).
|
||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#language-server-specific-settings
|
||||
ls_specific_settings: {}
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries.
|
||||
# Currently supported for: TypeScript.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
additional_workspace_folders: []
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Important: quote patterns that start with `*`, otherwise YAML treats them as aliases.
|
||||
# Example:
|
||||
# ignored_paths:
|
||||
# - "examples/**"
|
||||
# - ".worktrees/**"
|
||||
# - "**/bin/**"
|
||||
# - "**/obj/**"
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
@@ -131,3 +94,76 @@ read_only_memory_patterns: []
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
# Example: ["_archive/.*", "_episodes/.*"]
|
||||
ignored_memory_patterns: []
|
||||
|
||||
# list of additional workspace folder paths for cross-package reference support.
|
||||
# Paths can be absolute or relative to the project root.
|
||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
||||
# symbols and references across package boundaries, but these folders are not indexed by Serena,
|
||||
# i.e. the respective symbols will not be found using Serena's symbol search tools.
|
||||
# Example:
|
||||
# additional_workspace_folders:
|
||||
# - ../sibling-package
|
||||
# - ../shared-lib
|
||||
ls_additional_workspace_folders: []
|
||||
|
||||
# list of language servers to start when using the LSP backend; choose from:
|
||||
# ada al angular ansible bash
|
||||
# bsl clojure cpp cpp_ccls crystal
|
||||
# csharp csharp_omnisharp cue dart deno
|
||||
# elixir elm erlang fortran fsharp
|
||||
# gdscript gleam go groovy haskell
|
||||
# haxe hlsl html java json
|
||||
# julia kotlin latex lean4 lua
|
||||
# luau markdown matlab msl nextflow
|
||||
# nix ocaml pascal perl php
|
||||
# php_phpactor php_phpantom powershell python python_basedpyright
|
||||
# python_jedi python_pyrefly python_ty qml r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# scss solidity svelte swift systemverilog
|
||||
# terraform toml typescript typescript_vts vue
|
||||
# wolfram yaml zig
|
||||
# (This list may be outdated; generated with scripts/print_language_list.py;
|
||||
# For the current list, see values of the LanguageServerId enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py)
|
||||
# For some languages, there are several alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
||||
# - For Deno projects, use deno (serves the same .ts/.js files as typescript; requires the deno CLI on PATH)
|
||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some language servers require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple language servers, the first language server that supports a given file will be used for that file.
|
||||
# The first language server is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
language_servers:
|
||||
- go
|
||||
|
||||
# list of workspace folder paths (LSP backend only).
|
||||
# These folders will be used to build up Serena's symbol index.
|
||||
# Paths must be within the project root and should thus be relative to the project root.
|
||||
# Furthermore, the paths should not be filtered by ignore settings.
|
||||
# Default setting: The entire project root folder (".") is considered.
|
||||
# In (large) monorepos, this can be used to index only subfolders of the project root, e.g.
|
||||
# ls_workspace_folders:
|
||||
# - "./subproject1"
|
||||
# - "./subproject2"
|
||||
ls_workspace_folders:
|
||||
- .
|
||||
|
||||
# optional shell command to run before the language backend (LSP or JetBrains) is initialised.
|
||||
# the command runs in the project root directory and is only executed if the project is trusted
|
||||
# (see trusted_project_path_patterns in the global configuration).
|
||||
# serena waits for the command to exit: a non-zero exit code is logged as an error but does not
|
||||
# abort activation. a per-project timeout (activation_command_timeout, default 180s) is the safety
|
||||
# backstop for non-terminating commands; on expiry the process is killed and activation continues.
|
||||
# example: activation_command: "npx nx run-many -t build"
|
||||
activation_command:
|
||||
|
||||
# maximum time in seconds to wait for activation_command to complete before killing it (default 180s).
|
||||
# must be a positive number.
|
||||
activation_command_timeout: 180.0
|
||||
|
||||
31
README.md
31
README.md
@@ -17,7 +17,7 @@ internal/
|
||||
analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4)
|
||||
worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
|
||||
agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
|
||||
opencode/ # обёртка запуска opencode-процесса, парсинг вердикта (классы O1–O4)
|
||||
opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1–O5)
|
||||
storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5)
|
||||
update/ # автообновление из Gitea Packages (классы U1–U6)
|
||||
```
|
||||
@@ -129,6 +129,33 @@ update:
|
||||
| `/status` | версия бинаря + есть ли доступное обновление |
|
||||
| `/help` | справка по всем командам |
|
||||
|
||||
## Интеграция с opencode (субагенты)
|
||||
|
||||
Субагенты (analyst / dev / reviewer) запускаются через **headless** `opencode serve`
|
||||
по **v2 HTTP API** (префикс `/api/*`). Требуемая версия opencode: **>= 1.18.18**
|
||||
(сборки с v2 HTTP API). Старый бинарь, отвечающий только на `/global/health`,
|
||||
не подходит: healthcheck падает с понятной ошибкой (класс O1).
|
||||
|
||||
Что делает обёртка (`internal/opencode`):
|
||||
|
||||
- **Хардпин модели.** При создании сессии в конфиге opencode ищется top-level
|
||||
`"model"` (`internal/opencode/config.go`) и передаётся в `POST /api/session`
|
||||
как `{"model":{providerID,id}}`. Это убирает зависимость от fallback-логики
|
||||
opencode (которая молча выбирает «дефолтную» запись, если модель не задана).
|
||||
- **Весь код резолва модели устойчив к этому классу проблем (класс O5 WARN):**
|
||||
- если конфиг не читается / в нём нет `model` — в логи пишется warning;
|
||||
- фактическая модель ответа (из финального assistant-сообщения) сравнивается
|
||||
с ожидаемой; расхождение логируется как warning;
|
||||
- конфиг, написанный по **старой v1-схеме** (`provider.X.npm` / `options`),
|
||||
молча игнорируется v2 — обёртка этого не «чинит» сама, но предупреждает.
|
||||
Правильный v2-вид провайдера — `api: { type:"aisdk", package, url }` и
|
||||
`request.headers` вместо `options.headers`.
|
||||
- **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` →
|
||||
durable admit), вердикт собирается из новых assistant-сообщений
|
||||
(`GET .../message`); завершение ответа — сессия ушла из активных дренажей
|
||||
(`GET .../active`) и появилось финальное assistant-сообщение, стабильное
|
||||
несколько опросов подряд.
|
||||
|
||||
## Фазы аналитика
|
||||
|
||||
Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`:
|
||||
@@ -198,7 +225,7 @@ curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratat
|
||||
| C | C1–C4 | `internal/config` |
|
||||
| A | A1–A4 | `internal/analyst` |
|
||||
| M | M1–M5 | `internal/chat` |
|
||||
| O | O1–O4 | `internal/opencode` |
|
||||
| O | O1–O5 | `internal/opencode` |
|
||||
| S | S1–S5 | `internal/storage` |
|
||||
| W | W1–W5 | `internal/worker` |
|
||||
| E | E1–E4 | `internal/worker` (репозитории) |
|
||||
|
||||
@@ -33,4 +33,8 @@ telegram:
|
||||
|
||||
# paths:
|
||||
# worktree: "./worktrees"
|
||||
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
|
||||
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
|
||||
|
||||
# log (уровень логирования)
|
||||
# level: "info" # "info" (по умолчанию) или "debug" — debug включает
|
||||
# # отладочные логи (напр. все API-вызовы к opencode serve)
|
||||
|
||||
@@ -49,7 +49,7 @@ const packageOwner = "kamelion"
|
||||
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
|
||||
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
|
||||
// поднимается вручную перед каждым релизом/публикацией новой сборки.
|
||||
const Version = "0.1.0"
|
||||
const Version = "0.2.2"
|
||||
|
||||
// App — собранный конвейер.
|
||||
type App struct {
|
||||
@@ -60,6 +60,7 @@ type App struct {
|
||||
Worker *worker.Worker
|
||||
Updater *update.Updater
|
||||
tg *telegram.Channel // сохранена для Run
|
||||
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
||||
}
|
||||
|
||||
// New читает конфиг и собирает все зависимости.
|
||||
@@ -102,15 +103,24 @@ 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(),
|
||||
Debug: cfg.Log.Debug(),
|
||||
Stdout: os.Stderr,
|
||||
}
|
||||
|
||||
@@ -132,6 +142,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
||||
Config: cfg,
|
||||
Store: store,
|
||||
CoreCtx: coreCtx,
|
||||
pool: ocPool,
|
||||
}
|
||||
router := chat.NewRouter(a.handleIncoming)
|
||||
|
||||
@@ -154,6 +165,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
||||
GitBaseURL: cfg.Git.BaseURL,
|
||||
GitToken: cfg.Git.Token,
|
||||
Live: live,
|
||||
Notify: a, // авто-уведомления владельцу задачи через Router
|
||||
}
|
||||
a.Router = router
|
||||
a.Worker = w
|
||||
@@ -184,6 +196,19 @@ func (a *App) Run(ctx context.Context) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Уже отменённый контекст — не поднимаем подсистемы, 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)
|
||||
|
||||
@@ -326,6 +351,16 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Notify реализует worker.Notifier: авто-уведомление владельцу задачи через
|
||||
// chat.Router.Send (переходы статусов и хендоффы dev↔reviewer со стороны воркера).
|
||||
// Router nil (тесты без Router / ранняя инициализация) — тихо пропускаем.
|
||||
func (a *App) Notify(ctx context.Context, taskID int64, chatID, text string) error {
|
||||
if a.Router == nil {
|
||||
return nil
|
||||
}
|
||||
return a.Router.Send(ctx, chat.UserID(chatID), chat.Message{Text: text})
|
||||
}
|
||||
|
||||
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
|
||||
func cmdName(text string) string {
|
||||
s := strings.TrimSpace(text)
|
||||
|
||||
@@ -25,6 +25,7 @@ func TestNew(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
defer a.Store.Close()
|
||||
if a.Store == nil {
|
||||
t.Fatal("Store не создан")
|
||||
}
|
||||
@@ -87,6 +88,7 @@ func TestNew_RunCtxCancel(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
defer a.Store.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // сразу отменяем
|
||||
@@ -120,8 +122,8 @@ func TestNew_WorktreeCreated(t *testing.T) {
|
||||
" token: \"test:token\"",
|
||||
" chat_id: \"12345\"",
|
||||
"paths:",
|
||||
" db: \"" + filepath.Join(tmp, "test.db") + "\"",
|
||||
" worktree: \"" + wt + "\"",
|
||||
" db: '" + filepath.Join(tmp, "test.db") + "'",
|
||||
" worktree: '" + wt + "'",
|
||||
"",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
@@ -165,7 +167,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
||||
" base_url: \"https://hub.example.com\"",
|
||||
" token: \"cfg-update-token\"",
|
||||
"paths:",
|
||||
" db: \"" + dbPath + "\"",
|
||||
" db: '" + dbPath + "'",
|
||||
"", // пустая строка в конце
|
||||
}, "\n")
|
||||
if err := os.WriteFile(configPath, []byte(content), 0o600); err != nil {
|
||||
@@ -177,6 +179,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
defer a.Store.Close()
|
||||
if a.Updater == nil {
|
||||
t.Fatal("Updater не создан")
|
||||
}
|
||||
@@ -197,6 +200,7 @@ func TestNew_UpdateWiring(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("New() err = %v", err)
|
||||
}
|
||||
defer a2.Store.Close()
|
||||
if a2.Updater.Token != "embedded-update-token" {
|
||||
t.Errorf("Token = %q, want embedded-update-token (вшитый приоритетнее)", a2.Updater.Token)
|
||||
}
|
||||
|
||||
@@ -8,17 +8,22 @@ package app
|
||||
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||
//
|
||||
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
|
||||
// возвращая NDJSON-вердикты нужного формата для каждого.
|
||||
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
|
||||
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
|
||||
// вердикты возвращаются как text-части assistant-сообщений.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -30,63 +35,114 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||
)
|
||||
|
||||
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
|
||||
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
|
||||
// представление JSON-вердикта агента (как это делает реальный opencode).
|
||||
func ndjsonText(t *testing.T, payload string) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal payload: %v", err)
|
||||
}
|
||||
return `{"type":"text","part":{"text":` + string(b) + `}}`
|
||||
// вердикты фейкового агента по имени.
|
||||
var e2eAgentVerdicts = map[string]string{
|
||||
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||
"dev": `done`,
|
||||
"reviewer": `{"passed":true,"comments":[]}`,
|
||||
}
|
||||
|
||||
// e2eFakeOpenCode пишет shell-скрипт, имитирующий opencode run.
|
||||
// Различает агента по argv ($3 = имя агента после "--agent").
|
||||
//
|
||||
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
|
||||
// dev → простой NDJSON "done"
|
||||
// reviewer→ NDJSON c {"passed":true} в text-парте
|
||||
func e2eFakeOpenCode(t *testing.T, dir string) string {
|
||||
// e2eFakeAPI поднимает фейковый opencode serve, эмулирующий v2 HTTP API
|
||||
// (пути с префиксом /api/*, см. Client в internal/opencode). Агент
|
||||
// (analyst/dev/reviewer) определяется по тексту промпта на POST
|
||||
// /api/session/{id}/prompt; вердикт возвращается как text-часть завершённого
|
||||
// assistant-сообщения, которое отдаёт GET /api/session/{id}/message.
|
||||
func e2eFakeAPI(t *testing.T) string {
|
||||
t.Helper()
|
||||
var mu sync.Mutex
|
||||
sessions := map[string]string{} // id → agent
|
||||
|
||||
analystNDJSON := ndjsonText(t, `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`)
|
||||
reviewerNDJSON := ndjsonText(t, `{"passed":true,"comments":[]}`)
|
||||
|
||||
// каждый вариант печатаем через printf '%s' с одинарными кавычками:
|
||||
// NDJSON содержит двойные кавычки и бэкслеши, но не одинарные — безопасно.
|
||||
analystLine := "printf '%s\\n' '" + analystNDJSON + "'"
|
||||
reviewerLine := "printf '%s\\n' '" + reviewerNDJSON + "'"
|
||||
|
||||
script := `#!/bin/sh
|
||||
agent="$3"
|
||||
case "$agent" in
|
||||
analyst)
|
||||
` + analystLine + `
|
||||
;;
|
||||
reviewer)
|
||||
` + reviewerLine + `
|
||||
;;
|
||||
dev)
|
||||
printf '%%s\n' '{"type":"text","part":{"text":"done"}}'
|
||||
;;
|
||||
*)
|
||||
printf '%%s\n' '{"type":"text","part":{"text":"unknown agent"}}'
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
`
|
||||
bin := filepath.Join(dir, "opencode")
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write e2e fake opencode: %v", err)
|
||||
agentOf := func(prompt string) string {
|
||||
switch {
|
||||
case strings.Contains(prompt, "Ты — аналитик"):
|
||||
return "analyst"
|
||||
case strings.Contains(prompt, "Ты — dev-агент"):
|
||||
return "dev"
|
||||
case strings.Contains(prompt, "Ты — ревьюер"):
|
||||
return "reviewer"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
return bin
|
||||
|
||||
verdictFor := func(agent string) string {
|
||||
if v, ok := e2eAgentVerdicts[agent]; ok {
|
||||
return v
|
||||
}
|
||||
return "unknown agent"
|
||||
}
|
||||
|
||||
sessionID := func(path, suffix string) string {
|
||||
return strings.TrimSuffix(strings.TrimPrefix(path, "/api/session/"), suffix)
|
||||
}
|
||||
|
||||
assistantMsg := func(id, agent string) map[string]any {
|
||||
ts := time.Now().UnixMilli()
|
||||
return map[string]any{
|
||||
"id": "m-" + id,
|
||||
"type": "assistant",
|
||||
"content": []map[string]any{{"type": "text", "text": verdictFor(agent)}},
|
||||
"model": map[string]any{"providerID": "test", "id": "m"},
|
||||
"time": map[string]any{"created": ts, "completed": ts},
|
||||
}
|
||||
}
|
||||
|
||||
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/session":
|
||||
// v2 create: {model:{...}} → {data:{id}}
|
||||
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
|
||||
mu.Lock()
|
||||
sessions[id] = ""
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": id}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/prompt"):
|
||||
// v2 durable admit: {prompt:{text}} → {data:{id,timeCreated}}
|
||||
id := sessionID(r.URL.Path, "/prompt")
|
||||
var req struct {
|
||||
Prompt struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"prompt"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
mu.Lock()
|
||||
sessions[id] = agentOf(req.Prompt.Text)
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": "p-" + id, "timeCreated": time.Now().UnixMilli()}})
|
||||
|
||||
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"):
|
||||
// v2 поллинг: {data:[Session.Message]} (новейшие первыми).
|
||||
id := sessionID(r.URL.Path, "/message")
|
||||
mu.Lock()
|
||||
agent := sessions[id]
|
||||
mu.Unlock()
|
||||
writeJSON(w, map[string]any{"data": []map[string]any{assistantMsg(id, agent)}})
|
||||
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/session/active":
|
||||
// сессий в активных дренажах нет → ответ завершён.
|
||||
writeJSON(w, map[string]any{"data": map[string]any{}})
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/interrupt"):
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"ok": true}})
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
})
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
||||
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
|
||||
// каталог worktree и fake-канал (для проверки исходящих).
|
||||
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
|
||||
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
|
||||
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
@@ -99,13 +155,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
bin := e2eFakeOpenCode(t, dir)
|
||||
fakeURL := e2eFakeAPI(t)
|
||||
pool := opencode.NewPool(worktree)
|
||||
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
|
||||
runner := &opencode.Runner{
|
||||
Bin: bin,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
Pool: pool,
|
||||
PollInterval: 5 * time.Millisecond,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
HardTimeout: 30 * time.Second,
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
||||
coreCtx := core.New(store, an)
|
||||
@@ -117,6 +176,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
CoreCtx: coreCtx,
|
||||
}
|
||||
a.Router = chat.NewRouter(a.handleIncoming)
|
||||
fake.router = a.Router
|
||||
if err := a.Router.Attach(fake); err != nil {
|
||||
t.Fatalf("Attach fake channel: %v", err)
|
||||
}
|
||||
@@ -129,6 +189,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
Interval: 30 * time.Millisecond,
|
||||
MaxJobs: 1,
|
||||
Live: opencode.NewLiveRegistry(),
|
||||
Notify: a, // авто-уведомления владельцу через Router (как в app.New)
|
||||
// GitToken зададим пустым: origin в seed-репо локальный (file path),
|
||||
// http.extraHeader не нужен для локального пуша.
|
||||
}
|
||||
@@ -139,13 +200,14 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
||||
// и доставки входящих через роутер (как реальный канал).
|
||||
type e2eChannel struct {
|
||||
onMsg func(chat.Incoming)
|
||||
sent []chat.Message
|
||||
onMsg chat.Handler
|
||||
sent []chat.Message
|
||||
router *chat.Router
|
||||
}
|
||||
|
||||
func (c *e2eChannel) Run(_ context.Context) error { return nil }
|
||||
func (c *e2eChannel) OnMessage(f func(chat.Incoming)) {
|
||||
c.onMsg = f
|
||||
func (c *e2eChannel) OnMessage(h chat.Handler) {
|
||||
c.onMsg = h
|
||||
}
|
||||
func (c *e2eChannel) Send(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||
c.sent = append(c.sent, m)
|
||||
@@ -157,10 +219,21 @@ func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) erro
|
||||
func (c *e2eChannel) Close() error { return nil }
|
||||
|
||||
// deliver отправляет входящее сообщение через роутер: ставит маршрут
|
||||
// пользователя и вызывает app.handleIncoming (как в проде).
|
||||
// пользователя и вызывает app.handleIncoming (как в проде). Так как роутер
|
||||
// обрабатывает входящие асинхронно (процессор-горутина), deliver ждёт, пока
|
||||
// обработка события завершится, — иначе тесты (сразу читающие состояние БД)
|
||||
// гоняются с обработчиком.
|
||||
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
|
||||
if c.onMsg != nil {
|
||||
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
|
||||
if c.onMsg == nil {
|
||||
return
|
||||
}
|
||||
target := int64(0)
|
||||
if c.router != nil {
|
||||
target = c.router.Processed() + 1
|
||||
}
|
||||
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
|
||||
if c.router != nil && !c.router.WaitProcessed(target) {
|
||||
panic("e2e: роутер не обработал входящее за 30s")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -719,4 +792,108 @@ func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
|
||||
}
|
||||
|
||||
// TestE2ENotificationsOnTransitions — авто-уведомления владельцу на каждый
|
||||
// переход статуса задачи и отсутствие задвоения на approved.
|
||||
//
|
||||
// Проверяет сквозной поток сообщений через chat.Router:
|
||||
// - ready → approved («создавай»): ровно ОДНО сообщение (ответ «одобрена»),
|
||||
// отдельное уведомление "Задача #N: approved" НЕ дублируется;
|
||||
// - со стороны воркера: уведомления running → dev→reviewer → success
|
||||
// доходят владельцу через Router.Send.
|
||||
func TestE2ENotificationsOnTransitions(t *testing.T) {
|
||||
a, worktree, fake := e2eAssemble(t)
|
||||
a.seedFakeRepo(t, worktree, "calc")
|
||||
|
||||
ctx := context.Background()
|
||||
uid := chat.UserID("u-notif")
|
||||
|
||||
// --- 1. постановка: /start → draft→collecting (приветствие) ---
|
||||
fake.deliver(uid, "/start")
|
||||
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||
if err != nil {
|
||||
t.Fatalf("get task after /start: %v", err)
|
||||
}
|
||||
taskID := task.ID
|
||||
|
||||
// --- 2. текст → collecting→ready (сводка черновика) ---
|
||||
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||
task, err = a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusReady {
|
||||
t.Fatalf("status = %q, want ready", task.Status)
|
||||
}
|
||||
|
||||
// --- 3. «создавай» → ready→approved: один ответ, без дубля-уведомления ---
|
||||
fake.deliver(uid, "создавай")
|
||||
task, err = a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task after создавай: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusApproved {
|
||||
t.Fatalf("status = %q, want approved", task.Status)
|
||||
}
|
||||
|
||||
approvedReplies := 0
|
||||
var approvedText string
|
||||
approvedNotifs := 0
|
||||
for _, m := range fake.sent {
|
||||
if strings.Contains(m.Text, "одобрена") {
|
||||
approvedReplies++
|
||||
approvedText = m.Text
|
||||
}
|
||||
if strings.Contains(m.Text, fmt.Sprintf("Задача #%d: approved", taskID)) {
|
||||
approvedNotifs++
|
||||
}
|
||||
}
|
||||
if approvedReplies != 1 {
|
||||
t.Errorf("сообщений об одобрении = %d, want ровно 1 (нет задвоения)", approvedReplies)
|
||||
}
|
||||
if approvedNotifs != 0 {
|
||||
t.Errorf("отдельное уведомление 'Задача #%d: approved' продублировано (%d раз)", taskID, approvedNotifs)
|
||||
}
|
||||
if !strings.Contains(approvedText, "одобрена") {
|
||||
t.Errorf("ответ об одобрении = %q", approvedText)
|
||||
}
|
||||
|
||||
// --- 4. воркер: running → dev→reviewer → success через Router ---
|
||||
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||
defer wkCancel()
|
||||
a.Worker.Start(wkCtx)
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
tk, err := a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
// финальные статус-уведомления воркера в правильном порядке
|
||||
wantOrder := []string{
|
||||
fmt.Sprintf("Задача #%d: running", taskID),
|
||||
fmt.Sprintf("Задача #%d: dev → reviewer (итерация 1)", taskID),
|
||||
fmt.Sprintf("Задача #%d: success", taskID),
|
||||
}
|
||||
var gotOrder []string
|
||||
for _, m := range fake.sent {
|
||||
if strings.HasPrefix(m.Text, fmt.Sprintf("Задача #%d:", taskID)) {
|
||||
gotOrder = append(gotOrder, m.Text)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(gotOrder, wantOrder) {
|
||||
t.Errorf("уведомления воркера = %#v, want %#v", gotOrder, wantOrder)
|
||||
}
|
||||
|
||||
t.Logf("NOTIFICATIONS OK: задача #%d, сообщений одобрения=%d, уведомления=%v", taskID, approvedReplies, gotOrder)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Router — единый диспетчер входящих из всех каналов и маршрутизатор исходящих.
|
||||
@@ -18,6 +20,16 @@ type Router struct {
|
||||
|
||||
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
|
||||
onUserMsg func(Incoming)
|
||||
|
||||
// Асинхронная обработка входящих: handleIncoming кладёт событие в канал,
|
||||
// воркер-горутина последовательно вызывает onUserMsg. Благодаря этому
|
||||
// long-poll цикл канала (Telegram) не блокируется на время долгого
|
||||
// вызова аналитика и продолжает принимать новые сообщения.
|
||||
incoming chan Incoming
|
||||
|
||||
// processed — число обработанных воркером событий (для синхронизации
|
||||
// тестов с асинхронной очередью: WaitProcessed ждёт обработку события).
|
||||
processed atomic.Int64
|
||||
}
|
||||
|
||||
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
|
||||
@@ -25,12 +37,39 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
|
||||
if onUserMsg == nil {
|
||||
onUserMsg = func(Incoming) {}
|
||||
}
|
||||
return &Router{
|
||||
r := &Router{
|
||||
sessions: map[UserID]any{},
|
||||
routes: map[UserID]Route{},
|
||||
pending: map[UserID]PendingQ{},
|
||||
onUserMsg: onUserMsg,
|
||||
incoming: make(chan Incoming, 256),
|
||||
}
|
||||
go r.processLoop()
|
||||
return r
|
||||
}
|
||||
|
||||
// processLoop — воркер асинхронной обработки входящих (FIFO).
|
||||
func (r *Router) processLoop() {
|
||||
for inc := range r.incoming {
|
||||
r.onUserMsg(inc)
|
||||
r.processed.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Processed возвращает число обработанных воркером входящих событий.
|
||||
func (r *Router) Processed() int64 { return r.processed.Load() }
|
||||
|
||||
// WaitProcessed ждёт, пока воркер обработает не меньше target событий
|
||||
// (для синхронизации с асинхронной очередью в тестах).
|
||||
func (r *Router) WaitProcessed(target int64) bool {
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for r.processed.Load() < target {
|
||||
if time.Now().After(deadline) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Attach регистрирует канал и подключает его к обработчику входящих.
|
||||
@@ -59,7 +98,10 @@ func (r *Router) handleIncoming(inc Incoming) {
|
||||
delete(r.pending, inc.UserID)
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.onUserMsg(inc)
|
||||
|
||||
// Асинхронная обработка: кладём событие в очередь воркера и сразу
|
||||
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
|
||||
r.incoming <- inc
|
||||
}
|
||||
|
||||
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,
|
||||
|
||||
@@ -3,7 +3,9 @@ package chat
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -13,13 +15,58 @@ const (
|
||||
tui Address = "tui://local"
|
||||
)
|
||||
|
||||
// fakeOnMsg — тест-колбэк, копящий входящие.
|
||||
type fakeOnMsg struct{ got []Incoming }
|
||||
// fakeOnMsg — тест-колбэк, копящий входящие. Т.к. Router теперь обрабатывает
|
||||
// входящие асинхронно (воркер-горутина), доступ потокобезопасный, а ожидание
|
||||
// нужного числа сообщений — через wait.
|
||||
type fakeOnMsg struct {
|
||||
mu sync.Mutex
|
||||
ch chan struct{} // сигнал о появлении каждого нового входящего
|
||||
got []Incoming
|
||||
}
|
||||
|
||||
func (f *fakeOnMsg) h(inc Incoming) { f.got = append(f.got, inc) }
|
||||
func newFakeOnMsg() *fakeOnMsg {
|
||||
return &fakeOnMsg{ch: make(chan struct{}, 64)}
|
||||
}
|
||||
|
||||
func (f *fakeOnMsg) h(inc Incoming) {
|
||||
f.mu.Lock()
|
||||
f.got = append(f.got, inc)
|
||||
f.mu.Unlock()
|
||||
f.ch <- struct{}{}
|
||||
}
|
||||
|
||||
// wait блокируется, пока не наберётся n входящих. Возвращает false по таймауту.
|
||||
func (f *fakeOnMsg) wait(n int) bool {
|
||||
deadline := time.After(2 * time.Second)
|
||||
for {
|
||||
f.mu.Lock()
|
||||
got := len(f.got)
|
||||
f.mu.Unlock()
|
||||
if got >= n {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-f.ch:
|
||||
case <-deadline:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeOnMsg) get(i int) Incoming {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.got[i]
|
||||
}
|
||||
|
||||
func (f *fakeOnMsg) count() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.got)
|
||||
}
|
||||
|
||||
func TestRouter_AttachAndIncoming(t *testing.T) {
|
||||
cb := &fakeOnMsg{}
|
||||
cb := newFakeOnMsg()
|
||||
r := NewRouter(cb.h)
|
||||
|
||||
tgCh := newFakeChannel(tg)
|
||||
@@ -28,10 +75,10 @@ func TestRouter_AttachAndIncoming(t *testing.T) {
|
||||
}
|
||||
|
||||
tgCh.emit(uidA, tg, "привет")
|
||||
if len(cb.got) != 1 {
|
||||
t.Fatalf("handler got %d, want 1", len(cb.got))
|
||||
if !cb.wait(1) {
|
||||
t.Fatal("handler не получил входящее за таймаут")
|
||||
}
|
||||
got := cb.got[0]
|
||||
got := cb.get(0)
|
||||
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
|
||||
t.Errorf("incoming = %+v", got)
|
||||
}
|
||||
@@ -53,12 +100,15 @@ func TestRouter_Send_NoRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
||||
cb := &fakeOnMsg{}
|
||||
cb := newFakeOnMsg()
|
||||
r := NewRouter(cb.h)
|
||||
tgCh := newFakeChannel(tg)
|
||||
|
||||
_ = r.Attach(tgCh)
|
||||
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
|
||||
if !cb.wait(1) {
|
||||
t.Fatal("маршрут не установился за таймаут")
|
||||
}
|
||||
|
||||
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
@@ -72,7 +122,7 @@ func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
||||
cb := &fakeOnMsg{}
|
||||
cb := newFakeOnMsg()
|
||||
r := NewRouter(cb.h)
|
||||
tgCh := newFakeChannel(tg)
|
||||
tuiCh := newFakeChannel(tui)
|
||||
@@ -83,6 +133,9 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
||||
tgCh.emit(uidA, tg, "hi")
|
||||
// продолжил в GUI
|
||||
tuiCh.emit(uidA, tui, "продолжаю тут")
|
||||
if !cb.wait(2) {
|
||||
t.Fatal("входящие не обработаны за таймаут")
|
||||
}
|
||||
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
|
||||
t.Fatal("до Send ничего не шлём")
|
||||
}
|
||||
@@ -98,11 +151,14 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
||||
cb := &fakeOnMsg{}
|
||||
cb := newFakeOnMsg()
|
||||
r := NewRouter(cb.h)
|
||||
tgCh := newFakeChannel(tg)
|
||||
_ = r.Attach(tgCh)
|
||||
tgCh.emit(uidA, tg, "hi")
|
||||
if !cb.wait(1) {
|
||||
t.Fatal("первое входящее не обработано")
|
||||
}
|
||||
|
||||
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
|
||||
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
|
||||
@@ -118,13 +174,16 @@ func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
||||
|
||||
// ответ с того же адреса потребляет pending
|
||||
tgCh.emit(uidA, tg, "Анна")
|
||||
if !cb.wait(2) {
|
||||
t.Fatal("ответ не обработан")
|
||||
}
|
||||
if _, ok := r.Pending(uidA); ok {
|
||||
t.Fatal("pending должен быть закрыт после ответа")
|
||||
}
|
||||
if len(cb.got) != 2 {
|
||||
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
|
||||
if cb.count() != 2 {
|
||||
t.Fatalf("handler got %d, want 2 (hi + ответ)", cb.count())
|
||||
}
|
||||
if cb.got[1].Msg.QuestionID == "" {
|
||||
if cb.get(1).Msg.QuestionID == "" {
|
||||
t.Error("ответ должен нести QuestionID вопроса")
|
||||
}
|
||||
}
|
||||
@@ -137,7 +196,7 @@ func TestRouter_Ask_NoRoute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
|
||||
cb := &fakeOnMsg{}
|
||||
cb := newFakeOnMsg()
|
||||
r := NewRouter(cb.h)
|
||||
tgCh := newFakeChannel(tg)
|
||||
tuiCh := newFakeChannel(tui)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||
)
|
||||
@@ -109,7 +110,7 @@ func (ch *Channel) handleUpdate(ctx context.Context, upd update) {
|
||||
func (ch *Channel) sendMsg(chatID, text string) error {
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"chat_id": chatID,
|
||||
"text": text[:min(len(text), 4000)],
|
||||
"text": truncateUTF8(text, 4000),
|
||||
"parse_mode": "HTML",
|
||||
})
|
||||
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
|
||||
@@ -155,10 +156,10 @@ func (ch *Channel) getUpdates(ctx context.Context, offset int64, timeout int) ([
|
||||
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
|
||||
func formatOutgoing(m chat.Message) string {
|
||||
if len(m.Options) == 0 {
|
||||
return m.Text
|
||||
return escapeHTML(m.Text)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(m.Text)
|
||||
buf.WriteString(escapeHTML(m.Text))
|
||||
buf.WriteString("\n\n")
|
||||
for i, opt := range m.Options {
|
||||
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
|
||||
@@ -183,6 +184,18 @@ func escapeHTML(s string) string {
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// truncateUTF8 обрезает s до max байт, не разрывая UTF-8 последовательности.
|
||||
func truncateUTF8(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
s = s[:max]
|
||||
for len(s) > 0 && !utf8.ValidString(s) {
|
||||
s = s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ---- Telegram API types ----
|
||||
|
||||
type tgResponse struct {
|
||||
@@ -202,4 +215,4 @@ type message struct {
|
||||
Chat struct {
|
||||
ID int64 `json:"id"`
|
||||
} `json:"chat"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||
)
|
||||
@@ -111,6 +112,36 @@ func TestSendWithOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatOutgoingEscapesText(t *testing.T) {
|
||||
if got := formatOutgoing(chat.Message{Text: "2 < 3 & 4 > 1"}); got != "2 < 3 & 4 > 1" {
|
||||
t.Errorf("text escape = %q", got)
|
||||
}
|
||||
got := formatOutgoing(chat.Message{
|
||||
Text: "a<b",
|
||||
Options: []chat.Option{{ID: "x", Label: "l&l"}},
|
||||
})
|
||||
if !strings.Contains(got, "a<b") || !strings.Contains(got, "l&l") {
|
||||
t.Errorf("options escape = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateUTF8(t *testing.T) {
|
||||
long := strings.Repeat("я", 5000)
|
||||
tr := truncateUTF8(long, 4000)
|
||||
if len(tr) != 4000 {
|
||||
t.Fatalf("len = %d, want 4000", len(tr))
|
||||
}
|
||||
if !utf8.ValidString(tr) {
|
||||
t.Fatal("truncated string is not valid UTF-8")
|
||||
}
|
||||
if got := truncateUTF8("привет", 4000); got != "привет" {
|
||||
t.Fatalf("short text changed: %q", got)
|
||||
}
|
||||
if got := truncateUTF8("", 4000); got != "" {
|
||||
t.Fatalf("empty text changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIncoming(t *testing.T) {
|
||||
f := newFakeTG(t)
|
||||
ch := New("TOKEN", time.Second)
|
||||
@@ -143,4 +174,4 @@ func TestIncoming(t *testing.T) {
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout ожидания входящего")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -76,11 +77,75 @@ telegram:
|
||||
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
||||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
||||
}
|
||||
if cfg.OpenCode.Serve.Enabled {
|
||||
t.Errorf("serve.enabled = true, want false (дефолт)")
|
||||
}
|
||||
if cfg.OpenCode.Serve.Hostname != "127.0.0.1" {
|
||||
t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname)
|
||||
}
|
||||
if cfg.OpenCode.Serve.Port != 4096 {
|
||||
t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port)
|
||||
}
|
||||
if cfg.Paths.Worktree != "./worktrees" {
|
||||
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelDefault(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
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.Log.Level != "info" {
|
||||
t.Errorf("log.level = %q, want info (дефолт)", cfg.Log.Level)
|
||||
}
|
||||
if cfg.Log.Debug() {
|
||||
t.Errorf("Debug() = true при уровне info, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelDebug(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
yaml := `telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
log:
|
||||
level: debug
|
||||
`
|
||||
cfg, err := Load(writeCfg(t, yaml))
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if !cfg.Log.Debug() {
|
||||
t.Errorf("Debug() = false при level=debug, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_LogLevelInvalid(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
yaml := `telegram:
|
||||
token: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
log:
|
||||
level: warn
|
||||
`
|
||||
_, err := Load(writeCfg(t, yaml))
|
||||
if !errors.Is(err, ErrInvalidFormat) {
|
||||
t.Fatalf("Load: err = %v, want ErrInvalidFormat", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_OpenCodeConfigDir(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
@@ -186,14 +251,17 @@ func TestResolveExePaths_AbsoluteKept(t *testing.T) {
|
||||
t.Setenv("TG_TOKEN", "tok")
|
||||
t.Setenv("TG_CHAT_ID", "42")
|
||||
|
||||
absDB := filepath.Join(string(filepath.Separator), "data", "ratatoskr.db") // абсолютный для текущей ОС
|
||||
absWt := filepath.Join(string(filepath.Separator), "worktrees")
|
||||
// абсолютные пути «для текущей ОС»: на Windows слэш-относительный путь
|
||||
// (\data\...) НЕ является абсолютным — нужен корень тома (C:\data\...).
|
||||
root := filepath.VolumeName(os.TempDir()) + string(filepath.Separator)
|
||||
absDB := filepath.Join(root, "data", "ratatoskr.db")
|
||||
absWt := filepath.Join(root, "worktrees")
|
||||
yaml := `telegram:
|
||||
username: "${TG_TOKEN}"
|
||||
chat_id: "${TG_CHAT_ID}"
|
||||
paths:
|
||||
db: "` + absDB + `"
|
||||
worktree: "` + absWt + `"
|
||||
db: '` + absDB + `'
|
||||
worktree: '` + absWt + `'
|
||||
`
|
||||
cfg, err := Load(writeCfg(t, yaml))
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) {
|
||||
if fv.Kind() == reflect.String {
|
||||
fv.SetString(meta.defaultVal)
|
||||
}
|
||||
// int (значение по умолчанию, напр. port)
|
||||
if fv.Kind() == reflect.Int {
|
||||
if n, err := strconv.Atoi(meta.defaultVal); err == nil {
|
||||
fv.SetInt(int64(n))
|
||||
}
|
||||
}
|
||||
// bool (по умолчанию false/true)
|
||||
if fv.Kind() == reflect.Bool {
|
||||
if b, err := strconv.ParseBool(meta.defaultVal); err == nil {
|
||||
fv.SetBool(b)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -156,6 +169,16 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if fv.Kind() == reflect.String {
|
||||
fv.SetString(envVal)
|
||||
}
|
||||
if fv.Kind() == reflect.Int {
|
||||
if n, err := strconv.Atoi(envVal); err == nil {
|
||||
fv.SetInt(int64(n))
|
||||
}
|
||||
}
|
||||
if fv.Kind() == reflect.Bool {
|
||||
if b, err := strconv.ParseBool(envVal); err == nil {
|
||||
fv.SetBool(b)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -173,6 +196,10 @@ func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) {
|
||||
fn(fv, collectMeta(f))
|
||||
} else if fv.Type() == durType {
|
||||
fn(fv, collectMeta(f))
|
||||
} else if fv.Kind() == reflect.Int {
|
||||
fn(fv, collectMeta(f))
|
||||
} else if fv.Kind() == reflect.Bool {
|
||||
fn(fv, collectMeta(f))
|
||||
} else if fv.Kind() == reflect.Struct {
|
||||
walk(fv, fn)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ package config
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -40,8 +41,18 @@ type Config struct {
|
||||
Chat ChatCfg `yaml:"chat"`
|
||||
Paths PathsCfg `yaml:"paths"`
|
||||
Update UpdateCfg `yaml:"update"`
|
||||
Log LogCfg `yaml:"log"`
|
||||
}
|
||||
|
||||
// LogCfg — уровень логирования. Level: "info" (по умолчанию) или "debug".
|
||||
// debug включает отладочные логи (напр. все API-вызовы к opencode serve).
|
||||
type LogCfg struct {
|
||||
Level string `yaml:"level" default:"info"`
|
||||
}
|
||||
|
||||
// Debug возвращает true, если включён отладочный уровень логирования.
|
||||
func (l LogCfg) Debug() bool { return strings.EqualFold(l.Level, "debug") }
|
||||
|
||||
// GitCfg — источник репозиториев (для git clone).
|
||||
type GitCfg struct {
|
||||
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
|
||||
@@ -78,6 +89,23 @@ type OpenCodeCfg struct {
|
||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
||||
Serve ServeCfg `yaml:"serve"`
|
||||
}
|
||||
|
||||
// ServeCfg — настройки постоянных opencode serve-процессов (API-режим).
|
||||
// Runner ходит к serve по HTTP API (v1.17+, /api). ratatoskr сам поднимает
|
||||
// по одному serve на каталог через Pool (Hash: hostname/port; служебный
|
||||
// root-сервер в worktree живёт всё время app, остальные — лениво).
|
||||
//
|
||||
// Поля enabled/url оставлены для обратной совместимости конфига и сейчас не
|
||||
// меняют поведение: serve обязателен для API-режима, сервер всегда
|
||||
// спавнится пулом (hostname/port задают базовые значения).
|
||||
type ServeCfg struct {
|
||||
Enabled bool `yaml:"enabled" default:"false"`
|
||||
URL string `yaml:"url" env:"OPENCODE_SERVE_URL"`
|
||||
Hostname string `yaml:"hostname" default:"127.0.0.1"`
|
||||
Port int `yaml:"port" default:"4096"`
|
||||
Password string `yaml:"password" env:"OPENCODE_SERVE_PASSWORD"`
|
||||
}
|
||||
|
||||
type ChatCfg struct {
|
||||
@@ -99,5 +127,8 @@ func (c *Config) Validate() error {
|
||||
if c.Telegram.ChatID == "" {
|
||||
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
|
||||
}
|
||||
if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") {
|
||||
errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
|
||||
if task.Status == storage.StatusApproved {
|
||||
return Result{
|
||||
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
|
||||
Action: "send",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -106,7 +105,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
|
||||
default:
|
||||
return Result{
|
||||
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
|
||||
Action: "send",
|
||||
TaskID: taskID,
|
||||
}, nil
|
||||
}
|
||||
@@ -128,7 +126,6 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
|
||||
}
|
||||
return Result{
|
||||
Reply: greeting,
|
||||
Action: "greeting",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -146,7 +143,6 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
|
||||
}
|
||||
return Result{
|
||||
Reply: "🚫 Отменил.",
|
||||
Action: "drop",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -161,7 +157,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
|
||||
if task.Status == storage.StatusReady {
|
||||
return Result{
|
||||
Reply: "Напишите «создавай» — или правьте текст.",
|
||||
Action: "send",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -191,8 +186,7 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
||||
id, ok := parseTaskID(rest)
|
||||
if !ok {
|
||||
return Result{
|
||||
Reply: "Укажите номер задачи: `/retry 5`.",
|
||||
Action: "send",
|
||||
Reply: "Укажите номер задачи: `/retry 5`.",
|
||||
}, nil
|
||||
}
|
||||
task, err := c.Store.GetTask(ctx, id)
|
||||
@@ -204,7 +198,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
||||
if storage.IsTerminal(task.Status) {
|
||||
return Result{
|
||||
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
|
||||
Action: "send",
|
||||
TaskID: id,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -218,7 +211,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
||||
}
|
||||
return Result{
|
||||
Reply: "Задача перезапущена. Опишите, что меняем:",
|
||||
Action: "send",
|
||||
TaskID: id,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -229,8 +221,7 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
||||
id, ok := parseTaskID(rest)
|
||||
if !ok {
|
||||
return Result{
|
||||
Reply: "Укажите номер задачи: `/status 5`.",
|
||||
Action: "send",
|
||||
Reply: "Укажите номер задачи: `/status 5`.",
|
||||
}, nil
|
||||
}
|
||||
task, err := c.Store.GetTask(ctx, id)
|
||||
@@ -246,7 +237,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
||||
}
|
||||
return Result{
|
||||
Reply: reply,
|
||||
Action: "send",
|
||||
TaskID: id,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -257,8 +247,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
||||
id, ok := parseTaskID(rest)
|
||||
if !ok {
|
||||
return Result{
|
||||
Reply: "Укажите номер задачи: `/continue 5`.",
|
||||
Action: "send",
|
||||
Reply: "Укажите номер задачи: `/continue 5`.",
|
||||
}, nil
|
||||
}
|
||||
task, err := c.Store.GetTask(ctx, id)
|
||||
@@ -268,7 +257,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
||||
return Result{
|
||||
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
|
||||
". Резюм сессии — пока не реализован.",
|
||||
Action: "send",
|
||||
TaskID: id,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -277,8 +265,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
||||
// notFoundReply формирует reply для ненайденной задачи.
|
||||
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
|
||||
return Result{
|
||||
Reply: "Задача #" + itoa(id) + " не найдена.",
|
||||
Action: "send",
|
||||
Reply: "Задача #" + itoa(id) + " не найдена.",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -291,7 +278,6 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
|
||||
}
|
||||
return Result{
|
||||
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
|
||||
Action: "created:" + itoa(task.ID),
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -333,6 +319,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
if !force && c.MaxTurns > 0 {
|
||||
userTurns := 0
|
||||
for _, h := range history {
|
||||
if h.Role == "user" {
|
||||
userTurns++
|
||||
}
|
||||
}
|
||||
if userTurns > c.MaxTurns {
|
||||
return Result{
|
||||
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
msgs := make([]Message, 0, len(history))
|
||||
for _, h := range history {
|
||||
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
|
||||
@@ -353,7 +356,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
||||
if reply == "" {
|
||||
reply = "Недостаточно данных. Начните заново (/start)."
|
||||
}
|
||||
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
|
||||
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
|
||||
|
||||
case "propose", "ready":
|
||||
// применяем черновик (для ready — текущий, без изменений)
|
||||
@@ -365,7 +368,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
||||
return Result{}, err
|
||||
}
|
||||
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
|
||||
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
|
||||
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
|
||||
}
|
||||
task.Status = storage.StatusReady
|
||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||||
@@ -373,7 +376,6 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
||||
}
|
||||
return Result{
|
||||
Reply: formatSummary(*task),
|
||||
Action: "summary",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
@@ -385,7 +387,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
||||
return Result{}, err
|
||||
}
|
||||
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
|
||||
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
|
||||
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
|
||||
c, ctx, store := setupCore(t, nil)
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
res, err := c.ProcessTurn(ctx, id, "/start")
|
||||
_, err := c.ProcessTurn(ctx, id, "/start")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn /start: %v", err)
|
||||
}
|
||||
if res.Action != "greeting" {
|
||||
t.Fatalf("action = %q, want greeting", res.Action)
|
||||
}
|
||||
|
||||
task, _ := store.GetTask(ctx, id)
|
||||
if task.Status != storage.StatusCollecting {
|
||||
t.Fatalf("status = %s, want collecting", task.Status)
|
||||
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
|
||||
c, ctx, store := setupCore(t, nil)
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
res, err := c.ProcessTurn(ctx, id, "/cancel")
|
||||
_, err := c.ProcessTurn(ctx, id, "/cancel")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn /cancel: %v", err)
|
||||
}
|
||||
if res.Action != "drop" {
|
||||
t.Fatalf("action = %q, want drop", res.Action)
|
||||
}
|
||||
|
||||
task, _ := store.GetTask(ctx, id)
|
||||
if task.Status != storage.StatusCancelled {
|
||||
t.Fatalf("status = %s, want cancelled", task.Status)
|
||||
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
|
||||
})
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
||||
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn: %v", err)
|
||||
}
|
||||
if res.Action != "summary" {
|
||||
t.Fatalf("action = %q, want summary", res.Action)
|
||||
}
|
||||
|
||||
task, _ := store.GetTask(ctx, id)
|
||||
if task.Status != storage.StatusReady {
|
||||
t.Fatalf("status = %s, want ready", task.Status)
|
||||
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn: %v", err)
|
||||
}
|
||||
if res.Action != "send" {
|
||||
t.Fatalf("action = %q, want send", res.Action)
|
||||
}
|
||||
|
||||
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
|
||||
t.Fatalf("reply = %q", res.Reply)
|
||||
}
|
||||
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
|
||||
})
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
res, err := c.ProcessTurn(ctx, id, "привет")
|
||||
_, err := c.ProcessTurn(ctx, id, "привет")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn: %v", err)
|
||||
}
|
||||
if res.Action != "drop" {
|
||||
t.Fatalf("action = %q, want drop", res.Action)
|
||||
}
|
||||
|
||||
task, _ := store.GetTask(ctx, id)
|
||||
if task.Status != storage.StatusAborted {
|
||||
t.Fatalf("status = %s, want aborted", task.Status)
|
||||
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
|
||||
res, err := c.ProcessTurn(ctx, id, "создавай")
|
||||
_, err := c.ProcessTurn(ctx, id, "создавай")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn создавай: %v", err)
|
||||
}
|
||||
if res.Action != "created:"+itoa(id) {
|
||||
t.Fatalf("action = %q, want created:%d", res.Action, id)
|
||||
}
|
||||
|
||||
task, _ := store.GetTask(ctx, id)
|
||||
if task.Status != storage.StatusApproved {
|
||||
t.Fatalf("status после создавай = %s, want approved", task.Status)
|
||||
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
||||
|
||||
_, _ = c.ProcessTurn(ctx, id, "сделай X")
|
||||
// в ready пишем правку, не согласие
|
||||
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
||||
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn edit: %v", err)
|
||||
}
|
||||
if res.Action != "summary" {
|
||||
t.Fatalf("action = %q, want summary", res.Action)
|
||||
}
|
||||
|
||||
if calls != 2 {
|
||||
t.Fatalf("decide calls = %d, want 2", calls)
|
||||
}
|
||||
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
||||
|
||||
func TestRetryNotFound(t *testing.T) {
|
||||
c, ctx, _ := setupCore(t, nil)
|
||||
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
||||
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn /retry: %v", err)
|
||||
}
|
||||
if res.Action != "send" {
|
||||
t.Fatalf("action = %q, want send", res.Action)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestUnknownCommand(t *testing.T) {
|
||||
c, ctx, store := setupCore(t, nil)
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
res, err := c.ProcessTurn(ctx, id, "/bogus")
|
||||
_, err := c.ProcessTurn(ctx, id, "/bogus")
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessTurn: %v", err)
|
||||
}
|
||||
if res.Action != "send" {
|
||||
t.Fatalf("action = %q, want send", res.Action)
|
||||
|
||||
}
|
||||
|
||||
func TestMaxTurnsBlocksExcessCollection(t *testing.T) {
|
||||
var calls int
|
||||
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
|
||||
calls++
|
||||
return Decision{Phase: "ask", ChatReply: "Ещё вопрос"}, nil
|
||||
})
|
||||
c.MaxTurns = 2
|
||||
id := mkTask(t, store, ctx, "u1")
|
||||
|
||||
_, err := c.ProcessTurn(ctx, id, "первый факт")
|
||||
if err != nil {
|
||||
t.Fatalf("1-й ход: %v", err)
|
||||
}
|
||||
_, err = c.ProcessTurn(ctx, id, "второй факт")
|
||||
if err != nil {
|
||||
t.Fatalf("2-й ход: %v", err)
|
||||
}
|
||||
res, err := c.ProcessTurn(ctx, id, "третий факт")
|
||||
if err != nil {
|
||||
t.Fatalf("3-й ход: %v", err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("decide calls = %d, want 2", calls)
|
||||
}
|
||||
if !strings.Contains(res.Reply, "лимит") {
|
||||
t.Fatalf("reply = %q, want упоминание лимита", res.Reply)
|
||||
}
|
||||
|
||||
// /skip — принудительный вызов, лимит не мешает
|
||||
_, err = c.ProcessTurn(ctx, id, "/skip")
|
||||
if err != nil {
|
||||
t.Fatalf("/skip: %v", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("decide calls after /skip = %d, want 3", calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,4 +250,4 @@ func TestParseTaskID(t *testing.T) {
|
||||
if _, ok := parseTaskID("abc"); ok {
|
||||
t.Fatal("parseTaskID(abc) should be invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,10 +15,10 @@ type Message struct {
|
||||
|
||||
// Decision — вердикт аналитика.
|
||||
type Decision struct {
|
||||
Phase string // "ask" | "propose" | "ready" | "abort"
|
||||
Draft storage.Task // обновлённые поля черновика
|
||||
Questions []string // вопросы для phase=ask
|
||||
ChatReply string // ответ пользователю
|
||||
Phase string // "ask" | "propose" | "ready" | "abort"
|
||||
Draft storage.Task // обновлённые поля черновика
|
||||
Questions []string // вопросы для phase=ask
|
||||
ChatReply string // ответ пользователю
|
||||
}
|
||||
|
||||
// Decider — интерфейс для вызова аналитика (opencode).
|
||||
@@ -29,7 +29,6 @@ type Decider interface {
|
||||
// Result — результат одного хода.
|
||||
type Result struct {
|
||||
Reply string
|
||||
Action string // send | summary | created:N | abort | drop | greeting
|
||||
TaskID int64
|
||||
Status storage.Status
|
||||
}
|
||||
@@ -46,4 +45,4 @@ func isConsent(text string) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
331
internal/opencode/client.go
Normal file
331
internal/opencode/client.go
Normal file
@@ -0,0 +1,331 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — HTTP-взаимодействие с одним opencode serve (v2 HTTP API).
|
||||
//
|
||||
// Пути v2 начинаются с префикса /api (см. README, минимальная версия opencode):
|
||||
// - POST /api/session создать сессию {model:{...}} → {data: Session.Info}
|
||||
// - POST /api/session/{id}/prompt отправить промпт {prompt:{text}} →
|
||||
// НЕБЛОКИРУЮЩЕ (admit) → {data: Admitted}
|
||||
// - GET /api/session/{id}/message?order=desc → {data:[Message,...]}
|
||||
// - POST /api/session/{id}/interrupt прервать активный ответ (204)
|
||||
// - GET /api/session/active активные дренажи → {data:{sessionID:...}}
|
||||
//
|
||||
// Prompt не блокирует: вердикт собирается поллингом из content[].type=="text"
|
||||
// новых assistant-сообщений (см. Runner.awaitVerdict).
|
||||
type Client struct {
|
||||
BaseURL string // http://host:port (без завершающего слеша)
|
||||
Password string // basic auth (username "opencode")
|
||||
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
|
||||
http *http.Client // единый клиент: все операции быстрые (нет блокирующего Send)
|
||||
}
|
||||
|
||||
// ClientErr — классы ошибок клиента.
|
||||
type ClientErr struct {
|
||||
Op string // "connect" | "create" | "prompt" | "messages" | "active" | "abort"
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ClientErr) Error() string { return fmt.Sprintf("opencode api %s: %v", e.Op, e.Err) }
|
||||
func (e *ClientErr) Unwrap() error { return e.Err }
|
||||
|
||||
func (c *Client) defaults() {
|
||||
if c.http == nil {
|
||||
c.http = &http.Client{Timeout: 30 * time.Second}
|
||||
}
|
||||
}
|
||||
|
||||
// do выполняет запрос через c.http и возвращает тело при 2xx.
|
||||
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
|
||||
c.defaults()
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
rd = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
if c.Password != "" {
|
||||
req.SetBasicAuth("opencode", c.Password)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if c.Debug {
|
||||
log.Printf("opencode api %s -> %s %s%s", op, method, c.BaseURL, path)
|
||||
if len(body) > 0 {
|
||||
log.Printf("opencode api %s request body: %s", op, truncateStr(string(body), 5000))
|
||||
}
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, &ClientErr{Op: "connect", Err: err}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
if c.Debug {
|
||||
log.Printf("opencode api %s response: status %d: %s", op, resp.StatusCode, truncateStr(string(b), 1000))
|
||||
}
|
||||
return nil, &ClientErr{Op: op, Err: fmt.Errorf("status %d: %s", resp.StatusCode, truncateStr(string(b), 300))}
|
||||
}
|
||||
if c.Debug {
|
||||
log.Printf("opencode api %s response (%d bytes): %s", op, len(b), truncateStr(string(b), 5000))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// ModelRef — ссылка на модель (аналог v2 Model.Ref: {providerID, id, variant?}).
|
||||
// providerID — имя провайдера из конфига opencode, id — идентификатор модели.
|
||||
type ModelRef struct {
|
||||
ProviderID string `json:"providerID"`
|
||||
ID string `json:"id"`
|
||||
Variant string `json:"variant,omitempty"`
|
||||
}
|
||||
|
||||
// String возвращает каноничное представление "provider/id[/variant]".
|
||||
func (m *ModelRef) String() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if m.Variant != "" {
|
||||
return m.ProviderID + "/" + m.ID + "/" + m.Variant
|
||||
}
|
||||
return m.ProviderID + "/" + m.ID
|
||||
}
|
||||
|
||||
// CreateSession создаёт новую сессию и возвращает её id. model != nil —
|
||||
// хардпин модели (top-level "model" из конфига opencode), чтобы не зависеть
|
||||
// от fallback-логики выбора модели в самом opencode.
|
||||
func (c *Client) CreateSession(ctx context.Context, model *ModelRef) (string, error) {
|
||||
payload := map[string]any{}
|
||||
if model != nil {
|
||||
payload["model"] = model
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/api/session", "create", body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.Data.ID == "" {
|
||||
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||||
}
|
||||
return out.Data.ID, nil
|
||||
}
|
||||
|
||||
// Admitted — результат admit промпта (SessionInput.Admitted).
|
||||
type Admitted struct {
|
||||
ID string // id user-сообщения
|
||||
TimeCreated int64 // epoch ms создания промпта (граница «новых» ответов)
|
||||
}
|
||||
|
||||
// Prompt неблокирующе отправляет промпт в сессию (durable admit) и возвращает
|
||||
// границу времени, с которой следует считать assistant-сообщения «новыми».
|
||||
func (c *Client) Prompt(ctx context.Context, sessionID, prompt string) (*Admitted, error) {
|
||||
payload := map[string]any{
|
||||
"prompt": map[string]string{"text": prompt},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
raw, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/prompt", "prompt", body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
TimeCreated int64 `json:"timeCreated"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.Data.ID == "" {
|
||||
return nil, &ClientErr{Op: "prompt", Err: fmt.Errorf("пустой id промпта в ответе")}
|
||||
}
|
||||
return &Admitted{ID: out.Data.ID, TimeCreated: out.Data.TimeCreated}, nil
|
||||
}
|
||||
|
||||
// v2Message — минимальная проекция Session.Message (tagged union: тип в "type").
|
||||
// Поле "role" в v2 отсутствует; assistant определяется по type=="assistant".
|
||||
type v2Message struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "assistant" | "user" | "tool" | "system" | ...
|
||||
Content []v2Part `json:"content"`
|
||||
Model *ModelRef `json:"model"`
|
||||
Finish string `json:"finish,omitempty"`
|
||||
Error *v2Error `json:"error,omitempty"`
|
||||
Time v2Time `json:"time"`
|
||||
}
|
||||
|
||||
type v2Part struct {
|
||||
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type v2Time struct {
|
||||
Created *int64 `json:"created"`
|
||||
Completed *int64 `json:"completed"`
|
||||
}
|
||||
|
||||
type v2Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// finished — завершено ли assistant-сообщение (ответ агента закончен).
|
||||
func (m *v2Message) finished() bool {
|
||||
if m == nil {
|
||||
return false
|
||||
}
|
||||
if m.Error != nil {
|
||||
return true
|
||||
}
|
||||
if m.Finish != "" {
|
||||
return true
|
||||
}
|
||||
return m.Time.Completed != nil && *m.Time.Completed > 0
|
||||
}
|
||||
|
||||
// Messages возвращает сообщения сессии (новейшие первыми, до 200 за запрос).
|
||||
func (c *Client) Messages(ctx context.Context, sessionID string) ([]v2Message, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/api/session/"+sessionID+"/message?order=desc&limit=200", "messages", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out struct {
|
||||
Data []v2Message `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil, &ClientErr{Op: "messages", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
return out.Data, nil
|
||||
}
|
||||
|
||||
// Active возвращает true, если сессия ещё обрабатывается (есть в активных
|
||||
// дренажах этого serve). Сессии вне списка считаются завершёнными.
|
||||
func (c *Client) Active(ctx context.Context, sessionID string) (bool, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/api/session/active", "active", nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var out struct {
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return false, &ClientErr{Op: "active", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||
}
|
||||
if out.Data == nil {
|
||||
return false, nil
|
||||
}
|
||||
_, ok := out.Data[sessionID]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// Interrupt прерывает активный ответ сессии (аналог v1 abort).
|
||||
func (c *Client) Interrupt(ctx context.Context, sessionID string) error {
|
||||
_, err := c.do(ctx, http.MethodPost, "/api/session/"+sessionID+"/interrupt", "abort", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// assistantSince фильтрует assistant-сообщения, созданные не раньше since
|
||||
// (порядок сохраняется — как пришёл из API, новейшие первыми).
|
||||
func assistantSince(msgs []v2Message, since int64) []*v2Message {
|
||||
out := make([]*v2Message, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
m := &msgs[i]
|
||||
if m.Type != "assistant" {
|
||||
continue
|
||||
}
|
||||
if m.Time.Created == nil || *m.Time.Created < since {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textParts считает text-парты в одном assistant-сообщении (для прогресса).
|
||||
func textParts(m *v2Message) int {
|
||||
n := 0
|
||||
for _, p := range m.Content {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// newestAssistant возвращает самое новое assistant-сообщение (из фильтра) и
|
||||
// суммарное число text-партов. since — граница времени (epoch ms).
|
||||
func newestAssistant(msgs []v2Message, since int64) (*v2Message, int) {
|
||||
ass := assistantSince(msgs, since)
|
||||
var newest *v2Message
|
||||
count := 0
|
||||
for _, m := range ass {
|
||||
count += textParts(m)
|
||||
if newest == nil || *m.Time.Created > *newest.Time.Created {
|
||||
newest = m
|
||||
}
|
||||
}
|
||||
return newest, count
|
||||
}
|
||||
|
||||
// progressOf — «живой» прогресс новых assistant-сообщений: число контент-партов
|
||||
// (text/reasoning/tool) + суммарная длина их текста. Растёт во время стриминга,
|
||||
// когда один и тот же парт увеличивается (и при reasoning), — это и есть
|
||||
// сигнал, что LLM работает, а не висит.
|
||||
func progressOf(msgs []v2Message, since int64) (parts, textLen int) {
|
||||
for _, m := range assistantSince(msgs, since) {
|
||||
for _, p := range m.Content {
|
||||
parts++
|
||||
if p.Type == "text" || p.Type == "reasoning" {
|
||||
textLen += len(p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// assistantText объединяет text-парты новых assistant-сообщений в хронологическом
|
||||
// порядке (сообщения приходят новейшими первыми → идём с конца).
|
||||
func assistantText(msgs []v2Message, since int64) []string {
|
||||
ass := assistantSince(msgs, since)
|
||||
texts := make([]string, 0, len(ass))
|
||||
for i := len(ass) - 1; i >= 0; i-- {
|
||||
for _, p := range ass[i].Content {
|
||||
if p.Type == "text" && p.Text != "" {
|
||||
texts = append(texts, p.Text)
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
func truncateStr(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n] + "..."
|
||||
}
|
||||
301
internal/opencode/client_test.go
Normal file
301
internal/opencode/client_test.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeAPIServer — минимальный фейк opencode serve v2 HTTP API (пути /api/*).
|
||||
//
|
||||
// Сценарии:
|
||||
// - нормальный: Prompt ставит active=false и в messages кладётся финальное
|
||||
// assistant-сообщение (verdictText) → Runner собирает вердикт;
|
||||
// - blockPrompt: «агент завис» — active=true всегда, сообщений нет → idle abort;
|
||||
// - failCreate / failMessages — имитация ошибок;
|
||||
// - growStream: стрим одного растущего парта — текст/reasoning растёт с
|
||||
// каждым опросом GET /message (streamPolls раз), active=true, затем
|
||||
// active=false + финальное завершённое сообщение.
|
||||
type fakeAPIServer struct {
|
||||
sessionID string
|
||||
created bool
|
||||
active bool
|
||||
blockPrompt bool
|
||||
messages []v2Message
|
||||
verdictText string
|
||||
failCreate bool
|
||||
failMessages bool
|
||||
createdModel *ModelRef // модель, полученная на POST /api/session
|
||||
promptCalls int
|
||||
|
||||
// streamGrow: стрим одного растущего парта — текст/reasoning растёт с
|
||||
// каждым опросом GET /message, active=true, пока messageCalls не дойдёт до
|
||||
// streamPolls; затем active=false + финальное завершённое сообщение.
|
||||
streamGrow bool
|
||||
streamReasoning bool // растущий парт — reasoning вместо text
|
||||
streamPolls int // сколько опросов длится «стрим» до завершения
|
||||
messageCalls int
|
||||
}
|
||||
|
||||
func (f *fakeAPIServer) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/session", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if f.failCreate {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Model *ModelRef `json:"model"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
f.createdModel = in.Model
|
||||
f.sessionID = "sess-fake"
|
||||
f.created = true
|
||||
writeJSON(w, map[string]any{"data": map[string]any{"id": "sess-fake"}})
|
||||
})
|
||||
mux.HandleFunc("/api/session/active", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
data := map[string]any{}
|
||||
if f.active && f.sessionID != "" {
|
||||
data[f.sessionID] = map[string]any{"type": "running"}
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": data})
|
||||
})
|
||||
mux.HandleFunc("/api/session/{id}/prompt", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
f.promptCalls++
|
||||
if f.blockPrompt {
|
||||
// «зависший» агент: активен, но сообщений не появляется.
|
||||
f.active = true
|
||||
} else {
|
||||
f.active = false
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": map[string]any{
|
||||
"id": "msg_1",
|
||||
"sessionID": f.sessionID,
|
||||
"timeCreated": time.Now().UnixMilli(),
|
||||
}})
|
||||
})
|
||||
mux.HandleFunc("/api/session/{id}/interrupt", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
f.active = false
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
mux.HandleFunc("/api/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if f.failMessages {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if f.streamGrow {
|
||||
f.messageCalls++
|
||||
now := time.Now().UnixMilli()
|
||||
done := f.messageCalls >= f.streamPolls
|
||||
msg := v2Message{ID: "msg_stream", Type: "assistant", Time: v2Time{Created: &now}}
|
||||
switch {
|
||||
case done:
|
||||
msg.Content = []v2Part{{Type: "text", Text: "done-stream"}}
|
||||
msg.Finish = "end_turn"
|
||||
msg.Time.Completed = &now
|
||||
f.active = false
|
||||
case f.streamReasoning:
|
||||
msg.Content = []v2Part{{Type: "reasoning", Text: strings.Repeat("r", f.messageCalls)}}
|
||||
f.active = true
|
||||
default:
|
||||
msg.Content = []v2Part{{Type: "text", Text: strings.Repeat("x", f.messageCalls)}}
|
||||
f.active = true
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": []v2Message{msg}})
|
||||
return
|
||||
}
|
||||
msgs := f.messages
|
||||
if msgs == nil && f.verdictText != "" && !f.blockPrompt {
|
||||
msgs = []v2Message{f.assistantMsg(f.verdictText)}
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []v2Message{}
|
||||
}
|
||||
writeJSON(w, map[string]any{"data": msgs})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// assistantMsg строит завершённое assistant-сообщение с text-партом.
|
||||
func (f *fakeAPIServer) assistantMsg(text string) v2Message {
|
||||
now := time.Now().UnixMilli()
|
||||
return v2Message{
|
||||
ID: "msg_a",
|
||||
Type: "assistant",
|
||||
Content: []v2Part{{Type: "text", Text: text}},
|
||||
Finish: "end_turn",
|
||||
Time: v2Time{Created: &now, Completed: &now},
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// fakeClient — клиент к фейк-серверу.
|
||||
func fakeClient(t *testing.T, f *fakeAPIServer) *Client {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(f.handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return &Client{BaseURL: ts.URL}
|
||||
}
|
||||
|
||||
func TestClient_CreateSession(t *testing.T) {
|
||||
f := &fakeAPIServer{}
|
||||
c := fakeClient(t, f)
|
||||
id, err := c.CreateSession(context.Background(), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession err: %v", err)
|
||||
}
|
||||
if id != "sess-fake" {
|
||||
t.Errorf("id = %q, want sess-fake", id)
|
||||
}
|
||||
if f.createdModel != nil {
|
||||
t.Errorf("createdModel = %+v, want nil", f.createdModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateSessionHardpinsModel(t *testing.T) {
|
||||
want := &ModelRef{ProviderID: "tokentool", ID: "deepseek/deepseek-v4-flash-0731"}
|
||||
f := &fakeAPIServer{}
|
||||
c := fakeClient(t, f)
|
||||
if _, err := c.CreateSession(context.Background(), want); err != nil {
|
||||
t.Fatalf("CreateSession err: %v", err)
|
||||
}
|
||||
if f.createdModel == nil || f.createdModel.ProviderID != want.ProviderID || f.createdModel.ID != want.ID {
|
||||
t.Errorf("createdModel = %+v, want %+v", f.createdModel, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CreateSessionFail(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{failCreate: true})
|
||||
if _, err := c.CreateSession(context.Background(), nil); err == nil {
|
||||
t.Fatal("CreateSession должен упасть при 500, а не nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Prompt(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{})
|
||||
adm, err := c.Prompt(context.Background(), "sess-fake", "почини x")
|
||||
if err != nil {
|
||||
t.Fatalf("Prompt err: %v", err)
|
||||
}
|
||||
if adm.ID != "msg_1" {
|
||||
t.Errorf("adm.ID = %q, want msg_1", adm.ID)
|
||||
}
|
||||
if adm.TimeCreated == 0 {
|
||||
t.Error("adm.TimeCreated = 0, want epoch ms")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Messages(t *testing.T) {
|
||||
now := time.Now().UnixMilli()
|
||||
f := &fakeAPIServer{messages: []v2Message{{
|
||||
ID: "msg_a", Type: "assistant",
|
||||
Content: []v2Part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}},
|
||||
Finish: "end_turn",
|
||||
Time: v2Time{Created: &now, Completed: &now},
|
||||
}}}
|
||||
c := fakeClient(t, f)
|
||||
msgs, err := c.Messages(context.Background(), "sess-fake")
|
||||
if err != nil {
|
||||
t.Fatalf("Messages err: %v", err)
|
||||
}
|
||||
if len(msgs) != 1 {
|
||||
t.Fatalf("len(msgs) = %d, want 1", len(msgs))
|
||||
}
|
||||
if !msgs[0].finished() {
|
||||
t.Error("сообщение должно быть finished (Finish задан)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Active(t *testing.T) {
|
||||
f := &fakeAPIServer{active: true, sessionID: "sess-fake"}
|
||||
c := fakeClient(t, f)
|
||||
ok, err := c.Active(context.Background(), "sess-fake")
|
||||
if err != nil {
|
||||
t.Fatalf("Active err: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Error("Active = false, want true")
|
||||
}
|
||||
ok, _ = c.Active(context.Background(), "sess-other")
|
||||
if ok {
|
||||
t.Error("Active(чужой) = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Interrupt(t *testing.T) {
|
||||
c := fakeClient(t, &fakeAPIServer{})
|
||||
if err := c.Interrupt(context.Background(), "sess-fake"); err != nil {
|
||||
t.Fatalf("Interrupt err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_newestAssistant(t *testing.T) {
|
||||
older := time.Now().Add(-time.Minute).UnixMilli()
|
||||
newer := time.Now().UnixMilli()
|
||||
msgs := []v2Message{
|
||||
{ID: "a", Type: "assistant", Content: []v2Part{{Type: "text", Text: "x"}}, Time: v2Time{Created: &newer}},
|
||||
{ID: "b", Type: "user", Time: v2Time{Created: &newer}},
|
||||
{ID: "c", Type: "assistant", Content: []v2Part{{Type: "text", Text: "y"}}, Time: v2Time{Created: &older}},
|
||||
}
|
||||
cur, count := newestAssistant(msgs, older)
|
||||
if cur == nil || cur.ID != "a" {
|
||||
t.Errorf("newest = %v, want a", cur)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Errorf("count = %d, want 2", count)
|
||||
}
|
||||
texts := assistantText(msgs, older)
|
||||
if len(texts) != 2 || texts[0] != "y" || texts[1] != "x" {
|
||||
t.Errorf("assistantText order = %v, want [y x]", texts)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseModelString(t *testing.T) {
|
||||
m := parseModelString("tokentool/deepseek/deepseek-v4-flash-0731")
|
||||
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||
t.Errorf("parse = %+v, want tokentool/deepseek-v4-flash-0731", m)
|
||||
}
|
||||
if parseModelString("onlyprovider") != nil {
|
||||
t.Error("parse без '/' должен вернуть nil")
|
||||
}
|
||||
if parseModelString("") != nil {
|
||||
t.Error("parse пустой должен вернуть nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientErr_Unwrap(t *testing.T) {
|
||||
ce := &ClientErr{Op: "prompt", Err: errors.New("boom")}
|
||||
var target *ClientErr
|
||||
if !errors.As(ce, &target) {
|
||||
t.Fatal("expected *ClientErr")
|
||||
}
|
||||
}
|
||||
176
internal/opencode/config.go
Normal file
176
internal/opencode/config.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Чтение top-level "model" из эффективного конфига opencode.
|
||||
//
|
||||
// Зачем: ratatoskr хардпинит модель в сессии (CreateSession), чтобы не зависеть
|
||||
// от fallback-логики opencode. Если в конфиге модель не задана (или конфиг
|
||||
// написан по старой v1-схеме — npm/options, которые v2 молча игнорирует),
|
||||
// opencode сам выберет «дефолтную» модельную запись, и это может оказаться не
|
||||
// той моделью. Поэтому мы явно логируем предупреждение (класс O5 WARN).
|
||||
|
||||
// opencodeConfigPath определяет путь к конфигу opencode, который видит
|
||||
// serve-процесс этого пула (см. README): (1) явный OPENCODE_CONFIG из Server
|
||||
// или окружения процесса, (2) OPENCODE_CONFIG_DIR / глобальный каталог
|
||||
// ~/.config/opencode. Возвращает "" если ничего не найдено.
|
||||
func opencodeConfigPath(cfgFile, cfgDir string) string {
|
||||
// (1) явный файл конфига — Server.Config или env OPENCODE_CONFIG.
|
||||
p := cfgFile
|
||||
if p == "" {
|
||||
p = os.Getenv("OPENCODE_CONFIG")
|
||||
}
|
||||
if p != "" {
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
// (2) каталог конфигов.
|
||||
dir := cfgDir
|
||||
if dir == "" {
|
||||
dir = os.Getenv("OPENCODE_CONFIG_DIR")
|
||||
}
|
||||
if dir == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return ""
|
||||
}
|
||||
dir = filepath.Join(home, ".config", "opencode")
|
||||
if x := os.Getenv("XDG_CONFIG_HOME"); x != "" {
|
||||
dir = filepath.Join(x, "opencode")
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"opencode.json", "opencode.jsonc"} {
|
||||
cand := filepath.Join(dir, name)
|
||||
if st, err := os.Stat(cand); err == nil && !st.IsDir() {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ReadModelRef извлекает top-level "model" из конфига opencode и возвращает
|
||||
// его как ModelRef. Модель не задана — вернёт (nil, nil); ошибка чтения/парсинга
|
||||
// возвращается (вызывающий логирует warning и продолжает без хардпина).
|
||||
func ReadModelRef(cfgFile, cfgDir string) (*ModelRef, error) {
|
||||
path := opencodeConfigPath(cfgFile, cfgDir)
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: читать %s: %w", path, err)
|
||||
}
|
||||
doc := struct {
|
||||
Model json.RawMessage `json:"model"`
|
||||
}{}
|
||||
if err := json.Unmarshal(stripJSONC(b), &doc); err != nil {
|
||||
return nil, fmt.Errorf("config: парсить %s: %w", path, err)
|
||||
}
|
||||
if len(doc.Model) == 0 || strings.TrimSpace(string(doc.Model)) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
// "model" может быть строкой "provider/id" или объектом {providerID, id}.
|
||||
var s string
|
||||
if err := json.Unmarshal(doc.Model, &s); err == nil {
|
||||
ref := parseModelString(s)
|
||||
if ref == nil {
|
||||
return nil, fmt.Errorf("config: некорректная model %q в %s (ожидается provider/id)", s, path)
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
var ref ModelRef
|
||||
if err := json.Unmarshal(doc.Model, &ref); err != nil {
|
||||
return nil, fmt.Errorf("config: некорректная model в %s", path)
|
||||
}
|
||||
if ref.ProviderID == "" || ref.ID == "" {
|
||||
return nil, fmt.Errorf("config: model без providerID/id в %s", path)
|
||||
}
|
||||
return &ref, nil
|
||||
}
|
||||
|
||||
// parseModelString разбирает "provider/id" (как ModelV2.parse: провайдер — всё
|
||||
// до первого '/', id — остаток). Возвращает nil при пустой/некорректной строке.
|
||||
func parseModelString(s string) *ModelRef {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
i := strings.IndexByte(s, '/')
|
||||
if i <= 0 || i == len(s)-1 {
|
||||
return nil
|
||||
}
|
||||
return &ModelRef{ProviderID: s[:i], ID: s[i+1:]}
|
||||
}
|
||||
|
||||
// stripJSONC удаляет // и /* */ комментарии (вне строк), сохраняя позиции
|
||||
// переводов строк, чтобы json.Unmarshal не споткнулся о trailing-комма.
|
||||
func stripJSONC(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
inStr := false
|
||||
esc := false
|
||||
i := 0
|
||||
for i < len(b) {
|
||||
c := b[i]
|
||||
if inStr {
|
||||
out = append(out, c)
|
||||
if esc {
|
||||
esc = false
|
||||
} else if c == '\\' {
|
||||
esc = true
|
||||
} else if c == '"' {
|
||||
inStr = false
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '"':
|
||||
inStr = true
|
||||
out = append(out, c)
|
||||
i++
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
||||
for i < len(b) && b[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
if i < len(b) {
|
||||
out = append(out, '\n')
|
||||
i++
|
||||
}
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
||||
i += 2
|
||||
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
|
||||
i++
|
||||
}
|
||||
i += 2
|
||||
default:
|
||||
out = append(out, c)
|
||||
i++
|
||||
}
|
||||
}
|
||||
return dropTrailingCommas(out)
|
||||
}
|
||||
|
||||
// dropTrailingCommas убирает запятые перед '}' / ']' (допускаются в JSONC).
|
||||
func dropTrailingCommas(b []byte) []byte {
|
||||
out := make([]byte, 0, len(b))
|
||||
for i := 0; i < len(b); i++ {
|
||||
if b[i] == ',' {
|
||||
j := i + 1
|
||||
for j < len(b) && (b[j] == ' ' || b[j] == '\t' || b[j] == '\n' || b[j] == '\r') {
|
||||
j++
|
||||
}
|
||||
if j < len(b) && (b[j] == '}' || b[j] == ']') {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, b[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
85
internal/opencode/config_test.go
Normal file
85
internal/opencode/config_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadModelRef_String(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.jsonc")
|
||||
// конфиг с комментариями и trailing-запятыми (JSONC).
|
||||
src := `{
|
||||
// комментарий
|
||||
"model": "tokentool/deepseek/deepseek-v4-flash-0731", /* и блочный */
|
||||
"provider": {
|
||||
"tokentool": {"api": {"type": "aisdk", "package": "@ai-sdk/openai-compatible", "url": "https://x"}},
|
||||
},
|
||||
}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m == nil || m.ProviderID != "tokentool" || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||
t.Errorf("model = %+v, want tokentool/deepseek-v4-flash-0731", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Object(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"model": {"providerID": "tokentool", "id": "deepseek/deepseek-v4-flash-0731"}}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m == nil || m.ID != "deepseek/deepseek-v4-flash-0731" {
|
||||
t.Errorf("model = %+v, want object-форма", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"provider": {}}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
m, err := ReadModelRef(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("model = %+v, want nil (model не задан)", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_NoFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
m, err := ReadModelRef(filepath.Join(dir, "nope.json"), dir)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadModelRef err: %v", err)
|
||||
}
|
||||
if m != nil {
|
||||
t.Errorf("model = %+v, want nil", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadModelRef_Bad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "opencode.json")
|
||||
src := `{"model": 12345}`
|
||||
if err := os.WriteFile(path, []byte(src), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if _, err := ReadModelRef(path, ""); err == nil {
|
||||
t.Error("ReadModelRef должен упасть на некорректной model")
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
||||
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
||||
// - ExtractJSON: fenced ```json``` → первый {...}
|
||||
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
|
||||
package opencode
|
||||
|
||||
import (
|
||||
@@ -14,9 +13,8 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||||
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
|
||||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||||
)
|
||||
|
||||
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
||||
@@ -76,11 +74,12 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
|
||||
func SessionIDFromOutput(out string) (string, bool) {
|
||||
m := sessionRe.FindStringSubmatch(out)
|
||||
if len(m) > 1 && m[1] != "" {
|
||||
return m[1], true
|
||||
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
|
||||
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
|
||||
func stripFence(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
|
||||
s = strings.TrimSpace(f[1])
|
||||
}
|
||||
return "", false
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
@@ -104,12 +104,3 @@ func TestExtractJSON(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIDFromOutput(t *testing.T) {
|
||||
if s, ok := SessionIDFromOutput(`{"session_id":"abc123"}`); !ok || s != "abc123" {
|
||||
t.Fatalf("got %q %v", s, ok)
|
||||
}
|
||||
if _, ok := SessionIDFromOutput("no session here"); ok {
|
||||
t.Fatal("expected no match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,36 +2,11 @@ package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
|
||||
// событие (text/tool/agent). Возвращает nil, если строка не является событием.
|
||||
func parseLiveStep(line string) *LiveStep {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, "{") {
|
||||
return nil
|
||||
}
|
||||
var obj struct {
|
||||
Type string `json:"type"`
|
||||
Part struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"part"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
return nil
|
||||
}
|
||||
if obj.Type == "" {
|
||||
return nil
|
||||
}
|
||||
return &LiveStep{Type: obj.Type, Text: obj.Part.Text, At: time.Now()}
|
||||
}
|
||||
|
||||
// LiveStep — один наблюдаемый шаг агента из NDJSON-потока opencode run.
|
||||
// Собирается из live-строк stdout, не из БД.
|
||||
// LiveStep — один наблюдаемый шаг агента.
|
||||
type LiveStep struct {
|
||||
Type string // "text" | "tool" | "agent" | ...
|
||||
Text string // содержимое text-парта (для других типов может быть пустым)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func sysProcAttr(proc *exec.Cmd) {
|
||||
proc.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// killProcGroup убивает всю process-group по лидеру pid (SIGKILL дочерним и
|
||||
// SIGTERM лидеру). Игнорирует ошибки: weakest-effort teardown.
|
||||
func killProcGroup(pid int) {
|
||||
pgid, err := syscall.Getpgid(pid)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-pgid, syscall.SIGKILL)
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package opencode
|
||||
|
||||
import "os/exec"
|
||||
|
||||
func sysProcAttr(_ *exec.Cmd) {}
|
||||
|
||||
func killProcGroup(pid int) {}
|
||||
234
internal/opencode/pool.go
Normal file
234
internal/opencode/pool.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Pool — контроль над пулом opencode serve-процессов (по одному на каталог).
|
||||
//
|
||||
// Ленивый: сервер для каталога поднимается при первом запросе (Ensure), кроме
|
||||
// служебного root-сервера (EnsureRoot), который живёт с момента старта app.
|
||||
// Завершение задачи снимает поднятые серверы кроме root'a (ReleaseTask).
|
||||
//
|
||||
// Каждый Server слушает свой порт (basePort + сдвиг), запускается в своей
|
||||
// директории → каждая сессия API привязана к правильному project-каталогу.
|
||||
type Pool struct {
|
||||
Bin string
|
||||
Config string
|
||||
ConfigDir string
|
||||
DBPath string
|
||||
Host string
|
||||
BasePort int
|
||||
Password string
|
||||
|
||||
rootDir string // каталог служебного сервера
|
||||
root *Server
|
||||
|
||||
ctx context.Context // базовый ctx для всех serve; живёт, пока пул активен
|
||||
cancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
segs map[string]*Server // dir → сервер (root тоже здесь)
|
||||
used map[int]bool // занятые порты
|
||||
next int // следующий кандидат порта
|
||||
}
|
||||
|
||||
// NewPool создаёт пул. rootDir помечен как служебный (не снимается ReleaseTask).
|
||||
func NewPool(rootDir string) *Pool {
|
||||
return &Pool{
|
||||
Host: "127.0.0.1",
|
||||
BasePort: 4096,
|
||||
rootDir: rootDir,
|
||||
segs: make(map[string]*Server),
|
||||
used: make(map[int]bool),
|
||||
next: 4096,
|
||||
}
|
||||
}
|
||||
|
||||
// startMonitored поднимает сервер и запускает его Run-перезапуск (reaper).
|
||||
// Наследует базовый ctx пула: Serve живёт, пока жив пул.
|
||||
func (p *Pool) startMonitored(ctx context.Context, s *Server) error {
|
||||
if p.ctx == nil {
|
||||
sctx, cancel := context.WithCancel(ctx)
|
||||
p.ctx, p.cancel = sctx, cancel
|
||||
}
|
||||
if err := s.Start(p.ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
go s.Run(p.ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnsureRoot поднимает служебный сервер в rootDir (идемпотентен).
|
||||
func (p *Pool) EnsureRoot(ctx context.Context) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.root != nil {
|
||||
return nil
|
||||
}
|
||||
s := &Server{
|
||||
Bin: p.Bin,
|
||||
Config: p.Config,
|
||||
ConfigDir: p.ConfigDir,
|
||||
DBPath: p.DBPath,
|
||||
Host: p.Host,
|
||||
Password: p.Password,
|
||||
Dir: p.rootDir,
|
||||
}
|
||||
if err := p.assign(s); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.startMonitored(ctx, s); err != nil {
|
||||
return fmt.Errorf("opencode serve (root): %w", err)
|
||||
}
|
||||
p.root = s
|
||||
p.segs[p.rootDir] = s
|
||||
log.Printf("opencode: root serve up at %s (dir %s)", s.Addr(), p.rootDir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure гарантирует наличие сервера для каталога dir (лениво).
|
||||
// Возвращает сервер; root-сервер для rootDir возвращается как есть.
|
||||
func (p *Pool) Ensure(ctx context.Context, dir string) (*Server, error) {
|
||||
p.mu.Lock()
|
||||
if s, ok := p.segs[dir]; ok {
|
||||
p.mu.Unlock()
|
||||
return s, nil
|
||||
}
|
||||
abs := filepath.Clean(dir)
|
||||
s := &Server{
|
||||
Bin: p.Bin,
|
||||
Config: p.Config,
|
||||
ConfigDir: p.ConfigDir,
|
||||
DBPath: p.DBPath,
|
||||
Host: p.Host,
|
||||
Password: p.Password,
|
||||
Dir: abs,
|
||||
}
|
||||
if err := p.assign(s); err != nil {
|
||||
p.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
p.segs[abs] = s
|
||||
p.mu.Unlock()
|
||||
|
||||
if err := p.startMonitored(ctx, s); err != nil {
|
||||
p.mu.Lock()
|
||||
delete(p.segs, abs)
|
||||
p.releasePort(s.Port)
|
||||
p.mu.Unlock()
|
||||
return nil, fmt.Errorf("opencode serve (%s): %w", abs, err)
|
||||
}
|
||||
log.Printf("opencode: serve up at %s (dir %s)", s.Addr(), abs)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RegisterExternal регистрирует внешний (уже запущенный) сервер для каталога
|
||||
// dir. Полезно, когда serve поднят вне пула (в т.ч. в тестах): Ensure вернёт
|
||||
// его без spawn'а. url — полный адрес, по которому Runner ходит через API.
|
||||
func (p *Pool) RegisterExternal(dir, url string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
abs := filepath.Clean(dir)
|
||||
p.segs[abs] = &Server{URL: url, Host: p.Host, PollInterval: 0}
|
||||
if p.rootDir != "" && abs == p.rootDir {
|
||||
p.root = p.segs[abs]
|
||||
}
|
||||
}
|
||||
|
||||
// ServerFor возвращает сервер для каталога (без поднятия). ok=false если нет.
|
||||
func (p *Pool) ServerFor(dir string) (*Server, bool) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
s, ok := p.segs[filepath.Clean(dir)]
|
||||
return s, ok
|
||||
}
|
||||
|
||||
// ReleaseTask закрывает все серверы пула, кроме служебного root. Вызывается
|
||||
// при завершении задачи.
|
||||
func (p *Pool) ReleaseTask() {
|
||||
p.mu.Lock()
|
||||
var toClose []*Server
|
||||
for dir, s := range p.segs {
|
||||
if p.root != nil && dir == p.rootDir {
|
||||
continue // служебный не снимаем
|
||||
}
|
||||
toClose = append(toClose, s)
|
||||
delete(p.segs, dir)
|
||||
p.releasePort(s.Port)
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, s := range toClose {
|
||||
log.Printf("opencode: closing serve %s (release task)", s.Addr())
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Close закрывает все серверы пула, включая root. Идемпотентен.
|
||||
func (p *Pool) Close() {
|
||||
p.mu.Lock()
|
||||
toClose := make([]*Server, 0, len(p.segs))
|
||||
for dir, s := range p.segs {
|
||||
toClose = append(toClose, s)
|
||||
delete(p.segs, dir)
|
||||
p.releasePort(s.Port)
|
||||
}
|
||||
p.root = nil
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
p.cancel = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
for _, s := range toClose {
|
||||
s.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// assign выделяет свободный порт и проставляет его серверу.
|
||||
func (p *Pool) assign(s *Server) error {
|
||||
port, err := p.allocPort()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Port = port
|
||||
return nil
|
||||
}
|
||||
|
||||
// allocPort находит свободный порт начиная с next, коммитит его.
|
||||
func (p *Pool) allocPort() (int, error) {
|
||||
for i := 0; i < 100; i++ {
|
||||
port := p.next
|
||||
p.next++
|
||||
if p.used[port] {
|
||||
continue
|
||||
}
|
||||
if !portFree(p.Host, port) {
|
||||
p.used[port] = true
|
||||
continue
|
||||
}
|
||||
p.used[port] = true
|
||||
return port, nil
|
||||
}
|
||||
return 0, fmt.Errorf("opencode: нет свободных портов в диапазоне")
|
||||
}
|
||||
|
||||
func (p *Pool) releasePort(port int) {
|
||||
if port != 0 {
|
||||
delete(p.used, port)
|
||||
}
|
||||
}
|
||||
|
||||
// portFree проверяет, свободен ли порт (bind probe).
|
||||
func portFree(host string, port int) bool {
|
||||
l, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
l.Close()
|
||||
return true
|
||||
}
|
||||
75
internal/opencode/pool_test.go
Normal file
75
internal/opencode/pool_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPool_EnsureRoot_NoSpawn(t *testing.T) {
|
||||
// без spawn: пул без Bin — EnsureRoot должен упасть (нет бинаря),
|
||||
// но НЕ упасть на пустой карте. Проверяем, что повтор вызова не паникует.
|
||||
dir := t.TempDir()
|
||||
p := NewPool(dir)
|
||||
// не запускаем — просто проверяем кэш
|
||||
p.mu.Lock()
|
||||
p.segs[dir] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Millisecond}
|
||||
p.mu.Unlock()
|
||||
|
||||
s, ok := p.ServerFor(dir)
|
||||
if !ok || s == nil {
|
||||
t.Fatal("ServerFor должен найти закэшированный сервер")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPool_ServerFor_ReleaseTask(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
a := filepath.Join(dir, "a")
|
||||
b := filepath.Join(dir, "b")
|
||||
p := NewPool(dir)
|
||||
p.Bin = fakeServeBin(t, dir)
|
||||
|
||||
// root не запускаем; добавляем серверы в карту напрямую (как после Ensure).
|
||||
p.mu.Lock()
|
||||
p.segs[a] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
|
||||
p.segs[b] = &Server{URL: "http://127.0.0.1:1", PollInterval: time.Nanosecond}
|
||||
p.mu.Unlock()
|
||||
|
||||
// ReleaseTask: rootDir в карте НЕ занят, значит оба закрываются.
|
||||
p.ReleaseTask()
|
||||
if _, ok := p.ServerFor(a); ok {
|
||||
t.Error("каталог a должен быть снят после ReleaseTask")
|
||||
}
|
||||
if _, ok := p.ServerFor(b); ok {
|
||||
t.Error("каталог b должен быть снят после ReleaseTask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPool_AllocPort_Unique(t *testing.T) {
|
||||
p := NewPool("/tmp/x")
|
||||
p.Host = "127.0.0.1"
|
||||
seen := map[int]bool{}
|
||||
var ports []int
|
||||
for i := 0; i < 5; i++ {
|
||||
port, err := p.allocPort()
|
||||
if err != nil {
|
||||
t.Fatalf("allocPort err: %v", err)
|
||||
}
|
||||
if seen[port] {
|
||||
t.Fatalf("дубль порта %d", port)
|
||||
}
|
||||
seen[port] = true
|
||||
ports = append(ports, port)
|
||||
}
|
||||
// освобождаем и убеждаемся, что порт можно переиспользовать
|
||||
p.releasePort(ports[0])
|
||||
if !p.free(ports[0]) {
|
||||
t.Errorf("порт %d должен быть свободен после releasePort", ports[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pool) free(port int) bool {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return !p.used[port]
|
||||
}
|
||||
24
internal/opencode/procgroup_unix.go
Normal file
24
internal/opencode/procgroup_unix.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build linux || darwin || freebsd || netbsd || openbsd || aix || solaris
|
||||
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setpgid выделяет дочернему процессу собственную process-group, чтобы
|
||||
// killGroup мог убить весь групповой процесс, а не чужой (тест-реннер и т.п.).
|
||||
func setpgid(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
}
|
||||
|
||||
// killGroup шлёт SIGKILL всей process-group процесса (pgid == pid из-за
|
||||
// Setpgid). Процесс уже завершён — возвращаем 0 и не отвлекаемся на ошибку
|
||||
// ESRCH (группа могла сама разойтись).
|
||||
func killGroup(proc *exec.Cmd) {
|
||||
if proc == nil || proc.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = syscall.Kill(-proc.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
20
internal/opencode/procgroup_windows.go
Normal file
20
internal/opencode/procgroup_windows.go
Normal file
@@ -0,0 +1,20 @@
|
||||
//go:build windows
|
||||
|
||||
package opencode
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// На Windows нет POSIX process-group (нет Setpgid / SIGKILL по -pgid).
|
||||
// setpgid — no-op: дочерние процессы не группируются в отдельную группу.
|
||||
func setpgid(cmd *exec.Cmd) {}
|
||||
|
||||
// killGroup на Windows может убить только сам процесс (Process.Kill),
|
||||
// дочерние процессы группы не завершаются. Для serve это приемлемо: при
|
||||
// остановке сервера дочерние подпроцессы opencode всё равно умирают вместе
|
||||
// с родителем/консолью.
|
||||
func killGroup(proc *exec.Cmd) {
|
||||
if proc == nil || proc.Process == nil {
|
||||
return
|
||||
}
|
||||
_ = proc.Process.Kill()
|
||||
}
|
||||
@@ -1,48 +1,44 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
||||
)
|
||||
|
||||
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
|
||||
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
|
||||
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
|
||||
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
|
||||
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
|
||||
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
|
||||
// (класс O2 Timeout — результат, не ошибка).
|
||||
type Result struct {
|
||||
RC int
|
||||
Stdout string
|
||||
SessionID string
|
||||
}
|
||||
|
||||
// Runner — конфигурация запуска opencode-субагентов.
|
||||
// Runner — запуск opencode-субагентов через v2 HTTP API serve.
|
||||
//
|
||||
// Runner ходит к opencode serve через Pool→Client (пути /api/*, см. README,
|
||||
// минимальная версия opencode). Промпт отправляется неблокирующе (durable
|
||||
// admit), вердикт собирается поллингом новых assistant-сообщений; завершение
|
||||
// ответа определяется по схеме «сессия больше не в активных дренажах» + финальное
|
||||
// assistant-сообщение.
|
||||
type Runner struct {
|
||||
Bin string // путь к opencode (по умолчанию "opencode")
|
||||
DBPath string // путь к opencode.db (idle-детекция активности)
|
||||
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
||||
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
||||
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
|
||||
HardTimeout time.Duration // общий лимит на запуск
|
||||
Pool *Pool // пул serve-серверов (обязательный)
|
||||
IdleTimeout time.Duration
|
||||
HardTimeout time.Duration
|
||||
PollInterval time.Duration
|
||||
Debug bool // отладочные логи API-вызовов (из log.level=debug)
|
||||
|
||||
// Заменяемые для тестов:
|
||||
// Заменяемый для тестов:
|
||||
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
||||
}
|
||||
|
||||
func (r *Runner) defaults() {
|
||||
if r.Bin == "" {
|
||||
r.Bin = "opencode"
|
||||
}
|
||||
if r.IdleTimeout == 0 {
|
||||
r.IdleTimeout = 5 * time.Minute
|
||||
}
|
||||
@@ -61,232 +57,170 @@ func (r *Runner) logf(format string, args ...any) {
|
||||
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
||||
}
|
||||
|
||||
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
|
||||
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
|
||||
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
|
||||
if r.DBPath == "" {
|
||||
return 0, false
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
defer db.Close()
|
||||
var ts sql.NullInt64
|
||||
err = db.QueryRowContext(ctx,
|
||||
"SELECT MAX(m.time_updated) FROM message m JOIN session s ON s.id = m.session_id WHERE s.directory = ?",
|
||||
worktree).Scan(&ts)
|
||||
if err != nil || !ts.Valid {
|
||||
return 0, false
|
||||
}
|
||||
return ts.Int64, true
|
||||
}
|
||||
|
||||
func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (string, bool) {
|
||||
if r.DBPath == "" {
|
||||
return "", false
|
||||
}
|
||||
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer db.Close()
|
||||
q := "SELECT id FROM session WHERE directory = ?"
|
||||
args := []any{worktree}
|
||||
if agent != "" {
|
||||
q += " AND agent = ?"
|
||||
args = append(args, agent)
|
||||
}
|
||||
q += " ORDER BY time_created DESC LIMIT 1"
|
||||
var id string
|
||||
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return id, id != ""
|
||||
}
|
||||
|
||||
// Run запускает opencode run. Возвращает *Result (rc, stdout, session_id).
|
||||
// Ошибка — только класс O1 ErrSpawn (не смог запустить бинарь). Таймауты
|
||||
// дают rc=-1 в Result, а не error (класс O2).
|
||||
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
|
||||
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
|
||||
//
|
||||
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
|
||||
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
|
||||
// Result, а не error (класс O2).
|
||||
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
||||
r.defaults()
|
||||
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
|
||||
if sessionID != "" {
|
||||
cmd = append(cmd, "--session", sessionID)
|
||||
}
|
||||
cmd = append(cmd, prompt)
|
||||
|
||||
env := append(os.Environ(),
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DISABLE_MODELS_FETCH=1")
|
||||
if r.Config != "" {
|
||||
env = append(env, "OPENCODE_CONFIG="+r.Config)
|
||||
}
|
||||
if r.ConfigDir != "" {
|
||||
env = append(env, "OPENCODE_CONFIG_DIR="+r.ConfigDir)
|
||||
if r.Pool == nil {
|
||||
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
|
||||
}
|
||||
|
||||
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
|
||||
proc.Env = env
|
||||
proc.Dir = cwd
|
||||
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
|
||||
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
|
||||
setpgid(proc)
|
||||
stdout, err := proc.StdoutPipe()
|
||||
srv, err := r.Pool.Ensure(ctx, cwd)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
|
||||
return nil, err
|
||||
}
|
||||
proc.Stderr = proc.Stdout
|
||||
if err := proc.Start(); err != nil {
|
||||
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
|
||||
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
|
||||
|
||||
// Модель по умолчанию из конфига opencode — хардпиним её в сессии, чтобы
|
||||
// не зависеть от fallback-логики opencode (класс O5 WARN: если модель не
|
||||
// считывается/не задана — предупреждаем и работаем без явного указания).
|
||||
model, mErr := ReadModelRef(srv.Config, srv.ConfigDir)
|
||||
if mErr != nil {
|
||||
r.logf("WARN opencode: не удалось прочитать model из конфига: %v", mErr)
|
||||
} else if model == nil {
|
||||
r.logf("WARN opencode: в конфиге opencode не задан top-level model — модель не хардпинится (риск fallback)")
|
||||
} else {
|
||||
r.logf("opencode(%s) model=%s", agent, model)
|
||||
}
|
||||
|
||||
var buf []string
|
||||
var mu sync.Mutex
|
||||
done := make(chan struct{})
|
||||
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
|
||||
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
|
||||
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
|
||||
var liveSeq atomic.Uint64
|
||||
prevLive := liveSeq.Load()
|
||||
// Живое наблюдение сессии (если задано через WithLive в контексте).
|
||||
liveReg, liveTask := liveFromContext(ctx)
|
||||
if liveReg != nil && liveTask != 0 {
|
||||
liveReg.Start(liveTask, agent)
|
||||
defer liveReg.Finish(liveTask)
|
||||
// Сессия: заданная (resume) или новая.
|
||||
sid := sessionID
|
||||
if sid == "" {
|
||||
sid, err = c.CreateSession(ctx, model)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||
}
|
||||
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
// NDJSON opencode пишет каждый объект одной строкой; большой text-парт с
|
||||
// вердиктом легко превышает дефолтный лимит Scanner в 64КБ → ErrTooLong и
|
||||
// потеря всего потока после первой строки. Поднимаем до 64МБ.
|
||||
sc.Buffer(make([]byte, 64*1024), 64*1024*1024)
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
mu.Lock()
|
||||
buf = append(buf, line)
|
||||
mu.Unlock()
|
||||
if st := parseLiveStep(line); st != nil {
|
||||
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
|
||||
liveSeq.Add(1)
|
||||
if liveReg != nil {
|
||||
liveReg.Observe(liveTask, *st)
|
||||
}
|
||||
}
|
||||
}
|
||||
scanErr := sc.Err()
|
||||
if scanErr != nil {
|
||||
r.logf("opencode(%s) scan err: %v", agent, scanErr)
|
||||
}
|
||||
}()
|
||||
|
||||
baseline, _ := r.maxDirMsgTS(ctx, cwd)
|
||||
return r.awaitVerdict(ctx, c, model, sid, agent, prompt)
|
||||
}
|
||||
|
||||
// settlePolls — сколько подряд опросов должно подтвердить завершение ответа,
|
||||
// прежде чем считать вердикт финальным (устойчивость к гонке между удалением
|
||||
// сессии из активных дренажей и финализацией последнего сообщения).
|
||||
const settlePolls = 2
|
||||
|
||||
// awaitVerdict отправляет промпт (неблокирующе) и поллит новые assistant-сообщения,
|
||||
// контролируя idle/hard таймауты. Завершение: сессия ушла из активных дренажей
|
||||
// И есть новое завершённое assistant-сообщение, стабильное в течение settlePolls
|
||||
// опросов. Возвращает вердикт (текст text-партов), либо rc=-1 при таймауте.
|
||||
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, model *ModelRef, sid, agent, prompt string) (*Result, error) {
|
||||
// admit промпта; граница «новых» сообщений — время создания user-сообщения.
|
||||
admittedAt := time.Now().UnixMilli()
|
||||
adm, err := c.Prompt(ctx, sid, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adm != nil && adm.TimeCreated > 0 {
|
||||
admittedAt = adm.TimeCreated
|
||||
}
|
||||
|
||||
// Прогресс = число контент-партов + суммарная длина их текста в новых
|
||||
// assistant-сообщениях (progressOf). Рост сбрасывает idle-таймер: LLM
|
||||
// стримит (даже в один растущий text-парт) или думает (reasoning) = жив.
|
||||
lastParts, lastTextLen := -1, -1
|
||||
lastProgress := time.Now()
|
||||
launch := time.Now()
|
||||
killed := false
|
||||
|
||||
pollLoop:
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
// процесс завершился (pipe EOF) — выходим, берём exit code
|
||||
break pollLoop
|
||||
case <-ctx.Done():
|
||||
killGroup(proc)
|
||||
killed = true
|
||||
break pollLoop
|
||||
default:
|
||||
doneSeen, emptySeen := 0, 0
|
||||
|
||||
abortAnd := func(rc int, why string) (*Result, error) {
|
||||
if err := c.Interrupt(ctx, sid); err != nil {
|
||||
r.logf("opencode(%s) interrupt %s: %v", agent, why, err)
|
||||
}
|
||||
if proc.ProcessState != nil && proc.ProcessState.Exited() {
|
||||
break pollLoop
|
||||
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
|
||||
}
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
|
||||
msgs, err := c.Messages(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
active, err := c.Active(ctx, sid)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return abortAnd(-1, "ctx")
|
||||
}
|
||||
var ce *ClientErr
|
||||
if errors.As(err, &ce) && ce.Op == "connect" {
|
||||
return nil, fmt.Errorf("opencode: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cur, _ := newestAssistant(msgs, admittedAt)
|
||||
parts, textLen := progressOf(msgs, admittedAt)
|
||||
if parts != lastParts || textLen != lastTextLen {
|
||||
lastProgress = time.Now()
|
||||
lastParts, lastTextLen = parts, textLen
|
||||
}
|
||||
now := time.Now()
|
||||
// «Пульс» LLM: если с прошлого поллинга появились live-строки
|
||||
// (text/tool/agent/reasoning) — LLM реально работает, сбрасываем idle.
|
||||
if cur := liveSeq.Load(); cur != prevLive {
|
||||
prevLive = cur
|
||||
lastProgress = now
|
||||
}
|
||||
ts, ok := r.maxDirMsgTS(ctx, cwd)
|
||||
if ok && ts > baseline {
|
||||
lastProgress = now
|
||||
}
|
||||
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
|
||||
killGroup(proc)
|
||||
killed = true
|
||||
break pollLoop
|
||||
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||
return abortAnd(-1, "idle")
|
||||
}
|
||||
if now.Sub(launch) > r.HardTimeout {
|
||||
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
|
||||
killGroup(proc)
|
||||
killed = true
|
||||
break pollLoop
|
||||
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||
return abortAnd(-1, "hard")
|
||||
}
|
||||
time.Sleep(r.PollInterval)
|
||||
}
|
||||
|
||||
<-done
|
||||
procErr := proc.Wait()
|
||||
rc := proc.ProcessState.ExitCode()
|
||||
if rc < 0 {
|
||||
rc = 1
|
||||
}
|
||||
if killed {
|
||||
rc = -1
|
||||
}
|
||||
_ = procErr
|
||||
switch {
|
||||
case !active && cur != nil && cur.finished():
|
||||
// ответ закончен — ждём стабильности, затем собираем вердикт
|
||||
doneSeen++
|
||||
emptySeen = 0
|
||||
if doneSeen >= settlePolls {
|
||||
return r.verdict(model, cur, msgs, admittedAt, sid)
|
||||
}
|
||||
case !active && cur == nil:
|
||||
// сессия завершилась, но нового assistant-сообщения так и нет
|
||||
emptySeen++
|
||||
if emptySeen >= settlePolls {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("агент не выдал ответ (сессия пуста)")}
|
||||
}
|
||||
default:
|
||||
doneSeen, emptySeen = 0, 0
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
out := strings.Join(buf, "\n")
|
||||
mu.Unlock()
|
||||
|
||||
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
|
||||
|
||||
sid := sessionID
|
||||
if s, ok := SessionIDFromOutput(out); ok {
|
||||
sid = s
|
||||
}
|
||||
if rc == -1 && sid == "" {
|
||||
if s, ok := r.latestSession(ctx, cwd, agent); ok {
|
||||
sid = s
|
||||
select {
|
||||
case <-time.After(r.PollInterval):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
r.logf("opencode(%s) rc=%d", agent, rc)
|
||||
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
|
||||
}
|
||||
|
||||
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
|
||||
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
|
||||
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит fallback.
|
||||
// Возвращает (result, timedOut).
|
||||
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
|
||||
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
|
||||
if err != nil {
|
||||
// spawn-ошибку не ретраим fallback'ом — она повторится
|
||||
return res, false
|
||||
// verdict собирает финальный результат из новых assistant-сообщений.
|
||||
// Проверяет фактическую модель ответа и логирует warning при расхождении
|
||||
// с ожидаемой (устойчивость к «не той» модели — класс O5 WARN).
|
||||
func (r *Runner) verdict(model *ModelRef, cur *v2Message, msgs []v2Message, since int64, sid string) (*Result, error) {
|
||||
if model != nil && cur.Model != nil && (model.ProviderID != cur.Model.ProviderID || model.ID != cur.Model.ID) {
|
||||
r.logf("WARN opencode: сессия %s отвечала моделью %s, а не ожидаемой %s — проверь providers в конфиге (v2-схема: provider.api / request, а не npm/options)", sid, cur.Model, model)
|
||||
}
|
||||
if res.RC != 0 && res.RC != -1 && sessionID != "" {
|
||||
r.logf("dev resume rc=%d — запускаю заново без --session (worktree сохраняю)", res.RC)
|
||||
res, _ = r.Run(ctx, prompt+resumeFallbackNote, cwd, "dev", "")
|
||||
if cur.Error != nil && cur.Error.Message != "" {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New(cur.Error.Message)}
|
||||
}
|
||||
return res, res.RC == -1
|
||||
}
|
||||
|
||||
const resumeFallbackNote = "\n\n(Возобновление сессии не удалось; продолжи с учётом уже сделанных изменений в worktree.)"
|
||||
|
||||
// --- process-group helpers (Linux) ---
|
||||
// Ставим процесс в собственную process-group, чтобы killGroup мог убить и
|
||||
// дочерние процессы (иначе они держат унаследованные stdout-fd и <-done виснет).
|
||||
|
||||
func setpgid(proc *exec.Cmd) {
|
||||
sysProcAttr(proc)
|
||||
}
|
||||
|
||||
func killGroup(proc *exec.Cmd) {
|
||||
if proc.Process != nil {
|
||||
killProcGroup(proc.Process.Pid)
|
||||
texts := assistantText(msgs, since)
|
||||
if len(texts) == 0 {
|
||||
return nil, &ClientErr{Op: "prompt", Err: errors.New("нет text-части в ответе")}
|
||||
}
|
||||
_ = proc.Process.Kill()
|
||||
vd := stripFence(strings.Join(texts, "\n"))
|
||||
r.logf("opencode вердикт готов (%d байт)", len(vd))
|
||||
return &Result{RC: 0, Stdout: vd, SessionID: sid}, nil
|
||||
}
|
||||
|
||||
@@ -2,60 +2,41 @@ package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"io"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
|
||||
//
|
||||
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
|
||||
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
|
||||
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
|
||||
func fakeOpenCode(t *testing.T, workdir string) string {
|
||||
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||||
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||||
// XDG_CONFIG_HOME уводится во временный каталог, чтобы ReadModelRef не читал
|
||||
// реальный пользовательский конфиг opencode (детерминизм тестов).
|
||||
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
|
||||
t.Helper()
|
||||
bin := filepath.Join(workdir, "opencode")
|
||||
script := `#!/bin/sh
|
||||
mode="${FAKE_MODE:-ok}"
|
||||
case "$mode" in
|
||||
ok)
|
||||
echo '{"type":"text","part":{"text":"done"}}'
|
||||
echo '{"session_id":"sess-123"}'
|
||||
exit 0
|
||||
;;
|
||||
slow)
|
||||
sleep 30
|
||||
;;
|
||||
live-reset)
|
||||
# шлём live-строку каждые 30мс долго — почти до hard timeout,
|
||||
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
|
||||
i=0
|
||||
while [ $i -lt 20 ]; do
|
||||
echo '{"type":"text","part":{"text":"tick"}}'
|
||||
sleep 0.03
|
||||
i=$((i+1))
|
||||
done
|
||||
sleep 30
|
||||
;;
|
||||
fail)
|
||||
echo '{"type":"text","part":{"text":"boom"}}'
|
||||
exit 7
|
||||
;;
|
||||
esac
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake opencode: %v", err)
|
||||
}
|
||||
return bin
|
||||
ts := httptestURL(t, f)
|
||||
p := NewPool(dir)
|
||||
p.mu.Lock()
|
||||
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
|
||||
p.mu.Unlock()
|
||||
return p, &Client{BaseURL: ts}
|
||||
}
|
||||
|
||||
// httptestURL запускает фейк-API и возвращает его URL.
|
||||
func httptestURL(t *testing.T, f *fakeAPIServer) string {
|
||||
t.Helper()
|
||||
ts := httptest.NewServer(f.handler())
|
||||
t.Cleanup(ts.Close)
|
||||
return ts.URL
|
||||
}
|
||||
|
||||
func TestRun_Success(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "ok")
|
||||
f := &fakeAPIServer{verdictText: "done"}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||||
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
@@ -63,86 +44,81 @@ func TestRun_Success(t *testing.T) {
|
||||
if res.RC != 0 {
|
||||
t.Errorf("RC = %d, want 0", res.RC)
|
||||
}
|
||||
if res.SessionID != "sess-123" {
|
||||
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
|
||||
if res.SessionID != "sess-fake" {
|
||||
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
|
||||
}
|
||||
if !contains(res.Stdout, "done") {
|
||||
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_LiveRegistry(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "ok")
|
||||
|
||||
reg := NewLiveRegistry()
|
||||
ctx := WithLive(context.Background(), reg, 42)
|
||||
|
||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||||
res, err := r.Run(ctx, "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
}
|
||||
if res.RC != 0 {
|
||||
t.Fatalf("RC = %d, want 0", res.RC)
|
||||
}
|
||||
// После завершения Finish удаляет сессию → Snap не найден.
|
||||
if _, ok := reg.Snap(42); ok {
|
||||
t.Error("сессия не удалена после Finish (должна быть, т.к. задача завершилась)")
|
||||
t.Errorf("Stdout = %q, want contain done", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_IdleTimeout(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "slow")
|
||||
// агент «завис»: active=true, прогресс не растёт → idle abort
|
||||
f := &fakeAPIServer{blockPrompt: true}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
||||
PollInterval: 10 * time.Millisecond}
|
||||
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
}
|
||||
if res.RC != -1 {
|
||||
t.Errorf("RC = %d, want -1 (timeout kill)", res.RC)
|
||||
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_LiveResetsIdle: пока LLM стримит live-строки, idle-таймер должен
|
||||
// сбрасываться, а не убивать процесс по истечении короткого IdleTimeout.
|
||||
func TestRun_LiveResetsIdle(t *testing.T) {
|
||||
func TestRun_StreamingGrowth(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "live-reset")
|
||||
// стрим: один text-парт растёт с каждым опросом (дольше, чем idle timeout),
|
||||
// но модель жива → idle НЕ должен сработать.
|
||||
f := &fakeAPIServer{streamGrow: true, streamPolls: 30}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
// idle очень короткий (50мс), hard большой (3с). live-reset стримит ~0.6с.
|
||||
// Если live-строки НЕ сбрасывают idle — процесс убьют на ~50мс, и Run
|
||||
// вернётся быстрее. Если сбрасывают — Run живёт ≥ стрима (~0.6с) до hard.
|
||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
||||
HardTimeout: 3 * time.Second, PollInterval: 10 * time.Millisecond}
|
||||
start := time.Now()
|
||||
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
}
|
||||
if res.RC != -1 {
|
||||
t.Errorf("RC = %d, want -1 (killed по hard timeout)", res.RC)
|
||||
if res.RC != 0 {
|
||||
t.Errorf("RC = %d, want 0 (растущий стрим не должен считаться hung)", res.RC)
|
||||
}
|
||||
if elapsed < 400*time.Millisecond {
|
||||
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
||||
if !contains(res.Stdout, "done-stream") {
|
||||
t.Errorf("Stdout = %q, want contain done-stream", res.Stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ReasoningGrowth(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
// стрим: растёт только reasoning-парт (текста нет) — тоже живая активность.
|
||||
f := &fakeAPIServer{streamGrow: true, streamReasoning: true, streamPolls: 30}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
r := &Runner{Pool: p, IdleTimeout: 60 * time.Millisecond,
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||
if err != nil {
|
||||
t.Fatalf("Run err: %v", err)
|
||||
}
|
||||
if res.RC != 0 {
|
||||
t.Errorf("RC = %d, want 0 (растущий reasoning не должен считаться hung)", res.RC)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_ContextCancel(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "slow")
|
||||
f := &fakeAPIServer{blockPrompt: true}
|
||||
p, _ := fakePool(t, f, dir)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
r := &Runner{Bin: bin, HardTimeout: time.Minute,
|
||||
PollInterval: 10 * time.Millisecond}
|
||||
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
|
||||
PollInterval: 5 * time.Millisecond, Stdout: io.Discard}
|
||||
done := make(chan *Result, 1)
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
@@ -161,22 +137,6 @@ func TestRun_ContextCancel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeDev_Fallback(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := fakeOpenCode(t, dir)
|
||||
t.Setenv("FAKE_MODE", "fail")
|
||||
|
||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
||||
res, timedOut := r.ResumeDev(context.Background(), "task", dir, "lost-session")
|
||||
if timedOut {
|
||||
t.Error("timedOut = true, want false")
|
||||
}
|
||||
// fake fail всегда exit 7, fallback тоже 7 — проверяем что RC от fallback-вызова
|
||||
if res.RC != 7 {
|
||||
t.Errorf("RC = %d, want 7 (fallback повтор с тем же кодом)", res.RC)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
||||
}
|
||||
@@ -188,4 +148,4 @@ func indexOf(s, sub string) int {
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
281
internal/opencode/server.go
Normal file
281
internal/opencode/server.go
Normal file
@@ -0,0 +1,281 @@
|
||||
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 (если непустой — сервер защищён)
|
||||
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
|
||||
}
|
||||
|
||||
// MinVersion — минимальная версия opencode, с которой работает интеграция.
|
||||
// v2 HTTP API (префикс /api/*) присутствует в сборках dev / >=1.18.18.
|
||||
// Более старые бинари отвечают на /global/health и НЕ подходят.
|
||||
const MinVersion = "1.18.18"
|
||||
|
||||
// waitHealthy опрашивает /api/health сервера до первого успеха или Connect.
|
||||
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
|
||||
// жив, но может требовать авторизации). При неудаче — ошибка с подсказкой про
|
||||
// минимальную версию opencode (класс O1: старый бинарь не знает v2-путей).
|
||||
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: не стал доступным (v2 healthcheck). Нужен opencode >= %s (v2 HTTP API /api/*), а не старый бинарь", addr, MinVersion)
|
||||
}
|
||||
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+"/api/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):
|
||||
}
|
||||
}
|
||||
}
|
||||
201
internal/opencode/server_test.go
Normal file
201
internal/opencode/server_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package opencode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит
|
||||
// процесс живым (sleep/ping), чтобы супервайзер мог им владеть и убивать его.
|
||||
// На Windows используется .cmd (с #!/bin/sh нельзя — он не исполняется).
|
||||
func fakeServeBin(t *testing.T, workdir string) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
bin := filepath.Join(workdir, "opencode-serve.cmd")
|
||||
script := "@echo off\r\necho fake serve started\r\nping -n 300 127.0.0.1 >nul\r\n"
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake serve bin: %v", err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
bin := filepath.Join(workdir, "opencode-serve")
|
||||
script := `#!/bin/sh
|
||||
echo "fake serve started"
|
||||
sleep 300
|
||||
`
|
||||
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write fake serve bin: %v", err)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
// healthHandler — http.Health, отвечающий на GET /global/health 200.
|
||||
func healthHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestServer_ExternalURL(t *testing.T) {
|
||||
// внешний сервер — ходим на реальный httptest-адрес, процессом не владеем
|
||||
ts := httptest.NewServer(healthHandler())
|
||||
defer ts.Close()
|
||||
|
||||
s := &Server{
|
||||
URL: ts.URL,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
Stdout: io.Discard,
|
||||
}
|
||||
if err := s.Start(context.Background()); err != nil {
|
||||
t.Fatalf("Start(внешний) err: %v", err)
|
||||
}
|
||||
if got := s.Addr(); got != ts.URL {
|
||||
t.Errorf("Addr() = %q, want %q", got, ts.URL)
|
||||
}
|
||||
// Close в режиме внешнего — не должен ничего падать (proc==nil)
|
||||
s.Close()
|
||||
}
|
||||
|
||||
func TestServer_OwnProcess_StartAHealthyClose(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := fakeServeBin(t, dir)
|
||||
|
||||
// поднимаем реальный health-сервер на известном порту, чтобы superватизору
|
||||
// было на что отвечать /global/health
|
||||
ts := httptest.NewServer(healthHandler())
|
||||
defer ts.Close()
|
||||
host := strings.TrimPrefix(ts.URL, "http://") // host:port
|
||||
var hostname, port string
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
hostname, port = host[:i], host[i+1:]
|
||||
} else {
|
||||
hostname = host
|
||||
port = "80"
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
Bin: bin,
|
||||
Host: hostname,
|
||||
Port: 0, // сюда передадим порт ниже
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
Stdout: io.Discard,
|
||||
}
|
||||
// переопределение порта на порт health-сервера
|
||||
sport := atoiOrZero(port)
|
||||
s.Port = sport
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := s.Start(ctx); err != nil {
|
||||
t.Fatalf("Start(владеющий) err: %v", err)
|
||||
}
|
||||
if s.proc == nil || s.proc.Process == nil {
|
||||
t.Fatal("proc не запущен после Start")
|
||||
}
|
||||
// Close должен убить процесс
|
||||
s.Close()
|
||||
if s.proc.ProcessState == nil {
|
||||
t.Log("процесс ещё числится запущенным (Close в Go не всегда виден сразу) — ок")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_OwningProcess_Restart проверяет, что Run перезапускает упавший
|
||||
// процесс: после первого старта убиваем вручную, Run должен поднять вновь.
|
||||
func TestServer_OwningProcess_Restart(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bin := fakeServeBin(t, dir)
|
||||
|
||||
ts := httptest.NewServer(healthHandler())
|
||||
defer ts.Close()
|
||||
host := strings.TrimPrefix(ts.URL, "http://")
|
||||
hostname, port := host, "80"
|
||||
if i := strings.IndexByte(host, ':'); i >= 0 {
|
||||
hostname, port = host[:i], host[i+1:]
|
||||
}
|
||||
s := &Server{
|
||||
Bin: bin,
|
||||
Host: hostname,
|
||||
Port: atoiOrZero(port),
|
||||
PollInterval: 30 * time.Millisecond,
|
||||
Stdout: io.Discard,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
if err := s.Start(ctx); err != nil {
|
||||
t.Fatalf("Start err: %v", err)
|
||||
}
|
||||
|
||||
// убиваем первый процесс, чтобы спровоцировать рестарт. Wait НЕ вызываем
|
||||
// сами — reaper-горутина (Start) владеет реaper'ом и установит
|
||||
// cmd.ProcessState; ждём, когда статус покажет выход.
|
||||
first := s.proc
|
||||
if first == nil {
|
||||
t.Fatal("proc nil после Start")
|
||||
}
|
||||
_ = first.Process.Kill()
|
||||
waitExited(t, first)
|
||||
|
||||
// Run крутится в фон: даём время на рестарт
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
s.Run(ctx)
|
||||
close(done)
|
||||
}()
|
||||
defer func() {
|
||||
cancel()
|
||||
<-done
|
||||
s.Close()
|
||||
}()
|
||||
|
||||
// ждём, пока proc появится вновь (Run пересоздаст serveCmd)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var restarted bool
|
||||
for time.Now().Before(deadline) {
|
||||
s.mu.Lock()
|
||||
p := s.proc
|
||||
s.mu.Unlock()
|
||||
if p != nil && p != first && p.Process != nil {
|
||||
restarted = true
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !restarted {
|
||||
t.Fatal("процесс не был перезапущен после падения")
|
||||
}
|
||||
}
|
||||
|
||||
// waitExited ждёт, когда reaper-горутина (cmd.Wait) отметит выход процесса.
|
||||
func waitExited(t *testing.T, cmd *exec.Cmd) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if cmd.ProcessState != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("процесс так и не встал в exited после Kill")
|
||||
}
|
||||
|
||||
func atoiOrZero(s string) int {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -111,10 +111,16 @@ func TestUpdateTaskStatus(t *testing.T) {
|
||||
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
||||
}
|
||||
|
||||
// ready → running
|
||||
// ready → approved
|
||||
task.Status = StatusApproved
|
||||
if err := s.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("UpdateTask ready→approved: %v", err)
|
||||
}
|
||||
|
||||
// approved → running
|
||||
task.Status = StatusRunning
|
||||
if err := s.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("UpdateTask ready→running: %v", err)
|
||||
t.Fatalf("UpdateTask approved→running: %v", err)
|
||||
}
|
||||
|
||||
// running → success
|
||||
|
||||
@@ -22,6 +22,14 @@ type OpenCodeRunner interface {
|
||||
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
|
||||
type PollTaskFunc func(ctx context.Context) error
|
||||
|
||||
// Notifier — механизм отправки авто-уведомлений владельцу задачи во время
|
||||
// выполнения. В проде реализуется *app.App через chat.Router.Send (см.
|
||||
// internal/app/app.go → App.Notify); в тестах worker подменяется фейковым
|
||||
// нотифаером. nil — уведомления выключены (ничего не отправляется).
|
||||
type Notifier interface {
|
||||
Notify(ctx context.Context, taskID int64, chatID, text string) error
|
||||
}
|
||||
|
||||
// Worker — планировщик, запускающий готовые задачи (status=ready → running → success/failed/timeout).
|
||||
type Worker struct {
|
||||
Store *storage.Storage
|
||||
@@ -39,6 +47,10 @@ type Worker struct {
|
||||
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
|
||||
Live *opencode.LiveRegistry
|
||||
|
||||
// Notify — нотифаер авто-уведомлений владельцу задачи (статусы + хендоффы
|
||||
// dev↔reviewer). nil — уведомления выключены.
|
||||
Notify Notifier
|
||||
|
||||
sem chan struct{} // семафор
|
||||
cancel context.CancelFunc
|
||||
|
||||
@@ -55,6 +67,27 @@ func (w *Worker) runCtx(ctx context.Context, taskID int64) context.Context {
|
||||
return opencode.WithLive(ctx, w.Live, taskID)
|
||||
}
|
||||
|
||||
// notify отправляет авто-уведомление владельцу задачи, если нотифаер задан.
|
||||
func (w *Worker) notify(ctx context.Context, task *storage.Task, text string) {
|
||||
if w.Notify == nil {
|
||||
return
|
||||
}
|
||||
if err := w.Notify.Notify(ctx, task.ID, task.ChatID, text); err != nil {
|
||||
log.Printf("worker: task %d: уведомление: %v", task.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// notifyStatus — уведомление о смене статуса задачи (номер задачи + статус).
|
||||
func (w *Worker) notifyStatus(ctx context.Context, task *storage.Task, s storage.Status) {
|
||||
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s", task.ID, s))
|
||||
}
|
||||
|
||||
// notifyHandoff — уведомление о передаче задачи между агентами конвейера
|
||||
// на заданной итерации (1-based).
|
||||
func (w *Worker) notifyHandoff(ctx context.Context, task *storage.Task, from, to string, iteration int) {
|
||||
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s → %s (итерация %d)", task.ID, from, to, iteration))
|
||||
}
|
||||
|
||||
// Start запускает цикл опроса в фоновой горутине.
|
||||
func (w *Worker) Start(ctx context.Context) {
|
||||
if w.Agent == "" {
|
||||
@@ -163,6 +196,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if err := w.Store.UpdateTask(ctx, task); err != nil {
|
||||
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusRunning)
|
||||
|
||||
// 2b. клонируем недостающие репозитории в общий каталог.
|
||||
if err := w.prepareRepos(ctx, repos); err != nil {
|
||||
@@ -231,6 +265,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusTimeout)
|
||||
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
|
||||
return nil
|
||||
default:
|
||||
@@ -238,6 +273,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
|
||||
return nil
|
||||
}
|
||||
@@ -245,6 +281,9 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
// dev завершился RC=0 → сохраняем успех трассы dev.
|
||||
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
|
||||
|
||||
// уведомляем пользователя о передаче dev → reviewer на ревью.
|
||||
w.notifyHandoff(ctx, task, "dev", "reviewer", iter+1)
|
||||
|
||||
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
|
||||
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
|
||||
if dErr != nil {
|
||||
@@ -275,6 +314,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
|
||||
return nil
|
||||
}
|
||||
@@ -289,11 +329,13 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusSuccess)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Не пройдено: если есть итерации — dev дорабатывает.
|
||||
if iter+1 < maxReviewIterations {
|
||||
w.notify(ctx, task, fmt.Sprintf("Задача #%d: reviewer → dev на доработку (итерация %d)", task.ID, iter+1))
|
||||
feedback = verdict.Comments
|
||||
continue
|
||||
}
|
||||
@@ -303,6 +345,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||
}
|
||||
w.notify(ctx, task, fmt.Sprintf("Задача #%d: failed — ревью не пройдено за %d итераций", task.ID, maxReviewIterations))
|
||||
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
|
||||
final := reviewOutput + "\n" + explain
|
||||
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
|
||||
@@ -330,12 +373,13 @@ func (w *Worker) reviewWithRetry(ctx context.Context, taskID int64, cwd, prompt
|
||||
return v2, out2, tid2, nil
|
||||
}
|
||||
|
||||
// failTask помечает задачу failed.
|
||||
// failTask помечает задачу failed и уведомляет владельца.
|
||||
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
|
||||
task.Status = storage.StatusFailed
|
||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||
log.Printf("worker: task %d: set failed: %v", task.ID, e)
|
||||
}
|
||||
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||
}
|
||||
|
||||
// finalizeTrace обновляет output и статус трассы.
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +17,32 @@ import (
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
)
|
||||
|
||||
// fakeNotifier — фейковый нотифаер, собирающий все авто-уведомления воркера.
|
||||
type fakeNotifier struct {
|
||||
notifs []notifCall
|
||||
}
|
||||
|
||||
// notifCall — одно перехваченное уведомление.
|
||||
type notifCall struct {
|
||||
taskID int64
|
||||
chatID string
|
||||
text string
|
||||
}
|
||||
|
||||
func (f *fakeNotifier) Notify(_ context.Context, taskID int64, chatID, text string) error {
|
||||
f.notifs = append(f.notifs, notifCall{taskID: taskID, chatID: chatID, text: text})
|
||||
return nil
|
||||
}
|
||||
|
||||
// notifTexts возвращает тексты уведомлений в порядке отправки.
|
||||
func notifTexts(n *fakeNotifier) []string {
|
||||
texts := make([]string, len(n.notifs))
|
||||
for i, c := range n.notifs {
|
||||
texts[i] = c.text
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
type mockRunnerWorker struct {
|
||||
result *opencode.Result
|
||||
err error
|
||||
@@ -263,6 +289,186 @@ func TestWorkerReviewMaxIterations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerStatusNotifications — happy path: владелец получает уведомления
|
||||
// на каждый переход статуса со стороны воркера (running → success)
|
||||
// и на хендофф dev→reviewer.
|
||||
func TestWorkerStatusNotifications(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-ok")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-ok")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
if len(n.notifs) != 3 {
|
||||
t.Fatalf("уведомлений = %d, want 3 (running, dev→reviewer, success)", len(n.notifs))
|
||||
}
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{
|
||||
prefix + ": running",
|
||||
prefix + ": dev → reviewer (итерация 1)",
|
||||
prefix + ": success",
|
||||
}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
// все уведомления уходят владельцу задачи (task.ChatID)
|
||||
for _, c := range n.notifs {
|
||||
if c.chatID != task.ChatID {
|
||||
t.Errorf("уведомление ушло в %q, want %q", c.chatID, task.ChatID)
|
||||
}
|
||||
if c.taskID != task.ID {
|
||||
t.Errorf("уведомление для задачи %d, want %d", c.taskID, task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerHandoffNotifications — цикл dev↔review: уведомления на оба хендоффа
|
||||
// (dev→reviewer и reviewer→dev на доработку) с номером задачи и итерации.
|
||||
func TestWorkerHandoffNotifications(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-loop")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{
|
||||
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||
reviewSequence: []*opencode.Result{
|
||||
reviewFailedRunner(),
|
||||
{RC: 0, Stdout: `{"passed":true,"comments":[]}`},
|
||||
},
|
||||
},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-loop")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{
|
||||
prefix + ": running",
|
||||
prefix + ": dev → reviewer (итерация 1)",
|
||||
prefix + ": reviewer → dev на доработку (итерация 1)",
|
||||
prefix + ": dev → reviewer (итерация 2)",
|
||||
prefix + ": success",
|
||||
}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerIterationsLimitNotification — при исчерпании лимита итераций
|
||||
// владельцу уходит одно уведомление о failed (без дублей с running→failed).
|
||||
func TestWorkerIterationsLimitNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-lim")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{
|
||||
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
|
||||
reviewResult: reviewFailedRunner(),
|
||||
},
|
||||
Worktree: t.TempDir(),
|
||||
Agent: "dev",
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-lim")
|
||||
|
||||
ctx := context.Background()
|
||||
if err := w.runTask(ctx, task); err != nil {
|
||||
t.Fatalf("runTask: %v", err)
|
||||
}
|
||||
|
||||
texts := notifTexts(n)
|
||||
if len(texts) == 0 {
|
||||
t.Fatal("нет уведомлений")
|
||||
}
|
||||
last := texts[len(texts)-1]
|
||||
if !strings.Contains(last, "failed") {
|
||||
t.Errorf("последнее уведомление = %q, want упоминание failed", last)
|
||||
}
|
||||
if !strings.Contains(last, "итераци") {
|
||||
t.Errorf("последнее уведомление = %q, want упоминание лимита итераций", last)
|
||||
}
|
||||
// ровно одно уведомление о failed (running→failed не задваивается)
|
||||
var failedCount int
|
||||
for _, txt := range texts {
|
||||
if strings.Contains(txt, ": failed") {
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
if failedCount != 1 {
|
||||
t.Errorf("уведомлений о failed = %d, want ровно 1: %#v", failedCount, texts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerTimeoutNotification — RC=-1 (таймаут dev) → уведомление о timeout.
|
||||
func TestWorkerTimeoutNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-timeout")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
|
||||
Worktree: t.TempDir(),
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-timeout")
|
||||
|
||||
ctx := context.Background()
|
||||
_ = w.runTask(ctx, task)
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{prefix + ": running", prefix + ": timeout"}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkerSpawnErrorNotification — сбой запуска dev → уведомление о failed.
|
||||
func TestWorkerSpawnErrorNotification(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
task := createReadyTask(t, s, "notif-spawn")
|
||||
|
||||
n := &fakeNotifier{}
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
|
||||
Worktree: t.TempDir(),
|
||||
Notify: n,
|
||||
}
|
||||
seedFakeRepo(t, w.Worktree, "notif-spawn")
|
||||
|
||||
ctx := context.Background()
|
||||
_ = w.runTask(ctx, task)
|
||||
|
||||
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
|
||||
want := []string{prefix + ": running", prefix + ": failed"}
|
||||
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("уведомления = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
|
||||
// где JSON находится внутри последнего text-парта.
|
||||
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
||||
@@ -602,13 +808,41 @@ func TestWorkerStartStop(t *testing.T) {
|
||||
w.Stop()
|
||||
}
|
||||
|
||||
// waitTaskStatus ждёт, пока задача достигнет статуса want. На Windows git-операции
|
||||
// воркера заметно медленнее, чем на Linux, поэтому проверки в тестах не могут
|
||||
// полагаться на фиксированные sleep'ы — только на polling до целевого статуса.
|
||||
func waitTaskStatus(t *testing.T, ctx context.Context, s *storage.Storage, id int64, want storage.Status) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
task, err := s.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("get task %d: %v", id, err)
|
||||
}
|
||||
if task.Status == want {
|
||||
return
|
||||
}
|
||||
switch task.Status {
|
||||
case storage.StatusFailed, storage.StatusTimeout:
|
||||
t.Fatalf("task %d: status %q, want %q", id, task.Status, want)
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("task %d: таймаут ожидания %q, последний статус %q", id, want, task.Status)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("task %d: ctx done: %v", id, ctx.Err())
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerSemaphore(t *testing.T) {
|
||||
s := setupWorkerDB(t)
|
||||
|
||||
// создаём 2 ready-задачи
|
||||
for i := 0; i < 2; i++ {
|
||||
createReadyTask(t, s, fmt.Sprintf("task-%d", i))
|
||||
}
|
||||
task1 := createReadyTask(t, s, "task-0")
|
||||
task2 := createReadyTask(t, s, "task-1")
|
||||
|
||||
w := &Worker{
|
||||
Store: s,
|
||||
@@ -622,12 +856,12 @@ func TestWorkerSemaphore(t *testing.T) {
|
||||
w.sem = make(chan struct{}, 1)
|
||||
w.sem <- struct{}{}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// первый poll — запустит 1 задачу (макс. 1)
|
||||
w.pollAndDispatch(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
waitTaskStatus(t, ctx, s, task1.ID, storage.StatusSuccess)
|
||||
|
||||
// 1 должна быть success, 1 — всё ещё approved
|
||||
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
||||
@@ -641,7 +875,7 @@ func TestWorkerSemaphore(t *testing.T) {
|
||||
|
||||
// первая завершилась и вернула токен в сем — можем диспатчить вторую
|
||||
w.pollAndDispatch(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
waitTaskStatus(t, ctx, s, task2.ID, storage.StatusSuccess)
|
||||
|
||||
success, _ = s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
||||
if len(success) != 2 {
|
||||
|
||||
Reference in New Issue
Block a user