17 Commits

Author SHA1 Message Date
ki.sagidullin
1459670ce9 refactor: чистка мёртвого кода, лимит ходов D3, HTML-экранирование и UTF-8 обрезка в Telegram
Some checks failed
CI / test (pull_request) Failing after 34s
CI / build-and-package (amd64, linux) (pull_request) Successful in 34s
CI / build-and-package (amd64, windows) (pull_request) Successful in 36s
2026-08-18 23:13:04 +05:00
Hermes
7eb3a0292c fix(opencode): убрать смешение слоёв API — перейти целиком на experimental (/session)
Some checks failed
CI / test (push) Failing after 32s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 38s
Корень проблемы «не получаем результаты»: клиент смешивал два слоя opencode
serve. CreateSession ходил на /api/session (v2, ждал {data.id}), Verdict — на
/api/session/{id}/message?order=desc и ждал {data:[{type,content}]}, где поле
content[].type/text физически отсутствует, поэтому вердикт никогда не находился
и поллинг уходил в вечный таймаут. Abort и вовсе звал несуществующий /interrupt.

Теперь весь код на experimental-слое, как сверено с sst/opencode (ветка dev):
- CreateSession: POST /session → голая Session, id в .id.
- Send: блокирующий POST /session/{id}/message, тело {parts:[{type:text,text}]},
  вердикт из частей parts[].type=="text" ответа. Это и есть результат — метод
  Verdict и отдельный GET удалены.
- textCount (прогресс): GET /session/{id}/message → голый массив [{info, parts}].
- Abort: POST /session/{id}/abort.

Runner: блокирующий Send запускается в горутине (канал вердикта/ошибки),
параллельно поллим textCount (рост text-частей сбрасывает idle-таймер). При
idle/hard-таймауте или отмене контекста — Abort + cancel() Send-горутины → rc=-1.

Send ходит через отдельный http.Client без жёсткого Timeout (управляется ctx),
чтобы длинная генерация не обрывалась на 30s. Тесты/fakeAPIServer переведены на
экспериментальный формат. Версия → 0.2.2.
2026-08-18 20:49:34 +05:00
Hermes
a64e3d6cc3 feat(chat): асинхронная обработка входящих — отзывчивый интерфейс
Some checks failed
CI / test (push) Failing after 31s
CI / build-and-package (amd64, linux) (push) Successful in 43s
CI / build-and-package (amd64, windows) (push) Successful in 43s
Router теперь обрабатывает входящие в воркер-горутине (FIFO-очередь с
буфером 256) вместо синхронного вызова onUserMsg из long-poll цикла
канала. Долгий вызов аналитика (Decide) больше не блокирует приём
новых сообщений от Telegram: цикл getUpdates продолжает работать.

- router.go: NewRouter запускает processLoop; handleIncoming кладёт
  событие в канал и возвращается; маршрутизация + pending по-прежнему
  обновляются синхронно под мьютексом.
- router_test.go: fakeOnMsg стал потокобезопасным с ожиданием числа
  входящих (wait), т.к. обработка теперь асинхронная.

Преимущества: интерфейс не замирает на время анализа; порядок входящих
сохраняется (FIFO). Ограничение: воркер один — при очень долгом аналитике
следующие сообщения ждут в очереди, но канал их продолжает принимать.
2026-08-18 19:55:46 +05:00
Hermes
774ebf135a fix(opencode): отправка сообщения — путь без /api (POST /session/:id/message)
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Successful in 39s
Убран префикс /api из POST-запроса на отправку сообщения, чтобы
совпадать с фактическим роутом serve v1.18. GET чтения сообщений
остаётся на /api/session/:id/message.

- client.go: Send → POST /session/:id/message
- тесты: fakeAPIServer отвечает на POST /session/{id}/message (GET — /api)
2026-08-18 19:28:45 +05:00
Hermes
e1167c9537 feat(opencode): v1.18 — перейти с POST /session/:id/prompt на /session/:id/message
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 36s
CI / build-and-package (amd64, windows) (push) Successful in 36s
Промпт отправляется в /api/session/:id/message (v1.18) с новым форматом
тела parts:[{type:"text"}], вместо {prompt:{text}}. Причина перехода:
/prompt сам подставляет не ту модель, что в конфиге; /message использует
модель из конфига.

- client.go: Send → POST /message + тело parts; шапка-док v1.18
- тесты: fakeAPIServer + e2eFakeAPI отвечают на POST /message (GET уже был)
2026-08-18 18:58:23 +05:00
Hermes
ad9c2dd522 feat(config): уровень логирования log.level (info|debug), по умолчанию info
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 35s
Отладочные логи API-вызовов к opencode serve теперь включаются только при
log.level=debug (гейт через Client.Debug <- Runner.Debug <- cfg.Log.Debug()).
По умолчанию 'info' — отладочных логов нет.

- config: добавлен LogCfg{Level}, дефолт 'info', валидация (info|debug)
- opencode: Client.Debug и Runner.Debug — гейттят логи do()
- app: Runner.Debug из cfg.Log.Debug()
- config.yaml.example: задокументирован log.level
- тесты: дефолт=info, level=debug, невалидный уровень
2026-08-18 18:44:37 +05:00
Hermes
f77a6000c7 build: bump version to 0.2.1
All checks were successful
CI / test (push) Successful in 41s
CI / build-and-package (amd64, linux) (push) Successful in 37s
CI / build-and-package (amd64, windows) (push) Successful in 36s
2026-08-18 18:32:04 +05:00
Hermes
ae00556923 feat(debug): логировать все API-вызовы к opencode serve с содержимым
All checks were successful
CI / test (push) Successful in 46s
CI / build-and-package (amd64, linux) (push) Successful in 44s
CI / build-and-package (amd64, windows) (push) Successful in 1m4s
В единой точке Client.do добавлены отладочные логи (всегда включены):
- строка вызова: opencode api <op> -> <METHOD> <url><path>
- тело запроса (усечено до 5000 байт)
- тело ответа 2xx (усечено до 5000 байт)
- тело ответа при ошибке/не-2xx (усечено до 1000 байт)

Нужно для диагностики проблемы аналитика (serve отвечал на модель
opencode/nemotron, ratatoskr не видел вердикт).
2026-08-18 18:23:56 +05:00
Hermes
baf7179085 fix: windows-сборка — заглушка setpgid/killGroup для отсутствия POSIX process-group
All checks were successful
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 36s
CI / build-and-package (amd64, windows) (push) Successful in 36s
procgroup_unix.go имеет unix build-tag; под windows setpgid/killGroup не
определялись → GOOS=windows сборка падала. Добавлен procgroup_windows.go:
setpgid — no-op (нет Setpgid), killGroup — Process.Kill (дочерние группы не
завершаются).
2026-08-18 13:45:05 +05:00
Hermes
733e63339a feat: opencode через HTTP API — пул serve-серверов вместо spawn/NDJSON
Some checks failed
CI / test (push) Successful in 40s
CI / build-and-package (amd64, linux) (push) Successful in 35s
CI / build-and-package (amd64, windows) (push) Failing after 26s
Runner теперь ходит к постоянным serve по HTTP API (v1.17+, /api):
- клиент Client (create/send/wait/abort/messages/verdict)
- Pool: по одному serve на каталог, ленивый подъём, root-сервер в worktree,
  выделение портов, ReleaseTask при завершении задачи
- Run: CreateSession('ratatoskr-<агент>') -> Send -> поллинг Verdict из
  text-частей assistant-сообщений; idle/hard таймауты дают RC=-1
- вердикт извлекается из последнего assistant text-парта (плоский text)
- тесты: unit на фейковом HTTP-сервере; e2e эмулирует serve через httptest,
  агент определяется по title сессии
2026-08-18 13:36:54 +05:00
Hermes
b60978121d chore: bump version to 0.2.0
All checks were successful
CI / test (push) Successful in 39s
CI / build-and-package (amd64, linux) (push) Successful in 30s
CI / build-and-package (amd64, windows) (push) Successful in 30s
2026-08-18 08:59:42 +05:00
Hermes
2f26b6ae88 feat: opencode serve — постоянный сервер вместо разовых subprocess (вариант A)
Some checks failed
CI / test (push) Successful in 54s
CI / build-and-package (amd64, linux) (push) Failing after 1h58m55s
CI / build-and-package (amd64, windows) (push) Successful in 59s
Супервайзер Server spawn'ит opencode serve (или ходит на внешний URL),
Runner ходит к нему через 'opencode run --attach <url>'. По умолчанию
serve.enabled=false — историческая spawn-модель сохранена; наличие
serve.url переключает на внешний сервер.

- config: ServeCfg (enabled/hostname/port/url/password) + дефолты; лоадер
  научился int/bool (раньше только string/Duration/struct).
- opencode: Server (Start/Run/Close, /global/health, рестарт упавшего,
  reaper-горутина владеет Wait; .Exited() непригоден для SIGKILL).
