Compare commits
16 Commits
feat/48f52
...
feat/83bf5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1459670ce9 | ||
|
|
7eb3a0292c | ||
|
|
a64e3d6cc3 | ||
|
|
774ebf135a | ||
|
|
e1167c9537 | ||
|
|
ad9c2dd522 | ||
|
|
f77a6000c7 | ||
|
|
ae00556923 | ||
|
|
baf7179085 | ||
|
|
733e63339a | ||
|
|
b60978121d | ||
|
|
2f26b6ae88 | ||
| ed11879cbd | |||
|
|
631387fda7 | ||
| 18b48bfdc4 | |||
|
|
7c7afa6875 |
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
/cache
|
||||||
|
/project.local.yml
|
||||||
39
.serena/memories/conventions.md
Normal file
39
.serena/memories/conventions.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# conventions
|
||||||
|
|
||||||
|
## Стиль / кодстайл
|
||||||
|
- Стандартный Go-стиль; гофм `gofmt`/`go fmt ./...`. Документация-комментарии и
|
||||||
|
package-doc на русском языке (в START-комментариях файлов и doc-комментариях).
|
||||||
|
- Типизация: строгие типы, интерфейсы для абстракций (Decider, LiveProber).
|
||||||
|
- Свой тип `config.Duration` для времени (YAML-строки "5s"/"10m"), метод `.Duration()`.
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
- Ошибки классифицируются по идентификаторам классов в исходниках (см. ниже).
|
||||||
|
- Ошибка-обёртка: `fmt.Errorf("%w: %v", ErrXxx, err)`.
|
||||||
|
- `errors.Join` для склейки нескольких ошибок валидации (config.Validate).
|
||||||
|
|
||||||
|
## Классы ошибок (маркируются в коде, документированы в README)
|
||||||
|
| Блок | Коды | Где |
|
||||||
|
|---|---|---|
|
||||||
|
| C | C1–C4 | internal/config |
|
||||||
|
| A | A1–A4 | internal/analyst |
|
||||||
|
| M | M1–M5 | internal/chat |
|
||||||
|
| O | O1–O4 | internal/opencode |
|
||||||
|
| S | S1–S5 | internal/storage |
|
||||||
|
| W | W1–W5 | internal/worker |
|
||||||
|
| E | E1–E4 | internal/worker (репозитории) |
|
||||||
|
| R | R1–R6 | internal/worker/review_errors.go |
|
||||||
|
| U | U1–U6 | internal/update |
|
||||||
|
|
||||||
|
## Архитектурные конвенции
|
||||||
|
- **Composition root** — internal/app; подсистемы собираются там, лимиты Core
|
||||||
|
(MaxTurns=15, MaxConfirmCycles=3, MaxQuestionsPerTurn=5) в core.New.
|
||||||
|
- **Агенты** (analyst.md/dev.md/reviewer.md) — markdown-промпты, встроены через go:embed
|
||||||
|
(internal/agents/*.md + embed.go), распаковываются в config_dir. Вердикт — строгий JSON.
|
||||||
|
- Репо-клонирование/воркеры — через gitops; ветки задач `feat/<taskTag>` (см. mem:core).
|
||||||
|
- Обратная совместимость: поле `Repo` (одиночный) и `Repos` (список); EffectiveRepos/
|
||||||
|
SetReposFromDB/ReposJoined в storage/models.go.
|
||||||
|
|
||||||
|
## Версии
|
||||||
|
- `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы,
|
||||||
|
minor=новая обратно-совместимая функциональность, major=несовместимые изменения).
|
||||||
|
- `main.version` (ldflag) — build-идентификатор `commit-<sha7>`, отдельно от app.Version.
|
||||||
51
.serena/memories/core.md
Normal file
51
.serena/memories/core.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# core
|
||||||
|
|
||||||
|
Ratatoskr-go — оркестратор конвейера Ratatoskr (порт с Python на Go) в единый
|
||||||
|
статический бинарь (CGO_ENABLED=0). Субагенты запускаются через внешний процесс
|
||||||
|
[opencode](https://opencode.ai). Взаимодействие — Telegram-бот.
|
||||||
|
|
||||||
|
## Структура (модули internal/)
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/ratatoskr/ точка входа, сборка бинаря; main.version и main.updateToken вшиваются ldflag'ом
|
||||||
|
internal/
|
||||||
|
app/ composition root/DI: App.New -> config.Load+Validate, ResolveExePaths, storage, Runner, Analyst, Core, Worker, Updater. packageOwner="kamelion", Version="0.1.0"
|
||||||
|
config/ YAML+env загрузка (${VAR:-default}), defaults, validate C1-C4
|
||||||
|
chat/ мультиканальный Router; telegram — long-poll канал. Коды M1-M5
|
||||||
|
core/ state-machine задач + Decider/analyst интерфейс. Коды D3/D4
|
||||||
|
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
|
||||||
|
storage/ SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history. Коды S1-S5
|
||||||
|
update/ автообновление из Gitea Packages. Коды U1-U6
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ключевые инварианты
|
||||||
|
|
||||||
|
- **Команды/ядро:** Core.ProcessTurn — state-machine поверх storage. Команды:
|
||||||
|
/start /cancel /skip /retry N /status N /continue N. Активная задача — одна на чат.
|
||||||
|
- **Фазы аналитика (Decision.Phase):** `ask` (уточняющие вопросы), `propose` (правки
|
||||||
|
черновика), `ready` (черновик полон как есть), `abort` (тема вне проекта). propose и ready
|
||||||
|
в core обрабатываются одинаково.
|
||||||
|
- **Статусы задач:** draft→collecting→ready→approved→running→success/failed/timeout,
|
||||||
|
плюс cancelled/aborted/closed. approved — финальное одобрение («создавай»), после чего
|
||||||
|
воркер берёт задачу. IsValidTransition/IsTerminal в storage/models.go.
|
||||||
|
- **Decider/Worker/Analyst/Reviewer:** Decider=analyst интерфейс (analyst пакет реализует);
|
||||||
|
Worker — polling-планировщик dev-агента; Reviewer проверяет diff dev-ветки (R1-R6),
|
||||||
|
вердикт JSON {passed, critical_issues, solid_violations, comments}.
|
||||||
|
- **gitops (worker):** worker работает с worktrees; feature-ветка = `feat/<taskTag>` от
|
||||||
|
origin/main; ensureBranch (reset --hard + clean -fd + checkout -B), branchDiff
|
||||||
|
(`origin/main...<branch>`), push через http.extraHeader, токен как Bearer.
|
||||||
|
- **Пути «всё рядом с .exe»:** относительные db/worktree резолвятся от каталога бинаря
|
||||||
|
(ExeDir), не от cwd. config.yaml ищутся рядом с бинарём, фоллбэк cwd.
|
||||||
|
- **Автообновление:** авто = только Check+уведомление; замена — по /update. Версии в
|
||||||
|
Gitea Packages `commit-<sha7>/` (не `latest/`). Вердикты агентов — строгий JSON.
|
||||||
|
|
||||||
|
## Контракты (не ломать)
|
||||||
|
|
||||||
|
- `App.New(configPath, version, updateToken string)` — сигнатура.
|
||||||
|
- `packageOwner` — константа "kamelion" (не плодить vars/ldflag/конфиг).
|
||||||
|
- `update.Updater` — создаётся структурой `&update.Updater{...}`, конструктора нет.
|
||||||
|
|
||||||
|
См. также `mem:tech_stack`, `mem:conventions`, `mem:task_completion`, `mem:suggested_commands`.
|
||||||
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Memory Maintenance
|
||||||
|
|
||||||
|
## Discovery Model
|
||||||
|
|
||||||
|
- Core principle: progressive discovery through references, building a graph of memories.
|
||||||
|
- Initially, agents are provided with the list of all memories (names only).
|
||||||
|
- Agents should read `mem:core` as the top-level entry point (graph root).
|
||||||
|
This memory should contain references to other memories covering major project domains.
|
||||||
|
The referenced memories shall, in turn, shall contain references to even more specific memories, and so on.
|
||||||
|
The depth of the graph shall depend on the project complexity.
|
||||||
|
- Use topics/folders to group related memories in order to make the content structure explicit.
|
||||||
|
Folders can mirror project structure (e.g. modules like frontend/backend) or topics like debugging, architecture, etc.
|
||||||
|
- Memory references must use a mem: prefix inside backticks, e.g. `mem:frontend/core`.
|
||||||
|
The surrounding text should clearly indicate when to read the memory/which content to expect.
|
||||||
|
The text should provide more precise guidance than the memory name alone,
|
||||||
|
i.e. avoid a reference like "frontend debugging: `mem:frontend/debugging` and instead make clear which aspects of frontend debugging are covered.
|
||||||
|
- Memories themselves should not contain information about when to read them; this is the responsibility of the referring memory.
|
||||||
|
|
||||||
|
## Style
|
||||||
|
|
||||||
|
Dense agent notes, not prose docs. Prefer invariants, terse bullets.
|
||||||
|
Avoid obvious context, rationale, and examples unless they prevent likely mistakes.
|
||||||
|
Keep guidance durable and generalizable, not task-local.
|
||||||
|
|
||||||
|
## Add/update threshold
|
||||||
|
|
||||||
|
Add or update memories only with stable, non-obvious project conventions that avoid complex rediscovery in the future.
|
||||||
|
Do not add: quick-read facts; generic language/framework knowledge; one-off task notes; volatile line-level details; behavior likely to change soon.
|
||||||
|
|
||||||
|
## Maintenance Actions
|
||||||
|
|
||||||
|
- Renaming memories: References are updated automatically if handled via Serena's memory rename tool.
|
||||||
|
- Checking for stale memories (e.g. after deletion): Call `serena memories check` for a report.
|
||||||
27
.serena/memories/suggested_commands.md
Normal file
27
.serena/memories/suggested_commands.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# suggested_commands
|
||||||
|
|
||||||
|
## Сборка / проверки (из корня репо)
|
||||||
|
- `go test ./... -v -count=1 -timeout 120s` (или `make test`) — все тесты.
|
||||||
|
- `go vet ./...` (или `make vet`).
|
||||||
|
- `go fmt ./...` (или `make fmt`).
|
||||||
|
- `go build -o ratatoskr ./cmd/ratatoskr/` (или `make build`).
|
||||||
|
- `./ratatoskr -config config.yaml` (или `make run`).
|
||||||
|
- `./ratatoskr -version` — показать версию бинаря.
|
||||||
|
- Кросс-сборка: `make cross` (linux/amd64 + windows/amd64).
|
||||||
|
|
||||||
|
## Go-тулчейн
|
||||||
|
Хост без `go` в PATH — экспорт вручную:
|
||||||
|
```
|
||||||
|
export PATH=/opt/data/.local/go/bin:$PATH
|
||||||
|
```
|
||||||
|
На машине разработки (Windows, cmd) — обычный system Go.
|
||||||
|
|
||||||
|
## git (worktree-процесс Ratatoskr)
|
||||||
|
- Feature-ветка: `feat/<taskTag>`, база — `origin/main`. Пример проверки diff всей ветки:
|
||||||
|
`git diff origin/main...HEAD`.
|
||||||
|
- Проверить состав отслеживаемых файлов (например наличие .serena): `git ls-files`.
|
||||||
|
- Статус: `git status`. Лог: `git log --oneline -10`.
|
||||||
|
|
||||||
|
## Замечания про среду
|
||||||
|
- ОС Windows + PowerShell 5.1 (shell: powershell) — команды собирать/запускать с учётом
|
||||||
|
этом (нет `&&`; использовать `;`/`if ($?) {}`; & для путей с пробелами).
|
||||||
15
.serena/memories/task_completion.md
Normal file
15
.serena/memories/task_completion.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# task_completion
|
||||||
|
|
||||||
|
Считается, что задача по коду выполнена строго после:
|
||||||
|
|
||||||
|
1. **Формат/линт:** `go fmt ./...` (без неотформатированных файлов).
|
||||||
|
2. **Тесты:** `go test ./... -v -count=1 -timeout 120s` — все проходят (`make test`).
|
||||||
|
3. **Статический анализ:** `go vet ./...` — чисто (`make vet`).
|
||||||
|
4. **Сборка:** `go build -o ratatoskr ./cmd/ratatoskr/` (`make build`) — компилируется.
|
||||||
|
(Кросс-сборка `make cross` — только при необходимости.)
|
||||||
|
5. **Пересмотр контрактов:** если менялась сигнатура `App.New(configPath, version,
|
||||||
|
updateToken string)` — обновить вызовы в `internal/app/app_test.go` (иначе go vet падает).
|
||||||
|
6. Коммит осмысленными атомарными коммитами в feature-ветку `feat/<taskTag>` от origin/main.
|
||||||
|
|
||||||
|
Рабочий процесс Ratatoskr (агент dev в этом конвейере): изучить код, реализовать так, чтобы
|
||||||
|
все acceptance criteria были закрыты, закоммитить в ветку, вернуть отчёт.
|
||||||
30
.serena/memories/tech_stack.md
Normal file
30
.serena/memories/tech_stack.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# tech_stack
|
||||||
|
|
||||||
|
## Язык / рантайм
|
||||||
|
- Go **1.25.0** (go.mod `go 1.25.0`). Модуль `github.com/kamelion/ratatoskr-go`.
|
||||||
|
- Сборка: статический бинарь, `CGO_ENABLED=0`. Локальный Go-тулчейн на хосте:
|
||||||
|
`export PATH=/opt/data/.local/go/bin:$PATH` (go1.25.0 linux/amd64).
|
||||||
|
|
||||||
|
## Основные зависимости (go.mod)
|
||||||
|
- `gopkg.in/yaml.v3 v3.0.1` — парсинг config.yaml.
|
||||||
|
- `modernc.org/sqlite v1.56.0` — SQLite без CGO (чистый Go).
|
||||||
|
- (indirect) google/uuid, go-humanize, mattn/go-isatty, x/sys, modernc.org/libc/mathutil/memory.
|
||||||
|
|
||||||
|
## Внешние процессы
|
||||||
|
- **opencode** (opencode.ai) — внешний процесс для субагентов (analyst/dev/reviewer).
|
||||||
|
Управляется через internal/opencode (Runner, LiveRegistry). Настраивается в конфиге
|
||||||
|
(opencode.bin / config / config_dir / hard_timeout / idle_timeout / poll_ms).
|
||||||
|
|
||||||
|
## Сборка / Makefile
|
||||||
|
- `make build` — go build -ldflags="-s -w -X main.version=commit-<sha7> -X main.updateToken=..." -o ratatoskr ./cmd/ratatoskr/.
|
||||||
|
- `make test` — go test ./... -v -count=1 -timeout 120s.
|
||||||
|
- `make vet` — go vet ./... `make fmt` — go fmt ./...
|
||||||
|
- `make run` — build + ./ratatoskr -config config.yaml.
|
||||||
|
- `make cross` — кросс-сборка linux/amd64 + windows/amd64 (ratatoskr-windows-amd64.exe — Windows-машина Камиля).
|
||||||
|
- GIT_SHA вшивается в main.version; UPDATE_TOKEN — в main.updateToken (секрет только у CI).
|
||||||
|
|
||||||
|
## CI (.gitea/workflows/ci.yaml)
|
||||||
|
- Job `test`: go test ./... + go vet ./....
|
||||||
|
- Job `build-and-package` (matrix linux/amd64+windows/amd64): собирает и публикует в
|
||||||
|
Gitea Packages на `main` в версию `commit-<sha7>/` + companion-файлы `.version`/`.sha256`.
|
||||||
|
- Секреты: TC_GITEA_TOKEN (write:packages), TC_UPDATE_TOKEN (read:package), GIT_MAIN_URL.
|
||||||
133
.serena/project.yml
Normal file
133
.serena/project.yml
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
# the name by which the project can be referenced within Serena
|
||||||
|
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"
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# The language backend to use for this project.
|
||||||
|
# If not set, the global setting from serena_config.yml is used.
|
||||||
|
# Valid values: LSP, JetBrains
|
||||||
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
|
# is activated post-init, an error will be returned.
|
||||||
|
language_backend:
|
||||||
|
|
||||||
|
# whether to use project's .gitignore files to ignore files
|
||||||
|
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.
|
||||||
|
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 **.
|
||||||
|
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude.
|
||||||
|
# This extends the existing exclusions (e.g. from the global configuration)
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
||||||
|
# This extends the existing inclusions (e.g. from the global configuration).
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
included_optional_tools: []
|
||||||
|
|
||||||
|
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||||
|
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||||
|
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
||||||
|
fixed_tools: []
|
||||||
|
|
||||||
|
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
||||||
|
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||||
|
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
||||||
|
# for this project.
|
||||||
|
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
default_modes:
|
||||||
|
|
||||||
|
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
||||||
|
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
||||||
|
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
||||||
|
added_modes:
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||||
|
# such as docstrings or parameter information.
|
||||||
|
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||||
|
# If null or missing, use the setting from the global configuration.
|
||||||
|
symbol_info_budget:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|
||||||
|
# list of regex patterns for memories to completely ignore.
|
||||||
|
# Matching memories will not appear in list_memories or activate_project output
|
||||||
|
# and cannot be accessed via read_memory or write_memory.
|
||||||
|
# To access ignored memory files, use the read_file tool on the raw file path.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
# Example: ["_archive/.*", "_episodes/.*"]
|
||||||
|
ignored_memory_patterns: []
|
||||||
@@ -33,4 +33,8 @@ telegram:
|
|||||||
|
|
||||||
# paths:
|
# paths:
|
||||||
# worktree: "./worktrees"
|
# 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>),
|
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
|
||||||
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
|
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
|
||||||
// поднимается вручную перед каждым релизом/публикацией новой сборки.
|
// поднимается вручную перед каждым релизом/публикацией новой сборки.
|
||||||
const Version = "0.1.0"
|
const Version = "0.2.2"
|
||||||
|
|
||||||
// App — собранный конвейер.
|
// App — собранный конвейер.
|
||||||
type App struct {
|
type App struct {
|
||||||
@@ -60,6 +60,7 @@ type App struct {
|
|||||||
Worker *worker.Worker
|
Worker *worker.Worker
|
||||||
Updater *update.Updater
|
Updater *update.Updater
|
||||||
tg *telegram.Channel // сохранена для Run
|
tg *telegram.Channel // сохранена для Run
|
||||||
|
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
|
||||||
}
|
}
|
||||||
|
|
||||||
// New читает конфиг и собирает все зависимости.
|
// New читает конфиг и собирает все зависимости.
|
||||||
@@ -102,15 +103,24 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
}
|
}
|
||||||
log.Printf("app: db opened %s", cfg.Paths.DB)
|
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 — один на аналитика и воркер
|
// OpenCode runner — один на аналитика и воркер
|
||||||
ocRunner := &opencode.Runner{
|
ocRunner := &opencode.Runner{
|
||||||
Bin: cfg.OpenCode.Bin,
|
Pool: ocPool,
|
||||||
DBPath: cfg.OpenCode.DBPath,
|
|
||||||
Config: cfg.OpenCode.Config,
|
|
||||||
ConfigDir: cfg.OpenCode.ConfigDir,
|
|
||||||
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
|
||||||
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
|
||||||
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
PollInterval: cfg.OpenCode.PollMs.Duration(),
|
||||||
|
Debug: cfg.Log.Debug(),
|
||||||
Stdout: os.Stderr,
|
Stdout: os.Stderr,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +142,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
Config: cfg,
|
Config: cfg,
|
||||||
Store: store,
|
Store: store,
|
||||||
CoreCtx: coreCtx,
|
CoreCtx: coreCtx,
|
||||||
|
pool: ocPool,
|
||||||
}
|
}
|
||||||
router := chat.NewRouter(a.handleIncoming)
|
router := chat.NewRouter(a.handleIncoming)
|
||||||
|
|
||||||
@@ -185,6 +196,19 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
defer cancel()
|
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)
|
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
|
||||||
tgErr := make(chan error, 1)
|
tgErr := make(chan error, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,22 @@ package app
|
|||||||
// Worker.runTask: dev → reviewer → настоящий git push → success
|
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||||
//
|
//
|
||||||
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||||
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
|
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
|
||||||
// возвращая NDJSON-вердикты нужного формата для каждого.
|
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
|
||||||
|
// вердикты возвращаются как text-части assistant-сообщений.
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -31,63 +35,81 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/worker"
|
"github.com/kamelion/ratatoskr-go/internal/worker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
|
// вердикты фейкового агента по имени.
|
||||||
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
|
var (
|
||||||
// представление JSON-вердикта агента (как это делает реальный opencode).
|
e2eAgentVerdicts = map[string]string{
|
||||||
func ndjsonText(t *testing.T, payload string) string {
|
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
|
||||||
t.Helper()
|
"dev": `done`,
|
||||||
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
|
"reviewer": `{"passed":true,"comments":[]}`,
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("json.Marshal payload: %v", err)
|
|
||||||
}
|
}
|
||||||
return `{"type":"text","part":{"text":` + string(b) + `}}`
|
)
|
||||||
|
|
||||||
|
// e2eFakeAPI поднимает фейковый opencode serve experimental HTTP API (пути
|
||||||
|
// БЕЗ /api) и возвращает URL. По title сессии (ratatoskr-<agent>) определяет
|
||||||
|
// агента и возвращает его вердикт как text-часть ответа на POST /message.
|
||||||
|
func e2eFakeAPI(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
sessions := map[string]string{} // id → agent
|
||||||
|
|
||||||
|
verdictFor := func(agent string) string {
|
||||||
|
if v, ok := e2eAgentVerdicts[agent]; ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return "unknown agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/session":
|
||||||
|
var req struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
agent := strings.TrimPrefix(req.Title, "ratatoskr-")
|
||||||
|
mu.Lock()
|
||||||
|
id := fmt.Sprintf("e2e-%d", len(sessions)+1)
|
||||||
|
sessions[id] = agent
|
||||||
|
mu.Unlock()
|
||||||
|
// experimental: голая Session, id напрямую.
|
||||||
|
writeJSON(w, map[string]any{"id": id, "agent": agent, "model": map[string]any{"id": "m"}})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/message"):
|
||||||
|
// блокирующий ответ: вердикт как text-часть.
|
||||||
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/session/"), "/message")
|
||||||
|
mu.Lock()
|
||||||
|
agent := sessions[id]
|
||||||
|
mu.Unlock()
|
||||||
|
writeJSON(w, map[string]any{"info": map[string]any{"role": "assistant"}, "parts": []map[string]any{{"type": "text", "text": verdictFor(agent)}}})
|
||||||
|
|
||||||
|
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/message"):
|
||||||
|
// поллинг прогресса: голый массив [{info, parts}].
|
||||||
|
id := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/session/"), "/message")
|
||||||
|
mu.Lock()
|
||||||
|
agent := sessions[id]
|
||||||
|
mu.Unlock()
|
||||||
|
writeJSON(w, []map[string]any{{"info": map[string]any{"role": "assistant"}, "parts": []map[string]any{{"type": "text", "text": verdictFor(agent)}}}})
|
||||||
|
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/abort"):
|
||||||
|
writeJSON(w, map[string]any{"ok": true})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return srv.URL
|
||||||
}
|
}
|
||||||
|
|
||||||
// e2eFakeOpenCode пишет shell-скрипт, имитирующий opencode run.
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
// Различает агента по argv ($3 = имя агента после "--agent").
|
w.Header().Set("Content-Type", "application/json")
|
||||||
//
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
|
|
||||||
// dev → простой NDJSON "done"
|
|
||||||
// reviewer→ NDJSON c {"passed":true} в text-парте
|
|
||||||
func e2eFakeOpenCode(t *testing.T, dir string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
return bin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
||||||
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
|
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
|
||||||
// каталог worktree и fake-канал (для проверки исходящих).
|
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
|
||||||
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -100,13 +122,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { store.Close() })
|
t.Cleanup(func() { store.Close() })
|
||||||
|
|
||||||
bin := e2eFakeOpenCode(t, dir)
|
fakeURL := e2eFakeAPI(t)
|
||||||
|
pool := opencode.NewPool(worktree)
|
||||||
|
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
|
||||||
runner := &opencode.Runner{
|
runner := &opencode.Runner{
|
||||||
Bin: bin,
|
Pool: pool,
|
||||||
PollInterval: 20 * time.Millisecond,
|
PollInterval: 5 * time.Millisecond,
|
||||||
IdleTimeout: 5 * time.Second,
|
IdleTimeout: 5 * time.Second,
|
||||||
HardTimeout: 30 * time.Second,
|
HardTimeout: 30 * time.Second,
|
||||||
}
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
||||||
coreCtx := core.New(store, an)
|
coreCtx := core.New(store, an)
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ type Router struct {
|
|||||||
|
|
||||||
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
|
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
|
||||||
onUserMsg func(Incoming)
|
onUserMsg func(Incoming)
|
||||||
|
|
||||||
|
// Асинхронная обработка входящих: handleIncoming кладёт событие в канал,
|
||||||
|
// воркер-горутина последовательно вызывает onUserMsg. Благодаря этому
|
||||||
|
// long-poll цикл канала (Telegram) не блокируется на время долгого
|
||||||
|
// вызова аналитика и продолжает принимать новые сообщения.
|
||||||
|
incoming chan Incoming
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
|
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
|
||||||
@@ -25,11 +31,21 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
|
|||||||
if onUserMsg == nil {
|
if onUserMsg == nil {
|
||||||
onUserMsg = func(Incoming) {}
|
onUserMsg = func(Incoming) {}
|
||||||
}
|
}
|
||||||
return &Router{
|
r := &Router{
|
||||||
sessions: map[UserID]any{},
|
sessions: map[UserID]any{},
|
||||||
routes: map[UserID]Route{},
|
routes: map[UserID]Route{},
|
||||||
pending: map[UserID]PendingQ{},
|
pending: map[UserID]PendingQ{},
|
||||||
onUserMsg: onUserMsg,
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +75,10 @@ func (r *Router) handleIncoming(inc Incoming) {
|
|||||||
delete(r.pending, inc.UserID)
|
delete(r.pending, inc.UserID)
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
r.onUserMsg(inc)
|
|
||||||
|
// Асинхронная обработка: кладём событие в очередь воркера и сразу
|
||||||
|
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
|
||||||
|
r.incoming <- inc
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,
|
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package chat
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -13,13 +15,58 @@ const (
|
|||||||
tui Address = "tui://local"
|
tui Address = "tui://local"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOnMsg — тест-колбэк, копящий входящие.
|
// fakeOnMsg — тест-колбэк, копящий входящие. Т.к. Router теперь обрабатывает
|
||||||
type fakeOnMsg struct{ got []Incoming }
|
// входящие асинхронно (воркер-горутина), доступ потокобезопасный, а ожидание
|
||||||
|
// нужного числа сообщений — через 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) {
|
func TestRouter_AttachAndIncoming(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
|
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
@@ -28,10 +75,10 @@ func TestRouter_AttachAndIncoming(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tgCh.emit(uidA, tg, "привет")
|
tgCh.emit(uidA, tg, "привет")
|
||||||
if len(cb.got) != 1 {
|
if !cb.wait(1) {
|
||||||
t.Fatalf("handler got %d, want 1", len(cb.got))
|
t.Fatal("handler не получил входящее за таймаут")
|
||||||
}
|
}
|
||||||
got := cb.got[0]
|
got := cb.get(0)
|
||||||
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
|
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
|
||||||
t.Errorf("incoming = %+v", got)
|
t.Errorf("incoming = %+v", got)
|
||||||
}
|
}
|
||||||
@@ -53,12 +100,15 @@ func TestRouter_Send_NoRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
|
|
||||||
_ = r.Attach(tgCh)
|
_ = r.Attach(tgCh)
|
||||||
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
|
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
|
||||||
|
if !cb.wait(1) {
|
||||||
|
t.Fatal("маршрут не установился за таймаут")
|
||||||
|
}
|
||||||
|
|
||||||
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
|
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
|
||||||
t.Fatalf("Send: %v", err)
|
t.Fatalf("Send: %v", err)
|
||||||
@@ -72,7 +122,7 @@ func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
tuiCh := newFakeChannel(tui)
|
tuiCh := newFakeChannel(tui)
|
||||||
@@ -83,6 +133,9 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
|||||||
tgCh.emit(uidA, tg, "hi")
|
tgCh.emit(uidA, tg, "hi")
|
||||||
// продолжил в GUI
|
// продолжил в GUI
|
||||||
tuiCh.emit(uidA, tui, "продолжаю тут")
|
tuiCh.emit(uidA, tui, "продолжаю тут")
|
||||||
|
if !cb.wait(2) {
|
||||||
|
t.Fatal("входящие не обработаны за таймаут")
|
||||||
|
}
|
||||||
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
|
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
|
||||||
t.Fatal("до Send ничего не шлём")
|
t.Fatal("до Send ничего не шлём")
|
||||||
}
|
}
|
||||||
@@ -98,11 +151,14 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
_ = r.Attach(tgCh)
|
_ = r.Attach(tgCh)
|
||||||
tgCh.emit(uidA, tg, "hi")
|
tgCh.emit(uidA, tg, "hi")
|
||||||
|
if !cb.wait(1) {
|
||||||
|
t.Fatal("первое входящее не обработано")
|
||||||
|
}
|
||||||
|
|
||||||
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
|
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
|
||||||
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
|
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
|
||||||
@@ -118,13 +174,16 @@ func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
|
|||||||
|
|
||||||
// ответ с того же адреса потребляет pending
|
// ответ с того же адреса потребляет pending
|
||||||
tgCh.emit(uidA, tg, "Анна")
|
tgCh.emit(uidA, tg, "Анна")
|
||||||
|
if !cb.wait(2) {
|
||||||
|
t.Fatal("ответ не обработан")
|
||||||
|
}
|
||||||
if _, ok := r.Pending(uidA); ok {
|
if _, ok := r.Pending(uidA); ok {
|
||||||
t.Fatal("pending должен быть закрыт после ответа")
|
t.Fatal("pending должен быть закрыт после ответа")
|
||||||
}
|
}
|
||||||
if len(cb.got) != 2 {
|
if cb.count() != 2 {
|
||||||
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
|
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 вопроса")
|
t.Error("ответ должен нести QuestionID вопроса")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +196,7 @@ func TestRouter_Ask_NoRoute(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
|
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
|
||||||
cb := &fakeOnMsg{}
|
cb := newFakeOnMsg()
|
||||||
r := NewRouter(cb.h)
|
r := NewRouter(cb.h)
|
||||||
tgCh := newFakeChannel(tg)
|
tgCh := newFakeChannel(tg)
|
||||||
tuiCh := newFakeChannel(tui)
|
tuiCh := newFakeChannel(tui)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
"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 {
|
func (ch *Channel) sendMsg(chatID, text string) error {
|
||||||
body, _ := json.Marshal(map[string]string{
|
body, _ := json.Marshal(map[string]string{
|
||||||
"chat_id": chatID,
|
"chat_id": chatID,
|
||||||
"text": text[:min(len(text), 4000)],
|
"text": truncateUTF8(text, 4000),
|
||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
})
|
})
|
||||||
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
|
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.
|
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
|
||||||
func formatOutgoing(m chat.Message) string {
|
func formatOutgoing(m chat.Message) string {
|
||||||
if len(m.Options) == 0 {
|
if len(m.Options) == 0 {
|
||||||
return m.Text
|
return escapeHTML(m.Text)
|
||||||
}
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
buf.WriteString(m.Text)
|
buf.WriteString(escapeHTML(m.Text))
|
||||||
buf.WriteString("\n\n")
|
buf.WriteString("\n\n")
|
||||||
for i, opt := range m.Options {
|
for i, opt := range m.Options {
|
||||||
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
|
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()
|
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 ----
|
// ---- Telegram API types ----
|
||||||
|
|
||||||
type tgResponse struct {
|
type tgResponse struct {
|
||||||
@@ -202,4 +215,4 @@ type message struct {
|
|||||||
Chat struct {
|
Chat struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
} `json:"chat"`
|
} `json:"chat"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
"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) {
|
func TestIncoming(t *testing.T) {
|
||||||
f := newFakeTG(t)
|
f := newFakeTG(t)
|
||||||
ch := New("TOKEN", time.Second)
|
ch := New("TOKEN", time.Second)
|
||||||
@@ -143,4 +174,4 @@ func TestIncoming(t *testing.T) {
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
t.Fatal("timeout ожидания входящего")
|
t.Fatal("timeout ожидания входящего")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -76,11 +77,75 @@ telegram:
|
|||||||
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
|
||||||
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
|
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" {
|
if cfg.Paths.Worktree != "./worktrees" {
|
||||||
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
|
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) {
|
func TestLoad_OpenCodeConfigDir(t *testing.T) {
|
||||||
t.Setenv("TG_TOKEN", "tok")
|
t.Setenv("TG_TOKEN", "tok")
|
||||||
t.Setenv("TG_CHAT_ID", "42")
|
t.Setenv("TG_CHAT_ID", "42")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) {
|
|||||||
if fv.Kind() == reflect.String {
|
if fv.Kind() == reflect.String {
|
||||||
fv.SetString(meta.defaultVal)
|
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 {
|
if fv.Kind() == reflect.String {
|
||||||
fv.SetString(envVal)
|
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))
|
fn(fv, collectMeta(f))
|
||||||
} else if fv.Type() == durType {
|
} else if fv.Type() == durType {
|
||||||
fn(fv, collectMeta(f))
|
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 {
|
} else if fv.Kind() == reflect.Struct {
|
||||||
walk(fv, fn)
|
walk(fv, fn)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +41,18 @@ type Config struct {
|
|||||||
Chat ChatCfg `yaml:"chat"`
|
Chat ChatCfg `yaml:"chat"`
|
||||||
Paths PathsCfg `yaml:"paths"`
|
Paths PathsCfg `yaml:"paths"`
|
||||||
Update UpdateCfg `yaml:"update"`
|
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).
|
// GitCfg — источник репозиториев (для git clone).
|
||||||
type GitCfg struct {
|
type GitCfg struct {
|
||||||
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
|
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
|
||||||
@@ -78,6 +89,23 @@ type OpenCodeCfg struct {
|
|||||||
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
|
||||||
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
|
||||||
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
PollMs Duration `yaml:"poll_ms" default:"2s"`
|
||||||
|
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 {
|
type ChatCfg struct {
|
||||||
@@ -99,5 +127,8 @@ func (c *Config) Validate() error {
|
|||||||
if c.Telegram.ChatID == "" {
|
if c.Telegram.ChatID == "" {
|
||||||
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
|
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...)
|
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 {
|
if task.Status == storage.StatusApproved {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
|
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
|
||||||
Action: "send",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -106,7 +105,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
|
|||||||
default:
|
default:
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
|
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
|
||||||
Action: "send",
|
|
||||||
TaskID: taskID,
|
TaskID: taskID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -128,7 +126,6 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: greeting,
|
Reply: greeting,
|
||||||
Action: "greeting",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -146,7 +143,6 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "🚫 Отменил.",
|
Reply: "🚫 Отменил.",
|
||||||
Action: "drop",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -161,7 +157,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
|
|||||||
if task.Status == storage.StatusReady {
|
if task.Status == storage.StatusReady {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Напишите «создавай» — или правьте текст.",
|
Reply: "Напишите «создавай» — или правьте текст.",
|
||||||
Action: "send",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -191,8 +186,7 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
|||||||
id, ok := parseTaskID(rest)
|
id, ok := parseTaskID(rest)
|
||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/retry 5`.",
|
Reply: "Укажите номер задачи: `/retry 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
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) {
|
if storage.IsTerminal(task.Status) {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
|
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -218,7 +211,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача перезапущена. Опишите, что меняем:",
|
Reply: "Задача перезапущена. Опишите, что меняем:",
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -229,8 +221,7 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
|||||||
id, ok := parseTaskID(rest)
|
id, ok := parseTaskID(rest)
|
||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/status 5`.",
|
Reply: "Укажите номер задачи: `/status 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
task, err := c.Store.GetTask(ctx, id)
|
||||||
@@ -246,7 +237,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: reply,
|
Reply: reply,
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -257,8 +247,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
id, ok := parseTaskID(rest)
|
id, ok := parseTaskID(rest)
|
||||||
if !ok {
|
if !ok {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Укажите номер задачи: `/continue 5`.",
|
Reply: "Укажите номер задачи: `/continue 5`.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
task, err := c.Store.GetTask(ctx, id)
|
task, err := c.Store.GetTask(ctx, id)
|
||||||
@@ -268,7 +257,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
|
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
|
||||||
". Резюм сессии — пока не реализован.",
|
". Резюм сессии — пока не реализован.",
|
||||||
Action: "send",
|
|
||||||
TaskID: id,
|
TaskID: id,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -277,8 +265,7 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
|
|||||||
// notFoundReply формирует reply для ненайденной задачи.
|
// notFoundReply формирует reply для ненайденной задачи.
|
||||||
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
|
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "Задача #" + itoa(id) + " не найдена.",
|
Reply: "Задача #" + itoa(id) + " не найдена.",
|
||||||
Action: "send",
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,7 +278,6 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
|
|||||||
}
|
}
|
||||||
return Result{
|
return Result{
|
||||||
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
|
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
|
||||||
Action: "created:" + itoa(task.ID),
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -333,6 +319,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
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))
|
msgs := make([]Message, 0, len(history))
|
||||||
for _, h := range history {
|
for _, h := range history {
|
||||||
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
|
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 == "" {
|
if reply == "" {
|
||||||
reply = "Недостаточно данных. Начните заново (/start)."
|
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":
|
case "propose", "ready":
|
||||||
// применяем черновик (для ready — текущий, без изменений)
|
// применяем черновик (для ready — текущий, без изменений)
|
||||||
@@ -365,7 +368,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
|
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
|
task.Status = storage.StatusReady
|
||||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
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{
|
return Result{
|
||||||
Reply: formatSummary(*task),
|
Reply: formatSummary(*task),
|
||||||
Action: "summary",
|
|
||||||
TaskID: task.ID,
|
TaskID: task.ID,
|
||||||
Status: task.Status,
|
Status: task.Status,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -385,7 +387,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
|
|||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||||
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
|
|||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/start")
|
_, err := c.ProcessTurn(ctx, id, "/start")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /start: %v", err)
|
t.Fatalf("ProcessTurn /start: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "greeting" {
|
|
||||||
t.Fatalf("action = %q, want greeting", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusCollecting {
|
if task.Status != storage.StatusCollecting {
|
||||||
t.Fatalf("status = %s, want collecting", task.Status)
|
t.Fatalf("status = %s, want collecting", task.Status)
|
||||||
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
|
|||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/cancel")
|
_, err := c.ProcessTurn(ctx, id, "/cancel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /cancel: %v", err)
|
t.Fatalf("ProcessTurn /cancel: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "drop" {
|
|
||||||
t.Fatalf("action = %q, want drop", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusCancelled {
|
if task.Status != storage.StatusCancelled {
|
||||||
t.Fatalf("status = %s, want cancelled", task.Status)
|
t.Fatalf("status = %s, want cancelled", task.Status)
|
||||||
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
|
|||||||
})
|
})
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "summary" {
|
|
||||||
t.Fatalf("action = %q, want summary", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusReady {
|
if task.Status != storage.StatusReady {
|
||||||
t.Fatalf("status = %s, want ready", task.Status)
|
t.Fatalf("status = %s, want ready", task.Status)
|
||||||
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "send" {
|
|
||||||
t.Fatalf("action = %q, want send", res.Action)
|
|
||||||
}
|
|
||||||
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
|
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
|
||||||
t.Fatalf("reply = %q", res.Reply)
|
t.Fatalf("reply = %q", res.Reply)
|
||||||
}
|
}
|
||||||
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
|
|||||||
})
|
})
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "привет")
|
_, err := c.ProcessTurn(ctx, id, "привет")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
t.Fatalf("ProcessTurn: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "drop" {
|
|
||||||
t.Fatalf("action = %q, want drop", res.Action)
|
|
||||||
}
|
|
||||||
task, _ := store.GetTask(ctx, id)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusAborted {
|
if task.Status != storage.StatusAborted {
|
||||||
t.Fatalf("status = %s, want aborted", task.Status)
|
t.Fatalf("status = %s, want aborted", task.Status)
|
||||||
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
|
|||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
|
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
|
||||||
res, err := c.ProcessTurn(ctx, id, "создавай")
|
_, err := c.ProcessTurn(ctx, id, "создавай")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn создавай: %v", err)
|
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)
|
task, _ := store.GetTask(ctx, id)
|
||||||
if task.Status != storage.StatusApproved {
|
if task.Status != storage.StatusApproved {
|
||||||
t.Fatalf("status после создавай = %s, want approved", task.Status)
|
t.Fatalf("status после создавай = %s, want approved", task.Status)
|
||||||
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
|||||||
|
|
||||||
_, _ = c.ProcessTurn(ctx, id, "сделай X")
|
_, _ = c.ProcessTurn(ctx, id, "сделай X")
|
||||||
// в ready пишем правку, не согласие
|
// в ready пишем правку, не согласие
|
||||||
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn edit: %v", err)
|
t.Fatalf("ProcessTurn edit: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "summary" {
|
|
||||||
t.Fatalf("action = %q, want summary", res.Action)
|
|
||||||
}
|
|
||||||
if calls != 2 {
|
if calls != 2 {
|
||||||
t.Fatalf("decide calls = %d, want 2", calls)
|
t.Fatalf("decide calls = %d, want 2", calls)
|
||||||
}
|
}
|
||||||
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
|
|||||||
|
|
||||||
func TestRetryNotFound(t *testing.T) {
|
func TestRetryNotFound(t *testing.T) {
|
||||||
c, ctx, _ := setupCore(t, nil)
|
c, ctx, _ := setupCore(t, nil)
|
||||||
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn /retry: %v", err)
|
t.Fatalf("ProcessTurn /retry: %v", err)
|
||||||
}
|
}
|
||||||
if res.Action != "send" {
|
|
||||||
t.Fatalf("action = %q, want send", res.Action)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownCommand(t *testing.T) {
|
func TestUnknownCommand(t *testing.T) {
|
||||||
c, ctx, store := setupCore(t, nil)
|
c, ctx, store := setupCore(t, nil)
|
||||||
id := mkTask(t, store, ctx, "u1")
|
id := mkTask(t, store, ctx, "u1")
|
||||||
|
|
||||||
res, err := c.ProcessTurn(ctx, id, "/bogus")
|
_, err := c.ProcessTurn(ctx, id, "/bogus")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ProcessTurn: %v", err)
|
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 {
|
if _, ok := parseTaskID("abc"); ok {
|
||||||
t.Fatal("parseTaskID(abc) should be invalid")
|
t.Fatal("parseTaskID(abc) should be invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ type Message struct {
|
|||||||
|
|
||||||
// Decision — вердикт аналитика.
|
// Decision — вердикт аналитика.
|
||||||
type Decision struct {
|
type Decision struct {
|
||||||
Phase string // "ask" | "propose" | "ready" | "abort"
|
Phase string // "ask" | "propose" | "ready" | "abort"
|
||||||
Draft storage.Task // обновлённые поля черновика
|
Draft storage.Task // обновлённые поля черновика
|
||||||
Questions []string // вопросы для phase=ask
|
Questions []string // вопросы для phase=ask
|
||||||
ChatReply string // ответ пользователю
|
ChatReply string // ответ пользователю
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decider — интерфейс для вызова аналитика (opencode).
|
// Decider — интерфейс для вызова аналитика (opencode).
|
||||||
@@ -29,7 +29,6 @@ type Decider interface {
|
|||||||
// Result — результат одного хода.
|
// Result — результат одного хода.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
Reply string
|
Reply string
|
||||||
Action string // send | summary | created:N | abort | drop | greeting
|
|
||||||
TaskID int64
|
TaskID int64
|
||||||
Status storage.Status
|
Status storage.Status
|
||||||
}
|
}
|
||||||
@@ -46,4 +45,4 @@ func isConsent(text string) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
219
internal/opencode/client.go
Normal file
219
internal/opencode/client.go
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client — HTTP-взаимодействие с одним opencode serve (режим API).
|
||||||
|
//
|
||||||
|
// Ходит по experimental HTTP API opencode serve (пути БЕЗ префикса /api):
|
||||||
|
// - POST /session создать сессию → голая Session {id}
|
||||||
|
// - POST /session/{id}/message отправить промпт {parts:[{type:"text"}]} →
|
||||||
|
// блокирует и возвращает {info,parts}; вердикт из parts
|
||||||
|
// - GET /session/{id}/message история → голый массив [{info, parts}] (для прогресса)
|
||||||
|
// - POST /session/{id}/abort прервать выполняющийся ответ
|
||||||
|
//
|
||||||
|
// Вердикт собирается из parts[] ответа на POST /message: текст тех частей,
|
||||||
|
// где type == "text".
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string // http://host:port (без завершающего слеша)
|
||||||
|
Password string // basic auth (username "opencode")
|
||||||
|
Debug bool // включать отладочные логи API-вызовов (log.level=debug)
|
||||||
|
http *http.Client // для быстрых операций (create/messages/abort)
|
||||||
|
httpSend *http.Client // для блокирующего Send — без жёсткого таймаута,
|
||||||
|
// отменяется только через контекст (idle/hard)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientErr — классы ошибок клиента.
|
||||||
|
type ClientErr struct {
|
||||||
|
Op string // "connect" | "create" | "prompt" | "messages" | "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}
|
||||||
|
}
|
||||||
|
if c.httpSend == nil {
|
||||||
|
c.httpSend = &http.Client{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do выполняет запрос через c.http (с таймаутом 30s) и возвращает тело при 2xx.
|
||||||
|
func (c *Client) do(ctx context.Context, method, path, op string, body []byte) ([]byte, error) {
|
||||||
|
c.defaults()
|
||||||
|
return c.doHTTP(ctx, method, path, op, body, c.http)
|
||||||
|
}
|
||||||
|
|
||||||
|
// doHTTP — общая реализация запроса; hc — клиент, которым выполняется запрос.
|
||||||
|
func (c *Client) doHTTP(ctx context.Context, method, path, op string, body []byte, hc *http.Client) ([]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 := hc.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
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession создаёт новую сессию и возвращает её id.
|
||||||
|
func (c *Client) CreateSession(ctx context.Context, title string) (string, error) {
|
||||||
|
body := map[string]string{}
|
||||||
|
if title != "" {
|
||||||
|
body["title"] = title
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
raw, err := c.do(ctx, http.MethodPost, "/session", "create", b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// experimental: ответ — голая Session (без обёртки {data}).
|
||||||
|
var out struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", &ClientErr{Op: "create", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||||
|
}
|
||||||
|
if out.ID == "" {
|
||||||
|
return "", &ClientErr{Op: "create", Err: fmt.Errorf("пустой id сессии")}
|
||||||
|
}
|
||||||
|
return out.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send отправляет промпт в сессию, БЛОКИРУЯСЬ до завершения ответа, и
|
||||||
|
// возвращает вердикт (текст text-частей из parts). Отмена — только через ctx
|
||||||
|
// (используется отдельный клиент без жёсткого таймаута; idle/hard в Runner'е
|
||||||
|
// отменяют контекст, что прерывает этот запрос).
|
||||||
|
func (c *Client) Send(ctx context.Context, sessionID, prompt string) (string, error) {
|
||||||
|
payload := map[string]any{
|
||||||
|
"parts": []map[string]string{{"type": "text", "text": prompt}},
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(payload)
|
||||||
|
c.defaults()
|
||||||
|
raw, err := c.doHTTP(ctx, http.MethodPost, "/session/"+sessionID+"/message", "prompt", b, c.httpSend)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Parts []part `json:"parts"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("невалидный ответ: %v", err)}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
for _, p := range out.Parts {
|
||||||
|
if p.Type == "text" && p.Text != "" {
|
||||||
|
if buf.Len() > 0 {
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
buf.WriteString(p.Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if buf.Len() == 0 {
|
||||||
|
return "", &ClientErr{Op: "prompt", Err: fmt.Errorf("нет text-части в ответе")}
|
||||||
|
}
|
||||||
|
return stripFence(buf.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort прерывает выполняющийся ответ сессии.
|
||||||
|
func (c *Client) Abort(ctx context.Context, sessionID string) error {
|
||||||
|
_, err := c.do(ctx, http.MethodPost, "/session/"+sessionID+"/abort", "abort", nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// part — минимальная часть сообщения (из parts[]).
|
||||||
|
type part struct {
|
||||||
|
Type string `json:"type"` // "text" | "reasoning" | "tool" | ...
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// message — элемент голого массива из GET /session/{id}/message.
|
||||||
|
type message struct {
|
||||||
|
Info struct {
|
||||||
|
Role string `json:"role"` // "assistant" | "user" | ...
|
||||||
|
} `json:"info"`
|
||||||
|
Parts []part `json:"parts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// messages возвращает сырые сообщения сессии (для поллинга прогресса).
|
||||||
|
func (c *Client) messages(ctx context.Context, sessionID string) ([]message, error) {
|
||||||
|
raw, err := c.do(ctx, http.MethodGet, "/session/"+sessionID+"/message", "messages", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var out []message
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// textCount считает число text-частей в assistant-сообщениях (для progress).
|
||||||
|
func (c *Client) textCount(ctx context.Context, sessionID string) (int, error) {
|
||||||
|
msgs, err := c.messages(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Info.Role != "assistant" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, p := range m.Parts {
|
||||||
|
if p.Type == "text" && p.Text != "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateStr(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
148
internal/opencode/client_test.go
Normal file
148
internal/opencode/client_test.go
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeAPIServer — минимальный фейк opencode serve experimental HTTP API
|
||||||
|
// (пути БЕЗ префикса /api).
|
||||||
|
type fakeAPIServer struct {
|
||||||
|
messages []message
|
||||||
|
failCreate bool
|
||||||
|
verdictParts []part // ответ на POST /session/{id}/message (вердикт)
|
||||||
|
blockPrompt bool // POST /message блокируется до отмены ctx (эмуляция зависания)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeAPIServer) handler() http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/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
|
||||||
|
}
|
||||||
|
// experimental: голая Session (без обёртки {data}).
|
||||||
|
writeJSON(w, map[string]any{"id": "sess-fake"})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/session/{id}/abort", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{})
|
||||||
|
})
|
||||||
|
mux.HandleFunc("/session/{id}/message", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
if f.blockPrompt {
|
||||||
|
// Эмуляция «зависшего» агента: ответ приходит позже idle-таймаута,
|
||||||
|
// но handler всё равно завершится, чтобы не блокировать shutdown.
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusRequestTimeout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// блокирующий ответ: {info, parts}, где вердикт — text-части.
|
||||||
|
info := map[string]any{"role": "assistant"}
|
||||||
|
parts := f.verdictParts
|
||||||
|
if parts == nil {
|
||||||
|
parts = []part{}
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]any{"info": info, "parts": parts})
|
||||||
|
case http.MethodGet:
|
||||||
|
// голый массив [{info, parts}].
|
||||||
|
if f.messages == nil {
|
||||||
|
writeJSON(w, []message{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, f.messages)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{})
|
||||||
|
id, err := c.CreateSession(context.Background(), "ratatoskr-analyst")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateSession err: %v", err)
|
||||||
|
}
|
||||||
|
if id != "sess-fake" {
|
||||||
|
t.Errorf("id = %q, want sess-fake", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_CreateSessionFail(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{failCreate: true})
|
||||||
|
if _, err := c.CreateSession(context.Background(), "x"); err == nil {
|
||||||
|
t.Fatal("CreateSession должен упасть при 500, а не nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Send(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{
|
||||||
|
verdictParts: []part{{Type: "text", Text: `{"phase":"ready"}`}},
|
||||||
|
})
|
||||||
|
vd, err := c.Send(context.Background(), "sess-fake", "почини x")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Send err: %v", err)
|
||||||
|
}
|
||||||
|
if vd != `{"phase":"ready"}` {
|
||||||
|
t.Errorf("verdict = %q, want вердикт модели", vd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_SendNoText(t *testing.T) {
|
||||||
|
c := fakeClient(t, &fakeAPIServer{}) // нет text-части в ответе
|
||||||
|
if _, err := c.Send(context.Background(), "sess-fake", "почини x"); err == nil {
|
||||||
|
t.Fatal("Send должен упасть, когда нет text-части")
|
||||||
|
} else {
|
||||||
|
var ce *ClientErr
|
||||||
|
if !errors.As(err, &ce) {
|
||||||
|
t.Errorf("ожидался *ClientErr, got %T", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_textCount(t *testing.T) {
|
||||||
|
f := &fakeAPIServer{messages: []message{{
|
||||||
|
Info: struct {
|
||||||
|
Role string `json:"role"`
|
||||||
|
}{Role: "assistant"},
|
||||||
|
Parts: []part{{Type: "text", Text: "a"}, {Type: "reasoning", Text: "x"}},
|
||||||
|
}}}
|
||||||
|
c := fakeClient(t, f)
|
||||||
|
n, err := c.textCount(context.Background(), "sess-fake")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("textCount err: %v", err)
|
||||||
|
}
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("textCount = %d, want 1 (одна text-часть в assistant)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
|
||||||
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
|
||||||
// - ExtractJSON: fenced ```json``` → первый {...}
|
// - ExtractJSON: fenced ```json``` → первый {...}
|
||||||
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
|
|
||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -14,9 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
|
||||||
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
|
||||||
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
|
||||||
@@ -76,11 +74,12 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
|
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
|
||||||
func SessionIDFromOutput(out string) (string, bool) {
|
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
|
||||||
m := sessionRe.FindStringSubmatch(out)
|
func stripFence(s string) string {
|
||||||
if len(m) > 1 && m[1] != "" {
|
s = strings.TrimSpace(s)
|
||||||
return m[1], true
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
|
// LiveStep — один наблюдаемый шаг агента.
|
||||||
// событие (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, не из БД.
|
|
||||||
type LiveStep struct {
|
type LiveStep struct {
|
||||||
Type string // "text" | "tool" | "agent" | ...
|
Type string // "text" | "tool" | "agent" | ...
|
||||||
Text string // содержимое text-парта (для других типов может быть пустым)
|
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,42 @@
|
|||||||
package opencode
|
package opencode
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
|
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
|
||||||
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
|
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
|
||||||
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
|
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
|
||||||
|
// (класс O2 Timeout — результат, не ошибка).
|
||||||
type Result struct {
|
type Result struct {
|
||||||
RC int
|
RC int
|
||||||
Stdout string
|
Stdout string
|
||||||
SessionID string
|
SessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Runner — конфигурация запуска opencode-субагентов.
|
// Runner — запуск opencode-субагентов через HTTP API serve.
|
||||||
|
//
|
||||||
|
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
|
||||||
|
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
|
||||||
|
// (в нём запущен serve → он его project).
|
||||||
type Runner struct {
|
type Runner struct {
|
||||||
Bin string // путь к opencode (по умолчанию "opencode")
|
Pool *Pool // пул serve-серверов (обязательный)
|
||||||
DBPath string // путь к opencode.db (idle-детекция активности)
|
IdleTimeout time.Duration
|
||||||
Config string // путь к opencode.json (OPENCODE_CONFIG)
|
HardTimeout time.Duration
|
||||||
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
|
|
||||||
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
|
|
||||||
HardTimeout time.Duration // общий лимит на запуск
|
|
||||||
PollInterval time.Duration
|
PollInterval time.Duration
|
||||||
|
Debug bool // отладочные логи API-вызовов (из log.level=debug)
|
||||||
|
|
||||||
// Заменяемые для тестов:
|
// Заменяемый для тестов:
|
||||||
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) defaults() {
|
func (r *Runner) defaults() {
|
||||||
if r.Bin == "" {
|
|
||||||
r.Bin = "opencode"
|
|
||||||
}
|
|
||||||
if r.IdleTimeout == 0 {
|
if r.IdleTimeout == 0 {
|
||||||
r.IdleTimeout = 5 * time.Minute
|
r.IdleTimeout = 5 * time.Minute
|
||||||
}
|
}
|
||||||
@@ -61,232 +55,113 @@ func (r *Runner) logf(format string, args ...any) {
|
|||||||
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
fmt.Fprintf(r.Stdout, format+"\n", args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
|
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
|
||||||
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
|
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
|
||||||
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
|
//
|
||||||
if r.DBPath == "" {
|
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
|
||||||
return 0, false
|
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
|
||||||
}
|
// Result, а не error (класс O2).
|
||||||
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).
|
|
||||||
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
|
||||||
r.defaults()
|
r.defaults()
|
||||||
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
|
if r.Pool == nil {
|
||||||
if sessionID != "" {
|
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
|
srv, err := r.Pool.Ensure(ctx, cwd)
|
||||||
proc.Env = env
|
|
||||||
proc.Dir = cwd
|
|
||||||
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
|
|
||||||
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
|
|
||||||
setpgid(proc)
|
|
||||||
stdout, err := proc.StdoutPipe()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
proc.Stderr = proc.Stdout
|
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
|
||||||
if err := proc.Start(); err != nil {
|
|
||||||
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
|
// Сессия: заданная (resume) или новая.
|
||||||
|
sid := sessionID
|
||||||
|
if sid == "" {
|
||||||
|
sid, err = c.CreateSession(ctx, "ratatoskr-"+agent)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opencode: create session: %w", err)
|
||||||
|
}
|
||||||
|
r.logf("opencode(%s) session=%s на %s", agent, sid, srv.Addr())
|
||||||
}
|
}
|
||||||
|
|
||||||
var buf []string
|
// Отправляем промпт (блокирующий Send в горутине; вердикт придёт из него),
|
||||||
var mu sync.Mutex
|
// параллельно поллим прогресс и контролируем idle/hard таймауты.
|
||||||
done := make(chan struct{})
|
return r.awaitVerdict(ctx, c, sid, agent, prompt)
|
||||||
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
|
}
|
||||||
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
|
|
||||||
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
|
// awaitVerdict запускает блокирующий Send и параллельно поллит прогресс
|
||||||
var liveSeq atomic.Uint64
|
// (рост числа text-частей = агент жив, сбрасывает idle). Возвращается вердикт
|
||||||
prevLive := liveSeq.Load()
|
// из ответа Send, либо rc=-1 при idle/hard таймауте (тогда Abort + отмена ctx).
|
||||||
// Живое наблюдение сессии (если задано через WithLive в контексте).
|
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt string) (*Result, error) {
|
||||||
liveReg, liveTask := liveFromContext(ctx)
|
sendCtx, cancel := context.WithCancel(ctx)
|
||||||
if liveReg != nil && liveTask != 0 {
|
defer cancel()
|
||||||
liveReg.Start(liveTask, agent)
|
type sendOut struct {
|
||||||
defer liveReg.Finish(liveTask)
|
vd string
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
sendCh := make(chan sendOut, 1)
|
||||||
go func() {
|
go func() {
|
||||||
defer close(done)
|
vd, err := c.Send(sendCtx, sid, prompt)
|
||||||
sc := bufio.NewScanner(stdout)
|
sendCh <- sendOut{vd: vd, err: err}
|
||||||
// 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)
|
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
|
||||||
|
// сбрасывает idle-таймер (LLM стримит = жив).
|
||||||
|
var mu sync.Mutex
|
||||||
|
lastCount := -1
|
||||||
lastProgress := time.Now()
|
lastProgress := time.Now()
|
||||||
launch := time.Now()
|
launch := time.Now()
|
||||||
killed := false
|
|
||||||
|
|
||||||
pollLoop:
|
abortAnd := func(rc int, why string) (*Result, error) {
|
||||||
|
if err := c.Abort(ctx, sid); err != nil {
|
||||||
|
r.logf("opencode(%s) abort %s: %v", agent, why, err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
return &Result{RC: rc, Stdout: "", SessionID: sid}, nil
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
if ctx.Err() != nil {
|
||||||
case <-done:
|
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
|
||||||
// процесс завершился (pipe EOF) — выходим, берём exit code
|
return abortAnd(-1, "ctx")
|
||||||
break pollLoop
|
|
||||||
case <-ctx.Done():
|
|
||||||
killGroup(proc)
|
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
default:
|
|
||||||
}
|
}
|
||||||
if proc.ProcessState != nil && proc.ProcessState.Exited() {
|
|
||||||
break pollLoop
|
count, _ := c.textCount(ctx, sid)
|
||||||
|
mu.Lock()
|
||||||
|
if count != lastCount {
|
||||||
|
lastProgress = time.Now()
|
||||||
|
lastCount = count
|
||||||
}
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
now := time.Now()
|
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 {
|
if now.Sub(lastProgress) > r.IdleTimeout {
|
||||||
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
|
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
|
||||||
killGroup(proc)
|
return abortAnd(-1, "idle")
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
}
|
}
|
||||||
|
// hard — общий бюджет от старта запуска.
|
||||||
if now.Sub(launch) > r.HardTimeout {
|
if now.Sub(launch) > r.HardTimeout {
|
||||||
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
|
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
|
||||||
killGroup(proc)
|
return abortAnd(-1, "hard")
|
||||||
killed = true
|
|
||||||
break pollLoop
|
|
||||||
}
|
}
|
||||||
time.Sleep(r.PollInterval)
|
|
||||||
}
|
|
||||||
|
|
||||||
<-done
|
select {
|
||||||
procErr := proc.Wait()
|
case out := <-sendCh:
|
||||||
rc := proc.ProcessState.ExitCode()
|
// Send завершился. Ошибка — connect (сервер недоступен) и ctx жив →
|
||||||
if rc < 0 {
|
// фатально, не таймаут. Если ctx уже отменён — это обрыв, а не ошибка.
|
||||||
rc = 1
|
if out.err != nil {
|
||||||
}
|
var ce *ClientErr
|
||||||
if killed {
|
if errors.As(out.err, &ce) && ce.Op == "connect" && ctx.Err() == nil {
|
||||||
rc = -1
|
return nil, fmt.Errorf("opencode: %w", out.err)
|
||||||
}
|
}
|
||||||
_ = procErr
|
if ctx.Err() != nil {
|
||||||
|
return abortAnd(-1, "ctx")
|
||||||
mu.Lock()
|
}
|
||||||
out := strings.Join(buf, "\n")
|
return nil, out.err
|
||||||
mu.Unlock()
|
}
|
||||||
|
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(out.vd))
|
||||||
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
|
return &Result{RC: 0, Stdout: out.vd, SessionID: sid}, nil
|
||||||
|
case <-time.After(r.PollInterval):
|
||||||
sid := sessionID
|
case <-ctx.Done():
|
||||||
if s, ok := SessionIDFromOutput(out); ok {
|
|
||||||
sid = s
|
|
||||||
}
|
|
||||||
if rc == -1 && sid == "" {
|
|
||||||
if s, ok := r.latestSession(ctx, cwd, agent); ok {
|
|
||||||
sid = s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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
|
|
||||||
}
|
|
||||||
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", "")
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
_ = proc.Process.Kill()
|
|
||||||
}
|
|
||||||
@@ -2,60 +2,39 @@ package opencode
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
|
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
|
||||||
//
|
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
|
||||||
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
|
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
|
||||||
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
|
|
||||||
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
|
|
||||||
func fakeOpenCode(t *testing.T, workdir string) string {
|
|
||||||
t.Helper()
|
t.Helper()
|
||||||
bin := filepath.Join(workdir, "opencode")
|
ts := httptestURL(t, f)
|
||||||
script := `#!/bin/sh
|
p := NewPool(dir)
|
||||||
mode="${FAKE_MODE:-ok}"
|
p.mu.Lock()
|
||||||
case "$mode" in
|
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
|
||||||
ok)
|
p.mu.Unlock()
|
||||||
echo '{"type":"text","part":{"text":"done"}}'
|
return p, &Client{BaseURL: ts}
|
||||||
echo '{"session_id":"sess-123"}'
|
}
|
||||||
exit 0
|
|
||||||
;;
|
// httptestURL запускает фейк-API и возвращает его URL.
|
||||||
slow)
|
func httptestURL(t *testing.T, f *fakeAPIServer) string {
|
||||||
sleep 30
|
t.Helper()
|
||||||
;;
|
ts := httptest.NewServer(f.handler())
|
||||||
live-reset)
|
t.Cleanup(ts.Close)
|
||||||
# шлём live-строку каждые 30мс долго — почти до hard timeout,
|
return ts.URL
|
||||||
# чтобы 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_Success(t *testing.T) {
|
func TestRun_Success(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{
|
||||||
t.Setenv("FAKE_MODE", "ok")
|
verdictParts: []part{{Type: "text", Text: "done"}},
|
||||||
|
}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
|
r := &Runner{Pool: p, PollInterval: 5 * time.Millisecond}
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
@@ -63,86 +42,39 @@ func TestRun_Success(t *testing.T) {
|
|||||||
if res.RC != 0 {
|
if res.RC != 0 {
|
||||||
t.Errorf("RC = %d, want 0", res.RC)
|
t.Errorf("RC = %d, want 0", res.RC)
|
||||||
}
|
}
|
||||||
if res.SessionID != "sess-123" {
|
if res.SessionID != "sess-fake" {
|
||||||
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
|
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
|
||||||
}
|
}
|
||||||
if !contains(res.Stdout, "done") {
|
if !contains(res.Stdout, "done") {
|
||||||
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
|
t.Errorf("Stdout = %q, want 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 (должна быть, т.к. задача завершилась)")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_IdleTimeout(t *testing.T) {
|
func TestRun_IdleTimeout(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
// prompt блокируется (агент «завис»), прогресс не растёт → idle abort
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
f := &fakeAPIServer{blockPrompt: true}
|
||||||
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
|
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond}
|
||||||
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
res, err := r.Run(context.Background(), "task", dir, "dev", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Run err: %v", err)
|
t.Fatalf("Run err: %v", err)
|
||||||
}
|
}
|
||||||
if res.RC != -1 {
|
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) {
|
|
||||||
dir := t.TempDir()
|
|
||||||
bin := fakeOpenCode(t, dir)
|
|
||||||
t.Setenv("FAKE_MODE", "live-reset")
|
|
||||||
|
|
||||||
// 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()
|
|
||||||
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 elapsed < 400*time.Millisecond {
|
|
||||||
t.Errorf("Run вернулся за %v — idle убил во время стрима (live не сбросил таймер)", elapsed)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_ContextCancel(t *testing.T) {
|
func TestRun_ContextCancel(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
bin := fakeOpenCode(t, dir)
|
f := &fakeAPIServer{blockPrompt: true}
|
||||||
t.Setenv("FAKE_MODE", "slow")
|
p, _ := fakePool(t, f, dir)
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
r := &Runner{Bin: bin, HardTimeout: time.Minute,
|
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
|
||||||
PollInterval: 10 * time.Millisecond}
|
PollInterval: 5 * time.Millisecond}
|
||||||
done := make(chan *Result, 1)
|
done := make(chan *Result, 1)
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -161,22 +93,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 {
|
func contains(s, sub string) bool {
|
||||||
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
|
||||||
}
|
}
|
||||||
@@ -188,4 +104,4 @@ func indexOf(s, sub string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
275
internal/opencode/server.go
Normal file
275
internal/opencode/server.go
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitHealthy опрашивает /global/health сервера до первого успеха или Connect.
|
||||||
|
// Возвращает nil, как только сервер ответил {healthy:true} (или 200/401 — сервер
|
||||||
|
// жив, но может требовать авторизации).
|
||||||
|
func (s *Server) waitHealthy(ctx context.Context, addr string) error {
|
||||||
|
deadline := time.Now().Add(60 * time.Second)
|
||||||
|
poll := s.PollInterval
|
||||||
|
for {
|
||||||
|
if healthy := s.health(ctx, addr); healthy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("opencode serve %s: не стал доступным (healthcheck)", addr)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(poll):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// healthGET делает GET на адрес и возвращает true, если сервер ответил.
|
||||||
|
// 401 (basic auth требуется) тоже считается «жив» — сервер доступен.
|
||||||
|
func (s *Server) healthGET(ctx context.Context, addr string) bool {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, addr+"/global/health", nil)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.Password != "" {
|
||||||
|
req.SetBasicAuth("opencode", s.Password)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: 2 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
// 200/403/401 — сервер жив (остальное считаем недоступным)
|
||||||
|
return resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run следит за сервером, пока ctx не отменён.
|
||||||
|
//
|
||||||
|
// В режиме владения (URL=="") перезапускает owned-процесс, если тот вышел
|
||||||
|
// (cmd.ProcessState указывает на завершение). Внешний сервер (URL!="") просто
|
||||||
|
// поллится на доступность и логирует сбои — процессом не владеем.
|
||||||
|
func (s *Server) Run(ctx context.Context) {
|
||||||
|
s.defaults()
|
||||||
|
addr := s.Addr()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.Close()
|
||||||
|
return
|
||||||
|
case <-time.After(s.PollInterval):
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
proc := s.proc
|
||||||
|
closed := s.closed
|
||||||
|
s.mu.Unlock()
|
||||||
|
if closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.URL != "" {
|
||||||
|
// внешний сервер упал — не наша работа перезапускать, но логируем
|
||||||
|
if !s.health(ctx, addr) {
|
||||||
|
log.Printf("opencode serve: внешний сервер %s недоступен", addr)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// наш процесс: перезапускаем, если он вышел. Признак выхода —
|
||||||
|
// cmd.ProcessState != nil: его выставляет reaper-горутина (cmd.Wait)
|
||||||
|
// только когда процесс завершился любым способом (exit, сигнал, OOM).
|
||||||
|
// .Exited() использовать нельзя — для SIGKILL он false.
|
||||||
|
exited := proc == nil || proc.ProcessState != nil
|
||||||
|
if !exited {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("opencode serve: процесс упал — перезапускаю")
|
||||||
|
s.mu.Lock()
|
||||||
|
cmd := s.serveCmd(ctx)
|
||||||
|
cmd.Stdout = s.Stdout
|
||||||
|
cmd.Stderr = s.Stdout
|
||||||
|
done := make(chan struct{})
|
||||||
|
s.proc = cmd
|
||||||
|
s.done = done
|
||||||
|
s.mu.Unlock()
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
log.Printf("opencode serve: перезапуск не удался: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
_ = cmd.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// health оборачивает healthGET, игнорируя нерелевантные ошибки.
|
||||||
|
func (s *Server) health(ctx context.Context, addr string) bool {
|
||||||
|
hctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return s.healthGET(hctx, addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close гасит процесс, которым владеет супервайзер. Идемпотентен.
|
||||||
|
// Wait НЕ вызываем — reaper-горутина единственный владелец Wait; Close лишь
|
||||||
|
// убивает процесс и ждёт, когда reaper закроет канал done.
|
||||||
|
func (s *Server) Close() {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.closed {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.closed = true
|
||||||
|
proc := s.proc
|
||||||
|
done := s.done
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if proc != nil && proc.Process != nil {
|
||||||
|
killGroup(proc)
|
||||||
|
}
|
||||||
|
if done != nil {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
internal/opencode/server_test.go
Normal file
191
internal/opencode/server_test.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package opencode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeServeBin создаёт скрипт, имитирующий opencode serve: просто держит
|
||||||
|
// процесс живым (sleep), чтобы супервайзер мог им владеть и убивать его.
|
||||||
|
func fakeServeBin(t *testing.T, workdir string) string {
|
||||||
|
t.Helper()
|
||||||
|
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)
|
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
|
task.Status = StatusRunning
|
||||||
if err := s.UpdateTask(ctx, task); err != nil {
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
t.Fatalf("UpdateTask ready→running: %v", err)
|
t.Fatalf("UpdateTask approved→running: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// running → success
|
// running → success
|
||||||
|
|||||||
Reference in New Issue
Block a user