# Ratatoskr-go Ratatoskr на Go — **оркестратор в единый статический бинарь** + субагенты через [opencode](https://opencode.ai) как внешний процесс. Переезд с Python-версии: все подсистемы (диалог, воркер, автообновление) собраны в один исполняемый файл без runtime-зависимостей (`CGO_ENABLED=0`), что упрощает доставку и деплой. ## Структура ``` cmd/ratatoskr/ # точка входа, сборка бинаря (App.New + Run) internal/ app/ # компоновка (composition root), config, DI, packageOwner config/ # загрузка YAML+env: ε подстановка ${VAR:-default}, defaults, validate (классы C1–C4) chat/ # мультиканальный Router; telegram — long-poll канал core/ # state-machine задач: /start /cancel /skip /retry N /status N /continue N analyst/ # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1–A4) worker/ # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*) agents/ # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed opencode/ # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1–O5) storage/ # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1–S5) update/ # автообновление из Gitea Packages (классы U1–U6) ``` ## Сборка Требуется Go (на хосте без `go` в PATH — экспорт вручную): ```bash export PATH=/opt/data/.local/go/bin:$PATH # локальный Go-тулчейн go version # go1.25.0 linux/amd64 ``` ### Обычная сборка (текущая платформа) ```bash cd /opt/data/src/ratatoskr-go make build # go build -ldflags="-s -w" -o ratatoskr ./cmd/ratatoskr/ ./ratatoskr -version ``` ### Кросс-сборка под linux/amd64 и windows/amd64 (только эти две платформы) ```bash make cross # → ratatoskr-linux-amd64 # → ratatoskr-windows-amd64.exe (Windows-машина Камиля) ``` `Makefile` вшивает в бинарь версию (ldflag): ```make GIT_SHA := $(shell git rev-parse --short HEAD || echo dev) GOFLAGS ?= -ldflags="-s -w -X main.version=commit-$(GIT_SHA) -X main.updateToken=$(UPDATE_TOKEN)" ``` - `main.version` = `commit-` — та же версия, под которой бинарь публикуется в Gitea Packages. - `main.updateToken` = токен read:package для автообновления (см. ниже). Если не задан (`UPDATE_TOKEN ?= ""`) — пустая встройка, тогда обновление берёт `update.token` из конфига. **Как на самом деле публикуется рабочий бинарь:** локальный `go build` токен НЕ вшивает (значение секрета есть только у CI). Запушь `main` — CI (см. ниже) сам соберёт оба бинаря со вшитым `TC_UPDATE_TOKEN` и загрузит их в Gitea Packages. Так соблюдён least-privilege: read-токен не светится на хосте разработки. ### Проверки перед сборкой ```bash make test # go test ./... -v -count=1 -timeout 120s make vet # go vet ./... ``` ### Прогон ```bash make run # build + ./ratatoskr -config config.yaml ``` ## CI (.gitea/workflows/ci.yaml) - **Job `test`**: checkout → setup-go → `go test ./...` → `go vet ./...`. - **Job `build-and-package`** (matrix linux/amd64 + windows/amd64, без артефактов): собирает бинарь со вшитым `-X main.updateToken=${secrets.TC_UPDATE_TOKEN}` и **публикует в Gitea Packages** — только на `main`, сразу в версию `commit-/` (псевдо-версия `latest` НЕ используется — см. автообновление). Плюс companion-файлы `.version` и `.sha256`. Секреты/vars: `TC_GITEA_TOKEN` (write:packages), `TC_UPDATE_TOKEN` (read:package), `GIT_MAIN_URL`. ## Конфигурация (config.yaml) Минимальный рабочий конфиг (порядок полей произвольный) и блок автообновления (подробнее в `config.yaml.example`): ```yaml telegram: enabled: true # false — отключить Telegram (без сетевых вызовов к api.telegram.org) token: "..." # TG_TOKEN (обязательно, если enabled) chat_id: "..." # TG_CHAT_ID (обязательно, если enabled) opencode: bin: "opencode" paths: db: "./ratatoskr.db" # дефолт; резолвится от каталога бинаря (не от cwd) worktree: "./worktrees" # то же правило «всё рядом с .exe» update: enabled: true base_url: "http://gitea.hal9000.home" # env UPDATE_BASE_URL; пуст = обновление ОТКЛЮЧЕНО token: "${UPDATE_TOKEN}" # env UPDATE_TOKEN — фоллбэк, если не вшит ldflag package: "ratatoskr" check_interval: 24h ``` **Пути «всё рядом с .exe»:** относительные `db`/`worktree` резолвятся к каталогу бинаря (`config.ExeDir()`), а НЕ к cwd. `config.yaml` ищется сначала рядом с бинарём, фоллбэк — cwd. Абсолютные пути не трогаются. ## Команды бота | Команда | Описание | |---|---| | `/start` | начать новую задачу | | `/cancel` | отменить активную задачу | | `/skip` | пропустить шаг | | `/retry N` | повторить задачу N | | `/status N` | статус задачи N | | `/continue N` | продолжить | | `/update` | проверить + применить обновление из Gitea Packages | | `/status` | версия бинаря + есть ли доступное обновление | | `/help` | справка по всем командам | ## Интеграция с opencode (субагенты) Субагенты (analyst / dev / reviewer / postmortem) запускаются через **headless** `opencode serve` по **v2 HTTP API** (префикс `/api/*`). Требуемая версия opencode: **>= 1.18.18** (сборки с v2 HTTP API). Старый бинарь, отвечающий только на `/global/health`, не подходит: healthcheck падает с понятной ошибкой (класс O1). Что делает обёртка (`internal/opencode`): - **Модель — только глобальный конфиг opencode.** ratatoskr модель не выбирает и про неё не знает: opencode сам берёт модель по умолчанию из своего глобального конфига (`~/.config/opencode/opencode.jsonc`). Наш код конфиг opencode не читает. - **Свой агент.** При создании сессии в `POST /api/session` передаётся имя встроенного агента ratatoskr (analyst/dev/reviewer/chat/postmortem). Агенты распаковываются в `/.opencode/agent/*.md`, где opencode находит их через project-каталог `.opencode` (см. `internal/agents`); глобальный конфиг при этом не трогается. - **Поллинг вердикта.** Промпт отправляется неблокирующе (`POST .../prompt` → durable admit), вердикт собирается из новых assistant-сообщений (`GET .../message`); завершение ответа — сессия ушла из активных дренажей (`GET .../active`) и появилось финальное assistant-сообщение, стабильное несколько опросов подряд. ## Фазы аналитика Аналитик (`internal/analyst`) возвращает JSON-вердикт с полем `phase`: | phase | Смысл | Требования валидатора | |---|---|---| | `ask` | данных не хватает — задаёт уточняющие вопросы | есть `chat_reply` или `questions` | | `propose` | черновик достаточно заполнен, вносятся правки | хотя бы одно изменённое поле (`title`/`goal`/`repos`/`why`/`ac`) | | `ready` | черновик уже полный и готов как есть — менять нечего | изменённых полей не требуется | | `abort` | тема не про код / не подходит | опционально `abort_reason` | `ready` был добавлен как явная фаза для случая, когда аналитик видит полностью готовый черновик и просто запускает задачу агенту-кодеру (раньше модель не могла это выразить и возвращала пустой `propose`, который валидатор резал как «нет изменённых полей»). В `internal/core` фазы `propose` и `ready` обрабатываются одинаково (применить черновик, проверить репозитории, поставить `ready` и отдать резюме). ## Постмортем после failed/timeout Когда задача завершилась `failed` или `timeout`, воркер дополнительно запускает **постмортем-анализ** (`internal/worker/postmortem.go`, agent `postmortem`): - анализирует сессии dev/reviewer (промпты и выводы из `traces`); - оценивает законченность этапов и причины сбоя; - сохраняет результат как trace `agent=postmortem` и шлёт владельцу уведомление «🔍 анализ (после <статус>): почему так случилось / что сделать». Статус задачи постмортем не меняет; сбои самого анализа не влияют на исход задачи. ## Автообновление из Gitea Packages Бинарь умеет сам себя обновлять из generic-пакета в Gitea. Модель: **авто = только Check + уведомление** владельцу («доступна версия X, выполни /update»), **применение — только по `/update`**. - **Источник версий:** `{update.base_url}/api/packages/{owner}/generic/{package}`, где `owner = "kamelion"` (константа `packageOwner` в коде), `package = "ratatoskr"`. - **Каждая публикация — в конкретную версию `commit-/`** (никакого `latest/`: при параллельных PUT матрицы CI псевдо-версия `latest` разъезжается, и контрольные суммы не сходятся — класс ошибки U4). Новейшая применимая версия ищется листингом: фильтр `commit-*`, проба скачать бинарь платформы, берём версию с макс `id`. - **Companion-файлы** рядом с бинарём в той же версии: `..version` (id версии) и `.sha256` (контрольная сумма). Verify всегда сверяет все три файла **одной** версии. - **Update-хост ОТЛЕН от git-хоста:** `update.base_url` свой (env `UPDATE_BASE_URL`), фоллбэка на `git.base_url` НЕТ. Пустой `update.base_url` полностью отключает обновление. - **Токен доступа — только `read:package`, НЕ `git.token`** (least-privilege: update ≠ git). Приоритет: вшитый ldflag `-X main.updateToken` (секрет CI `TC_UPDATE_TOKEN`) > `update.token` из конфига (`UPDATE_TOKEN`). Заголовок: `Authorization: token <токен>`. - **Применение (свап):** POSIX — exec-перезапуск процесса; Windows — мгновенный exec невозможен (файл занят), поэтому `.new` применяется при следующем старте (`ApplyPendingSwap` в main.go до подсистем). Резервный `.old` для отката. ### Как опубликовать новую версию 1. Внеси правки, коммитни, запуши в `main`. 2. CI соберёт оба бинаря со вшитым `TC_UPDATE_TOKEN` и опубликует в `.../generic/ratatoskr/commit-/` (бинарь + `.version` + `.sha256`). 3. На обновляемой машине: бот сам уведомит о новой версии → `/update`. Проверку автообновления после публикации гоняет владелец на своей Windows-машине (не прогоняется агентом). ### Валидация опубликованной версии ```bash B=http://gitea.hal9000.home V=commit-; f=ratatoskr-linux-amd64 curl -s -H "Authorization: token $TOKEN" -o /tmp/probe.bin "$B/api/packages/kamelion/generic/ratatoskr/$V/$f" echo "actual: $(sha256sum /tmp/probe.bin)" # реальная сумма скачанного curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratatoskr/$V/$f.version" curl -s -H "Authorization: token $TOKEN" "$B/api/packages/kamelion/generic/ratatoskr/$V/$f.sha256" # .version == $V и .sha256 == actual → версия целостна ``` ## Классы ошибок | Блок | Коды | Где | |---|---|---| | C | C1–C4 | `internal/config` | | A | A1–A4 | `internal/analyst` | | M | M1–M5 | `internal/chat` | | O | O1–O5 | `internal/opencode` | | S | S1–S5 | `internal/storage` | | W | W1–W5 | `internal/worker` | | E | E1–E4 | `internal/worker` (репозитории) | | R | R1–R6 | `internal/worker/review_errors.go` (ревьюер) | | U | U1–U6 | `internal/update` | ## Контракты (не ломать) - `App.New(configPath, version, updateToken string)` — сигнатура: при правках обновлять все вызовы в `internal/app/app_test.go` (иначе `go vet` падает). - `packageOwner` — константа (`"kamelion"`) в `internal/app/app.go`: владелец пакета == владелец репо == создатель токена; инвариант на проект, НЕ плодить vars/ldflag/конфиг. - `update.Updater` создаётся структурой `&update.Updater{...}` напрямую (конструктора `NewUpdater` нет). - `process_turn` (Python-версия) всегда возвращает 3 значения; `decide_fn` — кортеж `(decision, err)`. Вердикты агентов — строгий JSON. ## Дополнительно - Дизайн eval-suite (парсинг A / контракты B / качество C) — `../wiki/concepts/ratatoskr-evals.md`. - Прочие команды/go-модель — см. скилл `ratatoskr-development`.