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 (классы C1C4)
  chat/               # мультиканальный Router; telegram — long-poll канал
  core/               # state-machine задач: /start /cancel /skip /retry N /status N /continue N
  analyst/            # аналитик: промпт + разбор JSON-решения (opencode agent, классы A1A4)
  worker/             # polling-планировщик + dev/reviewer-конвейер (классы W*, E*, R*)
  agents/             # встроенные агенты opencode (analyst.md, dev.md, reviewer.md) через go:embed
  opencode/           # HTTP-клиент v2 API opencode serve, поллинг вердикта (классы O1O5)
  storage/            # SQLite (modernc.org/sqlite, без CGO): tasks, traces, task_history (S1S5)
  update/             # автообновление из Gitea Packages (классы U1U6)

Сборка

Требуется 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 свой (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-<sha7>/ (бинарь + .version + .sha256).
  3. На обновляемой машине: бот сам уведомит о новой версии → /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 C1C4 internal/config
A A1A4 internal/analyst
M M1M5 internal/chat
O O1O5 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

Контракты (не ломать)

  • 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.
Description
No description provided
Readme 1.6 MiB
Languages
Go 98.4%
PowerShell 1.4%
Makefile 0.2%