Compare commits
1 Commits
631387fda7
...
feat/48f52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07bae203c3 |
2
.serena/.gitignore
vendored
2
.serena/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/cache
|
|
||||||
/project.local.yml
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# conventions
|
|
||||||
|
|
||||||
## Стиль / кодстайл
|
|
||||||
- Стандартный Go-стиль; гофм `gofmt`/`go fmt ./...`. Документация-комментарии и
|
|
||||||
package-doc на русском языке (в START-комментариях файлов и doc-комментариях).
|
|
||||||
- Типизация: строгие типы, интерфейсы для абстракций (Decider, LiveProber).
|
|
||||||
- Свой тип `config.Duration` для времени (YAML-строки "5s"/"10m"), метод `.Duration()`.
|
|
||||||
|
|
||||||
## Обработка ошибок
|
|
||||||
- Ошибки классифицируются по идентификаторам классов в исходниках (см. ниже).
|
|
||||||
- Ошибка-обёртка: `fmt.Errorf("%w: %v", ErrXxx, err)`.
|
|
||||||
- `errors.Join` для склейки нескольких ошибок валидации (config.Validate).
|
|
||||||
|
|
||||||
## Классы ошибок (маркируются в коде, документированы в README)
|
|
||||||
| Блок | Коды | Где |
|
|
||||||
|---|---|---|
|
|
||||||
| C | C1–C4 | internal/config |
|
|
||||||
| A | A1–A4 | internal/analyst |
|
|
||||||
| M | M1–M5 | internal/chat |
|
|
||||||
| O | O1–O4 | internal/opencode |
|
|
||||||
| S | S1–S5 | internal/storage |
|
|
||||||
| W | W1–W5 | internal/worker |
|
|
||||||
| E | E1–E4 | internal/worker (репозитории) |
|
|
||||||
| R | R1–R6 | internal/worker/review_errors.go |
|
|
||||||
| U | U1–U6 | internal/update |
|
|
||||||
|
|
||||||
## Архитектурные конвенции
|
|
||||||
- **Composition root** — internal/app; подсистемы собираются там, лимиты Core
|
|
||||||
(MaxTurns=15, MaxConfirmCycles=3, MaxQuestionsPerTurn=5) в core.New.
|
|
||||||
- **Агенты** (analyst.md/dev.md/reviewer.md) — markdown-промпты, встроены через go:embed
|
|
||||||
(internal/agents/*.md + embed.go), распаковываются в config_dir. Вердикт — строгий JSON.
|
|
||||||
- Репо-клонирование/воркеры — через gitops; ветки задач `feat/<taskTag>` (см. mem:core).
|
|
||||||
- Обратная совместимость: поле `Repo` (одиночный) и `Repos` (список); EffectiveRepos/
|
|
||||||
SetReposFromDB/ReposJoined в storage/models.go.
|
|
||||||
|
|
||||||
## Версии
|
|
||||||
- `app.Version` — семантическая major.minor.patch (ручной инкремент: patch=фиксы,
|
|
||||||
minor=новая обратно-совместимая функциональность, major=несовместимые изменения).
|
|
||||||
- `main.version` (ldflag) — build-идентификатор `commit-<sha7>`, отдельно от app.Version.
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
# 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`.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
# 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 ($?) {}`; & для путей с пробелами).
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 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 были закрыты, закоммитить в ветку, вернуть отчёт.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
# the name by which the project can be referenced within Serena
|
|
||||||
project_name: "ratatoskr-go"
|
|
||||||
|
|
||||||
|
|
||||||
# list of languages for which language servers are started; choose from:
|
|
||||||
# al angular ansible bash clojure
|
|
||||||
# cpp cpp_ccls crystal csharp csharp_omnisharp
|
|
||||||
# dart elixir elm erlang fortran
|
|
||||||
# fsharp go groovy haskell haxe
|
|
||||||
# hlsl html java json julia
|
|
||||||
# kotlin lean4 lua luau markdown
|
|
||||||
# matlab msl nix ocaml pascal
|
|
||||||
# perl php php_phpactor powershell python
|
|
||||||
# python_jedi python_ty r rego ruby
|
|
||||||
# ruby_solargraph rust scala scss solidity
|
|
||||||
# svelte swift systemverilog terraform toml
|
|
||||||
# typescript typescript_vts vue yaml zig
|
|
||||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
|
||||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
|
||||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
|
||||||
# Note:
|
|
||||||
# - For C, use cpp
|
|
||||||
# - For JavaScript, use typescript
|
|
||||||
# - For Angular projects, use angular (subsumes typescript+html; requires `npm install` in the project root)
|
|
||||||
# - For Svelte projects, use svelte (subsumes typescript/javascript for .svelte projects; requires npm)
|
|
||||||
# - For SCSS / Sass / plain CSS, use scss (some-sass-language-server handles all three)
|
|
||||||
# - For Free Pascal/Lazarus, use pascal
|
|
||||||
# Special requirements:
|
|
||||||
# Some languages require additional setup/installations.
|
|
||||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
|
||||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
|
||||||
# The first language is the default language and the respective language server will be used as a fallback.
|
|
||||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
|
||||||
languages:
|
|
||||||
- go
|
|
||||||
|
|
||||||
# the encoding used by text files in the project
|
|
||||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
|
||||||
encoding: "utf-8"
|
|
||||||
|
|
||||||
# line ending convention to use when writing source files.
|
|
||||||
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
|
||||||
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
|
||||||
line_ending:
|
|
||||||
|
|
||||||
# The language backend to use for this project.
|
|
||||||
# If not set, the global setting from serena_config.yml is used.
|
|
||||||
# Valid values: LSP, JetBrains
|
|
||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
|
||||||
# is activated post-init, an error will be returned.
|
|
||||||
language_backend:
|
|
||||||
|
|
||||||
# whether to use project's .gitignore files to ignore files
|
|
||||||
ignore_all_files_in_gitignore: true
|
|
||||||
|
|
||||||
# advanced configuration option allowing to configure language server-specific options.
|
|
||||||
# Maps the language key to the options.
|
|
||||||
# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available.
|
|
||||||
# No documentation on options means no options are available.
|
|
||||||
ls_specific_settings: {}
|
|
||||||
|
|
||||||
# list of additional workspace folder paths for cross-package reference support (e.g. in monorepos).
|
|
||||||
# Paths can be absolute or relative to the project root.
|
|
||||||
# Each folder is registered as an LSP workspace folder, enabling language servers to discover
|
|
||||||
# symbols and references across package boundaries.
|
|
||||||
# Currently supported for: TypeScript.
|
|
||||||
# Example:
|
|
||||||
# additional_workspace_folders:
|
|
||||||
# - ../sibling-package
|
|
||||||
# - ../shared-lib
|
|
||||||
additional_workspace_folders: []
|
|
||||||
|
|
||||||
# list of additional paths to ignore in this project.
|
|
||||||
# Same syntax as gitignore, so you can use * and **.
|
|
||||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
|
||||||
ignored_paths: []
|
|
||||||
|
|
||||||
# whether the project is in read-only mode
|
|
||||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
|
||||||
# Added on 2025-04-18
|
|
||||||
read_only: false
|
|
||||||
|
|
||||||
# list of tool names to exclude.
|
|
||||||
# This extends the existing exclusions (e.g. from the global configuration)
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
excluded_tools: []
|
|
||||||
|
|
||||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default).
|
|
||||||
# This extends the existing inclusions (e.g. from the global configuration).
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
included_optional_tools: []
|
|
||||||
|
|
||||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
|
||||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
|
||||||
# Find the list of tools here: https://oraios.github.io/serena/01-about/035_tools.html
|
|
||||||
fixed_tools: []
|
|
||||||
|
|
||||||
# list of mode names that are to be activated by default, overriding the setting in the global configuration.
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# If the setting is undefined/empty, the default_modes from the global configuration (serena_config.yml) apply.
|
|
||||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
|
||||||
# Therefore, you can set this to [] if you do not want the default modes defined in the global config to apply
|
|
||||||
# for this project.
|
|
||||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
default_modes:
|
|
||||||
|
|
||||||
# list of mode names to be activated additionally for this project, e.g. ["query-projects"]
|
|
||||||
# The full set of modes to be activated is base_modes (from global config) + default_modes + added_modes.
|
|
||||||
# See https://oraios.github.io/serena/02-usage/050_configuration.html#modes
|
|
||||||
added_modes:
|
|
||||||
|
|
||||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
|
||||||
# (contrary to the memories, which are loaded on demand).
|
|
||||||
initial_prompt: ""
|
|
||||||
|
|
||||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
|
||||||
# such as docstrings or parameter information.
|
|
||||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
|
||||||
# If null or missing, use the setting from the global configuration.
|
|
||||||
symbol_info_budget:
|
|
||||||
|
|
||||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
read_only_memory_patterns: []
|
|
||||||
|
|
||||||
# list of regex patterns for memories to completely ignore.
|
|
||||||
# Matching memories will not appear in list_memories or activate_project output
|
|
||||||
# and cannot be accessed via read_memory or write_memory.
|
|
||||||
# To access ignored memory files, use the read_file tool on the raw file path.
|
|
||||||
# Extends the list from the global configuration, merging the two lists.
|
|
||||||
# Example: ["_archive/.*", "_episodes/.*"]
|
|
||||||
ignored_memory_patterns: []
|
|
||||||
@@ -154,6 +154,7 @@ func New(configPath, version, updateToken string) (*App, error) {
|
|||||||
GitBaseURL: cfg.Git.BaseURL,
|
GitBaseURL: cfg.Git.BaseURL,
|
||||||
GitToken: cfg.Git.Token,
|
GitToken: cfg.Git.Token,
|
||||||
Live: live,
|
Live: live,
|
||||||
|
Notify: a, // авто-уведомления владельцу задачи через Router
|
||||||
}
|
}
|
||||||
a.Router = router
|
a.Router = router
|
||||||
a.Worker = w
|
a.Worker = w
|
||||||
@@ -326,6 +327,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 извлекает команду (первое слово до пробела, нижний регистр).
|
// cmdName извлекает команду (первое слово до пробела, нижний регистр).
|
||||||
func cmdName(text string) string {
|
func cmdName(text string) string {
|
||||||
s := strings.TrimSpace(text)
|
s := strings.TrimSpace(text)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -129,6 +130,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
|||||||
Interval: 30 * time.Millisecond,
|
Interval: 30 * time.Millisecond,
|
||||||
MaxJobs: 1,
|
MaxJobs: 1,
|
||||||
Live: opencode.NewLiveRegistry(),
|
Live: opencode.NewLiveRegistry(),
|
||||||
|
Notify: a, // авто-уведомления владельцу через Router (как в app.New)
|
||||||
// GitToken зададим пустым: origin в seed-репо локальный (file path),
|
// GitToken зададим пустым: origin в seed-репо локальный (file path),
|
||||||
// http.extraHeader не нужен для локального пуша.
|
// http.extraHeader не нужен для локального пуша.
|
||||||
}
|
}
|
||||||
@@ -139,7 +141,7 @@ func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
|||||||
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
||||||
// и доставки входящих через роутер (как реальный канал).
|
// и доставки входящих через роутер (как реальный канал).
|
||||||
type e2eChannel struct {
|
type e2eChannel struct {
|
||||||
onMsg func(chat.Incoming)
|
onMsg chat.Handler
|
||||||
sent []chat.Message
|
sent []chat.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -719,4 +721,108 @@ func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
|
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)
|
||||||
}
|
}
|
||||||
@@ -111,16 +111,10 @@ func TestUpdateTaskStatus(t *testing.T) {
|
|||||||
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
t.Fatalf("UpdateTask collecting→ready: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ready → approved
|
// ready → running
|
||||||
task.Status = StatusApproved
|
|
||||||
if err := s.UpdateTask(ctx, task); err != nil {
|
|
||||||
t.Fatalf("UpdateTask ready→approved: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// approved → running
|
|
||||||
task.Status = StatusRunning
|
task.Status = StatusRunning
|
||||||
if err := s.UpdateTask(ctx, task); err != nil {
|
if err := s.UpdateTask(ctx, task); err != nil {
|
||||||
t.Fatalf("UpdateTask approved→running: %v", err)
|
t.Fatalf("UpdateTask ready→running: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// running → success
|
// running → success
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ type OpenCodeRunner interface {
|
|||||||
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
|
// PollTaskFunc — callback для обработки готовой задачи (подменяемый в тестах).
|
||||||
type PollTaskFunc func(ctx context.Context) error
|
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).
|
// Worker — планировщик, запускающий готовые задачи (status=ready → running → success/failed/timeout).
|
||||||
type Worker struct {
|
type Worker struct {
|
||||||
Store *storage.Storage
|
Store *storage.Storage
|
||||||
@@ -39,6 +47,10 @@ type Worker struct {
|
|||||||
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
|
// Через него Runner пишет live-шаги задачи; nil — наблюдение выключено.
|
||||||
Live *opencode.LiveRegistry
|
Live *opencode.LiveRegistry
|
||||||
|
|
||||||
|
// Notify — нотифаер авто-уведомлений владельцу задачи (статусы + хендоффы
|
||||||
|
// dev↔reviewer). nil — уведомления выключены.
|
||||||
|
Notify Notifier
|
||||||
|
|
||||||
sem chan struct{} // семафор
|
sem chan struct{} // семафор
|
||||||
cancel context.CancelFunc
|
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)
|
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 запускает цикл опроса в фоновой горутине.
|
// Start запускает цикл опроса в фоновой горутине.
|
||||||
func (w *Worker) Start(ctx context.Context) {
|
func (w *Worker) Start(ctx context.Context) {
|
||||||
if w.Agent == "" {
|
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 {
|
if err := w.Store.UpdateTask(ctx, task); err != nil {
|
||||||
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
|
return fmt.Errorf("%w: set running: %v", ErrUpdate, err)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusRunning)
|
||||||
|
|
||||||
// 2b. клонируем недостающие репозитории в общий каталог.
|
// 2b. клонируем недостающие репозитории в общий каталог.
|
||||||
if err := w.prepareRepos(ctx, repos); err != nil {
|
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 {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusTimeout)
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceTimeout, output)
|
||||||
return nil
|
return nil
|
||||||
default:
|
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 {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceFailed, output)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -245,6 +281,9 @@ func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
|||||||
// dev завершился RC=0 → сохраняем успех трассы dev.
|
// dev завершился RC=0 → сохраняем успех трассы dev.
|
||||||
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
|
w.finalizeTrace(ctx, traceID, storage.TraceSuccess, output)
|
||||||
|
|
||||||
|
// уведомляем пользователя о передаче dev → reviewer на ревью.
|
||||||
|
w.notifyHandoff(ctx, task, "dev", "reviewer", iter+1)
|
||||||
|
|
||||||
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
|
// 8. РЕВЬЮ: собираем diff всей ветки, запускаем reviewer.
|
||||||
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
|
diffText, dErr := w.branchDiffAll(ctx, repos, branch)
|
||||||
if dErr != nil {
|
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 {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
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)
|
w.finalizeTrace(ctx, reviewTraceID, storage.TraceFailed, reviewOutput+"\n"+explain)
|
||||||
return nil
|
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 {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusSuccess)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Не пройдено: если есть итерации — dev дорабатывает.
|
// Не пройдено: если есть итерации — dev дорабатывает.
|
||||||
if iter+1 < maxReviewIterations {
|
if iter+1 < maxReviewIterations {
|
||||||
|
w.notify(ctx, task, fmt.Sprintf("Задача #%d: reviewer → dev на доработку (итерация %d)", task.ID, iter+1))
|
||||||
feedback = verdict.Comments
|
feedback = verdict.Comments
|
||||||
continue
|
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 {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
return fmt.Errorf("%w: set %s: %v", ErrUpdate, task.Status, e)
|
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)
|
explain := fmt.Sprintf("Ревью не пройдено за %d итераций.", maxReviewIterations)
|
||||||
final := reviewOutput + "\n" + explain
|
final := reviewOutput + "\n" + explain
|
||||||
if e := w.Store.UpdateTraceOutput(ctx, reviewTraceID, final); e != nil {
|
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
|
return v2, out2, tid2, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// failTask помечает задачу failed.
|
// failTask помечает задачу failed и уведомляет владельца.
|
||||||
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
|
func (w *Worker) failTask(ctx context.Context, task *storage.Task) {
|
||||||
task.Status = storage.StatusFailed
|
task.Status = storage.StatusFailed
|
||||||
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
if e := w.Store.UpdateTask(ctx, task); e != nil {
|
||||||
log.Printf("worker: task %d: set failed: %v", task.ID, e)
|
log.Printf("worker: task %d: set failed: %v", task.ID, e)
|
||||||
}
|
}
|
||||||
|
w.notifyStatus(ctx, task, storage.StatusFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// finalizeTrace обновляет output и статус трассы.
|
// finalizeTrace обновляет output и статус трассы.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -17,6 +18,32 @@ import (
|
|||||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
"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 {
|
type mockRunnerWorker struct {
|
||||||
result *opencode.Result
|
result *opencode.Result
|
||||||
err error
|
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,
|
// reviewNDJSONRunner возвращает вердикт ревьюера как реальный NDJSON-поток opencode,
|
||||||
// где JSON находится внутри последнего text-парта.
|
// где JSON находится внутри последнего text-парта.
|
||||||
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
func reviewNDJSONRunner(v *reviewVerdict) *opencode.Result {
|
||||||
|
|||||||
Reference in New Issue
Block a user