OPENCODE_CONFIG_DIR в opencode v1.18.18 перенаправляет Global.Path.config (global.ts: config = OPENCODE_CONFIG_DIR ?? ~/.config/opencode), из-за чего глобальный конфиг (модель/провайдеры, напр. tokentool) не загружался и opencode уходил в fallback-модель. Агенты открывались, т.к. OPENCODE_CONFIG_DIR дополнительно сканируется как каталог для agent/*.md. Теперь агенты распаковываются в <worktree>/.opencode/agent/*.md, где opencode находит их через project-каталог .opencode (paths.ts, cwd=worktree). Глобальный конфиг не трогаем вовсе. - internal/opencode: удалены Server.ConfigDir/Pool.ConfigDir и env OPENCODE_CONFIG_DIR - internal/config: удалено поле OpenCodeCfg.ConfigDir (config_dir) - internal/app: ensureAgentsDir пишет в <worktree>/.opencode/agent - internal/agents: WriteTo(dir) → dir/agent/*.md - README/config.yaml.example/memory обновлены
16 KiB
Ratatoskr-go
Ratatoskr на Go — оркестратор в единый статический бинарь + субагенты через
opencode как внешний процесс. Переезд с 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 — экспорт вручную):
export PATH=/opt/data/.local/go/bin:$PATH # локальный Go-тулчейн
go version # go1.25.0 linux/amd64
Обычная сборка (текущая платформа)
cd /opt/data/src/ratatoskr-go
make build # go build -ldflags="-s -w" -o ratatoskr ./cmd/ratatoskr/
./ratatoskr -version
Кросс-сборка под linux/amd64 и windows/amd64 (только эти две платформы)
make cross
# → ratatoskr-linux-amd64
# → ratatoskr-windows-amd64.exe (Windows-машина Камиля)
Makefile вшивает в бинарь версию (ldflag):
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-<sha7>— та же версия, под которой бинарь публикуется в Gitea Packages.main.updateToken= токен read:package для автообновления (см. ниже). Если не задан (UPDATE_TOKEN ?= "") — пустая встройка, тогда обновление берётupdate.tokenиз конфига.
Как на самом деле публикуется рабочий бинарь: локальный go build токен НЕ вшивает
(значение секрета есть только у CI). Запушь main — CI (см. ниже) сам соберёт оба бинаря
со вшитым TC_UPDATE_TOKEN и загрузит их в Gitea Packages. Так соблюдён least-privilege:
read-токен не светится на хосте разработки.
Проверки перед сборкой
make test # go test ./... -v -count=1 -timeout 120s
make vet # go vet ./...
Прогон
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-<sha7>/(псевдо-версияlatestНЕ используется — см. автообновление). Плюс companion-файлы<file>.versionи<file>.sha256.
Секреты/vars: TC_GITEA_TOKEN (write:packages), TC_UPDATE_TOKEN (read:package),
GIT_MAIN_URL.
Конфигурация (config.yaml)
Минимальный рабочий конфиг (порядок полей произвольный) и блок автообновления
(подробнее в config.yaml.example):
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). Агенты распаковываются в<worktree>/.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-<sha7>/(никакогоlatest/: при параллельных PUT матрицы CI псевдо-версияlatestразъезжается, и контрольные суммы не сходятся — класс ошибки U4). Новейшая применимая версия ищется листингом: фильтрcommit-*, проба скачать бинарь платформы, берём версию с максid. - Companion-файлы рядом с бинарём в той же версии:
.<file>.version(id версии) и<file>.sha256(контрольная сумма). Verify всегда сверяет все три файла одной версии. - Update-хост ОТЛЕН от git-хоста:
update.base_urlсвой (envUPDATE_BASE_URL), фоллбэка наgit.base_urlНЕТ. Пустойupdate.base_urlполностью отключает обновление. - Токен доступа — только
read:package, НЕgit.token(least-privilege: update ≠ git). Приоритет: вшитый ldflag-X main.updateToken(секрет CITC_UPDATE_TOKEN) >update.tokenиз конфига (UPDATE_TOKEN). Заголовок:Authorization: token <токен>. - Применение (свап): POSIX — exec-перезапуск процесса; Windows — мгновенный exec
невозможен (файл занят), поэтому
.newприменяется при следующем старте (ApplyPendingSwapв main.go до подсистем). Резервный.oldдля отката.
Как опубликовать новую версию
- Внеси правки, коммитни, запуши в
main. - CI соберёт оба бинаря со вшитым
TC_UPDATE_TOKENи опубликует в.../generic/ratatoskr/commit-<sha7>/(бинарь +.version+.sha256). - На обновляемой машине: бот сам уведомит о новой версии →
/update.
Проверку автообновления после публикации гоняет владелец на своей Windows-машине (не прогоняется агентом).
Валидация опубликованной версии
B=http://gitea.hal9000.home
V=commit-<sha7>; 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.