- Runner.AttachURL: run --attach <url> при заданном URL, иначе как раньше.
- app: composition root — при включённом serve запускает супервайзер.
- тесты: TestRun_AttachMode, TestServer_{ExternalURL,OwnProcess,Restart}.
2026-08-18 08:35:16 +05:00
ed11879cbd Merge pull request 'feat: авто-уведомления о статусах и хендоффах задачи' (#3) from feat/48f52f8bf4354293 into main
All checks were successful
CI / test (push) Successful in 44s
CI / build-and-package (amd64, linux) (push) Successful in 38s
CI / build-and-package (amd64, windows) (push) Successful in 43s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/3
2026-08-17 23:54:45 +05:00
Hermes
631387fda7 fix: CI — тесты падали на сборке пакета app и на старом переходе
All checks were successful
CI / test (push) Successful in 49s
CI / build-and-package (amd64, linux) (push) Successful in 47s
CI / build-and-package (amd64, windows) (push) Successful in 46s
- e2e_channel.OnMessage принимал func(chat.Incoming), а интерфейс
  chat.Channel требует chat.Handler (named type) — пакет app не компилировался;
- storage_test готовил цепочку ready→running напрямую, но ready→running
  больше невалиден (нужен approved): вставлен переход approved.

Всё проверено локально: go build ./... + go test ./... зелёные.
2026-08-17 23:50:50 +05:00
ki.sagidullin
07bae203c3 feat: авто-уведомления о статусах и хендоффах задачи
Some checks failed
CI / test (pull_request) Failing after 35s
CI / build-and-package (amd64, linux) (pull_request) Successful in 40s
CI / build-and-package (amd64, windows) (pull_request) Successful in 43s
2026-08-17 23:32:26 +05:00
18b48bfdc4 Merge pull request 'chore: onboard Serena — add .serena with project memories' (#2) from feat/2efdd0d048b719a5 into main
Some checks failed
CI / test (push) Failing after 35s
CI / build-and-package (amd64, linux) (push) Successful in 40s
CI / build-and-package (amd64, windows) (push) Successful in 42s
Reviewed-on: http://gitea.hal9000.home/kamelion/ratatoskr-go/pulls/2
2026-08-17 23:22:59 +05:00
ki.sagidullin
7c7afa6875 chore: onboard Serena — add .serena with project memories
Some checks failed
CI / test (pull_request) Failing after 41s
CI / build-and-package (amd64, linux) (pull_request) Successful in 50s
CI / build-and-package (amd64, windows) (pull_request) Successful in 39s
Сохраняем онбоардинг-память проекта (core, tech_stack, conventions,
suggested_commands, task_completion) в .serena и коммитим служебную
директорию, чтобы она была доступна на сервере. Директория НЕ исключена
в .gitignore (локально исключаются только cache и project.local.yml).
2026-08-17 22:48:53 +05:00
39 changed files with 2508 additions and 574 deletions

2
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/cache
/project.local.yml

View 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 | C1C4 | internal/config |
| A | A1A4 | internal/analyst |
| M | M1M5 | internal/chat |
| O | O1O4 | internal/opencode |
| S | S1S5 | internal/storage |
| W | W1W5 | internal/worker |
| E | E1E4 | internal/worker (репозитории) |
| R | R1R6 | internal/worker/review_errors.go |
| U | U1U6 | 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
View 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`.

View 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.

View 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 ($?) {}`; & для путей с пробелами).

View 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 были закрыты, закоммитить в ветку, вернуть отчёт.

View 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
View 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 readonly.
# 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: []

View File

@@ -34,3 +34,7 @@ telegram:
# paths:
# worktree: "./worktrees"
# db: "./ratatoskr.db" # или через env RATATOSKR_DB
# log (уровень логирования)
# level: "info" # "info" (по умолчанию) или "debug" — debug включает
# # отладочные логи (напр. все API-вызовы к opencode serve)

View File

@@ -49,7 +49,7 @@ const packageOwner = "kamelion"
// не следует путать с build-идентификатором `main.version` (commit-<sha7>),
// который вшивается ldflag'ом и используется автообновлением. Здесь номер
// поднимается вручную перед каждым релизом/публикацией новой сборки.
const Version = "0.1.0"
const Version = "0.2.2"
// App — собранный конвейер.
type App struct {
@@ -60,6 +60,7 @@ type App struct {
Worker *worker.Worker
Updater *update.Updater
tg *telegram.Channel // сохранена для Run
pool *opencode.Pool // пул opencode serve-серверов (API-режим)
}
// New читает конфиг и собирает все зависимости.
@@ -102,15 +103,24 @@ func New(configPath, version, updateToken string) (*App, error) {
}
log.Printf("app: db opened %s", cfg.Paths.DB)
// OpenCode: пул serve-процессов (по одному на каталог) + API-runner.
// Служебный root-сервер (worktree) живёт всё время app; остальные лениво.
ocPool := opencode.NewPool(cfg.Paths.Worktree)
ocPool.Bin = cfg.OpenCode.Bin
ocPool.Config = cfg.OpenCode.Config
ocPool.ConfigDir = cfg.OpenCode.ConfigDir
ocPool.DBPath = cfg.OpenCode.DBPath
ocPool.Host = cfg.OpenCode.Serve.Hostname
ocPool.BasePort = cfg.OpenCode.Serve.Port
ocPool.Password = cfg.OpenCode.Serve.Password
// OpenCode runner — один на аналитика и воркер
ocRunner := &opencode.Runner{
Bin: cfg.OpenCode.Bin,
DBPath: cfg.OpenCode.DBPath,
Config: cfg.OpenCode.Config,
ConfigDir: cfg.OpenCode.ConfigDir,
Pool: ocPool,
IdleTimeout: cfg.OpenCode.IdleTimeout.Duration(),
HardTimeout: cfg.OpenCode.HardTimeout.Duration(),
PollInterval: cfg.OpenCode.PollMs.Duration(),
Debug: cfg.Log.Debug(),
Stdout: os.Stderr,
}
@@ -132,6 +142,7 @@ func New(configPath, version, updateToken string) (*App, error) {
Config: cfg,
Store: store,
CoreCtx: coreCtx,
pool: ocPool,
}
router := chat.NewRouter(a.handleIncoming)
@@ -154,6 +165,7 @@ func New(configPath, version, updateToken string) (*App, error) {
GitBaseURL: cfg.Git.BaseURL,
GitToken: cfg.Git.Token,
Live: live,
Notify: a, // авто-уведомления владельцу задачи через Router
}
a.Router = router
a.Worker = w
@@ -184,6 +196,19 @@ func (a *App) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Уже отменённый контекст — не поднимаем подсистемы, graceful shutdown сразу.
if ctx.Err() != nil {
log.Print("app: context already cancelled, skipped start")
return nil
}
// opencode serve: поднимаем служебный корневой сервер (worktree) до старта
// воркера, остальные каталоги — лениво. При неудаче — не стартуем.
if err := a.pool.EnsureRoot(ctx); err != nil {
return fmt.Errorf("opencode: %w", err)
}
defer a.pool.Close()
// Канал для проверки Telegram-ошибки (горутина оборачивает Run)
tgErr := make(chan error, 1)
@@ -326,6 +351,16 @@ func (a *App) send(ctx context.Context, uid chat.UserID, text string) {
}
}
// Notify реализует worker.Notifier: авто-уведомление владельцу задачи через
// chat.Router.Send (переходы статусов и хендоффы dev↔reviewer со стороны воркера).
// Router nil (тесты без Router / ранняя инициализация) — тихо пропускаем.
func (a *App) Notify(ctx context.Context, taskID int64, chatID, text string) error {
if a.Router == nil {
return nil
}
return a.Router.Send(ctx, chat.UserID(chatID), chat.Message{Text: text})
}
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
func cmdName(text string) string {
s := strings.TrimSpace(text)

View File

@@ -8,17 +8,22 @@ package app
// Worker.runTask: dev → reviewer → настоящий git push → success
//
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
// возвращая NDJSON-вердикты нужного формата для каждого.
// а opencode serve эмулируется фейковым HTTP API-сервером (e2eFakeAPI). Агент
// определяется по title сессии (ratatoskr-analyst / ratatoskr-dev / ratatoskr-reviewer),
// вердикты возвращаются как text-части assistant-сообщений.
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
@@ -30,63 +35,81 @@ import (
"github.com/kamelion/ratatoskr-go/internal/worker"
)
// ndjsonText собирает строку NDJSON-события opencode с text-партом:
// {"type":"text","part":{"text":"<payload>"}}. payload — строковое
// представление JSON-вердикта агента (как это делает реальный opencode).
func ndjsonText(t *testing.T, payload string) string {
t.Helper()
b, err := json.Marshal(payload) // экранирует payload как JSON-строку
if err != nil {
t.Fatalf("json.Marshal payload: %v", err)
// вердикты фейкового агента по имени.
var (
e2eAgentVerdicts = map[string]string{
"analyst": `{"phase":"propose","title":"Калькулятор","goal":"Сделать веб-калькулятор","repo":"calc","why":"Нужен для учёта","ac":"Работает + - * /","chat_reply":"Черновик готов."}`,
"dev": `done`,
"reviewer": `{"passed":true,"comments":[]}`,
}
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.
// Различает агента по argv ($3 = имя агента после "--agent").
//
// analyst → NDJSON c вердиктом propose (черновик с репозиторием calc)
// dev → простой NDJSON "done"
// reviewer→ NDJSON c {"passed":true} в text-парте
func e2eFakeOpenCode(t *testing.T, dir string) string {
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
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
// каталог worktree и fake-канал (для проверки исходящих).
// но с фейк-сервером opencode (e2eFakeAPI), зарегистрированным в пуле.
// Возвращает App, каталог worktree и fake-канал (для проверки исходящих).
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
t.Helper()
dir := t.TempDir()
@@ -99,13 +122,16 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
}
t.Cleanup(func() { store.Close() })
bin := e2eFakeOpenCode(t, dir)
fakeURL := e2eFakeAPI(t)
pool := opencode.NewPool(worktree)
pool.RegisterExternal(worktree, fakeURL) // worktree обслуживается фейком
runner := &opencode.Runner{
Bin: bin,
PollInterval: 20 * time.Millisecond,
Pool: pool,
PollInterval: 5 * time.Millisecond,
IdleTimeout: 5 * time.Second,
HardTimeout: 30 * time.Second,
}
t.Cleanup(pool.Close)
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
coreCtx := core.New(store, an)
@@ -129,6 +155,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
Interval: 30 * time.Millisecond,
MaxJobs: 1,
Live: opencode.NewLiveRegistry(),
Notify: a, // авто-уведомления владельцу через Router (как в app.New)
// GitToken зададим пустым: origin в seed-репо локальный (file path),
// http.extraHeader не нужен для локального пуша.
}
@@ -139,13 +166,13 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
// e2eChannel — минимальный fake-канал для перехвата исходящих
// и доставки входящих через роутер (как реальный канал).
type e2eChannel struct {
onMsg func(chat.Incoming)
onMsg chat.Handler
sent []chat.Message
}
func (c *e2eChannel) Run(_ context.Context) error { return nil }
func (c *e2eChannel) OnMessage(f func(chat.Incoming)) {
c.onMsg = f
func (c *e2eChannel) OnMessage(h chat.Handler) {
c.onMsg = h
}
func (c *e2eChannel) Send(_ context.Context, _ chat.Address, m chat.Message) error {
c.sent = append(c.sent, m)
@@ -720,3 +747,107 @@ func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
}
// TestE2ENotificationsOnTransitions — авто-уведомления владельцу на каждый
// переход статуса задачи и отсутствие задвоения на approved.
//
// Проверяет сквозной поток сообщений через chat.Router:
// - ready → approved («создавай»): ровно ОДНО сообщение (ответ «одобрена»),
// отдельное уведомление "Задача #N: approved" НЕ дублируется;
// - со стороны воркера: уведомления running → dev→reviewer → success
// доходят владельцу через Router.Send.
func TestE2ENotificationsOnTransitions(t *testing.T) {
a, worktree, fake := e2eAssemble(t)
a.seedFakeRepo(t, worktree, "calc")
ctx := context.Background()
uid := chat.UserID("u-notif")
// --- 1. постановка: /start → draft→collecting (приветствие) ---
fake.deliver(uid, "/start")
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
if err != nil {
t.Fatalf("get task after /start: %v", err)
}
taskID := task.ID
// --- 2. текст → collecting→ready (сводка черновика) ---
fake.deliver(uid, "Сделай калькулятор в calc")
task, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if task.Status != storage.StatusReady {
t.Fatalf("status = %q, want ready", task.Status)
}
// --- 3. «создавай» → ready→approved: один ответ, без дубля-уведомления ---
fake.deliver(uid, "создавай")
task, err = a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task after создавай: %v", err)
}
if task.Status != storage.StatusApproved {
t.Fatalf("status = %q, want approved", task.Status)
}
approvedReplies := 0
var approvedText string
approvedNotifs := 0
for _, m := range fake.sent {
if strings.Contains(m.Text, "одобрена") {
approvedReplies++
approvedText = m.Text
}
if strings.Contains(m.Text, fmt.Sprintf("Задача #%d: approved", taskID)) {
approvedNotifs++
}
}
if approvedReplies != 1 {
t.Errorf("сообщений об одобрении = %d, want ровно 1 (нет задвоения)", approvedReplies)
}
if approvedNotifs != 0 {
t.Errorf("отдельное уведомление 'Задача #%d: approved' продублировано (%d раз)", taskID, approvedNotifs)
}
if !strings.Contains(approvedText, "одобрена") {
t.Errorf("ответ об одобрении = %q", approvedText)
}
// --- 4. воркер: running → dev→reviewer → success через Router ---
wkCtx, wkCancel := context.WithCancel(ctx)
defer wkCancel()
a.Worker.Start(wkCtx)
deadline := time.Now().Add(30 * time.Second)
for {
tk, err := a.Store.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("get task: %v", err)
}
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
break
}
if time.Now().After(deadline) {
t.Fatalf("таймаут ожидания success, последний статус %q", tk.Status)
}
time.Sleep(50 * time.Millisecond)
}
// финальные статус-уведомления воркера в правильном порядке
wantOrder := []string{
fmt.Sprintf("Задача #%d: running", taskID),
fmt.Sprintf("Задача #%d: dev → reviewer (итерация 1)", taskID),
fmt.Sprintf("Задача #%d: success", taskID),
}
var gotOrder []string
for _, m := range fake.sent {
if strings.HasPrefix(m.Text, fmt.Sprintf("Задача #%d:", taskID)) {
gotOrder = append(gotOrder, m.Text)
}
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Errorf("уведомления воркера = %#v, want %#v", gotOrder, wantOrder)
}
t.Logf("NOTIFICATIONS OK: задача #%d, сообщений одобрения=%d, уведомления=%v", taskID, approvedReplies, gotOrder)
}

View File

@@ -18,6 +18,12 @@ type Router struct {
// Hook, вызываемый на каждое входящее событие (обычно → process_turn).
onUserMsg func(Incoming)
// Асинхронная обработка входящих: handleIncoming кладёт событие в канал,
// воркер-горутина последовательно вызывает onUserMsg. Благодаря этому
// long-poll цикл канала (Telegram) не блокируется на время долгого
// вызова аналитика и продолжает принимать новые сообщения.
incoming chan Incoming
}
// NewRouter создаёт роутер. onUserMsg — колбэк обработки входящего.
@@ -25,11 +31,21 @@ func NewRouter(onUserMsg func(Incoming)) *Router {
if onUserMsg == nil {
onUserMsg = func(Incoming) {}
}
return &Router{
r := &Router{
sessions: map[UserID]any{},
routes: map[UserID]Route{},
pending: map[UserID]PendingQ{},
onUserMsg: onUserMsg,
incoming: make(chan Incoming, 256),
}
go r.processLoop()
return r
}
// processLoop — воркер асинхронной обработки входящих (FIFO).
func (r *Router) processLoop() {
for inc := range r.incoming {
r.onUserMsg(inc)
}
}
@@ -59,7 +75,10 @@ func (r *Router) handleIncoming(inc Incoming) {
delete(r.pending, inc.UserID)
}
r.mu.Unlock()
r.onUserMsg(inc)
// Асинхронная обработка: кладём событие в очередь воркера и сразу
// возвращаемся, не блокируя вызывающий long-poll цикл канала.
r.incoming <- inc
}
// Send уведомляет пользователя через текущий маршрут. M1 (нет маршрута) — no-op,

View File

@@ -3,7 +3,9 @@ package chat
import (
"context"
"errors"
"sync"
"testing"
"time"
)
const (
@@ -13,13 +15,58 @@ const (
tui Address = "tui://local"
)
// fakeOnMsg — тест-колбэк, копящий входящие.
type fakeOnMsg struct{ got []Incoming }
// fakeOnMsg — тест-колбэк, копящий входящие. Т.к. Router теперь обрабатывает
// входящие асинхронно (воркер-горутина), доступ потокобезопасный, а ожидание
// нужного числа сообщений — через wait.
type fakeOnMsg struct {
mu sync.Mutex
ch chan struct{} // сигнал о появлении каждого нового входящего
got []Incoming
}
func (f *fakeOnMsg) h(inc Incoming) { f.got = append(f.got, inc) }
func newFakeOnMsg() *fakeOnMsg {
return &fakeOnMsg{ch: make(chan struct{}, 64)}
}
func (f *fakeOnMsg) h(inc Incoming) {
f.mu.Lock()
f.got = append(f.got, inc)
f.mu.Unlock()
f.ch <- struct{}{}
}
// wait блокируется, пока не наберётся n входящих. Возвращает false по таймауту.
func (f *fakeOnMsg) wait(n int) bool {
deadline := time.After(2 * time.Second)
for {
f.mu.Lock()
got := len(f.got)
f.mu.Unlock()
if got >= n {
return true
}
select {
case <-f.ch:
case <-deadline:
return false
}
}
}
func (f *fakeOnMsg) get(i int) Incoming {
f.mu.Lock()
defer f.mu.Unlock()
return f.got[i]
}
func (f *fakeOnMsg) count() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.got)
}
func TestRouter_AttachAndIncoming(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
@@ -28,10 +75,10 @@ func TestRouter_AttachAndIncoming(t *testing.T) {
}
tgCh.emit(uidA, tg, "привет")
if len(cb.got) != 1 {
t.Fatalf("handler got %d, want 1", len(cb.got))
if !cb.wait(1) {
t.Fatal("handler не получил входящее за таймаут")
}
got := cb.got[0]
got := cb.get(0)
if got.UserID != uidA || got.Address != tg || got.Msg.Text != "привет" {
t.Errorf("incoming = %+v", got)
}
@@ -53,12 +100,15 @@ func TestRouter_Send_NoRoute(t *testing.T) {
}
func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi") // устанавливает маршрут
if !cb.wait(1) {
t.Fatal("маршрут не установился за таймаут")
}
if err := r.Send(context.Background(), uidA, Message{Text: "отв"}); err != nil {
t.Fatalf("Send: %v", err)
@@ -72,7 +122,7 @@ func TestRouter_Send_UsesCurrentRoute(t *testing.T) {
}
func TestRouter_SwitchChannel_Continues(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)
@@ -83,6 +133,9 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
tgCh.emit(uidA, tg, "hi")
// продолжил в GUI
tuiCh.emit(uidA, tui, "продолжаю тут")
if !cb.wait(2) {
t.Fatal("входящие не обработаны за таймаут")
}
if tgCh.sentCount() != 0 || tuiCh.sentCount() != 0 {
t.Fatal("до Send ничего не шлём")
}
@@ -98,11 +151,14 @@ func TestRouter_SwitchChannel_Continues(t *testing.T) {
}
func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
_ = r.Attach(tgCh)
tgCh.emit(uidA, tg, "hi")
if !cb.wait(1) {
t.Fatal("первое входящее не обработано")
}
prompt := Message{Text: "Как зовут?", Options: []Option{{ID: "a", Label: "Анна"}}}
if err := r.Ask(context.Background(), uidA, prompt); err != nil {
@@ -118,13 +174,16 @@ func TestRouter_Ask_PendingThenAnswer(t *testing.T) {
// ответ с того же адреса потребляет pending
tgCh.emit(uidA, tg, "Анна")
if !cb.wait(2) {
t.Fatal("ответ не обработан")
}
if _, ok := r.Pending(uidA); ok {
t.Fatal("pending должен быть закрыт после ответа")
}
if len(cb.got) != 2 {
t.Fatalf("handler got %d, want 2 (hi + ответ)", len(cb.got))
if cb.count() != 2 {
t.Fatalf("handler got %d, want 2 (hi + ответ)", cb.count())
}
if cb.got[1].Msg.QuestionID == "" {
if cb.get(1).Msg.QuestionID == "" {
t.Error("ответ должен нести QuestionID вопроса")
}
}
@@ -137,7 +196,7 @@ func TestRouter_Ask_NoRoute(t *testing.T) {
}
func TestRouter_Ask_PendingNotConsumedFromOtherAddr(t *testing.T) {
cb := &fakeOnMsg{}
cb := newFakeOnMsg()
r := NewRouter(cb.h)
tgCh := newFakeChannel(tg)
tuiCh := newFakeChannel(tui)

View File

@@ -16,6 +16,7 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -109,7 +110,7 @@ func (ch *Channel) handleUpdate(ctx context.Context, upd update) {
func (ch *Channel) sendMsg(chatID, text string) error {
body, _ := json.Marshal(map[string]string{
"chat_id": chatID,
"text": text[:min(len(text), 4000)],
"text": truncateUTF8(text, 4000),
"parse_mode": "HTML",
})
url := fmt.Sprintf(ch.apiURL+"sendMessage", ch.token)
@@ -155,10 +156,10 @@ func (ch *Channel) getUpdates(ctx context.Context, offset int64, timeout int) ([
// formatOutgoing собирает Message в HTML-строку: текст + нумерованные Options.
func formatOutgoing(m chat.Message) string {
if len(m.Options) == 0 {
return m.Text
return escapeHTML(m.Text)
}
var buf bytes.Buffer
buf.WriteString(m.Text)
buf.WriteString(escapeHTML(m.Text))
buf.WriteString("\n\n")
for i, opt := range m.Options {
buf.WriteString(fmt.Sprintf("<b>%d.</b> %s\n", i+1, escapeHTML(opt.Label)))
@@ -183,6 +184,18 @@ func escapeHTML(s string) string {
return buf.String()
}
// truncateUTF8 обрезает s до max байт, не разрывая UTF-8 последовательности.
func truncateUTF8(s string, max int) string {
if len(s) <= max {
return s
}
s = s[:max]
for len(s) > 0 && !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ---- Telegram API types ----
type tgResponse struct {

View File

@@ -9,6 +9,7 @@ import (
"sync"
"testing"
"time"
"unicode/utf8"
"github.com/kamelion/ratatoskr-go/internal/chat"
)
@@ -111,6 +112,36 @@ func TestSendWithOptions(t *testing.T) {
}
}
func TestFormatOutgoingEscapesText(t *testing.T) {
if got := formatOutgoing(chat.Message{Text: "2 < 3 & 4 > 1"}); got != "2 &lt; 3 &amp; 4 &gt; 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&lt;b") || !strings.Contains(got, "l&amp;l") {
t.Errorf("options escape = %q", got)
}
}
func TestTruncateUTF8(t *testing.T) {
long := strings.Repeat("я", 5000)
tr := truncateUTF8(long, 4000)
if len(tr) != 4000 {
t.Fatalf("len = %d, want 4000", len(tr))
}
if !utf8.ValidString(tr) {
t.Fatal("truncated string is not valid UTF-8")
}
if got := truncateUTF8("привет", 4000); got != "привет" {
t.Fatalf("short text changed: %q", got)
}
if got := truncateUTF8("", 4000); got != "" {
t.Fatalf("empty text changed: %q", got)
}
}
func TestIncoming(t *testing.T) {
f := newFakeTG(t)
ch := New("TOKEN", time.Second)

View File

@@ -1,6 +1,7 @@
package config
import (
"errors"
"os"
"path/filepath"
"strings"
@@ -76,11 +77,75 @@ telegram:
if cfg.OpenCode.IdleTimeout.Duration() != 5*time.Minute {
t.Errorf("idle timeout = %v", cfg.OpenCode.IdleTimeout)
}
if cfg.OpenCode.Serve.Enabled {
t.Errorf("serve.enabled = true, want false (дефолт)")
}
if cfg.OpenCode.Serve.Hostname != "127.0.0.1" {
t.Errorf("serve.hostname = %q, want 127.0.0.1", cfg.OpenCode.Serve.Hostname)
}
if cfg.OpenCode.Serve.Port != 4096 {
t.Errorf("serve.port = %d, want 4096", cfg.OpenCode.Serve.Port)
}
if cfg.Paths.Worktree != "./worktrees" {
t.Errorf("worktree = %q, want ./worktrees", cfg.Paths.Worktree)
}
}
func TestLoad_LogLevelDefault(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Log.Level != "info" {
t.Errorf("log.level = %q, want info (дефолт)", cfg.Log.Level)
}
if cfg.Log.Debug() {
t.Errorf("Debug() = true при уровне info, want false")
}
}
func TestLoad_LogLevelDebug(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
log:
level: debug
`
cfg, err := Load(writeCfg(t, yaml))
if err != nil {
t.Fatalf("Load: %v", err)
}
if !cfg.Log.Debug() {
t.Errorf("Debug() = false при level=debug, want true")
}
}
func TestLoad_LogLevelInvalid(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")
yaml := `telegram:
token: "${TG_TOKEN}"
chat_id: "${TG_CHAT_ID}"
log:
level: warn
`
_, err := Load(writeCfg(t, yaml))
if !errors.Is(err, ErrInvalidFormat) {
t.Fatalf("Load: err = %v, want ErrInvalidFormat", err)
}
}
func TestLoad_OpenCodeConfigDir(t *testing.T) {
t.Setenv("TG_TOKEN", "tok")
t.Setenv("TG_CHAT_ID", "42")

View File

@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"gopkg.in/yaml.v3"
@@ -133,6 +134,18 @@ func applyDefaults(cfg *Config) {
if fv.Kind() == reflect.String {
fv.SetString(meta.defaultVal)
}
// int (значение по умолчанию, напр. port)
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(meta.defaultVal); err == nil {
fv.SetInt(int64(n))
}
}
// bool (по умолчанию false/true)
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(meta.defaultVal); err == nil {
fv.SetBool(b)
}
}
})
}
@@ -156,6 +169,16 @@ func applyEnvOverrides(cfg *Config) {
if fv.Kind() == reflect.String {
fv.SetString(envVal)
}
if fv.Kind() == reflect.Int {
if n, err := strconv.Atoi(envVal); err == nil {
fv.SetInt(int64(n))
}
}
if fv.Kind() == reflect.Bool {
if b, err := strconv.ParseBool(envVal); err == nil {
fv.SetBool(b)
}
}
})
}
@@ -173,6 +196,10 @@ func walk(v reflect.Value, fn func(reflect.Value, fieldMeta)) {
fn(fv, collectMeta(f))
} else if fv.Type() == durType {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Int {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Bool {
fn(fv, collectMeta(f))
} else if fv.Kind() == reflect.Struct {
walk(fv, fn)
}

View File

@@ -10,6 +10,7 @@ package config
import (
"errors"
"fmt"
"strings"
"time"
)
@@ -40,8 +41,18 @@ type Config struct {
Chat ChatCfg `yaml:"chat"`
Paths PathsCfg `yaml:"paths"`
Update UpdateCfg `yaml:"update"`
Log LogCfg `yaml:"log"`
}
// LogCfg — уровень логирования. Level: "info" (по умолчанию) или "debug".
// debug включает отладочные логи (напр. все API-вызовы к opencode serve).
type LogCfg struct {
Level string `yaml:"level" default:"info"`
}
// Debug возвращает true, если включён отладочный уровень логирования.
func (l LogCfg) Debug() bool { return strings.EqualFold(l.Level, "debug") }
// GitCfg — источник репозиториев (для git clone).
type GitCfg struct {
BaseURL string `yaml:"base_url" env:"GIT_BASE_URL"`
@@ -78,6 +89,23 @@ type OpenCodeCfg struct {
HardTimeout Duration `yaml:"hard_timeout" default:"20m"`
IdleTimeout Duration `yaml:"idle_timeout" default:"5m"`
PollMs Duration `yaml:"poll_ms" default:"2s"`
Serve ServeCfg `yaml:"serve"`
}
// ServeCfg — настройки постоянных opencode serve-процессов (API-режим).
// Runner ходит к serve по HTTP API (v1.17+, /api). ratatoskr сам поднимает
// по одному serve на каталог через Pool (Hash: hostname/port; служебный
// root-сервер в worktree живёт всё время app, остальные — лениво).
//
// Поля enabled/url оставлены для обратной совместимости конфига и сейчас не
// меняют поведение: serve обязателен для API-режима, сервер всегда
// спавнится пулом (hostname/port задают базовые значения).
type ServeCfg struct {
Enabled bool `yaml:"enabled" default:"false"`
URL string `yaml:"url" env:"OPENCODE_SERVE_URL"`
Hostname string `yaml:"hostname" default:"127.0.0.1"`
Port int `yaml:"port" default:"4096"`
Password string `yaml:"password" env:"OPENCODE_SERVE_PASSWORD"`
}
type ChatCfg struct {
@@ -99,5 +127,8 @@ func (c *Config) Validate() error {
if c.Telegram.ChatID == "" {
errs = append(errs, fmt.Errorf("%w: telegram.chat_id", ErrMissingField))
}
if !c.Log.Debug() && !strings.EqualFold(c.Log.Level, "info") {
errs = append(errs, fmt.Errorf("%w: log.level (ожидается \"info\" или \"debug\")", ErrInvalidFormat))
}
return errors.Join(errs...)
}

View File

@@ -66,7 +66,6 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
if task.Status == storage.StatusApproved {
return Result{
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -106,7 +105,6 @@ func (c *Core) handleCommand(ctx context.Context, taskID int64, text string) (Re
default:
return Result{
Reply: "Неизвестная команда. Доступно: /start /cancel /skip /retry N /status N",
Action: "send",
TaskID: taskID,
}, nil
}
@@ -128,7 +126,6 @@ func (c *Core) handleStart(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: greeting,
Action: "greeting",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -146,7 +143,6 @@ func (c *Core) handleCancel(ctx context.Context, taskID int64) (Result, error) {
}
return Result{
Reply: "🚫 Отменил.",
Action: "drop",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -161,7 +157,6 @@ func (c *Core) handleSkip(ctx context.Context, taskID int64) (Result, error) {
if task.Status == storage.StatusReady {
return Result{
Reply: "Напишите «создавай» — или правьте текст.",
Action: "send",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -192,7 +187,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if !ok {
return Result{
Reply: "Укажите номер задачи: `/retry 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -204,7 +198,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
if storage.IsTerminal(task.Status) {
return Result{
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -218,7 +211,6 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: "Задача перезапущена. Опишите, что меняем:",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -230,7 +222,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
if !ok {
return Result{
Reply: "Укажите номер задачи: `/status 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -246,7 +237,6 @@ func (c *Core) handleStatus(ctx context.Context, rest string) (Result, error) {
}
return Result{
Reply: reply,
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -258,7 +248,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
if !ok {
return Result{
Reply: "Укажите номер задачи: `/continue 5`.",
Action: "send",
}, nil
}
task, err := c.Store.GetTask(ctx, id)
@@ -268,7 +257,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
return Result{
Reply: "Задача #" + itoa(id) + " в статусе " + string(task.Status) +
". Резюм сессии — пока не реализован.",
Action: "send",
TaskID: id,
Status: task.Status,
}, nil
@@ -278,7 +266,6 @@ func (c *Core) handleContinue(ctx context.Context, rest string) (Result, error)
func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result, error) {
return Result{
Reply: "Задача #" + itoa(id) + " не найдена.",
Action: "send",
}, nil
}
@@ -291,7 +278,6 @@ func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, e
}
return Result{
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
Action: "created:" + itoa(task.ID),
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -333,6 +319,23 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if err != nil {
return Result{}, err
}
if !force && c.MaxTurns > 0 {
userTurns := 0
for _, h := range history {
if h.Role == "user" {
userTurns++
}
}
if userTurns > c.MaxTurns {
return Result{
Reply: "Превышен лимит ходов сбора (" + itoa(int64(c.MaxTurns)) + "). Используйте /skip чтобы сформулировать черновик, или /start для новой задачи.",
TaskID: task.ID,
Status: task.Status,
}, nil
}
}
msgs := make([]Message, 0, len(history))
for _, h := range history {
msgs = append(msgs, Message{Role: h.Role, Content: h.Content})
@@ -353,7 +356,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
if reply == "" {
reply = "Недостаточно данных. Начните заново (/start)."
}
return Result{Reply: reply, Action: "drop", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
case "propose", "ready":
// применяем черновик (для ready — текущий, без изменений)
@@ -365,7 +368,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := decChatReply(decision, "Укажи, в каком репозитории(ях) вести работу.")
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
task.Status = storage.StatusReady
if err := c.Store.UpdateTask(ctx, task); err != nil {
@@ -373,7 +376,6 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
}
return Result{
Reply: formatSummary(*task),
Action: "summary",
TaskID: task.ID,
Status: task.Status,
}, nil
@@ -385,7 +387,7 @@ func (c *Core) runDecide(ctx context.Context, task *storage.Task, force bool) (R
return Result{}, err
}
reply := buildAskReply(decision, c.MaxQuestionsPerTurn)
return Result{Reply: reply, Action: "send", TaskID: task.ID, Status: task.Status}, nil
return Result{Reply: reply, TaskID: task.ID, Status: task.Status}, nil
}
}

View File

@@ -2,6 +2,7 @@ package core
import (
"context"
"strings"
"testing"
"github.com/kamelion/ratatoskr-go/internal/storage"
@@ -44,13 +45,11 @@ func TestStartCreatesCollecting(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/start")
_, err := c.ProcessTurn(ctx, id, "/start")
if err != nil {
t.Fatalf("ProcessTurn /start: %v", err)
}
if res.Action != "greeting" {
t.Fatalf("action = %q, want greeting", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCollecting {
t.Fatalf("status = %s, want collecting", task.Status)
@@ -61,13 +60,11 @@ func TestCancelSetsCancelled(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/cancel")
_, err := c.ProcessTurn(ctx, id, "/cancel")
if err != nil {
t.Fatalf("ProcessTurn /cancel: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusCancelled {
t.Fatalf("status = %s, want cancelled", task.Status)
@@ -84,13 +81,11 @@ func TestSingleTurnPropose(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
_, err := c.ProcessTurn(ctx, id, "Сделай калькулятор")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusReady {
t.Fatalf("status = %s, want ready", task.Status)
@@ -114,9 +109,7 @@ func TestAskReturnsQuestions(t *testing.T) {
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
if res.Reply != "Уточню\n1. Какой язык?\n2. Какой срок?" {
t.Fatalf("reply = %q", res.Reply)
}
@@ -133,13 +126,11 @@ func TestAbortReturnsDrop(t *testing.T) {
})
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "привет")
_, err := c.ProcessTurn(ctx, id, "привет")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "drop" {
t.Fatalf("action = %q, want drop", res.Action)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusAborted {
t.Fatalf("status = %s, want aborted", task.Status)
@@ -153,13 +144,11 @@ func TestConsentInReady(t *testing.T) {
id := mkTask(t, store, ctx, "u1")
_, _ = c.ProcessTurn(ctx, id, "сделай задачу")
res, err := c.ProcessTurn(ctx, id, "создавай")
_, err := c.ProcessTurn(ctx, id, "создавай")
if err != nil {
t.Fatalf("ProcessTurn создавай: %v", err)
}
if res.Action != "created:"+itoa(id) {
t.Fatalf("action = %q, want created:%d", res.Action, id)
}
task, _ := store.GetTask(ctx, id)
if task.Status != storage.StatusApproved {
t.Fatalf("status после создавай = %s, want approved", task.Status)
@@ -176,13 +165,11 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
_, _ = c.ProcessTurn(ctx, id, "сделай X")
// в ready пишем правку, не согласие
res, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
_, err := c.ProcessTurn(ctx, id, "нет, лучше Y")
if err != nil {
t.Fatalf("ProcessTurn edit: %v", err)
}
if res.Action != "summary" {
t.Fatalf("action = %q, want summary", res.Action)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
@@ -194,25 +181,59 @@ func TestEditInReadyGoesCollecting(t *testing.T) {
func TestRetryNotFound(t *testing.T) {
c, ctx, _ := setupCore(t, nil)
res, err := c.ProcessTurn(ctx, 999, "/retry 999")
_, err := c.ProcessTurn(ctx, 999, "/retry 999")
if err != nil {
t.Fatalf("ProcessTurn /retry: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
}
func TestUnknownCommand(t *testing.T) {
c, ctx, store := setupCore(t, nil)
id := mkTask(t, store, ctx, "u1")
res, err := c.ProcessTurn(ctx, id, "/bogus")
_, err := c.ProcessTurn(ctx, id, "/bogus")
if err != nil {
t.Fatalf("ProcessTurn: %v", err)
}
if res.Action != "send" {
t.Fatalf("action = %q, want send", res.Action)
}
func TestMaxTurnsBlocksExcessCollection(t *testing.T) {
var calls int
c, ctx, store := setupCore(t, func(ctx context.Context, history []Message, draft storage.Task, force bool) (Decision, error) {
calls++
return Decision{Phase: "ask", ChatReply: "Ещё вопрос"}, nil
})
c.MaxTurns = 2
id := mkTask(t, store, ctx, "u1")
_, err := c.ProcessTurn(ctx, id, "первый факт")
if err != nil {
t.Fatalf("1-й ход: %v", err)
}
_, err = c.ProcessTurn(ctx, id, "второй факт")
if err != nil {
t.Fatalf("2-й ход: %v", err)
}
res, err := c.ProcessTurn(ctx, id, "третий факт")
if err != nil {
t.Fatalf("3-й ход: %v", err)
}
if calls != 2 {
t.Fatalf("decide calls = %d, want 2", calls)
}
if !strings.Contains(res.Reply, "лимит") {
t.Fatalf("reply = %q, want упоминание лимита", res.Reply)
}
// /skip — принудительный вызов, лимит не мешает
_, err = c.ProcessTurn(ctx, id, "/skip")
if err != nil {
t.Fatalf("/skip: %v", err)
}
if calls != 3 {
t.Fatalf("decide calls after /skip = %d, want 3", calls)
}
}

View File

@@ -29,7 +29,6 @@ type Decider interface {
// Result — результат одного хода.
type Result struct {
Reply string
Action string // send | summary | created:N | abort | drop | greeting
TaskID int64
Status storage.Status
}

219
internal/opencode/client.go Normal file
View 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] + "..."
}

View 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)
}
}

View File

@@ -4,7 +4,6 @@
// Контракты перенесены 1-в-1 из Python-версии (extract.py / opencode.py):
// - ExtractVerdict: последний text-парт из NDJSON-потока opencode run --format json
// - ExtractJSON: fenced ```json``` → первый {...}
// - Run/ResumeDev: запуск процесса с idle/hard timeout по opencode.db
package opencode
import (
@@ -16,7 +15,6 @@ import (
var (
fenceRe = regexp.MustCompile("```(?:json)?\\s*([\\s\\S]*?)```")
jsonBlockRe = regexp.MustCompile("\\{[\\s\\S]*\\}")
sessionRe = regexp.MustCompile(`"session_id"\s*:\s*"([^"]+)"`)
)
// ExtractVerdict возвращает текст вердикта из NDJSON-потока opencode run --format json.
@@ -76,11 +74,12 @@ func ExtractJSON(text string) (map[string]json.RawMessage, bool) {
return nil, false
}
// SessionIDFromOutput извлекает session_id из текстового вывода opencode.
func SessionIDFromOutput(out string) (string, bool) {
m := sessionRe.FindStringSubmatch(out)
if len(m) > 1 && m[1] != "" {
return m[1], true
// stripFence обрезает внешние ```json``` (или ```) ограждения вокруг фрагмента.
// Используется для вердиктов, которые модель может вернуть в markdown-фенсе.
func stripFence(s string) string {
s = strings.TrimSpace(s)
if f := fenceRe.FindStringSubmatch(s); len(f) > 1 {
s = strings.TrimSpace(f[1])
}
return "", false
return strings.TrimSpace(s)
}

View File

@@ -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")
}
}

View File

@@ -2,36 +2,11 @@ package opencode
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
)
// parseLiveStep пытается распарсить одну NDJSON-строку stdout opencode как
// событие (text/tool/agent). Возвращает nil, если строка не является событием.
func parseLiveStep(line string) *LiveStep {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "{") {
return nil
}
var obj struct {
Type string `json:"type"`
Part struct {
Text string `json:"text"`
} `json:"part"`
}
if err := json.Unmarshal([]byte(line), &obj); err != nil {
return nil
}
if obj.Type == "" {
return nil
}
return &LiveStep{Type: obj.Type, Text: obj.Part.Text, At: time.Now()}
}
// LiveStep — один наблюдаемый шаг агента из NDJSON-потока opencode run.
// Собирается из live-строк stdout, не из БД.
// LiveStep — один наблюдаемый шаг агента.
type LiveStep struct {
Type string // "text" | "tool" | "agent" | ...
Text string // содержимое text-парта (для других типов может быть пустым)

View File

@@ -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)
}

View File

@@ -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
View 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
}

View 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]
}

View 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)
}

View 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()
}

View File

@@ -1,48 +1,42 @@
package opencode
import (
"bufio"
"context"
"database/sql"
"errors"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"time"
_ "modernc.org/sqlite" // чисто-Go драйвер, без CGO → один статический бинарь
)
// Result — результат запуска opencode run. rc=-1 означает «убит по таймауту»
// (idle/hard): вызывающий НЕ должен ронять задачу, а обязан закоммитить/запушить
// готовую работу и отправить на ревью (класс O2 Timeout — результат, не ошибка).
// Result — результат запуска opencode-субагента через HTTP API. rc=-1 означает
// «оборван по таймауту/контексту» (idle/hard): вызывающий НЕ должен ронять
// задачу, а обязан закоммитить/запушить готовую работу и отправить на ревью
// (класс O2 Timeout — результат, не ошибка).
type Result struct {
RC int
Stdout string
SessionID string
}
// Runner — конфигурация запуска opencode-субагентов.
// Runner — запуск opencode-субагентов через HTTP API serve.
//
// Полный переход на API: Runner ходит к opencode serve через Pool→Client
// (нет spawn-модели, нет NDJSON). Агент идёт в сервер пула для своего каталога
// (в нём запущен serve → он его project).
type Runner struct {
Bin string // путь к opencode (по умолчанию "opencode")
DBPath string // путь к opencode.db (idle-детекция активности)
Config string // путь к opencode.json (OPENCODE_CONFIG)
ConfigDir string // путь к каталогу с агентами (OPENCODE_CONFIG_DIR)
IdleTimeout time.Duration // нет активных live-строк в стриме И сообщений в БД → завис
HardTimeout time.Duration // общий лимит на запуск
Pool *Pool // пул serve-серверов (обязательный)
IdleTimeout time.Duration
HardTimeout time.Duration
PollInterval time.Duration
Debug bool // отладочные логи API-вызовов (из log.level=debug)
// Заменяемые для тестов:
// Заменяемый для тестов:
Stdout io.Writer // диагностика (лог), по умолчанию os.Stderr
}
func (r *Runner) defaults() {
if r.Bin == "" {
r.Bin = "opencode"
}
if r.IdleTimeout == 0 {
r.IdleTimeout = 5 * time.Minute
}
@@ -61,232 +55,113 @@ func (r *Runner) logf(format string, args ...any) {
fmt.Fprintf(r.Stdout, format+"\n", args...)
}
// maxDirMsgTS — максимальный time_updated (мс) по всем сообщениям сессий этого
// worktree: сигнал «модель/субагенты ещё активны». nil-nil если БД нет/пуста.
func (r *Runner) maxDirMsgTS(ctx context.Context, worktree string) (int64, bool) {
if r.DBPath == "" {
return 0, false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return 0, false
}
defer db.Close()
var ts sql.NullInt64
err = db.QueryRowContext(ctx,
"SELECT MAX(m.time_updated) FROM message m JOIN session s ON s.id = m.session_id WHERE s.directory = ?",
worktree).Scan(&ts)
if err != nil || !ts.Valid {
return 0, false
}
return ts.Int64, true
}
func (r *Runner) latestSession(ctx context.Context, worktree, agent string) (string, bool) {
if r.DBPath == "" {
return "", false
}
db, err := sql.Open("sqlite", "file:"+r.DBPath+"?mode=ro")
if err != nil {
return "", false
}
defer db.Close()
q := "SELECT id FROM session WHERE directory = ?"
args := []any{worktree}
if agent != "" {
q += " AND agent = ?"
args = append(args, agent)
}
q += " ORDER BY time_created DESC LIMIT 1"
var id string
if err := db.QueryRowContext(ctx, q, args...).Scan(&id); err != nil {
return "", false
}
return id, id != ""
}
// Run запускает opencode run. Возвращает *Result (rc, stdout, session_id).
// Ошибка — только класс O1 ErrSpawn (не смог запустить бинарь). Таймауты
// дают rc=-1 в Result, а не error (класс O2).
// Run запускает opencode-субагента через HTTP API: создаёт/продолжает сессию
// в сервере пула для каталога cwd, отправляет промпт, ждёт вердикт.
//
// Возвращает *Result (rc, stdout=вердикт, session_id). Ошибка — только класс
// O1 ErrRun (не смог обратиться к серверу/сессии). Таймауты дают rc=-1 в
// Result, а не error (класс O2).
func (r *Runner) Run(ctx context.Context, prompt, cwd, agent, sessionID string) (*Result, error) {
r.defaults()
cmd := []string{r.Bin, "run", "--agent", agent, "--format", "json", "--dir", cwd}
if sessionID != "" {
cmd = append(cmd, "--session", sessionID)
}
cmd = append(cmd, prompt)
env := append(os.Environ(),
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DISABLE_MODELS_FETCH=1")
if r.Config != "" {
env = append(env, "OPENCODE_CONFIG="+r.Config)
}
if r.ConfigDir != "" {
env = append(env, "OPENCODE_CONFIG_DIR="+r.ConfigDir)
if r.Pool == nil {
return nil, fmt.Errorf("opencode: Pool не задан (API-режим обязателен)")
}
proc := exec.CommandContext(ctx, cmd[0], cmd[1:]...)
proc.Env = env
proc.Dir = cwd
// Убиваем всю process-group, чтобы дочерние процессы (sleep и т.п.) тоже
// умерли и закрыли унаследованные stdout-fd (иначе <-done виснет).
setpgid(proc)
stdout, err := proc.StdoutPipe()
srv, err := r.Pool.Ensure(ctx, cwd)
if err != nil {
return nil, fmt.Errorf("opencode: stdout pipe: %w", err)
return nil, err
}
proc.Stderr = proc.Stdout
if err := proc.Start(); err != nil {
return nil, fmt.Errorf("opencode: start %v: %w", cmd[0], err)
c := &Client{BaseURL: srv.Addr(), Password: srv.Password, Debug: r.Debug}
// Сессия: заданная (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
var mu sync.Mutex
done := make(chan struct{})
// liveSeq — кол-во распознанных live-строк (text/tool/agent/reasoning) в
// NDJSON-потоке. Инкрементится из goroutine чтения; поллинг сравнивает,
// чтобы сбросить idle-таймер «пока LLM стримит» (а не только по БД).
var liveSeq atomic.Uint64
prevLive := liveSeq.Load()
// Живое наблюдение сессии (если задано через WithLive в контексте).
liveReg, liveTask := liveFromContext(ctx)
if liveReg != nil && liveTask != 0 {
liveReg.Start(liveTask, agent)
defer liveReg.Finish(liveTask)
// Отправляем промпт (блокирующий Send в горутине; вердикт придёт из него),
// параллельно поллим прогресс и контролируем idle/hard таймауты.
return r.awaitVerdict(ctx, c, sid, agent, prompt)
}
// awaitVerdict запускает блокирующий Send и параллельно поллит прогресс
// (рост числа text-частей = агент жив, сбрасывает idle). Возвращается вердикт
// из ответа Send, либо rc=-1 при idle/hard таймауте (тогда Abort + отмена ctx).
func (r *Runner) awaitVerdict(ctx context.Context, c *Client, sid, agent, prompt string) (*Result, error) {
sendCtx, cancel := context.WithCancel(ctx)
defer cancel()
type sendOut struct {
vd string
err error
}
sendCh := make(chan sendOut, 1)
go func() {
defer close(done)
sc := bufio.NewScanner(stdout)
// NDJSON opencode пишет каждый объект одной строкой; большой text-парт с
// вердиктом легко превышает дефолтный лимит Scanner в 64КБ → ErrTooLong и
// потеря всего потока после первой строки. Поднимаем до 64МБ.
sc.Buffer(make([]byte, 64*1024), 64*1024*1024)
for sc.Scan() {
line := sc.Text()
mu.Lock()
buf = append(buf, line)
mu.Unlock()
if st := parseLiveStep(line); st != nil {
// «пульс» LLM: что-то стримится/вызывается — сбрасываем idle
liveSeq.Add(1)
if liveReg != nil {
liveReg.Observe(liveTask, *st)
}
}
}
scanErr := sc.Err()
if scanErr != nil {
r.logf("opencode(%s) scan err: %v", agent, scanErr)
}
vd, err := c.Send(sendCtx, sid, prompt)
sendCh <- sendOut{vd: vd, err: err}
}()
baseline, _ := r.maxDirMsgTS(ctx, cwd)
// Прогресс = сумма text-частей во всех assistant-сообщениях сессии. Рост
// сбрасывает idle-таймер (LLM стримит = жив).
var mu sync.Mutex
lastCount := -1
lastProgress := 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 {
select {
case <-done:
// процесс завершился (pipe EOF) — выходим, берём exit code
break pollLoop
case <-ctx.Done():
killGroup(proc)
killed = true
break pollLoop
default:
}
if proc.ProcessState != nil && proc.ProcessState.Exited() {
break pollLoop
}
now := time.Now()
// «Пульс» LLM: если с прошлого поллинга появились live-строки
// (text/tool/agent/reasoning) — LLM реально работает, сбрасываем idle.
if cur := liveSeq.Load(); cur != prevLive {
prevLive = cur
lastProgress = now
}
ts, ok := r.maxDirMsgTS(ctx, cwd)
if ok && ts > baseline {
lastProgress = now
}
if now.Sub(lastProgress) > r.IdleTimeout {
r.logf("opencode(%s) idle %.0fs (нет новых сообщений) — kill", agent, r.IdleTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
}
if now.Sub(launch) > r.HardTimeout {
r.logf("opencode(%s) hard timeout %.0fs — kill", agent, r.HardTimeout.Seconds())
killGroup(proc)
killed = true
break pollLoop
}
time.Sleep(r.PollInterval)
if ctx.Err() != nil {
r.logf("opencode(%s) ctx cancelled — обрыв (rc=-1)", agent)
return abortAnd(-1, "ctx")
}
<-done
procErr := proc.Wait()
rc := proc.ProcessState.ExitCode()
if rc < 0 {
rc = 1
}
if killed {
rc = -1
}
_ = procErr
count, _ := c.textCount(ctx, sid)
mu.Lock()
out := strings.Join(buf, "\n")
if count != lastCount {
lastProgress = time.Now()
lastCount = count
}
mu.Unlock()
r.logf("opencode(%s) lines=%d bytes=%d", agent, len(buf), len(out))
now := time.Now()
if now.Sub(lastProgress) > r.IdleTimeout {
r.logf("opencode(%s) idle %.0fs — abort", agent, r.IdleTimeout.Seconds())
return abortAnd(-1, "idle")
}
// hard — общий бюджет от старта запуска.
if now.Sub(launch) > r.HardTimeout {
r.logf("opencode(%s) hard timeout %.0fs — abort", agent, r.HardTimeout.Seconds())
return abortAnd(-1, "hard")
}
sid := sessionID
if s, ok := SessionIDFromOutput(out); ok {
sid = s
select {
case out := <-sendCh:
// Send завершился. Ошибка — connect (сервер недоступен) и ctx жив →
// фатально, не таймаут. Если ctx уже отменён — это обрыв, а не ошибка.
if out.err != nil {
var ce *ClientErr
if errors.As(out.err, &ce) && ce.Op == "connect" && ctx.Err() == nil {
return nil, fmt.Errorf("opencode: %w", out.err)
}
if rc == -1 && sid == "" {
if s, ok := r.latestSession(ctx, cwd, agent); ok {
sid = s
if ctx.Err() != nil {
return abortAnd(-1, "ctx")
}
return nil, out.err
}
r.logf("opencode(%s) вердикт готов (%d байт)", agent, len(out.vd))
return &Result{RC: 0, Stdout: out.vd, SessionID: sid}, nil
case <-time.After(r.PollInterval):
case <-ctx.Done():
}
}
r.logf("opencode(%s) rc=%d", agent, rc)
return &Result{RC: rc, Stdout: out, SessionID: sid}, nil
}
// ResumeDev — запуск dev-агента с resume-fallback. Если resume (sessionID)
// падает с rc!=0 (напр. сессия потеряна) — повторяем ОДИН раз свежей сессией
// в том же worktree. rc=-1 (kill по таймауту) НЕ триггерит fallback.
// Возвращает (result, timedOut).
func (r *Runner) ResumeDev(ctx context.Context, prompt, cwd, sessionID string) (*Result, bool) {
res, err := r.Run(ctx, prompt, cwd, "dev", sessionID)
if err != nil {
// spawn-ошибку не ретраим fallback'ом — она повторится
return res, false
}
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()
}

View File

@@ -2,60 +2,39 @@ package opencode
import (
"context"
"os"
"path/filepath"
"net/http/httptest"
"testing"
"time"
)
// fakeOpenCode создаёт shell-скрипт, имитирующий opencode run:
//
// $FAKE_MODE=ok -> мгновенный успех, печатает NDJSON c session_id
// $FAKE_MODE=slow-> спит долго (для idle/hard timeout)
// $FAKE_MODE=fail-> exit 7 (resume-fallback)
func fakeOpenCode(t *testing.T, workdir string) string {
// fakePool создаёт Pool, в котором уже «живёт» сервер для каталога (без spawn):
// Server{URL: fake.URL}, поэтому Runner ходит по HTTP на фейк-API.
func fakePool(t *testing.T, f *fakeAPIServer, dir string) (*Pool, *Client) {
t.Helper()
bin := filepath.Join(workdir, "opencode")
script := `#!/bin/sh
mode="${FAKE_MODE:-ok}"
case "$mode" in
ok)
echo '{"type":"text","part":{"text":"done"}}'
echo '{"session_id":"sess-123"}'
exit 0
;;
slow)
sleep 30
;;
live-reset)
# шлём live-строку каждые 30мс долго — почти до hard timeout,
# чтобы idle-таймер (50мс) НЕ убил из-за стрима
i=0
while [ $i -lt 20 ]; do
echo '{"type":"text","part":{"text":"tick"}}'
sleep 0.03
i=$((i+1))
done
sleep 30
;;
fail)
echo '{"type":"text","part":{"text":"boom"}}'
exit 7
;;
esac
`
if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
t.Fatalf("write fake opencode: %v", err)
}
return bin
ts := httptestURL(t, f)
p := NewPool(dir)
p.mu.Lock()
p.segs[dir] = &Server{URL: ts, PollInterval: time.Millisecond}
p.mu.Unlock()
return p, &Client{BaseURL: ts}
}
// httptestURL запускает фейк-API и возвращает его URL.
func httptestURL(t *testing.T, f *fakeAPIServer) string {
t.Helper()
ts := httptest.NewServer(f.handler())
t.Cleanup(ts.Close)
return ts.URL
}
func TestRun_Success(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "ok")
f := &fakeAPIServer{
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", "")
if err != nil {
t.Fatalf("Run err: %v", err)
@@ -63,86 +42,39 @@ func TestRun_Success(t *testing.T) {
if res.RC != 0 {
t.Errorf("RC = %d, want 0", res.RC)
}
if res.SessionID != "sess-123" {
t.Errorf("SessionID = %q, want sess-123", res.SessionID)
if res.SessionID != "sess-fake" {
t.Errorf("SessionID = %q, want sess-fake", res.SessionID)
}
if !contains(res.Stdout, "done") {
t.Errorf("Stdout = %q, want to contain done", res.Stdout)
}
}
func TestRun_LiveRegistry(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "ok")
reg := NewLiveRegistry()
ctx := WithLive(context.Background(), reg, 42)
r := &Runner{Bin: bin, PollInterval: 20 * time.Millisecond}
res, err := r.Run(ctx, "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != 0 {
t.Fatalf("RC = %d, want 0", res.RC)
}
// После завершения Finish удаляет сессию → Snap не найден.
if _, ok := reg.Snap(42); ok {
t.Error("сессия не удалена после Finish (должна быть, т.к. задача завершилась)")
t.Errorf("Stdout = %q, want contain done", res.Stdout)
}
}
func TestRun_IdleTimeout(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
// prompt блокируется (агент «завис»), прогресс не растёт → idle abort
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
r := &Runner{Bin: bin, IdleTimeout: 50 * time.Millisecond,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: 30 * time.Millisecond,
PollInterval: 5 * time.Millisecond}
res, err := r.Run(context.Background(), "task", dir, "dev", "")
if err != nil {
t.Fatalf("Run err: %v", err)
}
if res.RC != -1 {
t.Errorf("RC = %d, want -1 (timeout kill)", res.RC)
}
}
// 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)
t.Errorf("RC = %d, want -1 (idle timeout)", res.RC)
}
}
func TestRun_ContextCancel(t *testing.T) {
dir := t.TempDir()
bin := fakeOpenCode(t, dir)
t.Setenv("FAKE_MODE", "slow")
f := &fakeAPIServer{blockPrompt: true}
p, _ := fakePool(t, f, dir)
ctx, cancel := context.WithCancel(context.Background())
r := &Runner{Bin: bin, HardTimeout: time.Minute,
PollInterval: 10 * time.Millisecond}
r := &Runner{Pool: p, IdleTimeout: time.Minute, HardTimeout: time.Minute,
PollInterval: 5 * time.Millisecond}
done := make(chan *Result, 1)
errCh := make(chan error, 1)
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 {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && indexOf(s, sub) >= 0)
}

275
internal/opencode/server.go Normal file
View 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):
}
}
}

View 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
}

View File

@@ -111,10 +111,16 @@ func TestUpdateTaskStatus(t *testing.T) {
t.Fatalf("UpdateTask collecting→ready: %v", err)
}
// ready → running
// ready → approved
task.Status = StatusApproved
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask ready→approved: %v", err)
}
// approved → running
task.Status = StatusRunning
if err := s.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask ready→running: %v", err)
t.Fatalf("UpdateTask approved→running: %v", err)
}
// running → success

View File

@@ -22,6 +22,14 @@ type OpenCodeRunner interface {
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
type PollTaskFunc func(ctx context.Context) error
// Notifier — механизм отправки авто-уведомлений владельцу задачи во время
// выполнения. В проде реализуется *app.App через chat.Router.Send (см.
// internal/app/app.go → App.Notify); в тестах worker подменяется фейковым
// нотифаером. nil — уведомления выключены (ничего не отправляется).
type Notifier interface {
Notify(ctx context.Context, taskID int64, chatID, text string) error
}
// Worker — планировщик, запускающий готовые задачи (status=ready → running → success/failed/timeout).
type Worker struct {
Store *storage.Storage
@@ -39,6 +47,10 @@ type Worker struct {
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
Live *opencode.LiveRegistry
// Notify — нотифаер авто-уведомлений владельцу задачи (статусы + хендоффы
// dev↔reviewer). nil — уведомления выключены.
Notify Notifier
sem chan struct{} // семафор
cancel context.CancelFunc
@@ -55,6 +67,27 @@ func (w *Worker) runCtx(ctx context.Context, taskID int64) context.Context {
return opencode.WithLive(ctx, w.Live, taskID)
}
// notify отправляет авто-уведомление владельцу задачи, если нотифаер задан.
func (w *Worker) notify(ctx context.Context, task *storage.Task, text string) {
if w.Notify == nil {
return
}
if err := w.Notify.Notify(ctx, task.ID, task.ChatID, text); err != nil {
log.Printf("worker: task %d: уведомление: %v", task.ID, err)
}
}
// notifyStatus — уведомление о смене статуса задачи (номер задачи + статус).
func (w *Worker) notifyStatus(ctx context.Context, task *storage.Task, s storage.Status) {
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s", task.ID, s))
}
// notifyHandoff — уведомление о передаче задачи между агентами конвейера
// на заданной итерации (1-based).
func (w *Worker) notifyHandoff(ctx context.Context, task *storage.Task, from, to string, iteration int) {
w.notify(ctx, task, fmt.Sprintf("Задача #%d: %s → %s (итерация %d)", task.ID, from, to, iteration))
}
// Start запускает цикл опроса в фоновой горутине.
func (w *Worker) Start(ctx context.Context) {
if w.Agent == "" {
@@ -163,6 +196,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if err := w.Store.UpdateTask(ctx, task); err != nil {
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
}
w.notifyStatus(ctx, task, storage.StatusRunning)
// 2b. клонируем недостающие репозитории в общий каталог.
if err := w.prepareRepos(ctx, repos); err != nil {
@@ -231,6 +265,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusTimeout)
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
return nil
default:
@@ -238,6 +273,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusFailed)
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
return nil
}
@@ -245,6 +281,9 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
// dev завершился RC=0 → сохраняем успех трассы dev.
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
// уведомляем пользователя о передаче dev → reviewer на ревью.
w.notifyHandoff(ctx, task, "dev", "reviewer", iter+1)
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
if dErr != nil {
@@ -275,6 +314,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusFailed)
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
return nil
}
@@ -289,11 +329,13 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notifyStatus(ctx, task, storage.StatusSuccess)
return nil
}
// Не пройдено: если есть итерации — dev дорабатывает.
if iter+1 < maxReviewIterations {
w.notify(ctx, task, fmt.Sprintf("Задача #%d: reviewer → dev на доработку (итерация %d)", task.ID, iter+1))
feedback = verdict.Comments
continue
}
@@ -303,6 +345,7 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
if e := w.Store.UpdateTask(ctx, task); e != nil {
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
}
w.notify(ctx, task, fmt.Sprintf("Задача #%d: failed — ревью не пройдено за %d итераций", task.ID, maxReviewIterations))
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
final := reviewOutput + "\n" + explain
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
@@ -330,12 +373,13 @@ func (w *Worker) reviewWithRetry(ctx context.Context, taskID int64, cwd, prompt
return v2, out2, tid2, nil
}
// failTask помечает задачу failed.
// failTask помечает задачу failed и уведомляет владельца.
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
task.Status = storage.StatusFailed
if e := w.Store.UpdateTask(ctx, task); e != nil {
log.Printf("worker: task %d: set failed: %v", task.ID, e)
}
w.notifyStatus(ctx, task, storage.StatusFailed)
}
// finalizeTrace обновляет output и статус трассы.

View File

@@ -8,6 +8,7 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
@@ -17,6 +18,32 @@ import (
"github.com/kamelion/ratatoskr-go/internal/storage"
)
// fakeNotifier — фейковый нотифаер, собирающий все авто-уведомления воркера.
type fakeNotifier struct {
notifs []notifCall
}
// notifCall — одно перехваченное уведомление.
type notifCall struct {
taskID int64
chatID string
text string
}
func (f *fakeNotifier) Notify(_ context.Context, taskID int64, chatID, text string) error {
f.notifs = append(f.notifs, notifCall{taskID: taskID, chatID: chatID, text: text})
return nil
}
// notifTexts возвращает тексты уведомлений в порядке отправки.
func notifTexts(n *fakeNotifier) []string {
texts := make([]string, len(n.notifs))
for i, c := range n.notifs {
texts[i] = c.text
}
return texts
}
type mockRunnerWorker struct {
result *opencode.Result
err error
@@ -263,6 +290,186 @@ func TestWorkerReviewMaxIterations(t *testing.T) {
}
}
// TestWorkerStatusNotifications — happy path: владелец получает уведомления
// на каждый переход статуса со стороны воркера (running → success)
// и на хендофф dev→reviewer.
func TestWorkerStatusNotifications(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "notif-ok")
n := &fakeNotifier{}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"}},
Worktree: t.TempDir(),
Agent: "dev",
Notify: n,
}
seedFakeRepo(t, w.Worktree, "notif-ok")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
t.Fatalf("runTask: %v", err)
}
if len(n.notifs) != 3 {
t.Fatalf("уведомлений = %d, want 3 (running, dev→reviewer, success)", len(n.notifs))
}
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
want := []string{
prefix + ": running",
prefix + ": dev → reviewer (итерация 1)",
prefix + ": success",
}
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
t.Errorf("уведомления = %#v, want %#v", got, want)
}
// все уведомления уходят владельцу задачи (task.ChatID)
for _, c := range n.notifs {
if c.chatID != task.ChatID {
t.Errorf("уведомление ушло в %q, want %q", c.chatID, task.ChatID)
}
if c.taskID != task.ID {
t.Errorf("уведомление для задачи %d, want %d", c.taskID, task.ID)
}
}
}
// TestWorkerHandoffNotifications — цикл dev↔review: уведомления на оба хендоффа
// (dev→reviewer и reviewer→dev на доработку) с номером задачи и итерации.
func TestWorkerHandoffNotifications(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "notif-loop")
n := &fakeNotifier{}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
reviewSequence: []*opencode.Result{
reviewFailedRunner(),
{RC: 0, Stdout: `{"passed":true,"comments":[]}`},
},
},
Worktree: t.TempDir(),
Agent: "dev",
Notify: n,
}
seedFakeRepo(t, w.Worktree, "notif-loop")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
t.Fatalf("runTask: %v", err)
}
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
want := []string{
prefix + ": running",
prefix + ": dev → reviewer (итерация 1)",
prefix + ": reviewer → dev на доработку (итерация 1)",
prefix + ": dev → reviewer (итерация 2)",
prefix + ": success",
}
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
t.Errorf("уведомления = %#v, want %#v", got, want)
}
}
// TestWorkerIterationsLimitNotification — при исчерпании лимита итераций
// владельцу уходит одно уведомление о failed (без дублей с running→failed).
func TestWorkerIterationsLimitNotification(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "notif-lim")
n := &fakeNotifier{}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{
result: &opencode.Result{RC: 0, Stdout: "done", SessionID: "sess-1"},
reviewResult: reviewFailedRunner(),
},
Worktree: t.TempDir(),
Agent: "dev",
Notify: n,
}
seedFakeRepo(t, w.Worktree, "notif-lim")
ctx := context.Background()
if err := w.runTask(ctx, task); err != nil {
t.Fatalf("runTask: %v", err)
}
texts := notifTexts(n)
if len(texts) == 0 {
t.Fatal("нет уведомлений")
}
last := texts[len(texts)-1]
if !strings.Contains(last, "failed") {
t.Errorf("последнее уведомление = %q, want упоминание failed", last)
}
if !strings.Contains(last, "итераци") {
t.Errorf("последнее уведомление = %q, want упоминание лимита итераций", last)
}
// ровно одно уведомление о failed (running→failed не задваивается)
var failedCount int
for _, txt := range texts {
if strings.Contains(txt, ": failed") {
failedCount++
}
}
if failedCount != 1 {
t.Errorf("уведомлений о failed = %d, want ровно 1: %#v", failedCount, texts)
}
}
// TestWorkerTimeoutNotification — RC=-1 (таймаут dev) → уведомление о timeout.
func TestWorkerTimeoutNotification(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "notif-timeout")
n := &fakeNotifier{}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{result: &opencode.Result{RC: -1, Stdout: ""}},
Worktree: t.TempDir(),
Notify: n,
}
seedFakeRepo(t, w.Worktree, "notif-timeout")
ctx := context.Background()
_ = w.runTask(ctx, task)
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
want := []string{prefix + ": running", prefix + ": timeout"}
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
t.Errorf("уведомления = %#v, want %#v", got, want)
}
}
// TestWorkerSpawnErrorNotification — сбой запуска dev → уведомление о failed.
func TestWorkerSpawnErrorNotification(t *testing.T) {
s := setupWorkerDB(t)
task := createReadyTask(t, s, "notif-spawn")
n := &fakeNotifier{}
w := &Worker{
Store: s,
Runner: &mockRunnerWorker{err: errors.New("opencode not found")},
Worktree: t.TempDir(),
Notify: n,
}
seedFakeRepo(t, w.Worktree, "notif-spawn")
ctx := context.Background()
_ = w.runTask(ctx, task)
prefix := "Задача #" + strconv.FormatInt(task.ID, 10)
want := []string{prefix + ": running", prefix + ": failed"}
if got := notifTexts(n); !reflect.DeepEqual(got, want) {
t.Errorf("уведомления = %#v, want %#v", got, want)
}
}
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
// где JSON находится внутри последнего text-парта.
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {