422 lines
36 KiB
Markdown
422 lines
36 KiB
Markdown
# Архитектура Orchestrator
|
||
|
||
## Обзор
|
||
|
||
Orchestrator — event-driven FastAPI сервис, который управляет жизненным циклом задач разработки через мульти-агентный пайплайн. Каждая задача проходит через фиксированные стадии, на каждой из которых работает специализированный Claude CLI агент.
|
||
|
||
## Компоненты
|
||
|
||
### 1. Webhook Receivers
|
||
|
||
#### Plane Webhook (`src/webhooks/plane.py`)
|
||
- **Фильтр по проекту (ORCH-6):** извлекает `data.project` (Plane project uuid) и игнорирует событие, если проект не в реестре (`known_plane_project_ids()`) → ответ `{"status":"ignored","reason":"unknown project"}`. Это предотвращает инцидент 2026-06-02 (webhook на весь workspace без фильтра).
|
||
- Принимает `work_item.created` — резолвит repo/prefix/Plane-проект из реестра по `project`, создаёт задачу в DB, запускает analyst
|
||
- Принимает `work_item.updated` — синхронизация статусов
|
||
|
||
#### Реестр проектов (`src/projects.py`, multi-repo, ORCH-6)
|
||
Маппинг **Plane project id → (repo, work_item_prefix, name)**. Позволяет одному
|
||
оркестратору обслуживать несколько репозиториев, не путая их.
|
||
|
||
```python
|
||
@dataclass(frozen=True)
|
||
class ProjectConfig:
|
||
plane_project_id: str # uuid Plane-проекта (ключ реестра)
|
||
repo: str # имя gitea-репо (= папка в /repos)
|
||
work_item_prefix: str # ET / ORCH
|
||
name: str # человекочитаемое
|
||
```
|
||
|
||
Резолверы:
|
||
- `get_project_by_plane_id(uuid) -> ProjectConfig | None` — для фильтра/резолва в plane-webhook.
|
||
- `get_project_by_repo(repo) -> ProjectConfig | None` — когда известен только repo (gitea-webhook, plane_sync).
|
||
- `known_plane_project_ids() -> set[str]` — множество разрешённых проектов (фильтр).
|
||
|
||
**Источник конфигурации:** env `ORCH_PROJECTS_JSON` (JSON-массив `ProjectConfig`).
|
||
Если пусто/битый JSON — используется встроенный дефолт-реестр (enduro-trails + orchestrator),
|
||
чтобы система работала из коробки. Парсинг устойчив: битые записи пропускаются,
|
||
полностью невалидный JSON → fallback на дефолт.
|
||
|
||
Следствия multi-repo:
|
||
- **repo per project:** `repo = get_project_by_plane_id(project_id).repo` вместо хардкода `default_repo`.
|
||
- **prefix per project:** `get_next_work_item_id(repo, prefix)` нумерует независимо — `ORCH-001` vs `ET-010` (`src/db.py`).
|
||
- **plane_sync в правильный проект:** state/comment пишутся в Plane-проект самой задачи (резолв по repo через `get_project_by_repo`), а не в единственный хардкоженный `PROJECT_ID` (обратная совместимость сохранена дефолтом на enduro).
|
||
- **gitea-webhook:** push в repo вне реестра → `ignored` (не триггерит конвейер).
|
||
|
||
#### Gitea Webhook (`src/webhooks/gitea.py`)
|
||
- **push** — проверяет наличие артефактов (docs/, src/), продвигает стадию
|
||
- **pull_request\*** (wildcard) — обрабатывает review approved/rejected, PR merge
|
||
- **status** — CI green/failure, продвигает development → review
|
||
|
||
### 2. State Machine (`src/stages.py`)
|
||
|
||
Линейный пайплайн с одним возможным откатом (review → development):
|
||
|
||
```
|
||
STAGE_TRANSITIONS = {
|
||
created: → analysis (agent: None)
|
||
analysis: → architecture (agent: architect, QG: check_analysis_approved)
|
||
architecture: → development (agent: developer, QG: check_architecture_done)
|
||
development: → review (agent: reviewer, QG: check_tests_local)
|
||
review: → testing (agent: tester, QG: check_reviewer_verdict)
|
||
testing: → deploy-staging (agent: deployer, QG: check_tests_passed)
|
||
deploy-staging: → deploy (agent: deployer, QG: check_staging_status)
|
||
deploy: → done (agent: None, QG: None)
|
||
cancelled: → None (agent: None, QG: None) # ORCH-090: терминал-сток отмены
|
||
}
|
||
```
|
||
|
||
**Терминальные стоки (ORCH-090):** `done` и `cancelled` — равноправные терминальные состояния
|
||
(`{"next": None, "agent": None, "qg": None}`). `cancelled` — это **не новое ребро** (exit-гейты
|
||
рёбер не меняются), а терминал STOP-отмены. Системный предикат «задача завершена» —
|
||
`stage ∈ {done, cancelled}` (синхронно в `reconciler`/`serial_gate`/`task_deps`; adr-0026).
|
||
|
||
### 3. Quality Gates (`src/qg/checks.py`)
|
||
|
||
| Check | Метод проверки |
|
||
|-------|---------------|
|
||
| check_analysis_approved | Filesystem: 4 файла + :approved: comment в Plane |
|
||
| check_architecture_done | Filesystem: ADR dir или infra-requirements.md |
|
||
| check_tests_local | Оркестратор сам гоняет `make test` в **worktree задачи** `/repos/_wt/<repo>/<branch>` (judge по exit-code). Заменил check_ci_green: Gitea CI не сконфигурирован. Worktree-изоляция → безопасно при параллельных задачах (ORCH-2 / S-4). |
|
||
| check_reviewer_verdict | Filesystem: читает `verdict: APPROVED\|REQUEST_CHANGES` из YAML-frontmatter `12-review.md` (только машиночитаемое поле, не подстроки в тексте) |
|
||
| check_tests_passed | Filesystem: test-report.md содержит "PASS" |
|
||
| check_ci_green | (legacy) Gitea API: GET /commits/{branch}/status — больше не используется как QG развития |
|
||
| check_review_approved | (legacy) Gitea API: GET /pulls/{n}/reviews — не используется в STAGE_TRANSITIONS |
|
||
|
||
### 4. Agent Launcher (`src/agents/launcher.py`)
|
||
|
||
Запускает Claude CLI как subprocess:
|
||
|
||
```bash
|
||
claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
|
||
```
|
||
|
||
Каждый запуск:
|
||
1. Записывает run в DB (agent_runs)
|
||
2. Запускает subprocess. **stdout/stderr перенаправляются СРАЗУ в файл `/app/data/runs/{id}.log` на уровне ОС** (Popen `stdout=log_fh`). Никакого PIPE в памяти оркестратора → нет PIPE-deadlock, нет потока-читателя, нет зомби (B-2).
|
||
3. Стартует **watchdog thread** (timeout 30 мин → SIGKILL по pid)
|
||
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance
|
||
|
||
### 5. Auto-advance (`launcher._try_advance_stage`)
|
||
|
||
После успешного завершения агента:
|
||
1. Определяет текущую стадию задачи
|
||
2. Проверяет QG для выхода из стадии
|
||
3. Если QG пройден — продвигает стадию
|
||
4. Запускает следующего агента (если определён)
|
||
|
||
Примечание: переход `review → testing` использует `check_reviewer_verdict` (читается из frontmatter `12-review.md`); `development → review` — `check_tests_local` (оркестратор сам прогоняет тесты, не зависит от Gitea CI).
|
||
|
||
**Багфикс-трек: routing-override на ребре выхода из `analysis` (ORCH-019 — design).** Для задачи
|
||
с `tasks.track='bug'` (помечена в `start_pipeline` по метке Plane `Bug` через аппарат ORCH-089)
|
||
`advance_stage` на шаге 3 переопределяет результат `get_next_stage('analysis')`: `next_stage` →
|
||
`development` (вместо `architecture`), а на шаге 4 `next_agent` → `developer` (вместо `architect`)
|
||
→ стадия `architecture` и её exit-гейт `check_architecture_done` для багфикса не исполняются.
|
||
`STAGE_TRANSITIONS`/`get_next_stage`/`get_agent_for_stage` остаются чистыми (1:1) — override живёт
|
||
только в `advance_stage`. Чистый предикат `bug_fast_track.skips_architecture(track)` (leaf
|
||
`src/bug_fast_track.py`, never-raise) под `bug_fast_track_enabled`; `track` читается из БД, не из
|
||
сети (NFR-4). `False`/неприменимый репо → маршрут байт-в-байт прежний. Детали —
|
||
[adr-0032](adr/adr-0032-bug-fast-track.md).
|
||
|
||
### 6. Review Bounce
|
||
|
||
При REQUEST_CHANGES:
|
||
1. Считает количество developer runs для задачи
|
||
2. Если < MAX_DEV_RETRIES (3) — откатывает в development, перезапускает developer
|
||
3. Если >= MAX_DEV_RETRIES — эскалация (логирование + уведомление)
|
||
|
||
### 7. Live Telegram tracker (`src/notifications.py`)
|
||
|
||
Вместо ~15 отдельных сообщений на задачу оркестратор держит **ОДНУ** live-карточку на задачу (`update_task_tracker`), которая обновляется на каждом переходе стадии. Текст рендерится статически из БД (`render_task_tracker`: стадии, токены, стоимость, BRD-подтверждение, итоги). Карточка всегда тихая (`disable_notification=True`); отдельные пинги шлют только `notify_approve_requested` / `notify_error`. `message_id` хранится в `tasks.tracker_message_id`; helpers `get_tracker_message_id` / `set_tracker_message_id`. Контракт всего компонента — **never raises**.
|
||
|
||
**Режимы (ORCH-042, `ORCH_TRACKER_MODE` → `Settings.tracker_mode`; дефолт переключён `edit → bump` в ORCH-067).** Резолвится в `update_task_tracker` (case-insensitive, trim); всё, что ≠ `"bump"` (включая пустое/мусор/None), трактуется как `edit` → безопасный фолбэк. Инвариант «одна карточка на задачу» сохраняется в обоих режимах.
|
||
|
||
| Режим | Поведение при обновлении |
|
||
|-------|--------------------------|
|
||
| `bump` (дефолт, ORCH-067) | карточка пересоздаётся внизу чата: best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)` **только** при успешном send (`new_mid is not None`). За один вызов — не более одного нового сообщения. Живая карточка всегда «догоняет» переписку. |
|
||
| `edit` | первый вызов → `send_telegram` (тихо) + сохранение `message_id`; далее → `edit_telegram` на сохранённый id. Новое сообщение шлётся ТОЛЬКО при `EDIT_GONE` (удалено/старше 48ч/невалидный id). `EDIT_NOT_MODIFIED` / `EDIT_FAILED` → нового сообщения нет (анти-дубль). |
|
||
|
||
**`delete_telegram(message_id) -> bool`** (low-level, never raises). Семантика возврата — «исчезло ли старое сообщение»:
|
||
- `ok:true` → `True`;
|
||
- `ok:false` с маркерами `_DELETE_GONE_MARKERS` (`message to delete not found`, `message can't be deleted`, `message_id_invalid`) → `True` (старше 48ч / уже удалено — не транзиент);
|
||
- прочий `ok:false` / 5xx / исключение (сеть/таймаут) → `False` + `logger.warning`;
|
||
- нет токена/chat_id → `False`, HTTP не выполняется.
|
||
|
||
Результат `delete_telegram` **не** блокирует отправку новой карточки (BR-6: delete-fail у сообщения >48ч → всё равно шлём новое); `False` означает лишь «старое, возможно, ещё живо» — будет вычищено повторной попыткой на следующем переходе. При транзиентном сбое send (`None`) указатель `tracker_message_id` **не** затирается (анти-затирание, симметрично edit-fallback).
|
||
|
||
**Текст карточки (оба режима, ORCH-042):** метка `Подтверждение BRD` (была «Ревью БРД»); после прохождения approve-gate строка BRD начинается с ✅ (ветка ожидания сохраняет ⏸️/⏳); русские display-labels стадий (`Анализ / Архитектура / Разработка / Код ревью / Тестирование / Внедрение`); финальная строка `📦 Внедрено` (было `deployed`). Меняются только отображаемые строки — ключи стадий и имена агентов (завязаны на `_STAGE_ACTIVE_AGENT`, `last_done`, БД) не трогаются.
|
||
|
||
**Строки стадий: отражение откатов + суммирование метрик (ORCH-091).** Цикл рендера строк стадий (`render_task_tracker` → `_stage_line`) исправлен по двум осям. (1) **Откат (Деф.2):** `✅`-строка стадии рисуется только если её позиция в конвейере `≤` текущей позиции задачи; позиция берётся из порядка `STAGE_TRANSITIONS` (read-only хелпер `_pipeline_pos`, never-raise; неизвестная стадия → «далёкое будущее» → ✅ не пере-подавляется) с нормализацией `deploy-staging → deploy` ТОЛЬКО в гейте подавления (схлопнутая строка «Внедрение» несёт `stage_key="deploy"`). После отката (`deploy-staging → development`, `review → development`) строки стадий ПОЗЖЕ текущей больше не рисуются как пройденные — пропадает абсурд «✅ Внедрение + 🔄 Разработка»; `is_active_stage` не тронут. (2) **Метрики (Деф.3):** `_stage_line` агрегирует ВСЕ `agent_runs` агента стадии (Σ cost / Σ токены / Σ время теми же per-run-формулами, что блок тоталов задачи), а не последний прогон — каждый агент привязан ровно к одной строке `_TRACKER_STAGES`, поэтому Σ(строк стадий) ≡ тоталы ≡ `SUM(agent_runs)` по `task_id`; модель/эффорт/«попытка N» берутся из последнего прогона. Прогоны, подавлённые откатом, по-прежнему входят в тоталы (намеренная семантика отката).
|
||
|
||
**Строка Plane-статуса и кликабельный номер (ORCH-067, слой B — индикация).** Под заголовком карточка несёт строку `📍 <Plane-статус>` по модели ORCH-066. Источник — двухслойный, контракт **never raises**:
|
||
- **Оффлайн-ядро** `plane_status_label(task_row)` — чистая функция БЕЗ сети: `stage → статус` (`created→To Analyse`, `analysis→Analysis`, `architecture→Architecture`, `development→Development`, `review→Code-Review`, `testing→Testing`, `deploy-staging→Deploying (staging)` [ORCH-091], `deploy→⏸️ Awaiting Deploy`, `done→Done`, `cancelled→Cancelled` [ORCH-091]) + `⏸️ In Review` из brd-часов (`brd_review_started_at` задан, `…_ended_at` пуст). **ORCH-091:** карта `_STAGE_STATUS_LABEL` покрывает ВСЕ ключи `STAGE_TRANSITIONS` (полнота — тестом, не статичным списком); неизвестная/будущая стадия → нейтральный фолбэк (капитализированное имя стадии), а НЕ «To Analyse» (он остаётся лишь явным лейблом `created` и безопасной деградацией на истинно-битом входе).
|
||
- **Live-overlay** `_live_plane_branch_override` — best-effort: дорисовывает ветви-статусы, неразличимые оффлайн (Needs Input / Blocked / Rejected / Cancelled / Deploying / Monitoring after Deploy), чтением живого Plane-статуса (`fetch_issue_state` с коротким `tracker_live_status_timeout_s`, TTL-кэш `tracker_live_status_ttl_s`, kill-switch `tracker_live_status`). Любой сбой / выключенный флаг / нехватка данных → оффлайн-метка; `⏸️ In Review` (авторитет brd-часов) overlay не консультирует. Анти-false-positive: `deploying/monitoring`, алиасящие базовый UUID на проекте без выделенного статуса (enduro), не вызывают override.
|
||
|
||
**Кликабельный номер задачи (ORCH-067).** Номер в заголовке карточки И во всех уведомлениях орка, где упоминается `work_item_id`, — HTML-ссылка на issue в Plane через общий `plane_issue_link` / `link_for` (URL строит `_plane_issue_url` с loopback/workspace/project-гардами, переиспользуя резолв ORCH-017). Fail-safe: при нехватке любого из (web-base/не-loopback, workspace, project_id, plane_issue_id) → `html.escape(work_item_id)` без `<a>`; динамические части экранируются, `<a>`-разметка валидна под `parse_mode=HTML`. Алерты `stage_engine`/`launcher`/`security_gate`/`reconciler` переведены на `link_for` (резолвит `repo`+`plane_issue_id` из БД по `task_id` или `work_item_id`).
|
||
|
||
**HTML-безопасность данных карточки (ORCH-095).** Текст карточки шлётся с `parse_mode=HTML` и собирается из слотов двух категорий: **markup** (намеренная разметка — `num_html`/`plane_issue_link`, `link_for(...)`, `_done_link(...)`, уже-экранированный `esc_title`) и **data** (подставляемые значения — длительности `_fmt_minutes`/`_capped_review_str`, статус-лейбл `_card_status_label`, имя модели `short_model_name`, эффорт `_run_effort`, токены/стоимость `fmt_tokens`/`fmt_cost`). Инвариант: **каждый data-слот экранируется `html.escape` ровно один раз на границе рендера** (`render_task_tracker`/`_stage_line`); функции-источники остаются HTML-агностичными, markup-слоты не экранируются (двойное экранирование запрещено). Это устранило класс «неэкранированные данные в HTML-тексте»: до фикса `_fmt_minutes(<60s)` возвращал литерал `<1м`, который Telegram парсил как открывающий тег → `editMessageText` `400 can't parse entities` → `EDIT_FAILED` → ранний `return` (анти-дубль ORCH-087) → карточка застывала (инцидент ORCH-093). `_fmt_minutes` по-прежнему возвращает `<1м` — escape на границе (`<1м`) рендерит его визуально идентично; формат не меняется. Застрявшая (в окне) карточка авто-восстанавливается следующим безопасным рендером; `edit_telegram`/`update_task_tracker`/леджер сирот/режимы `bump`/`edit` не тронуты. Детали — [ORCH-095 ADR-001](../work-items/ORCH-095/06-adr/ADR-001-html-safe-card-data-render.md).
|
||
|
||
## Database Schema
|
||
|
||
```sql
|
||
-- Задачи
|
||
CREATE TABLE tasks (
|
||
id INTEGER PRIMARY KEY,
|
||
work_item_id TEXT, -- Plane issue identifier (e.g. "ET-006")
|
||
plane_issue_id TEXT, -- Plane UUID
|
||
repo TEXT,
|
||
branch TEXT,
|
||
stage TEXT DEFAULT 'created',
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- Запуски агентов
|
||
CREATE TABLE agent_runs (
|
||
id INTEGER PRIMARY KEY,
|
||
task_id INTEGER REFERENCES tasks(id),
|
||
agent TEXT, -- analyst/architect/developer/reviewer/tester
|
||
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
finished_at TIMESTAMP,
|
||
exit_code INTEGER,
|
||
output_path TEXT -- /app/data/runs/{id}.log
|
||
);
|
||
|
||
-- Сырые события
|
||
CREATE TABLE events (
|
||
id INTEGER PRIMARY KEY,
|
||
source TEXT, -- plane/gitea
|
||
event_type TEXT,
|
||
payload TEXT,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
```
|
||
|
||
## Deployment
|
||
|
||
### Docker Compose
|
||
|
||
```yaml
|
||
services:
|
||
orchestrator:
|
||
build: .
|
||
container_name: orchestrator
|
||
restart: unless-stopped
|
||
network_mode: host
|
||
volumes:
|
||
- ./data:/app/data # SQLite + logs
|
||
- /home/slin/repos:/repos # Git repositories
|
||
- /var/run/docker.sock:/var/run/docker.sock # Docker CLI
|
||
- claude-code:/opt/claude-code:ro # Claude CLI binary
|
||
- /home/slin/.claude:/home/slin/.claude # Claude config
|
||
env_file: .env
|
||
group_add: ["999"] # docker group
|
||
```
|
||
|
||
### Dockerfile
|
||
|
||
- Base: python:3.12-slim
|
||
- Docker CLI (sibling containers)
|
||
- **tini** как PID 1 (proper zombie reaping)
|
||
- `git config --global safe.directory '*'`
|
||
- ENTRYPOINT: tini → uvicorn
|
||
|
||
## Потоки данных
|
||
|
||
### Happy path (ET-006 пример)
|
||
|
||
```
|
||
1. Plane webhook: work_item.created → task created, analyst launched
|
||
2. Analyst: пишет BRD/TRZ/AC/TestPlan → git push docs/
|
||
3. Plane comment :approved: → QG check_analysis_approved → PASS
|
||
4. Auto-advance: analysis → architecture, architect launched
|
||
5. Architect: пишет ADR, infra-requirements → git push docs/
|
||
6. Gitea push webhook: ADR detected → QG check_architecture_done → PASS
|
||
7. Auto-advance: architecture → development, developer launched
|
||
8. Developer: пишет код src/ + tests/ → git push, creates PR
|
||
9. Gitea status webhook: CI green → QG check_ci_green → PASS
|
||
10. Auto-advance: development → review, reviewer launched
|
||
11. Reviewer: оставляет review (APPROVED или REQUEST_CHANGES)
|
||
12. Gitea PR webhook: review event → QG check_review_approved → PASS
|
||
13. Advance: review → testing, tester launched
|
||
14. Tester: прогоняет тесты, пишет test-report.md → git push
|
||
15. Auto-advance: testing → deploy-staging (QG check_tests_passed → PASS)
|
||
16. Deployer: runs staging checks → writes 15-staging-log.md (staging_status: SUCCESS)
|
||
17. Auto-advance: deploy-staging → deploy (QG check_staging_status → PASS)
|
||
18. PR merge → Gitea PR webhook: action=closed, merged=true → done
|
||
```
|
||
|
||
### Review bounce path
|
||
|
||
```
|
||
11. Reviewer: REQUEST_CHANGES
|
||
12. Gitea PR webhook: review_state=REQUEST_CHANGES, stage=review
|
||
13. Rollback: review → development, developer relaunched (attempt N/3)
|
||
14. Developer: фиксит замечания → git push
|
||
15. CI green → development → review, reviewer relaunched
|
||
16. Reviewer: APPROVED → continue happy path
|
||
```
|
||
|
||
## Resilience
|
||
|
||
| Механизм | Описание |
|
||
|----------|----------|
|
||
| Watchdog | Каждый агент: timeout 30 мин → SIGKILL + exit_code=-9 |
|
||
| safe.directory | git операции работают в любой директории |
|
||
| Max retries | Developer: max 3 попытки, затем эскалация |
|
||
| Zombie-free | stdout идёт сразу в файл + monitor `proc.wait()` → процесс всегда reap'нут (B-2) |
|
||
| Orphan recovery | При старте: orphan-run'ы (finished_at IS NULL, старше 35 мин) помечаются exit=-1 с per-run warning + Telegram-уведомлением «нужна ручная проверка» (M-1) |
|
||
|
||
## Агенты
|
||
|
||
Каждый агент — Claude CLI с:
|
||
- **System prompt**: `.openclaw/agents/{role}.md` (в репозитории)
|
||
- **Task file**: `.task-{suffix}.md` — генерируется orchestrator **прямой записью в worktree задачи** `/repos/_wt/<repo>/<branch>/` (B-1, без docker; ORCH-2 — в изолированную рабочую копию, не в shared `/repos/<repo>`). В `.gitignore` репозитория проекта (рантайм-артефакт, не коммитится).
|
||
- **Tools**: Read, Write, Edit, Bash
|
||
- **Output**: `--print` mode (весь вывод в stdout после завершения)
|
||
|
||
| Агент | Артефакты | Время (типичное) |
|
||
|-------|-----------|-------------------|
|
||
| analyst | BRD, TRZ, AC, TestPlan | 5-10 мин |
|
||
| architect | ADR, infra-requirements, tech-risks | 5-10 мин |
|
||
| developer | src/, tests/, PR | 15-30 мин |
|
||
| reviewer | review report, PR review | 3-5 мин |
|
||
| tester | test-report.md, e2e results | 10-25 мин |
|
||
| deployer | merge PR + SSH deploy-hook + smoke | 5-10 мин |
|
||
|
||
## Изоляция через git worktree (ORCH-2 / S-4)
|
||
|
||
Каждая задача (= одна git-ветка) работает в **изолированной git worktree**, а не в общем
|
||
`/repos/<repo>`. Это убирает гонки `git checkout`, когда две задачи активны одновременно.
|
||
|
||
```
|
||
/repos/<repo> ← основной clone (fetch / управление worktree, read-only запросы)
|
||
/repos/_wt/<repo>/<safe-branch> ← worktree конкретной задачи (рабочая копия агента)
|
||
```
|
||
|
||
Модуль `src/git_worktree.py`:
|
||
- `get_worktree_path(repo, branch)` — путь worktree (не создаёт).
|
||
- `ensure_worktree(repo, branch)` — создаёт (или переиспользует) worktree на нужной ветке;
|
||
для новой ветки создаёт её от `origin/main`. Возвращает путь.
|
||
- `remove_worktree(repo, branch)` — опциональная очистка при `done`.
|
||
|
||
Где используется worktree:
|
||
- **launcher**: агент запускается с `cd <worktree>` (без `git checkout` в cmd); task-файл
|
||
пишется в worktree; commit/push в `_monitor_agent` идут в worktree.
|
||
- **qg/checks**: чтение артефактов агента (`check_analysis_complete`, `check_architecture_done`,
|
||
`check_tests_passed`, `check_reviewer_verdict`) и `check_tests_local` (`make test`) — из worktree.
|
||
Артефакт-функции принимают опциональный `branch`; без него падают на shared `/repos/<repo>`
|
||
(обратная совместимость).
|
||
- **webhooks/gitea**: `git branch -r --contains <sha>` оставлен в основном clone — это
|
||
**read-only** запрос (нет checkout/мутации), гонок не создаёт.
|
||
|
||
> Один branch может быть checked out только в одной worktree одновременно —
|
||
> это и есть нужное свойство: одна задача = одна ветка = одна worktree.
|
||
|
||
## Известные ограничения
|
||
|
||
- ~~Shared `/repos` checkout (гонки при параллельных задачах).~~ **РЕШЕНО (ORCH-2 / S-4):**
|
||
git worktree per task/branch — см. раздел «Изоляция через git worktree» ниже.
|
||
- ~~In-process daemon-потоки (рестарт → сироты, потеря работы).~~ **РЕШЕНО (ORCH-1 / F-2b):**
|
||
персистентная очередь jobs + фоновый воркер — см. раздел «Очередь задач (ORCH-1)» ниже.
|
||
Daemon-потоки monitor/watchdog остаются для одного запущенного агента, но при
|
||
рестарте его job возвращается в `queued` (queue-recovery) и переподхватывается.
|
||
|
||
## Очередь задач (ORCH-1 / F-2b)
|
||
|
||
Раньше webhook-хэндлер **синхронно** спавнил `subprocess.Popen` + 2 daemon-thread
|
||
прямо в процессе uvicorn (8 точек вызова). Рестарт = сироты + потеря работы,
|
||
нет лимита параллелизма, нет ретраев.
|
||
|
||
### Flow
|
||
|
||
```
|
||
webhook (plane/gitea) background thread (queue_worker)
|
||
│ │
|
||
enqueue_job() ---> [ jobs table ] <--- claim_next_job() (atomic queued->running)
|
||
(мгновенный status=queued │
|
||
ответ 200) launch_job(job)
|
||
│
|
||
AgentLauncher._spawn (Popen claude)
|
||
│
|
||
_monitor_agent (proc.wait, commit/push,
|
||
│ advance stage)
|
||
│
|
||
_finalize_job:
|
||
exit 0 -> mark_job done
|
||
exit !=0 & attempts<max -> requeue (queued)
|
||
exit !=0 & attempts>=max -> failed + Telegram
|
||
```
|
||
|
||
### Таблица `jobs`
|
||
|
||
| Колонка | Назначение |
|
||
|--------|------------|
|
||
| `status` | `queued` → `running` → `done` \| `failed` \| `cancelled` (ORCH-090: терминальный исход STOP-отмены, не реквью'ится) |
|
||
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
|
||
| `run_id` | FK на `agent_runs.id` после старта |
|
||
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
|
||
| `task_content` | ТЗ, которое пишется в task-файл агента |
|
||
| `error` | последняя ошибка |
|
||
|
||
`idx_jobs_status (status, id)` — быстрый FIFO-выбор queued.
|
||
|
||
### Атомарный claim
|
||
|
||
`claim_next_job()` делает `SELECT queued ORDER BY id LIMIT 1` → `UPDATE ... WHERE id=? AND
|
||
status='queued'` и проверяет `rowcount`. При гонке двух тиков лишь один UPDATE
|
||
переведёт строку в `running` (rowcount==1); проигравший берёт следующий job.
|
||
|
||
### Queue-recovery (рестарт-safe)
|
||
|
||
В `main.py` lifespan **после** M-1 orphan-recovery вызывается `requeue_running_jobs()`:
|
||
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
|
||
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
|
||
|
||
### Job-reaper (ORCH-065, рестарт НЕ требуется)
|
||
|
||
`requeue_running_jobs()` спасает ТОЛЬКО на старте процесса. Зомби-job, возникший
|
||
**без** рестарта (умер monitor-поток/дочерний процесс, а сервис жив), оставался
|
||
`running` навсегда и при `max_concurrency=1` блокировал всю очередь. Фоновый
|
||
daemon-поток `src/job_reaper.py` (каркас `reconciler`) периодически
|
||
(`reaper_interval_s`) сканирует `running`-jobs и реапит «мёртвые»:
|
||
- **Tier-1** — `jobs.pid` мёртв (`os.kill(pid,0)`→`ProcessLookupError`) на
|
||
протяжении `reaper_dead_ticks` подряд тиков (анти-ложноположительность);
|
||
- **Tier-2** — у `agent_runs[run_id]` записан `exit_code`, а `jobs.status` ещё
|
||
`running`. Окно неоднозначно: живой monitor пишет `exit_code` ПЕРВЫМ, затем
|
||
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
|
||
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
|
||
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
|
||
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
|
||
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
|
||
|
||
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
|
||
как `claim_next_job`) → совместим со стартовым `requeue_running_jobs` без двойной
|
||
обработки. Действие — **claim-before-act**: для exit0 канонический QG оценивается
|
||
read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и только победитель
|
||
claim делает `_try_advance_stage` (advance+enqueue) — проигравший (поздний monitor
|
||
/ стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
|
||
источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно →
|
||
`attempts<max`→`queued`, иначе `failed`+Telegram. Тот же поток на старте и
|
||
периодически делает проактивный реклейм stale/dead merge-lease (`merge_gate.py`:
|
||
`pid_alive`/`reclaim_stale_lease`). never-raise; kill-switch `ORCH_REAPER_ENABLED`
|
||
/ `ORCH_LEASE_RECLAIM_ENABLED`; снимок в `GET /queue` (блок `reaper`). Подробнее —
|
||
adr-0011.
|
||
|
||
### Конфиг
|
||
|
||
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
|
||
- `ORCH_QUEUE_POLL_INTERVAL` (default 2.0) — период опроса.
|
||
- `ORCH_AGENT_MODEL_DEFAULT` / `ORCH_AGENT_MODEL_<AGENT>` (ORCH-41) — модель агентов; дефолт `claude-opus-4-8`.
|
||
- `ORCH_AGENT_EFFORT_DEFAULT` / `ORCH_AGENT_EFFORT_<AGENT>` (ORCH-41) — режим `--effort` (low|medium|high|xhigh|max).
|
||
- `ORCH_AGENT_FALLBACK_MODEL` (ORCH-41) — опц. `--fallback-model` при overloaded.
|
||
- per-project override: `agent_models` / `agent_efforts` в `ORCH_PROJECTS_JSON`; резолверы `resolve_agent_model` / `resolve_agent_effort` (project > per-agent env > default > пусто).
|
||
|
||
Наблюдаемость: `GET /queue` — counts по статусам + последние 10 jobs.
|
||
|
||
> Совместимость: `launcher.launch()` (прямой синхронный запуск, `job_id=None`)
|
||
> сохранён для обратной совместимости. Очередь использует `launch_job()`;
|
||
> оба разделяют `_spawn()` (Popen-логика B-2 не изменена).
|
||
- **Gitea CI не настроен.** QG развития теперь локальный (`check_tests_local`);
|
||
Gitea CI-статусы не являются authoritative и не блокируют pipeline.
|
||
- **Docker внутри контейнера orchestrator НЕДОСТУПЕН.** Деплой идёт только через
|
||
SSH-хук `enduro-deploy-hook.sh` на хосте.
|