Compare commits
6 Commits
feat/200ac
...
631387fda7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
631387fda7 | ||
| 18b48bfdc4 | |||
|
|
7c7afa6875 | ||
|
|
43266ea04f | ||
|
|
f12ff04966 | ||
| a8a2de12c3 |
2
.serena/.gitignore
vendored
Normal file
2
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
39
.serena/memories/conventions.md
Normal file
39
.serena/memories/conventions.md
Normal 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 | 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.
|
||||
51
.serena/memories/core.md
Normal file
51
.serena/memories/core.md
Normal 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`.
|
||||
33
.serena/memories/memory_maintenance.md
Normal file
33
.serena/memories/memory_maintenance.md
Normal 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.
|
||||
27
.serena/memories/suggested_commands.md
Normal file
27
.serena/memories/suggested_commands.md
Normal 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 ($?) {}`; & для путей с пробелами).
|
||||
15
.serena/memories/task_completion.md
Normal file
15
.serena/memories/task_completion.md
Normal 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 были закрыты, закоммитить в ветку, вернуть отчёт.
|
||||
30
.serena/memories/tech_stack.md
Normal file
30
.serena/memories/tech_stack.md
Normal 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
133
.serena/project.yml
Normal 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 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: []
|
||||
722
internal/app/e2e_test.go
Normal file
722
internal/app/e2e_test.go
Normal file
@@ -0,0 +1,722 @@
|
||||
package app
|
||||
|
||||
// Интеграционный (сквозной) тест «всё приложение от постановки задачи».
|
||||
//
|
||||
// Закрывает оба слоя конвейера одним прогоном, как в проде:
|
||||
//
|
||||
// handleIncoming (/start) → Core.ProcessTurn → Analyst (opencode) → задача ready
|
||||
// Worker.runTask: dev → reviewer → настоящий git push → success
|
||||
//
|
||||
// Аналитик и воркер делят один и тот же *opencode.Runner (как собирает app.New),
|
||||
// а фейк-скрипт opencode различает агентов по argv (аналитик/dev/reviewer) —
|
||||
// возвращая NDJSON-вердикты нужного формата для каждого.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kamelion/ratatoskr-go/internal/analyst"
|
||||
"github.com/kamelion/ratatoskr-go/internal/chat"
|
||||
"github.com/kamelion/ratatoskr-go/internal/core"
|
||||
"github.com/kamelion/ratatoskr-go/internal/opencode"
|
||||
"github.com/kamelion/ratatoskr-go/internal/storage"
|
||||
"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)
|
||||
}
|
||||
return `{"type":"text","part":{"text":` + string(b) + `}}`
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// e2eAssemble собирает конвейер вручную (те же связи, что app.New),
|
||||
// но с фейк-бинарём, подменённым на e2eFakeOpenCode. Возвращает App,
|
||||
// каталог worktree и fake-канал (для проверки исходящих).
|
||||
func e2eAssemble(t *testing.T) (*App, string, *e2eChannel) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
worktree := filepath.Join(dir, "worktrees")
|
||||
|
||||
ctx := context.Background()
|
||||
store, err := storage.Open(ctx, filepath.Join(dir, "e2e.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("storage.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { store.Close() })
|
||||
|
||||
bin := e2eFakeOpenCode(t, dir)
|
||||
runner := &opencode.Runner{
|
||||
Bin: bin,
|
||||
PollInterval: 20 * time.Millisecond,
|
||||
IdleTimeout: 5 * time.Second,
|
||||
HardTimeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
an := &analyst.Analyst{Runner: runner, Worktree: worktree, Agent: "analyst"}
|
||||
coreCtx := core.New(store, an)
|
||||
|
||||
// Router с fake-каналом: ловим исходящие replies для проверки.
|
||||
fake := &e2eChannel{sent: make([]chat.Message, 0)}
|
||||
a := &App{
|
||||
Store: store,
|
||||
CoreCtx: coreCtx,
|
||||
}
|
||||
a.Router = chat.NewRouter(a.handleIncoming)
|
||||
if err := a.Router.Attach(fake); err != nil {
|
||||
t.Fatalf("Attach fake channel: %v", err)
|
||||
}
|
||||
|
||||
w := &worker.Worker{
|
||||
Store: store,
|
||||
Runner: runner,
|
||||
Worktree: worktree,
|
||||
Agent: "dev",
|
||||
Interval: 30 * time.Millisecond,
|
||||
MaxJobs: 1,
|
||||
Live: opencode.NewLiveRegistry(),
|
||||
// GitToken зададим пустым: origin в seed-репо локальный (file path),
|
||||
// http.extraHeader не нужен для локального пуша.
|
||||
}
|
||||
a.Worker = w
|
||||
return a, worktree, fake
|
||||
}
|
||||
|
||||
// e2eChannel — минимальный fake-канал для перехвата исходящих
|
||||
// и доставки входящих через роутер (как реальный канал).
|
||||
type e2eChannel struct {
|
||||
onMsg func(chat.Incoming)
|
||||
sent []chat.Message
|
||||
}
|
||||
|
||||
func (c *e2eChannel) Run(_ context.Context) error { return nil }
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
func (c *e2eChannel) Ask(_ context.Context, _ chat.Address, m chat.Message) error {
|
||||
return nil
|
||||
}
|
||||
func (c *e2eChannel) Close() error { return nil }
|
||||
|
||||
// deliver отправляет входящее сообщение через роутер: ставит маршрут
|
||||
// пользователя и вызывает app.handleIncoming (как в проде).
|
||||
func (c *e2eChannel) deliver(uid chat.UserID, text string) {
|
||||
if c.onMsg != nil {
|
||||
c.onMsg(chat.Incoming{UserID: uid, Address: chat.Address("u://" + string(uid)), Msg: chat.Message{Text: text}, Channel: c})
|
||||
}
|
||||
}
|
||||
|
||||
// seedFakeRepo создаёт реальный git-репозиторий для worktree/<repo>:
|
||||
// bare-origin + клон с начальным коммитом на main, чтобы воркер мог
|
||||
// выполнять fetch origin, создавать ветку и пушить. (Дубликат из worker_test,
|
||||
// вынесен сюда, т.к. интеграционный тест живёт в пакете app, а не worker.)
|
||||
func (a *App) seedFakeRepo(t *testing.T, worktree, repo string) {
|
||||
t.Helper()
|
||||
gitRun := func(dir string, args ...string) string {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", args...)
|
||||
cmd.Dir = dir
|
||||
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
|
||||
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
base := filepath.Join(worktree, repo)
|
||||
if err := os.MkdirAll(base, 0o755); err != nil {
|
||||
t.Fatalf("seed repo %s: %v", repo, err)
|
||||
}
|
||||
origin := filepath.Join(worktree, repo+"-origin.git")
|
||||
gitRun(worktree, "init", "--bare", origin)
|
||||
gitRun(base, "init")
|
||||
gitRun(base, "remote", "add", "origin", origin)
|
||||
if err := os.WriteFile(filepath.Join(base, "seed.txt"), []byte("seed\n"), 0o644); err != nil {
|
||||
t.Fatalf("write seed: %v", err)
|
||||
}
|
||||
gitRun(base, "add", "seed.txt")
|
||||
gitRun(base, "commit", "-m", "seed")
|
||||
gitRun(base, "branch", "-M", "main")
|
||||
gitRun(base, "push", "-u", "origin", "main")
|
||||
}
|
||||
|
||||
// TestE2EWholeAppFromTaskSetup — положительный сквозной путь «всё приложение»:
|
||||
// постановка задачи через аналитика (до ready), затем воркер (dev→reviewer→push)
|
||||
// до success, и проверка, что feature-ветка реально запушена в origin.
|
||||
func TestE2EWholeAppFromTaskSetup(t *testing.T) {
|
||||
a, worktree, fake := e2eAssemble(t)
|
||||
a.seedFakeRepo(t, worktree, "calc")
|
||||
|
||||
ctx := context.Background()
|
||||
uid := chat.UserID("u1")
|
||||
|
||||
// --- 1. Постановка: /start → задача collecting ---
|
||||
fake.deliver(uid, "/start")
|
||||
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||
if err != nil {
|
||||
t.Fatalf("get task after /start: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusCollecting {
|
||||
t.Errorf("status после /start = %q, want collecting", task.Status)
|
||||
}
|
||||
|
||||
// --- 2. Постановка: ответ пользователя → аналитик (opencode) → ready ---
|
||||
// Фейк-analyst возвращает propose с repo=calc → задача должна стать ready.
|
||||
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||
task, err = a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusReady {
|
||||
t.Fatalf("status после analyst = %q, want ready", task.Status)
|
||||
}
|
||||
if len(task.EffectiveRepos()) == 0 {
|
||||
t.Errorf("repos пусто, want [calc]")
|
||||
}
|
||||
|
||||
// --- 3. Согласие «создавай» → задача одобрена (approved) ---
|
||||
fake.deliver(uid, "создавай")
|
||||
task, err = a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusApproved {
|
||||
t.Errorf("status после создавай = %q, want approved", task.Status)
|
||||
}
|
||||
|
||||
// --- 4. Исполнение: воркер (poll) dev → reviewer → push ---
|
||||
// Запускаем реальный poll-диспетчер и ждём, пока задача доедет до success.
|
||||
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||
defer wkCancel()
|
||||
a.Worker.Start(wkCtx)
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var last storage.Status
|
||||
for {
|
||||
task, err = a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
last = task.Status
|
||||
if task.Status == storage.StatusSuccess || task.Status == storage.StatusFailed {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("таймаут ожидания success, последний статус %q", last)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
|
||||
task, err = a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusSuccess {
|
||||
t.Fatalf("status после воркера = %q, want success", task.Status)
|
||||
}
|
||||
|
||||
// ревью вызывается ровно один раз (passed сразу) — 2 трассы: dev + reviewer
|
||||
traces, err := a.Store.GetTraces(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get traces: %v", err)
|
||||
}
|
||||
if len(traces) != 2 {
|
||||
t.Fatalf("traces = %d, want 2 (dev + reviewer)", len(traces))
|
||||
}
|
||||
if traces[0].Agent != "dev" || traces[0].Status != storage.TraceSuccess {
|
||||
t.Errorf("trace[0] = %s/%s, want dev/success", traces[0].Agent, traces[0].Status)
|
||||
}
|
||||
if traces[1].Agent != "reviewer" || traces[1].Status != storage.TraceSuccess {
|
||||
t.Errorf("trace[1] = %s/%s, want reviewer/success", traces[1].Agent, traces[1].Status)
|
||||
}
|
||||
|
||||
// --- 5. Проверка настоящего push: feature-ветка есть в origin ---
|
||||
branch := "feat/" + task.TaskTag
|
||||
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ls-remote origin: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||
t.Errorf("feature-ветка %q не найдена в origin:\n%s", branch, out)
|
||||
}
|
||||
|
||||
// --- 6. Бот подтвердил через канал (хотя бы одно исходящее было) ---
|
||||
if len(fake.sent) == 0 {
|
||||
t.Error("нет ни одного исходящего сообщения через Router")
|
||||
}
|
||||
t.Logf("E2E OK: задача #%d %s, ветка %s, traces=%d", task.ID, task.Status, branch, len(traces))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TestE2ERetryFromStates — положительный сценарий «перезапуск задачи»:
|
||||
// задача перезапускается /retry N из различных стартовых состояний и после
|
||||
// повторного цикла (аналитик → ready → создавай → воркер) снова доходит до
|
||||
// success с повторным реальным push feature-ветки.
|
||||
//
|
||||
// Матрица стартовых состояний:
|
||||
// collecting / ready / cancelled — реальными командами через канал;
|
||||
// success — реальным полным циклом воркера;
|
||||
// failed / timeout — фикстурой через Store (валидной цепочкой переходов),
|
||||
// т.к. механика их достижения в воркере уже покрыта worker_test.go, а
|
||||
// timeout вообще нельзя сэмулировать без реального зависания (RC=-1
|
||||
// ставит только kill по таймауту).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// e2eChainToState приводит задачу к заданному статусу валидной цепочкой
|
||||
// переходов через Store.UpdateTask (фикстура для failed/timeout).
|
||||
func e2eChainToState(t *testing.T, store *storage.Storage, id int64, target storage.Status) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
// валидные цепочки draft → ... → target (проверяются UpdateTask'ом)
|
||||
var chain []storage.Status
|
||||
switch target {
|
||||
case storage.StatusFailed:
|
||||
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusFailed}
|
||||
case storage.StatusTimeout:
|
||||
chain = []storage.Status{storage.StatusCollecting, storage.StatusReady, storage.StatusApproved, storage.StatusRunning, storage.StatusTimeout}
|
||||
default:
|
||||
t.Fatalf("e2eChainToState: неподдерживаемый target %q", target)
|
||||
}
|
||||
for _, s := range chain {
|
||||
tk, err := store.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eChainToState: get task %d: %v", id, err)
|
||||
}
|
||||
tk.Status = s
|
||||
if err := store.UpdateTask(ctx, tk); err != nil {
|
||||
t.Fatalf("e2eChainToState: %s → %s: %v", tk.Status, s, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// e2eWaitWorker запускает poll-диспетчер воркера и ждёт, пока задача дойдёт
|
||||
// до success или failed (лимит 30s). Возвращает итоговый статус.
|
||||
func e2eWaitWorker(t *testing.T, a *App, id int64) storage.Status {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||
defer wkCancel()
|
||||
a.Worker.Start(wkCtx)
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
var last storage.Status
|
||||
for {
|
||||
tk, err := a.Store.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eWaitWorker: get task %d: %v", id, err)
|
||||
}
|
||||
last = tk.Status
|
||||
if tk.Status == storage.StatusSuccess || tk.Status == storage.StatusFailed {
|
||||
return tk.Status
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("e2eWaitWorker: таймаут, последний статус %q", last)
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// e2eRetryCommon — общий хвост после /retry N для любого стартового состояния.
|
||||
// Проверяет: collecting → аналитик → ready → создавай → воркер → success,
|
||||
// повторный push ветки, и что тег задачи переиспользован (не сменился).
|
||||
func e2eRetryCommon(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, taskID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
// тег до retry — должен остаться прежним после повторного цикла
|
||||
before, err := a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get task %d: %v", taskID, err)
|
||||
}
|
||||
origTag := before.TaskTag
|
||||
baseTraces, err := a.Store.GetTraces(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get traces (before): %v", err)
|
||||
}
|
||||
baseN := len(baseTraces)
|
||||
|
||||
// 1. /retry N → collecting, история очищена
|
||||
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
|
||||
tk, err := a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get task after /retry: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusCollecting {
|
||||
t.Errorf("retry: status = %q, want collecting", tk.Status)
|
||||
}
|
||||
|
||||
// 2. новый текст → аналитик → ready (repos не пуст)
|
||||
fake.deliver(chat.UserID(uid), "Переделай: добавь деление")
|
||||
tk, err = a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get task after analyst: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusReady {
|
||||
t.Fatalf("retry: status после аналитика = %q, want ready", tk.Status)
|
||||
}
|
||||
if len(tk.EffectiveRepos()) == 0 {
|
||||
t.Errorf("retry: repos пусто, want [calc]")
|
||||
}
|
||||
|
||||
// 3. «создавай» → approved (одобрено, воркер заберёт)
|
||||
fake.deliver(chat.UserID(uid), "создавай")
|
||||
tk, err = a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get task after создавай: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusApproved {
|
||||
t.Errorf("retry: status после создавай = %q, want approved", tk.Status)
|
||||
}
|
||||
|
||||
// 4. воркер → success
|
||||
if got := e2eWaitWorker(t, a, taskID); got != storage.StatusSuccess {
|
||||
t.Fatalf("retry: итог = %q, want success", got)
|
||||
}
|
||||
|
||||
// 5. проверки после повторного цикла
|
||||
tk, err = a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get task final: %v", err)
|
||||
}
|
||||
if tk.TaskTag != origTag {
|
||||
t.Errorf("retry: task_tag сменился %q → %q, want неизменный", origTag, tk.TaskTag)
|
||||
}
|
||||
branch := "feat/" + tk.TaskTag
|
||||
out, err := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("retry: ls-remote origin: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||
t.Errorf("retry: feature-ветка %q не найдена в origin после повторного push:\n%s", branch, out)
|
||||
}
|
||||
|
||||
// 6. появились ровно 2 новые трассы (dev + reviewer), обе успешные
|
||||
traces, err := a.Store.GetTraces(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryCommon: get traces (after): %v", err)
|
||||
}
|
||||
if len(traces) != baseN+2 {
|
||||
t.Fatalf("retry: трасс = %d, want %d (base %d + dev + reviewer)", len(traces), baseN+2, baseN)
|
||||
}
|
||||
if traces[baseN].Agent != "dev" || traces[baseN].Status != storage.TraceSuccess {
|
||||
t.Errorf("retry: trace[%d] = %s/%s, want dev/success", baseN, traces[baseN].Agent, traces[baseN].Status)
|
||||
}
|
||||
if traces[baseN+1].Agent != "reviewer" || traces[baseN+1].Status != storage.TraceSuccess {
|
||||
t.Errorf("retry: trace[%d] = %s/%s, want reviewer/success", baseN+1, traces[baseN+1].Agent, traces[baseN+1].Status)
|
||||
}
|
||||
|
||||
// 7. бот ответил
|
||||
if len(fake.sent) == 0 {
|
||||
t.Error("retry: нет ни одного исходящего сообщения через Router")
|
||||
}
|
||||
t.Logf("RETRY OK: задача #%d %s (из %s), ветка %s, new traces=%d", taskID, tk.Status, before.Status, branch, len(traces)-baseN)
|
||||
}
|
||||
|
||||
// e2eSetupState приводит задачу к стартовому состоянию S и возвращает её ID.
|
||||
// Для состояний, достижимых командами — реальный путь через канал; для
|
||||
// failed/timeout — фикстура через Store.
|
||||
func e2eSetupState(t *testing.T, a *App, fake *e2eChannel, worktree, uid string, state storage.Status) int64 {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
switch state {
|
||||
case storage.StatusCollecting:
|
||||
fake.deliver(chat.UserID(uid), "/start")
|
||||
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("setup collecting: %v", err)
|
||||
}
|
||||
return tk.ID
|
||||
|
||||
case storage.StatusReady:
|
||||
fake.deliver(chat.UserID(uid), "/start")
|
||||
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
|
||||
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("setup ready: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusReady {
|
||||
t.Fatalf("setup ready: status = %q, want ready", tk.Status)
|
||||
}
|
||||
return tk.ID
|
||||
|
||||
case storage.StatusCancelled:
|
||||
fake.deliver(chat.UserID(uid), "/start")
|
||||
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("setup cancelled: get active: %v", err)
|
||||
}
|
||||
fake.deliver(chat.UserID(uid), "/cancel")
|
||||
tk, err = a.Store.GetTask(ctx, tk.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("setup cancelled: get task: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusCancelled {
|
||||
t.Fatalf("setup cancelled: status = %q, want cancelled", tk.Status)
|
||||
}
|
||||
return tk.ID
|
||||
|
||||
case storage.StatusSuccess:
|
||||
// реальный полный цикл до success
|
||||
fake.deliver(chat.UserID(uid), "/start")
|
||||
fake.deliver(chat.UserID(uid), "Сделай калькулятор в calc")
|
||||
fake.deliver(chat.UserID(uid), "создавай")
|
||||
tk, err := a.Store.GetActiveTaskByChatID(ctx, uid)
|
||||
if err != nil {
|
||||
t.Fatalf("setup success: get active: %v", err)
|
||||
}
|
||||
if got := e2eWaitWorker(t, a, tk.ID); got != storage.StatusSuccess {
|
||||
t.Fatalf("setup success: итог = %q, want success", got)
|
||||
}
|
||||
return tk.ID
|
||||
|
||||
case storage.StatusFailed, storage.StatusTimeout:
|
||||
// фикстура: создаём задачу и прогоняем валидную цепочку переходов
|
||||
id, err := a.Store.CreateTask(ctx, &storage.Task{ChatID: uid})
|
||||
if err != nil {
|
||||
t.Fatalf("setup %s: create task: %v", state, err)
|
||||
}
|
||||
e2eChainToState(t, a.Store, id, state)
|
||||
return id
|
||||
|
||||
default:
|
||||
t.Fatalf("setup: неподдерживаемое состояние %q", state)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// TestE2ERetryFromStates — перезапуск задачи /retry N из различных состояний.
|
||||
//
|
||||
// Позитивный блок (задача реально перезапускается и доходит до success):
|
||||
// collecting, ready, failed, timeout.
|
||||
// Негативный блок (завершённые состояния нельзя перезапускать — только /start):
|
||||
// success, cancelled → /retry отклоняется понятным сообщением, статус не меняется.
|
||||
func TestE2ERetryFromStates(t *testing.T) {
|
||||
a, worktree, fake := e2eAssemble(t)
|
||||
a.seedFakeRepo(t, worktree, "calc")
|
||||
|
||||
// --- Позитивный блок: перезапуск и повторный успешный цикл ---
|
||||
positive := []storage.Status{
|
||||
storage.StatusCollecting,
|
||||
storage.StatusReady,
|
||||
storage.StatusFailed,
|
||||
storage.StatusTimeout,
|
||||
}
|
||||
for _, st := range positive {
|
||||
st := st
|
||||
t.Run("positive/"+string(st), func(t *testing.T) {
|
||||
uid := fmt.Sprintf("retry-pos-%s", st)
|
||||
id := e2eSetupState(t, a, fake, worktree, uid, st)
|
||||
e2eRetryCommon(t, a, fake, worktree, uid, id)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Негативный блок: завершённые состояния перезапуску не подлежат ---
|
||||
terminal := []storage.Status{
|
||||
storage.StatusSuccess,
|
||||
storage.StatusCancelled,
|
||||
}
|
||||
for _, st := range terminal {
|
||||
st := st
|
||||
t.Run("negative/"+string(st), func(t *testing.T) {
|
||||
uid := fmt.Sprintf("retry-neg-%s", st)
|
||||
id := e2eSetupState(t, a, fake, worktree, uid, st)
|
||||
e2eRetryRejected(t, a, fake, uid, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// e2eRetryRejected проверяет, что /retry на завершённую задачу НЕ перезапускает:
|
||||
// статус остаётся терминальным, а пользователю уходит понятное сообщение.
|
||||
func e2eRetryRejected(t *testing.T, a *App, fake *e2eChannel, uid string, taskID int64) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
before, err := a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryRejected: get task %d: %v", taskID, err)
|
||||
}
|
||||
if !storage.IsTerminal(before.Status) {
|
||||
t.Fatalf("e2eRetryRejected: предпосылка — статус %q должен быть терминальным", before.Status)
|
||||
}
|
||||
|
||||
// сбрасываем перехваченные исходящие, чтобы ловить только ответ на /retry
|
||||
fake.sent = nil
|
||||
fake.deliver(chat.UserID(uid), "/retry "+fmt.Sprintf("%d", taskID))
|
||||
|
||||
after, err := a.Store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
t.Fatalf("e2eRetryRejected: get task after /retry: %v", err)
|
||||
}
|
||||
if after.Status != before.Status {
|
||||
t.Errorf("retry(neg): статус изменился %q → %q, want без изменений", before.Status, after.Status)
|
||||
}
|
||||
if after.TaskTag != before.TaskTag {
|
||||
t.Errorf("retry(neg): task_tag сменился, want неизменный")
|
||||
}
|
||||
|
||||
// понятное сообщение пользователю: говорим про невозможность перезапуска
|
||||
found := false
|
||||
for _, m := range fake.sent {
|
||||
if strings.Contains(m.Text, "нельзя перезапустить") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("retry(neg): нет понятного ответа «нельзя перезапустить», отправлено: %d сообщений", len(fake.sent))
|
||||
}
|
||||
t.Logf("RETRY-REJECTED OK: задача #%d осталась %q", taskID, after.Status)
|
||||
}
|
||||
|
||||
// TestE2EWorkerDoesNotTakeUnconfirmed — регрессия бага, когда воркер брал
|
||||
// задачу на выполнение ещё до «создавай» (по статусу ready, без одобрения).
|
||||
// Проверяет: пока задача в ready, воркер её НЕ трогает; она уходит в работу
|
||||
// только после «создавай» → approved.
|
||||
func TestE2EWorkerDoesNotTakeUnconfirmed(t *testing.T) {
|
||||
a, worktree, fake := e2eAssemble(t)
|
||||
a.seedFakeRepo(t, worktree, "calc")
|
||||
|
||||
ctx := context.Background()
|
||||
uid := chat.UserID("unconf")
|
||||
|
||||
// --- 1. Постановка до ready, без «создавай» ---
|
||||
fake.deliver(uid, "/start")
|
||||
fake.deliver(uid, "Сделай калькулятор в calc")
|
||||
task, err := a.Store.GetActiveTaskByChatID(ctx, string(uid))
|
||||
if err != nil {
|
||||
t.Fatalf("get task: %v", err)
|
||||
}
|
||||
if task.Status != storage.StatusReady {
|
||||
t.Fatalf("status после analyst = %q, want ready (черновик готов, но НЕ одобрен)", task.Status)
|
||||
}
|
||||
beforeTraces, _ := a.Store.GetTraces(ctx, task.ID)
|
||||
|
||||
// --- 2. Запускаем воркер и даём ему время «промахнуться» ---
|
||||
wkCtx, wkCancel := context.WithCancel(ctx)
|
||||
defer wkCancel()
|
||||
a.Worker.Start(wkCtx)
|
||||
|
||||
time.Sleep(600 * time.Millisecond) // несколько poll-итераций (Interval=30ms)
|
||||
|
||||
// --- 3. Проверяем, что воркер НЕ взял неодобренную задачу ---
|
||||
after, err := a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task after worker: %v", err)
|
||||
}
|
||||
if after.Status != storage.StatusReady {
|
||||
t.Fatalf("БАГ: воркер взял неодобренную задачу — status = %q, want ready. Задача должна ждать «создавай».", after.Status)
|
||||
}
|
||||
afterTraces, _ := a.Store.GetTraces(ctx, task.ID)
|
||||
if len(afterTraces) != len(beforeTraces) {
|
||||
t.Errorf("БАГ: появились трассы без одобрения (было %d, стало %d)", len(beforeTraces), len(afterTraces))
|
||||
}
|
||||
// ветка не должна была создаться
|
||||
branch := "feat/" + after.TaskTag
|
||||
out, _ := exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||
if strings.Contains(string(out), "refs/heads/"+branch) {
|
||||
t.Errorf("БАГ: feature-ветка %q уже в origin до одобрения", branch)
|
||||
}
|
||||
|
||||
// --- 4. «создавай» → approved, теперь воркер берёт и доезжает до success ---
|
||||
fake.deliver(uid, "создавай")
|
||||
tk, err := a.Store.GetTask(ctx, task.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task after создавай: %v", err)
|
||||
}
|
||||
if tk.Status != storage.StatusApproved {
|
||||
t.Fatalf("status после создавай = %q, want approved", tk.Status)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
tk, err = a.Store.GetTask(ctx, task.ID)
|
||||
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)
|
||||
}
|
||||
if tk.Status != storage.StatusSuccess {
|
||||
t.Fatalf("status после воркера = %q, want success", tk.Status)
|
||||
}
|
||||
|
||||
// ветка теперь должна быть в origin
|
||||
out, err = exec.Command("git", "-C", filepath.Join(worktree, "calc"),
|
||||
"ls-remote", "origin", "refs/heads/"+branch).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("ls-remote origin: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "refs/heads/"+branch) {
|
||||
t.Errorf("feature-ветка %q не найдена в origin после одобрения:\n%s", branch, out)
|
||||
}
|
||||
|
||||
t.Logf("WORKER-CONSENT OK: в ready воркер не трогал, после «создавай» → success, ветка %s", branch)
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
// 2. согласие в фазе ready → создание
|
||||
// 2. согласие в фазе ready → одобрение
|
||||
if task.Status == storage.StatusReady {
|
||||
if isConsent(text) {
|
||||
return c.handleConsent(ctx, task)
|
||||
@@ -61,6 +61,17 @@ func (c *Core) ProcessTurn(ctx context.Context, taskID int64, text string) (Resu
|
||||
return c.handleEdit(ctx, task, text)
|
||||
}
|
||||
|
||||
// 2b. approved — финальное одобрение, правка запрещена.
|
||||
// Воркер уже взял/заберёт задачу; текст не меняет статус.
|
||||
if task.Status == storage.StatusApproved {
|
||||
return Result{
|
||||
Reply: "Задача уже одобрена и передана на выполнение. Следите за статусом: /status " + itoa(task.ID),
|
||||
Action: "send",
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 3. обычный ход: накопление + аналитик
|
||||
return c.handleTurn(ctx, task, text)
|
||||
}
|
||||
@@ -188,6 +199,16 @@ func (c *Core) handleRetry(ctx context.Context, rest string) (Result, error) {
|
||||
if err != nil {
|
||||
return c.notFoundReply(ctx, id, err)
|
||||
}
|
||||
// Завершённые задачи (success/cancelled/aborted/closed) перезапускать нельзя:
|
||||
// переход → collecting для них невалиден. Только новая задача через /start.
|
||||
if storage.IsTerminal(task.Status) {
|
||||
return Result{
|
||||
Reply: "Задачу #" + itoa(id) + " нельзя перезапустить — она завершена (" + string(task.Status) + "). Создайте новую через /start.",
|
||||
Action: "send",
|
||||
TaskID: id,
|
||||
Status: task.Status,
|
||||
}, nil
|
||||
}
|
||||
task.Status = storage.StatusCollecting
|
||||
if err := c.Store.ClearHistory(ctx, id); err != nil {
|
||||
return Result{}, err
|
||||
@@ -261,14 +282,15 @@ func (c *Core) notFoundReply(ctx context.Context, id int64, err error) (Result,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleConsent создаёт задачу (статус ready → ...). Пока — подтверждение готовности.
|
||||
// handleConsent одобряет задачу: ready → approved (финальное одобрение,
|
||||
// после которого воркер забирает задачу на выполнение).
|
||||
func (c *Core) handleConsent(ctx context.Context, task *storage.Task) (Result, error) {
|
||||
task.Status = storage.StatusReady
|
||||
task.Status = storage.StatusApproved
|
||||
if err := c.Store.UpdateTask(ctx, task); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
return Result{
|
||||
Reply: "✅ Задача #" + itoa(task.ID) + " готова к запуску.",
|
||||
Reply: "✅ Задача #" + itoa(task.ID) + " одобрена. Запускаю выполнение.",
|
||||
Action: "created:" + itoa(task.ID),
|
||||
TaskID: task.ID,
|
||||
Status: task.Status,
|
||||
|
||||
@@ -160,6 +160,10 @@ func TestConsentInReady(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditInReadyGoesCollecting(t *testing.T) {
|
||||
|
||||
@@ -8,7 +8,8 @@ type Status string
|
||||
const (
|
||||
StatusDraft Status = "draft" // только что создана
|
||||
StatusCollecting Status = "collecting" // аналитик собирает детали
|
||||
StatusReady Status = "ready" // черновик готов, ждёт запуска
|
||||
StatusReady Status = "ready" // черновик готов, ждёт одобрения пользователя
|
||||
StatusApproved Status = "approved" // пользователь одобрил («создавай») — воркер берёт в работу
|
||||
StatusRunning Status = "running" // opencode работает
|
||||
StatusSuccess Status = "success" // задача выполнена
|
||||
StatusFailed Status = "failed" // ошибка выполнения
|
||||
@@ -20,7 +21,7 @@ const (
|
||||
|
||||
// AllStatuses — все возможные статусы для валидации.
|
||||
var AllStatuses = []Status{
|
||||
StatusDraft, StatusCollecting, StatusReady,
|
||||
StatusDraft, StatusCollecting, StatusReady, StatusApproved,
|
||||
StatusRunning, StatusSuccess, StatusFailed, StatusTimeout,
|
||||
StatusCancelled, StatusAborted, StatusClosed,
|
||||
}
|
||||
@@ -29,7 +30,10 @@ var AllStatuses = []Status{
|
||||
var validTransitions = map[Status][]Status{
|
||||
StatusDraft: {StatusCollecting, StatusCancelled, StatusAborted},
|
||||
StatusCollecting: {StatusReady, StatusDraft, StatusCancelled, StatusAborted},
|
||||
StatusReady: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting}, // правка готового
|
||||
// ready — черновик готов: «создавай» → approved, либо правка/отмена/закрытие.
|
||||
StatusReady: {StatusApproved, StatusCancelled, StatusAborted, StatusClosed, StatusCollecting},
|
||||
// approved — финальное одобрение: воркер берёт в running, либо отмена/сбой/закрытие.
|
||||
StatusApproved: {StatusRunning, StatusCancelled, StatusAborted, StatusClosed},
|
||||
StatusRunning: {StatusSuccess, StatusFailed, StatusTimeout, StatusCancelled},
|
||||
StatusSuccess: {StatusClosed},
|
||||
StatusFailed: {StatusReady, StatusClosed, StatusCancelled, StatusCollecting}, // retry: перезапуск сбора
|
||||
|
||||
@@ -25,6 +25,12 @@ func TestIsValidTransition(t *testing.T) {
|
||||
{StatusTimeout, StatusReady, true}, // retry
|
||||
{StatusTimeout, StatusCollecting, true}, // retry: перезапуск сбора
|
||||
{StatusTimeout, StatusRunning, false},
|
||||
{StatusReady, StatusApproved, true}, // «создавай» → одобрено
|
||||
{StatusReady, StatusRunning, false}, // без одобрения воркер не запускает
|
||||
{StatusApproved, StatusRunning, true}, // воркер берёт approved
|
||||
{StatusApproved, StatusCancelled, true}, // отмена до запуска
|
||||
{StatusApproved, StatusReady, false}, // финал: назад нельзя
|
||||
{StatusApproved, StatusCollecting, false},
|
||||
{StatusClosed, StatusDraft, false},
|
||||
{StatusClosed, StatusRunning, false},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -113,7 +113,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
|
||||
}
|
||||
|
||||
tasks, err := w.Store.ListTasks(ctx, storage.TaskFilter{
|
||||
Status: storage.StatusReady,
|
||||
Status: storage.StatusApproved,
|
||||
Limit: slots,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -142,7 +142,7 @@ func (w *Worker) pollAndDispatch(ctx context.Context) error {
|
||||
// dev дорабатывает по комментариям; прошло → push ветки + success.
|
||||
func (w *Worker) runTask(ctx context.Context, task *storage.Task) (err error) {
|
||||
// 1. проверяем статус
|
||||
if task.Status != storage.StatusReady {
|
||||
if task.Status != storage.StatusApproved {
|
||||
return fmt.Errorf("%w: task %d status=%q", ErrLaunch, task.ID, task.Status)
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,10 @@ func createReadyTask(t *testing.T, s *storage.Storage, title string) *storage.Ta
|
||||
if err := s.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("set ready: %v", err)
|
||||
}
|
||||
task.Status = storage.StatusApproved
|
||||
if err := s.UpdateTask(ctx, task); err != nil {
|
||||
t.Fatalf("set approved: %v", err)
|
||||
}
|
||||
task, _ = s.GetTask(ctx, id)
|
||||
return task
|
||||
}
|
||||
@@ -625,14 +629,14 @@ func TestWorkerSemaphore(t *testing.T) {
|
||||
w.pollAndDispatch(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// 1 должна быть success, 1 — всё ещё ready
|
||||
// 1 должна быть success, 1 — всё ещё approved
|
||||
success, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusSuccess})
|
||||
ready, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusReady})
|
||||
approved, _ := s.ListTasks(ctx, storage.TaskFilter{Status: storage.StatusApproved})
|
||||
if len(success) != 1 {
|
||||
t.Errorf("success = %d, want 1 (ready=%d)", len(success), len(ready))
|
||||
t.Errorf("success = %d, want 1 (approved=%d)", len(success), len(approved))
|
||||
}
|
||||
if len(ready) != 1 {
|
||||
t.Errorf("ready = %d, want 1", len(ready))
|
||||
if len(approved) != 1 {
|
||||
t.Errorf("approved = %d, want 1", len(approved))
|
||||
}
|
||||
|
||||
// первая завершилась и вернула токен в сем — можем диспатчить вторую
|
||||
|
||||
Reference in New Issue
Block a user