diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad10a1..20548f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Source-backed `00-business-request.md` вместо хардкода `TBD`** (ORCH-119, `fix`, Bug-трек): раздел «Description» файла `00-business-request.md` теперь несёт **фактический текст запроса** из Plane-issue (`description`/`description_stripped`) вместо литерала `TBD` — терялся source-backed контекст запроса. Фикс работает на **обоих** путях создания: прямой (путь A, `serial_gate` не применим — `start_pipeline` передаёт `description` в `_create_initial_docs`) и **отложенный срез ветки** (путь B, ORCH-088, доминирует на self-hosting `orchestrator`). Для пути B `description` **персистится durable** при создании задачи (аддитивная колонка `tasks.description` через `_ensure_column`, зеркало `tasks.title`, записывается **внутри того же атомарного INSERT** `create_task_atomic` — race-safe относительно анти-dup-claim ORCH-053) и читается из строки `tasks` в `launcher._spawn` → `_materialize_deferred_branch` на момент claim (без сетевого вызова в горячем пути, NFR-4). **Fail-safe (FR-4):** пустое/whitespace/`None`/нечитаемое описание → явный безопасный маркер `_(описание отсутствует в источнике)_` через чистый рендер-хелпер `_render_business_request` (never-raise; создание задачи не падает). **Идемпотентность:** Gitea 422 (файл существует) → no-op, ранее записанное тело не перезаписывается. **Инвариант (AC-5):** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict-ключи — байт-в-байт; единственное изменение схемы — аддитивная `tasks.description` (базовый `CREATE TABLE tasks` не тронут); анти-stale-base инвариант ORCH-088 цел (момент/условие среза не меняются — только источник данных дополняется). Обратимость — revert PR (колонка остаётся инертной). Покрытие — `tests/test_orch119_business_request.py` (TC-01 обязательный регресс red→green; TC-02…TC-07). Дополнительно в том же PR починена **тест-гермеичность** `tests/test_orch123_staging_runner_exec.py::test_r2_held_deploy_staging_not_rolled_back`: тест переиспользовал собственный (теперь смерженный в `main`) work-item id `ORCH-123`, и при дефолтном `repos_dir=/repos` staging-гейт через origin/main-fallback (`check_staging_status` → `_staging_log_from_main`) находил **реальный** `staging_status: SUCCESS`-лог ORCH-123 в `origin/main`, делая намеренно-красный гейт зелёным (флак проявлялся только в полном прогоне сьюта — singleton `repos_dir` берётся из первого импортирующего config файла, побеждая import-time `ORCH_REPOS_DIR=/tmp` этого модуля); autouse-фикстура `fresh_db` теперь пинит `repos_dir` в изолированный пустой tmp-каталог (зеркало уже пиннимого `worktrees_dir`), сохраняя проверяемость инварианта ORCH-123 R-2 (infra-held `deploy-staging` удерживается, не откатывается в `development`) независимо от порядка тестов. ADR: `docs/work-items/ORCH-119/06-adr/ADR-001-source-backed-business-request-doc.md`. - **Открытые вопросы аналитика → Needs Input (приоритет, неблокирование serial-gate, resume)** (ORCH-120, `fix`, трек Bug→escalate full-cycle): активирован и достроен ранее **мёртвый** путь «аналитик задаёт блокирующие вопросы → `01-questions.md` → Needs Input». Четыре согласованных изменения, аддитивно, под kill-switch, скоуп self-hosting, never-raise; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / схема БД — **байт-в-байт не тронуты** (поток — pre-gate-ветка движка, **не** Quality Gate; `01-questions.md` — **сигнальный** артефакт, **не** machine-verdict). (1) **Контракт + канон.** `.openclaw/agents/analyst.md` документирует канал «блокирующие вопросы → `01-questions.md`, НЕ фабриковать deliverables» + поведение на resume; новый скелет `docs/_templates/01-questions.md`; строка манифеста + примечание о префиксе `01-` в `docs/_standards/PIPELINE_DOCS.md`. (2) **Приоритет «вопросы активны» > «файлы готовы»** в `_handle_analysis_approved_flow` (DQ-3): чистая логика решения вынесена в leaf `src/analyst_questions.py` (`questions_gate_applies`/`autopause_applies`/`questions_active`), side-effects — в `stage_engine` (`_decide_analysis_outcome`/`_emit_analysis_needs_input`/`_emit_analysis_in_review`/`_emit_analysis_empty`); блокирующие вопросы достигают Needs Input даже при сфабрикованном полном пакете. (3) **Авто-park (DQ-1)** при Needs Input через ось «пауза» ORCH-124 (`db.set_task_paused`) → задача исключается из «активного» предиката serial-gate (ORCH-088), FIFO репо не клинит, пока ждём человека; **resume + unpark** в `handle_status_start` (analysis-ветка, `db.clear_task_paused`). (4) **Гигиена устаревания (DQ-2)** — детерминированный offline freshness-supersede по `mtime` (вопросы активны, пока пакет неполон ИЛИ `01-questions.md` не старше всех 4 deliverables) → полный свежий пакет supersede’ит старый файл без зависимости от LLM (нет бесконечной петли Needs Input). Флаги (`config.py`, безопасные дефолты): `analyst_questions_gate_enabled` (kill-switch) / `analyst_questions_gate_repos` (CSV; **пусто → self-hosting only**) / `analyst_needs_input_autopause_enabled` (независимый тумблер авто-park/unpark; `False` → operator-park `POST /serial-gate/pause`). off/out-of-scope → байт-в-байт как до ORCH-120 (enduro не затронут); ORCH-066 (Needs Input только у аналитика) не расширяется. Покрытие — `tests/test_orch120_analyst_needs_input.py` (TC-01 обязательный регресс: красный до фикса, зелёный после), `tests/test_orch120_serial_gate_needs_input.py`, `tests/test_orch120_resume_unpark.py`, `tests/test_orch120_questions_artifact_canon.py` + assert в `tests/test_agent_prompts_canon.py`. Витрина системы `docs/overview/` обновлена в том же PR (ось ORCH-011): абзац пауз `tech-pipeline.md` и пункт `GET /queue` в `tech-observability.md` теперь называют **два** источника паузы (оператор + авто-park движком на Needs Input), `tech-agents.md` — when-applicable сигнальный канал `01-questions.md` у `analyst` (`tests/test_system_docs.py` зелёный). ADR: `docs/work-items/ORCH-120/06-adr/ADR-001-analyst-open-questions-needs-input.md`, сквозной `docs/architecture/adr/adr-0053-analyst-open-questions-needs-input-flow.md`. - **Гигиена run-ownership строки `jobs` — инвариант «queued ⇒ run_id/pid/started_at IS NULL»** (ORCH-126, `fix`, трек Bug): багфикс контрол-плейна (инцидент ORCH-124/125) — при `ORCH_SERIAL_GATE_ENABLED=false` queued analyst-job'ы зависали навсегда (job 2286: `status=queued + run_id=759/760 + pid=35/42 + started_at=NULL` — физически невозможное состояние). **Причина:** ни один путь возврата job в `queued` (restart `requeue_running_jobs` / retry `mark_job('queued')` / transient `mark_job_transient` / reap `reap_running_job('queued')`) **не сбрасывал run-ownership** (`run_id`/`pid`); после рестарта контейнера pid мог быть **переиспользован** ОС → `pid_alive(stale)=True` → job-reaper (ORCH-065) Tier-1 «видел живой» фантомный `running` и при `max_concurrency=1` клинил клейм **всей** общей очереди всех проектов. **Инвариант (adr-0052):** `status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL` — queued-job никогда не несёт run-ownership (история run'а — в `agent_runs`, не в `jobs.run_id`). Фикс на **существующих колонках**: `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / `check_*` / machine-verdict-ключи / **схема БД** — байт-в-байт не тронуты; для здоровых job'ов и enduro поведение байт-в-байт; миграция не требуется. ADR: `docs/work-items/ORCH-126/06-adr/ADR-001-queued-job-run-ownership-hygiene.md`, сквозной `docs/architecture/adr/adr-0052-queued-job-run-ownership-invariant.md`. - **D1 — Forward-cleanup на всех путях возврата в `queued` (FR-1/AC-1):** `requeue_running_jobs` / `mark_job('queued')` / `mark_job_transient` / `reap_running_job('queued')` выставляют `run_id=NULL, pid=NULL` той же UPDATE-транзакцией, что чистит `started_at`/`finished_at`. Атомарные `status`-guard'ы (`reap_running_job … WHERE status='running'`, rowcount) — **сохранены байт-в-байт** (restart-safe, гонка worker↔reaper↔monitor — TR-4). Каллер-переданный `run_id` для `queued` **игнорируется** (инвариант важнее: `launcher._finalize_permanent`/reaper по-прежнему передают старый `run_id`, но для `queued` он сбрасывается). Безусловно — исправление инварианта данных, без флага (D6). diff --git a/docs/work-items/ORCH-119/00-business-request.md b/docs/work-items/ORCH-119/00-business-request.md new file mode 100644 index 0000000..17b06e2 --- /dev/null +++ b/docs/work-items/ORCH-119/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: 00-business-request.md is always TBD, losing source-backed request context + +Work Item ID: ORCH-119 + +## Description + +TBD diff --git a/docs/work-items/ORCH-119/01-brd.md b/docs/work-items/ORCH-119/01-brd.md new file mode 100644 index 0000000..c38a027 --- /dev/null +++ b/docs/work-items/ORCH-119/01-brd.md @@ -0,0 +1,151 @@ +--- +work_item: ORCH-119 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# 01 — BRD / Bug-report: ORCH-119 — `00-business-request.md` всегда `TBD`, теряется source-backed контекст запроса + +Work Item: **ORCH-119** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug** (укороченный маршрут, пропуск стадии `architecture`) + +> 🐞 **Багфикс-трек (ORCH-019).** Облегчённый пакет (bug-report + обязательный регресс-тест), но +> все 4 файла analysis (гейт `check_analysis_complete` не меняется). Экономия — в пропуске стадии +> `architecture`, не в числе файлов. +> +> **Эскалация в full-cycle рассмотрена и отклонена.** Дефект — контейнерный data-flow + рендеринг, +> чинится **точным зеркалированием уже существующего прецедента `tasks.title`** (персист при создании +> задачи → чтение в `_materialize_deferred_branch`). Нет нового компонента, нового QG, политического +> решения или визуального артефакта → ADR/макет не требуется. Если разработчик в ходе фикса упрётся в +> архитектурное решение (напр. иной механизм персиста, влияющий на схему/контракты) — снять трек: +> `POST /bug-fast-track/escalate?work_item=ORCH-119` и пометить здесь `escalate: full-cycle`. + +--- + +## 1. Бизнес-контекст и проблема + +### Симптом (наблюдаемое) +Для **каждой** созданной задачи файл `docs/work-items//00-business-request.md` генерируется +с телом раздела «Description» равным буквальному `TBD`. Реальный текст запроса (описание Plane-issue, +обогащённое из Plane API) **не попадает** в персистентный артефакт. Пример — сам этот work item: + +``` +# Business Request: BUG: 00-business-request.md is always TBD, losing source-backed request context +Work Item ID: ORCH-119 +## Description +TBD +``` + +### Последствие (бизнес-боль) +`00-business-request.md` — **точка входа конвейера** и источник для analyst (вход стадии `analysis`, +см. `PIPELINE_DOCS.md` §2). Когда тело всегда `TBD`: +- source-backed контекст запроса теряется из durable-артефакта репозитория (остаётся только эфемерно + в `task_content` analyst-job'а и в Plane); +- последующее чтение work item «глазами» (reviewer, человек, ретроспектива, петля уроков) видит пустой + бизнес-запрос — невозможно сверить, ту ли задачу решал конвейер; +- на **self-hosting** (`orchestrator`) задача почти всегда идёт **отложенным срезом ветки** (serial + gate, ORCH-088), где контекст теряется безвозвратно (см. §3, причина B). + +### Причина симптома (установленный факт, по коду) +`src/webhooks/plane.py::_create_initial_docs` (строка ~925) **хардкодит** тело: +```python +content = f"# Business Request: {name}\n\nWork Item ID: {work_item_id}\n\n## Description\n\nTBD\n" +``` +Функция принимает только `(repo, branch, work_item_id, name)` — **`description` ей не передаётся**, +хотя у вызывающего `start_pipeline` оно есть в области видимости и уже используется для analyst-job +(`task_desc`, строка ~725: `Description:\n{description}`). То есть данные **есть**, но в артефакт не +доходят. + +### Локализация (куда смотреть разработчику) — два пути создания + +**Путь A — прямой** (`serial_gate` не применим к репо): +`start_pipeline` (`src/webhooks/plane.py`) имеет `description` (строки ~518; обогащается из Plane API, +~539–551) → зовёт `_create_initial_docs(repo, branch, work_item_id, name)` (строка ~710) **без** +`description`. Достаточно дотянуть аргумент. + +**Путь B — отложенный (критичный для self-hosting)** (`serial_gate_applies(repo)` → для `orchestrator`): +`start_pipeline` **не** создаёт ветку/доки (ORCH-088, анти-stale-base); срез релоцирован в +`src/agents/launcher.py::_materialize_deferred_branch` (строки ~514–538), который вызывает то же +`_create_initial_docs`, **располагая только `title`** из строки `tasks` (`description` нигде не +персистится). Установленный факт схемы: таблица `tasks` **не имеет** колонки `description`; `title` +персистится через `_ensure_column` (`src/db.py:125`) и читается в `_spawn`/`_materialize_deferred_branch` +именно так. ⇒ Чтобы путь B рендерил описание, `description` надо **сохранить durable при создании +задачи** (зеркало `tasks.title`). + +### Предусловие истинности данных (установленный факт) +QG-0 (`_qg0_errors`, `src/webhooks/plane.py:490`) отклоняет создание при `description` короче 20 +символов (строка ~500). ⇒ любая задача, дошедшая до `_create_initial_docs`, **гарантированно имеет +непустое осмысленное описание** — терять его тем более недопустимо. Защитный fallback на случай +пустого описания всё равно предусмотреть (NFR-2). + +## 2. Объём (scope) + +### В объёме +- Рендер фактического `description` (предпочтительно `description_stripped`, plain-text) в раздел + «Description» файла `00-business-request.md` — на **обоих** путях (A прямой, B отложенный). +- Durable-персист `description` при создании задачи (зеркало `tasks.title`), чтобы путь B имел доступ + к нему на момент claim. +- Защитный fallback при отсутствии/пустом описании (без падения). +- Обязательный регресс-тест (красный до фикса, зелёный после). + +### Вне объёма +- Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключей / семантики гейтов. +- Изменение поведения serial-gate / отложенного среза ветки ORCH-088 (только **дополнить** данными, + не менять момент/условие среза). +- Ретро-генерация `00-business-request.md` для **уже существующих** задач (только новые создания). +- Переформатирование/обогащение структуры самого `00-business-request.md` сверх вставки описания + (заголовки/название источника остаются как есть). +- Любая запись в Plane (артефакт пишется только в Gitea-ветку, как сейчас). + +## 3. Заинтересованные стороны +- **Заказчик/оператор** — получает читаемый durable бизнес-запрос вместо `TBD`. +- **Агент analyst и reviewer** — могут сверять решённое с запросом по репозиторию. +- **Петля уроков / ретроспектива (ORCH-098)** — корректный контекст в артефакте. +- Приёмку результата выполняет конвейер (reviewer + Quality Gates), не аналитик. + +## 4. Бизнес-требования (BR) +- **BR-1** — Раздел «Description» в `00-business-request.md` содержит **фактический текст запроса** + (из Plane-issue, как он используется для analyst-job'а), а не литерал `TBD`, для вновь создаваемых + задач. +- **BR-2** — Поведение одинаково на **обоих** путях создания: прямом (A) и отложенном срезе ветки (B, + self-hosting/serial-gate). Путь B — приоритетный сценарий (доминирует на `orchestrator`). +- **BR-3** — При отсутствующем/пустом описании артефакт создаётся с **явным безопасным fallback**- + маркером (напр. «описание отсутствует в источнике»), без падения создания задачи. +- **BR-4** — Сохранён состав/имена артефактов: создаётся ровно `00-business-request.md` по тому же + пути; downstream-конвейер (analyst и далее) не затронут. + +## 5. Нефункциональные требования (NFR) +- **NFR-1 (обратная совместимость / never-break)** — изменение аддитивно: создание задачи **никогда** + не должно падать из-за нового рендера/персиста. Любая ошибка обогащения → деградация на безопасное + значение (fallback-маркер), а не отказ создания. Идемпотентность `_create_initial_docs` (422 = уже + существует → no-op) сохранена. +- **NFR-2 (целостность данных)** — описание рендерится **как есть** (plain-text `description_stripped`), + без обрезки/искажения; многострочный текст сохраняется. `00-business-request.md` — информационный + док (гейтом не парсится), поэтому markdown-спецсимволы в описании безопасны для гейтов. +- **NFR-3 (инварианты ORCH-088)** — момент и условие отложенного среза ветки не меняются; описание + лишь дополнительно переносится через durable-хранилище (зеркало `tasks.title`), анти-stale-base + логика цела. +- **NFR-4 (self-hosting-безопасность)** — фикс не деплоит/не рестартит прод, не трогает `main`, не + добавляет сетевых вызовов в горячий `claim_next_job`. + +## 6. Допущения и ограничения +- `description`/`description_stripped` доступны в `start_pipeline` и достаточны как источник (уже + используются для analyst-job). Plane-обогащение (ORCH «name_missing/desc_missing» блок) остаётся + единственным источником описания — новых сетевых обращений не вводим. +- QG-0 гарантирует ≥20 символов описания для прошедших задач (см. §1) — нормальный путь всегда имеет + реальный текст. +- Персист описания следует **установленному прецеденту `tasks.title`** (аддитивная колонка через + `_ensure_column`); это не новое архитектурное решение. + +## 7. Критерии успеха +Новые задачи получают `00-business-request.md` с реальным описанием на обоих путях; обязательный +регресс-тест красный до фикса и зелёный после; полный `pytest tests/ -q` зелёный. Детальные PASS/FAIL +— `03-acceptance-criteria.md`. + +## 8. Риски +- Путь B забыт (чинят только прямой путь A) → на self-hosting баг остаётся. Митигируется обязательным + integration-тестом пути B (TC-03). +- Регресс схемы/создания задачи при добавлении персиста → митигируется аддитивным `_ensure_column` и + тестом обратной совместимости (TC-05). Детальные тех-риски архитектором не выпускаются (bug-track). diff --git a/docs/work-items/ORCH-119/02-trz.md b/docs/work-items/ORCH-119/02-trz.md new file mode 100644 index 0000000..30afc0f --- /dev/null +++ b/docs/work-items/ORCH-119/02-trz.md @@ -0,0 +1,86 @@ +--- +work_item: ORCH-119 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-119 — source-backed генерация `00-business-request.md` + +Work Item: **ORCH-119** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug** + +> Bug-track: стадия `architecture` пропускается, поэтому ТЗ конкретизирует затрагиваемые модули и +> требование к данным. ТЗ описывает **что должно измениться и где**; точная форма реализации (имена +> символов, сигнатуры) — за разработчиком, в рамках указанного прецедента `tasks.title`. + +## 1. Сводка изменения +Source-backed `description` (текст запроса из Plane-issue) должен попадать в раздел «Description» +файла `00-business-request.md` вместо хардкода `TBD`. Для этого: (1) `description` рендерится в тело +артефакта; (2) `description` **персистится при создании задачи** (зеркало `tasks.title`), чтобы +отложенный путь среза ветки (ORCH-088, доминирует на self-hosting) имел к нему доступ на момент claim. +Изменение аддитивно, never-break, fail-safe. + +## 2. Задействованные модули / пути +| Путь | Действие | +|------|----------| +| `src/webhooks/plane.py` | изменить — `_create_initial_docs`: принять `description`, рендерить его в тело вместо `TBD`; рекомендуется выделить чистый рендер-хелпер (напр. `_render_business_request(work_item_id, name, description) -> str`) для unit-тестируемости без сети | +| `src/webhooks/plane.py` | изменить — `start_pipeline`: (а) прямой путь — передать `description` в `_create_initial_docs` (строка ~710); (б) персистить `description` при создании задачи (рядом со стампом `title`) | +| `src/agents/launcher.py` | изменить — `_materialize_deferred_branch`: прочитать персистнутое `description` из строки `tasks` и передать в `_create_initial_docs` (зеркало того, как уже читается/используется `title`) | +| `src/db.py` | изменить — аддитивная колонка `tasks.description` через `_ensure_column` (паттерн `tasks.title`, строка ~125); хелпер чтения/записи при необходимости; **не менять** базовый `CREATE TABLE tasks` | +| `tests/test_orch119_business_request.py` | создать — регресс + edge-кейсы (см. `04-test-plan.yaml`) | +| `CHANGELOG.md` | изменить — запись о фиксе (правило сопровождения) | + +## 3. Функциональные требования + +### FR-1 — Рендер описания в артефакт (BR-1) +Тело раздела «Description» в `00-business-request.md` = фактический `description` (предпочтительно +`description_stripped`, plain-text), без обрезки/искажения, многострочный текст сохраняется. Заголовок +(`# Business Request: {name}`) и `Work Item ID` — без изменений. + +### FR-2 — Прямой путь (BR-2, путь A) +`start_pipeline` при `serial_gate` НЕ применим → передаёт `description` в `_create_initial_docs`; +артефакт создаётся с реальным описанием в одном вызове. + +### FR-3 — Отложенный путь / персист (BR-2, путь B — критичный) +`description` персистится durable при создании задачи (зеркало `tasks.title`). +`_materialize_deferred_branch` читает его из строки `tasks` и передаёт в `_create_initial_docs`, так +что артефакт, материализованный на момент claim analyst-job, содержит реальное описание. Момент/условие +отложенного среза (ORCH-088) **не меняются** — только источник данных дополняется (NFR-3). + +### FR-4 — Fallback и устойчивость (BR-3, NFR-1) +Пустое/отсутствующее/нечитаемое `description` → явный безопасный маркер (напр. +`_(описание отсутствует в источнике)_`), **без падения** создания задачи. Любая ошибка рендера/чтения +персиста → деградация на fallback-маркер, не отказ. Идемпотентность сохранена: повторная +материализация (Gitea 422 = файл уже существует) → no-op, ранее записанное тело не перезаписывается. + +## 4. Изменения API +Нет. Эндпоинты не добавляются и не меняются. Запись артефакта остаётся в Gitea-ветку через +существующий `contents` API; в Plane ничего не пишется. + +## 5. Изменения схемы БД +Аддитивная колонка **`tasks.description TEXT`** через `_ensure_column` (идемпотентный ALTER, no-op на +существующей таблице — safe на боевой БД), строго по прецеденту `tasks.title`. Базовый +`CREATE TABLE tasks` не трогается. Индексы не требуются. Для уже существующих задач колонка `NULL` +(ретро-генерация — вне объёма). + +> Допустимая эквивалентная реализация (на усмотрение разработчика): переиспользовать уже доступный +> в `_spawn`/`_materialize_deferred_branch` источник данных, если он durable до момента claim. Если +> такой надёжно нет — канон именно колонка `tasks.description` (как `tasks.title`). Решение не должно +> вводить сетевой вызов в горячий путь claim (NFR-4). + +## 6. Требования к новым/изменённым QG checks +Нет. `00-business-request.md` — информационный документ (гейтом не парсится, `PIPELINE_DOCS.md` §2–§3). +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключи — байт-в-байт не трогаются. + +## 7. Совместимость / регресс +- **Обратная совместимость:** аддитивная колонка + аддитивный аргумент с дефолтом; существующие задачи + и enduro-trails не затронуты (для них тоже просто рендерится их описание — улучшение, не регресс). +- **Kill-switch:** отдельный флаг не требуется — изменение это исправление дефекта (улучшение + «всегда»), не рискованная фича; безопасность обеспечивается fail-safe fallback и never-break-контрактом. +- **Обратимость:** revert PR полностью возвращает прежнее поведение (колонка остаётся, инертна). +- **Self-hosting:** не деплоит/не рестартит прод, не трогает `main`, без новых сетевых вызовов в + `claim_next_job` (NFR-4). Анти-stale-base инвариант ORCH-088 цел (NFR-3) — перед правкой + `_materialize_deferred_branch`/отложенного среза свериться с `docs/work-items/ORCH-088/06-adr/` + (TRACEABILITY). diff --git a/docs/work-items/ORCH-119/03-acceptance-criteria.md b/docs/work-items/ORCH-119/03-acceptance-criteria.md new file mode 100644 index 0000000..94d9ce4 --- /dev/null +++ b/docs/work-items/ORCH-119/03-acceptance-criteria.md @@ -0,0 +1,109 @@ +--- +work_item: ORCH-119 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки: ORCH-119 — source-backed `00-business-request.md` + +Work Item: **ORCH-119** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug** + +Формат: каждый критерий — **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается +провалом). Reviewer/тесты проверяют буквально по файлам репозитория. + +--- + +## AC-1 — Реальное описание в артефакте вместо `TBD` + +**Условие:** при создании задачи с непустым описанием раздел «Description» файла +`00-business-request.md` содержит фактический текст запроса. +- **PASS:** тело раздела «Description» равно переданному `description` (plain-text); подстрока с + реальным текстом присутствует; литерал `TBD` как ВСЁ тело раздела отсутствует. +- **FAIL:** раздел «Description» = `TBD` (или иной плейсхолдер) при наличии непустого описания на входе. + +--- + +## AC-2 — Прямой путь создания (путь A) + +**Условие:** когда `serial_gate` не применим, `start_pipeline` передаёт `description` в +`_create_initial_docs`. +- **PASS:** артефакт, созданный прямым путём, содержит реальное описание (см. AC-1). +- **FAIL:** на прямом пути `description` не доходит до артефакта (остаётся `TBD`). + +--- + +## AC-3 — Отложенный путь / self-hosting (путь B, критичный) + +**Условие:** когда `serial_gate_applies(repo)` (напр. `orchestrator`), описание персистится при +создании задачи и рендерится при материализации ветки на момент claim +(`launcher._materialize_deferred_branch`). +- **PASS:** артефакт, материализованный отложенным путём, содержит реальное описание; описание + durable-доступно из строки `tasks` (не теряется между созданием задачи и claim). +- **FAIL:** на отложенном пути описание утрачено → артефакт = `TBD`; либо описание нигде не + персистится до момента claim. + +--- + +## AC-4 — Безопасный fallback при пустом описании + +**Условие:** описание отсутствует/пустое/нечитаемо. +- **PASS:** артефакт создаётся с явным безопасным fallback-маркером (не «голый» `TBD`-баг), создание + задачи не падает. +- **FAIL:** создание задачи падает/бросает исключение, либо тихо теряет контекст без маркера. + +--- + +## AC-5 — Никаких изменений гейтов / схемы сверх аддитивной колонки + +**Условие:** правка не трогает машинерию конвейера. +- **PASS:** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, имена/семантика `check_*`, machine-verdict-ключи + — байт-в-байт прежние; единственное изменение схемы — аддитивная `tasks.description` (или + эквивалент без сетевого вызова в claim); базовый `CREATE TABLE tasks` не тронут. +- **FAIL:** изменены гейты/переходы/вердикт-ключи; неаддитивная миграция; сетевой вызов добавлен в + `claim_next_job`. + +--- + +## AC-6 — Идемпотентность и обратная совместимость + +**Условие:** повторная материализация и существующие задачи. +- **PASS:** повторный вызов (`_create_initial_docs`, Gitea 422) — no-op, не перезаписывает тело; + `_ensure_column` идемпотентен (no-op при существующей колонке); enduro-trails и прочие репо не + затронуты негативно. +- **FAIL:** повторная материализация затирает/дублирует артефакт; миграция падает на боевой БД; + регресс для других репо. + +--- + +## AC-7 — Обязательный регресс-тест (red→green) и зелёный полный прогон + +**Условие:** дефект зафиксирован тестом. +- **PASS:** `tests/test_orch119_business_request.py::TC-01` падает на коде ДО фикса (доказывает баг) и + проходит ПОСЛЕ; полный `pytest tests/ -q` зелёный. +- **FAIL:** регресс-теста нет, либо он зелёный и до фикса (ничего не доказывает), либо полный прогон + красный. + +--- + +## AC-8 — Сопровождение + +**Условие:** документация/история обновлены в том же PR. +- **PASS:** `CHANGELOG.md` содержит запись о фиксе ORCH-119. +- **FAIL:** изменён функционал без обновления `CHANGELOG.md`. + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 | +| AC-2 | BR-2 / FR-2 | +| AC-3 | BR-2 / FR-3 / NFR-3 | +| AC-4 | BR-3 / FR-4 / NFR-1 | +| AC-5 | BR-4 / NFR-4 | +| AC-6 | NFR-1 / FR-4 | +| AC-7 | BR-1..BR-3 (доказательство) | +| AC-8 | правило сопровождения | diff --git a/docs/work-items/ORCH-119/04-test-plan.yaml b/docs/work-items/ORCH-119/04-test-plan.yaml new file mode 100644 index 0000000..47e6e92 --- /dev/null +++ b/docs/work-items/ORCH-119/04-test-plan.yaml @@ -0,0 +1,64 @@ +work_item: ORCH-119 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-17 +model_used: claude-opus-4-8 +title: "Source-backed генерация 00-business-request.md (фикс хардкода TBD)" +framework: pytest +scope: > + Покрывается: рендер фактического description в 00-business-request.md вместо литерала TBD на обоих + путях создания (прямой A и отложенный срез ветки B / self-hosting ORCH-088), durable-персист + description (зеркало tasks.title), безопасный fallback при пустом описании, аддитивность схемы и + обратная совместимость. ВНЕ покрытия: реальные сетевые вызовы Gitea/Plane (мокаются), ретро-генерация + артефактов для уже существующих задач. +notes: > + TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест: красный на коде ДО фикса (доказывает баг хардкода TBD), зелёный + ПОСЛЕ. Сетевые вызовы (_create_gitea_branch / _create_initial_docs httpx, Plane API) мокаются — + тесты без сети/прода. Рекомендуется тестировать чистый рендер-хелпер (_render_business_request) на + уровне unit, а пути A/B — на уровне integration с моками httpx и временной SQLite-БД. Полный регресс + pytest tests/ -q должен оставаться зелёным. Перед правкой отложенного среза ветки свериться с + docs/work-items/ORCH-088/06-adr/ (анти-stale-base инвариант, TRACEABILITY). + +tests: + - id: TC-01 + type: unit + description: "ОБЯЗАТЕЛЬНЫЙ РЕГРЕСС. Рендер 00-business-request.md при непустом description содержит фактический текст запроса в разделе 'Description' и НЕ равен литералу TBD. Красный до фикса (хардкод TBD), зелёный после." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-02 + type: unit + description: "Fallback: при пустом/whitespace/None description рендер даёт явный безопасный маркер (напр. 'описание отсутствует в источнике'), функция не бросает исключение." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-03 + type: integration + description: "Путь B (отложенный, self-hosting): description персистнут при создании задачи и доступен из строки tasks; launcher._materialize_deferred_branch рендерит реальное описание в артефакт (мок httpx; description не теряется между созданием и claim)." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-04 + type: integration + description: "Путь A (прямой, serial_gate не применим): start_pipeline передаёт description в _create_initial_docs; артефакт содержит реальное описание (мок httpx, перехват записанного content)." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-05 + type: integration + description: "Обратная совместимость схемы: init_db на пустой БД и на БД со старой таблицей tasks (без колонки description) проходит; _ensure_column идемпотентен (повторный init_db — no-op); создание задачи не падает." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-06 + type: unit + description: "Целостность данных: многострочное описание со спецсимволами markdown рендерится без обрезки/искажения; идемпотентность — повторная материализация (Gitea 422) не перезаписывает уже записанное тело." + module: tests/test_orch119_business_request.py + expected: PASS + + - id: TC-07 + type: unit + description: "Анти-регресс гейтов: STAGE_TRANSITIONS / реестр QG_CHECKS / имена check_* импортируются без изменений (00-business-request.md остаётся информационным, не гейтится)." + module: tests/test_orch119_business_request.py + expected: PASS diff --git a/docs/work-items/ORCH-119/06-adr/ADR-001-source-backed-business-request-doc.md b/docs/work-items/ORCH-119/06-adr/ADR-001-source-backed-business-request-doc.md new file mode 100644 index 0000000..570ad45 --- /dev/null +++ b/docs/work-items/ORCH-119/06-adr/ADR-001-source-backed-business-request-doc.md @@ -0,0 +1,162 @@ +--- +work_item: ORCH-119 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# ADR-001: Durable-персист `description` для source-backed `00-business-request.md` + +Work Item: **ORCH-119** — `00-business-request.md` всегда `TBD`, теряется source-backed контекст запроса +Стадия: **architecture** +Сквозная регистрация: **N/A, локальное решение задачи** (аддитивная скалярная колонка по +прецеденту `tasks.title`; не новый QG / стадия / компонент / смена БД-движка → сквозной +`docs/architecture/adr/adr-NNNN-*` не заводится). + +> **Примечание по треку (Bug, ORCH-019).** Задача классифицирована как Bug и по BRD должна была +> идти укороченным маршрутом (пропуск `architecture`). Фактически задача **дошла до стадии +> `architecture`** (routing-override ORCH-019 не сработал — нет метки `Bug` в Plane / выключен +> `bug_fast_track`), а exit-гейт `check_architecture_done` (`src/qg/checks.py:62`) требует ≥1 файла +> в `06-adr/` или `07-infra-requirements.md`. Поэтому архитектурный выход выпускается штатно. ADR +> намеренно компактный: он фиксирует **локальное** решение по data-flow и аддитивной схеме, без +> сквозных последствий. + +## Статус +Proposed + +## Контекст + +`src/webhooks/plane.py::_create_initial_docs` (`src/webhooks/plane.py:925`) **хардкодит** тело +раздела «Description»: + +```python +content = f"# Business Request: {name}\n\nWork Item ID: {work_item_id}\n\n## Description\n\nTBD\n" +``` + +Сигнатура `_create_initial_docs(repo, branch, work_item_id, name)` (`:917`) **не принимает** +`description`, хотя у вызывающего `start_pipeline` оно есть в области видимости (`:518`, обогащается +из Plane API `:540–551`) и уже используется для analyst-job (`task_desc`, `:723–725`). Данные есть — +в durable-артефакт не доходят. + +Есть **два** пути создания (сверено по коду): + +- **Путь A (прямой)** — `serial_gate` не применим к репо: `start_pipeline` сам зовёт + `_create_initial_docs(repo, branch, work_item_id, name)` (`src/webhooks/plane.py:710`). `description` + здесь в области видимости — достаточно дотянуть аргумент. +- **Путь B (отложенный, доминирует на self-hosting `orchestrator`)** — `serial_gate_applies(repo)` + (ORCH-088, анти-stale-base): `start_pipeline` **НЕ** режет ветку/доки (`:697–717`); срез + релоцирован на claim analyst-job в `src/agents/launcher.py::_materialize_deferred_branch` + (`:514–538`), который располагает только `title` из строки `tasks` + (`SELECT branch, work_item_id, title FROM tasks`, `:561`). **`description` в таблице `tasks` не + персистится** (базовый `CREATE TABLE tasks`, `src/db.py:31–42`, его не содержит) → путь B физически + не имеет доступа к описанию на момент claim. + +Предусловие истинности данных: QG-0 (`_qg0_errors`, `src/webhooks/plane.py:490–500`) отклоняет +создание при `description` короче 20 символов ⇒ нормальный путь всегда имеет осмысленный текст; терять +его недопустимо. + +Прямой прецедент: `tasks.title` — аддитивная колонка (`_ensure_column(conn,"tasks","title","TEXT")`, +`src/db.py:125`), записываемая атомарно при создании задачи (`create_task_atomic`, `src/db.py:678–683`) +и читаемая в `_spawn`/`_materialize_deferred_branch`. ORCH-119 решается **точным зеркалированием** +этого прецедента для `description`. + +## Решение + +### Сводка +Персистить `description` durable при создании задачи как **аддитивную колонку `tasks.description TEXT`** +(зеркало `tasks.title`), записываемую **внутри того же атомарного INSERT** `create_task_atomic` +(ORCH-053). На обоих путях создания тело `00-business-request.md` рендерится из фактического описания +через выделенный чистый хелпер с fail-safe fallback. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / +machine-verdict-ключи / базовый `CREATE TABLE tasks` — не трогаются. + +### D1 — Где персистить описание (ключевое решение) +**Решение: аддитивная колонка `tasks.description TEXT` через `_ensure_column`, записываемая атомарно +в `create_task_atomic`.** + +Путь B (отложенный срез) читает данные задачи из строки `tasks` на момент claim — единственное +durable-место, доступное и пути A, и пути B без сети. Колонка добавляется идемпотентным +`_ensure_column(conn, "tasks", "description", "TEXT")` рядом с `tasks.title` (`src/db.py:125`); базовый +`CREATE TABLE tasks` не меняется (no-op на боевой общей БД, restart-safe). Запись `description` +**встраивается в существующий INSERT** `create_task_atomic` (расширяется список колонок/значений, +`src/db.py:678–683`), а не отдельным `UPDATE` — чтобы персист был атомарен и race-safe относительно +анти-dup-claim ORCH-053 (никакого окна, в котором задача создана, но описание ещё не записано). +Сигнатура `create_task_atomic` расширяется аддитивным параметром `description` с дефолтом → обратная +совместимость прочих вызывающих (напр. F-2 reconciler) сохранена. Привязка: FR-3, AC-3, NFR-3, NFR-4. + +### D2 — Чистый рендер-хелпер + проброс на обоих путях +**Решение: выделить чистую функцию рендера тела и пробросить `description` в `_create_initial_docs` +на обоих путях.** + +`_create_initial_docs` принимает аддитивный аргумент `description` и делегирует формирование тела +чистому хелперу (напр. `_render_business_request(work_item_id, name, description) -> str`, +unit-тестируемому без сети — TC-01/TC-02/TC-06). Заголовок (`# Business Request: {name}`) и +`Work Item ID` — без изменений; меняется только тело раздела «Description». +- **Путь A:** `start_pipeline` передаёт `description` в `_create_initial_docs` (`:710`). +- **Путь B:** `_spawn` расширяет `SELECT` до `..., description` (`src/agents/launcher.py:561`), + `_materialize_deferred_branch` принимает `description` 5-м аргументом (`:514–516`, `:582–584`) и + передаёт в `_create_initial_docs` (`:538`). +Привязка: FR-1, FR-2, AC-1, AC-2. + +### D3 — Fail-safe fallback и идемпотентность (never-break) +**Решение: пустое/None/нечитаемое описание → явный безопасный маркер; любая ошибка рендера/чтения → +деградация на маркер, не отказ создания.** + +При `description` пустом/whitespace/`None` (включая `NULL` у исторических задач, для которых колонка +не заполнялась) хелпер возвращает явный маркер (напр. `_(описание отсутствует в источнике)_`), а не +голый `TBD`. Создание задачи **никогда** не падает из-за рендера/персиста — все обогащения изолированы +(`try/except` → fallback). Идемпотентность сохранена: `_create_initial_docs` на Gitea-`422` +(файл существует) → no-op, ранее записанное тело не перезаписывается (повторная материализация после +рестарта/реклейма безопасна). Привязка: FR-4, AC-4, AC-6, NFR-1. + +### D4 — Что НЕ трогаем (инварианты) +- **ORCH-088 (анти-stale-base):** момент и условие отложенного среза ветки не меняются — только + источник данных дополняется durable-колонкой; `_materialize_deferred_branch` по-прежнему режет ветку + из свежего `origin/main` на claim. Перед правкой блока ORCH-088 свериться с + `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` (TRACEABILITY). NFR-3. +- **ORCH-053 (атомарный анти-dup-claim):** запись `description` — в том же INSERT под + `_CREATE_TASK_LOCK`; семантика `(row, created)` не меняется. +- **Гейты:** `00-business-request.md` — информационный док (гейтом не парсится, `PIPELINE_DOCS.md` + §2–§3); `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / имена/семантика `check_*` / machine-verdict — + байт-в-байт. AC-5. +- **Без kill-switch:** это исправление дефекта (улучшение «всегда»), не рискованная фича; безопасность + обеспечивается fail-safe fallback и never-break-контрактом (TRZ §7). Обратимость — revert PR + (колонка остаётся инертной). + +## Альтернативы +- **Чинить только путь A (без персиста)** — отвергнуто: путь B (self-hosting `orchestrator`, доминирует) + останется `TBD`; нарушает BR-2 (приоритетный сценарий). +- **Хранить описание в `task_content` / payload job'а** — отвергнуто: эфемерно, привязано к жизненному + циклу job'а; `_materialize_deferred_branch` читает данные из строки `tasks`, а не из job'а; нет + durable-доступа на момент claim. +- **Re-fetch описания из Plane API в `_materialize_deferred_branch`** — отвергнуто: добавляет сетевой + вызов рядом с горячим путём claim (нарушает NFR-4) и вводит зависимость материализации от + доступности Plane; ORCH-088 сознательно сделал claim детерминированным. +- **Отдельная таблица `task_metadata`** — отвергнуто: оверинжиниринг; прецедент `tasks.title` уже + канонизирует аддитивную скалярную колонку per-task. +- **Эскалация в full-cycle (`arch:major-change` / `back-to:analysis`)** — отвергнуто: решение + аддитивно, по установленному прецеденту, без нового компонента/QG/стадии/смены БД-движка и без + нарушения принципов; ТЗ удовлетворяется штатно. + +## Последствия +- **+** Durable source-backed контекст в `00-business-request.md` на обоих путях; зеркало проверенного + прецедента (низкий риск). +- **+** Ноль изменений машинерии конвейера (гейты/переходы/вердикты/базовая схема) → ноль риска + регресса конвейера; enduro-trails не затронут (для него тоже просто рендерится его описание). +- **−** Схема общей прод-БД растёт на одну колонку → митигировано аддитивным `_ensure_column` (no-op + при наличии, без переписывания базового `CREATE TABLE`), обратная совместимость (`NULL` у + существующих строк, fallback-маркер при рендере). +- **−** Уже созданные задачи не ретро-генерируются (вне объёма, принято; колонка `NULL` → fallback). +- **Откат:** revert PR полностью возвращает прежнее поведение; аддитивная колонка остаётся инертной + (без обязательной down-миграции на общей БД). + +## Ссылки +- BRD: `docs/work-items/ORCH-119/01-brd.md` +- TRZ: `docs/work-items/ORCH-119/02-trz.md` +- Acceptance: `docs/work-items/ORCH-119/03-acceptance-criteria.md` +- Data-requirements: `docs/work-items/ORCH-119/08-data-requirements.md` +- Tech-risks: `docs/work-items/ORCH-119/10-tech-risks.md` +- Сверено по коду: `src/webhooks/plane.py:482,490,518,710,917,925` · `src/agents/launcher.py:514,531,538,561,582` · `src/db.py:31,125,647,678` · `src/qg/checks.py:62` +- Инвариант ORCH-088: `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md` +- Стандарт документов / ADR-naming: `docs/_standards/PIPELINE_DOCS.md` §4 diff --git a/docs/work-items/ORCH-119/08-data-requirements.md b/docs/work-items/ORCH-119/08-data-requirements.md new file mode 100644 index 0000000..f91e99a --- /dev/null +++ b/docs/work-items/ORCH-119/08-data-requirements.md @@ -0,0 +1,49 @@ +--- +work_item: ORCH-119 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-119 — durable-персист `description` задачи + +Work Item: **ORCH-119** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable / информационный (гейтом не парсится). Применимо: фикс требует durable-хранения +> `description` в строке `tasks` для пути B (отложенный срез ветки, ORCH-088). + +## Изменения схемы БД +- **Новая колонка `tasks.description TEXT`** — добавляется идемпотентным + `_ensure_column(conn, "tasks", "description", "TEXT")` (`src/db.py`, рядом с `tasks.title`, + `src/db.py:125`). Прецедент 1:1 — `tasks.title` / `tasks.track` / `tasks.cancelled_at`. +- Базовый `CREATE TABLE tasks` (`src/db.py:31–42`) **не трогается**. +- Индексы **не требуются** (колонка не участвует в выборках/JOIN; читается по PK `tasks.id`). +- `NULL` по умолчанию; для уже существующих задач остаётся `NULL` (ретро-генерация вне объёма). + +## Новые/изменённые сущности +- **`tasks.description`** — plain-text описание запроса (предпочтительно `description_stripped` + Plane-issue), записывается **при создании задачи** внутри атомарного INSERT + `create_task_atomic` (`src/db.py:678–683`; список колонок/значений расширяется, параметр + `description` аддитивен с дефолтом). Читается на пути B в `_spawn` + (`SELECT ..., description FROM tasks`, `src/agents/launcher.py:561`) и передаётся в + `_materialize_deferred_branch` → `_create_initial_docs`. +- Инвариант данных: значение пишется **как есть**, без обрезки/искажения; многострочный текст и + markdown-спецсимволы сохраняются (`00-business-request.md` гейтом не парсится — спецсимволы + безопасны, NFR-2). Пустое/`NULL` → рендер деградирует на fallback-маркер (ADR-001 D3), не на + отказ. + +## Совместимость данных / миграции +- **Аддитивность:** только `ADD COLUMN` через `_ensure_column`; существующая боевая ОБЩАЯ БД и + enduro-trails не затронуты (для них `description` тоже просто рендерится — улучшение, не регресс). +- **Идемпотентность:** `_ensure_column` — no-op при наличии колонки; повторный `init_db` безопасен + (TC-05). `_create_initial_docs` на Gitea-`422` — no-op (тело не перезаписывается, TC-06). +- **Restart-safe / атомарность:** запись `description` — в том же INSERT под `_CREATE_TASK_LOCK` + (ORCH-053), без окна «задача создана, описание отсутствует»; реклейм/материализация после + рестарта безопасны. +- **Down-миграция:** не требуется — revert PR оставляет колонку инертной (без обязательного DROP на + общей прод-БД). +- **Влияние на общую прод-БД (self-hosting):** одна аддитивная колонка, без рестарта прода в рамках + схемы (применяется на следующем `init_db`); без новых сетевых вызовов в горячем `claim_next_job` + (NFR-4). diff --git a/docs/work-items/ORCH-119/10-tech-risks.md b/docs/work-items/ORCH-119/10-tech-risks.md new file mode 100644 index 0000000..2bd2e41 --- /dev/null +++ b/docs/work-items/ORCH-119/10-tech-risks.md @@ -0,0 +1,37 @@ +--- +work_item: ORCH-119 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-17 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-119 — source-backed `00-business-request.md` + +Work Item: **ORCH-119** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Чинят только прямой путь A, путь B (отложенный срез, self-hosting) забыт → на `orchestrator` баг остаётся (`TBD`) | Сред. | Выс. | Обязательный integration-тест пути B (TC-03): персист в `tasks` + рендер в `_materialize_deferred_branch`; ADR-001 D1/D2 явно фиксирует оба пути | +| TR-2 | Регресс схемы/создания задачи при добавлении персиста на ОБЩЕЙ прод-БД | Низ. | Выс. | Аддитивный идемпотентный `_ensure_column` (no-op при наличии), базовый `CREATE TABLE` не трогается; тест обратной совместимости (TC-05) | +| TR-3 | Падение создания задачи из-за рендера/чтения описания (нарушение never-break) | Низ. | Выс. | Fail-safe fallback-маркер + изоляция обогащения (`try/except`); создание задачи не зависит от рендера (ADR-001 D3, TC-02) | +| TR-4 | Гонка ORCH-053: окно «задача создана, описание ещё не записано» при отдельном `UPDATE` | Низ. | Сред. | Запись `description` встроена в тот же атомарный INSERT `create_task_atomic` под `_CREATE_TASK_LOCK`, отдельного `UPDATE` нет (ADR-001 D1) | +| TR-5 | Повторная материализация (рестарт/реклейм) перезаписывает/дублирует тело артефакта | Низ. | Сред. | Идемпотентность `_create_initial_docs` (Gitea `422` → no-op), тело не перезаписывается (TC-06) | +| TR-6 | Нарушение анти-stale-base инварианта ORCH-088 при правке отложенного среза | Низ. | Выс. | Момент/условие среза НЕ меняются — только источник данных дополняется durable-колонкой; сверка с `docs/work-items/ORCH-088/06-adr/` перед правкой (ADR-001 D4, NFR-3) | +| TR-7 | Случайная правка `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict | Низ. | Выс. | `00-business-request.md` информационный (не гейтится); анти-регресс-тест импорта гейтов без изменений (TC-07, AC-5) | +| TR-8 | Markdown-спецсимволы/многострочность описания искажают артефакт | Низ. | Низ. | Рендер plain-text «как есть» без обрезки; артефакт гейтом не парсится → спецсимволы безопасны (NFR-2, TC-06) | + +## Сводный вывод +Доминирующий класс — **операционные риски реализации** (полнота покрытия обоих путей + сохранение +never-break/идемпотентности/инвариантов ORCH-088/ORCH-053), не архитектурные. Все они закрываются +обязательными регресс- и edge-тестами (`04-test-plan.yaml` TC-01…TC-07) и точным следованием +прецеденту `tasks.title`. Эскалация **не требуется**: решение аддитивно, обратимо (revert PR), +без нового компонента/QG/стадии/смены БД-движка → `arch:major-change` не выставляется, возврат в +анализ (`back-to:analysis`) не нужен. Остаточный риск для прод-конвейера (self-hosting) — **низкий**: +фикс не деплоит/не рестартит прод, не трогает `main`, не добавляет сетевых вызовов в горячий +`claim_next_job` (NFR-4). diff --git a/docs/work-items/ORCH-119/12-review.md b/docs/work-items/ORCH-119/12-review.md new file mode 100644 index 0000000..21b32e1 --- /dev/null +++ b/docs/work-items/ORCH-119/12-review.md @@ -0,0 +1,151 @@ +--- +verdict: APPROVED +work_item: ORCH-119 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-17 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-119 +version: 3 +--- + +# Review ORCH-119 — source-backed `00-business-request.md` вместо хардкода `TBD` + +## Summary + +Узко-скоупленный багфикс (Bug-трек). Раздел «Description» файла `00-business-request.md` +теперь несёт **фактический текст запроса** из Plane-issue вместо литерала `TBD` — на **обоих** +путях создания: прямой (путь A) и **отложенный срез ветки** (путь B, ORCH-088, доминирует на +self-hosting `orchestrator`). Реализация **точно зеркалирует** прецедент `tasks.title`, как и +предписал ADR-001: аддитивная колонка `tasks.description` через `_ensure_column`, записываемая +**внутри того же атомарного INSERT** `create_task_atomic` (race-safe vs ORCH-053), читаемая в +`_spawn` → `_materialize_deferred_branch` на момент claim (без сети в горячем пути, NFR-4). Тело +рендерится чистым unit-тестируемым хелпером `_render_business_request` с fail-safe +fallback-маркером. + +**Это независимый повторный review (v3).** Все 4 оси перепроверены лично против `origin/main` +(`f50f61c`, = merge-base): скоуп `src/` — ровно 3 файла, предписанных ТЗ §2 (`webhooks/plane.py`, +`agents/launcher.py`, `db.py`); тесты — `test_orch119_business_request.py` (новый, +263), +`test_serial_gate_branch.py` (+spy-арг); `CHANGELOG.md` + work-item доки. **Полный прогон +`pytest tests/ -q` — зелёный (2215 passed, изолированно от конкурентных сессий).** + +## Соответствие ТЗ / AC (ось 1) — PASS + +| AC / FR | Статус | Подтверждение | +|---------|--------|---------------| +| AC-1 / FR-1 — реальное описание вместо `TBD`, header/`Work Item ID` целы | ✅ | `_render_business_request` рендерит тело из `description`; TC-01 (genuine red→green). | +| AC-2 / FR-2 — прямой путь A | ✅ | `start_pipeline` передаёт `description` в `_create_initial_docs` (`plane.py:736`); TC-04 ассертит отсутствие `## Description\n\nTBD\n`. | +| AC-3 / FR-3 — отложенный путь B / durable-персист | ✅ | персист в атомарном INSERT `create_task_atomic` (`plane.py:665`, `db.py`); чтение `SELECT …, description` в `_spawn` → 5-й арг `_materialize_deferred_branch` (`_br_row[3]`); TC-03. | +| AC-4 / FR-4 — fail-safe fallback | ✅ | `_BUSINESS_REQUEST_FALLBACK` + `try/except` → never-raise; TC-02 (`""`/`" "`/`"\n\t"`/`None`). | +| AC-5 — гейты/схема не тронуты | ✅ | коммит не касается `stages.py`/`qg/checks.py`; единственная схема — аддитивная `tasks.description`, базовый `CREATE TABLE tasks` цел; TC-07. | +| AC-6 — идемпотентность / BC | ✅ | Gitea `422` → `return` (тело не перезаписывается, `plane.py:1006`); `_ensure_column` no-op; TC-05/TC-06. | +| AC-7 — регресс red→green + полный прогон зелёный | ✅ | На `origin/main` `_render_business_request` **отсутствует** (grep=0) и `TBD` хардкоден (`plane.py:945`) → TC-01 настоящий red; green после. Полный прогон **2215 passed** (изолированно). | +| AC-8 — сопровождение | ✅ | `CHANGELOG.md` содержит запись ORCH-119. | + +`description` определён в scope `start_pipeline` (`plane.py:538`) и **обогащается** из Plane API +(`plane.py:560–568`) **до** обоих call-site (`:665`, `:736`) — NameError исключён; персистится тот +же enriched-текст, что уходит analyst-job (`:751`) — консистентно. + +## Соответствие ADR (ось 2) — PASS + +- **D1** (аддитивная колонка в атомарном INSERT) — реализовано буквально: + `_ensure_column(conn,"tasks","description","TEXT")` рядом с `tasks.title`; запись встроена в + существующий INSERT `create_task_atomic`, не отдельным UPDATE → нет race-окна vs ORCH-053. ✅ +- **D2** (чистый рендер-хелпер + проброс на обоих путях) — `_render_business_request` + чистый/network-free; проброс A и B. ✅ +- **D3** (fail-safe fallback + идемпотентность) — маркер + never-raise + 422-no-op. ✅ +- **D4** (инварианты) — ORCH-088 момент/условие отложенного среза не меняются (дополняется только + источник данных), ORCH-053 семантика `(row, created)` цела, гейты байт-в-байт, без kill-switch + (обосновано — фикс дефекта). ✅ +- **Трассировка (TRACEABILITY):** правки блоков с маркерами **ORCH-088** + (`_materialize_deferred_branch`) и **ORCH-053** (`create_task_atomic`) сверены с их `06-adr` — + аддитивные, все новые параметры с дефолтами; зафиксированные инварианты не сломаны; комментарии + кода корректно ссылаются на оба ADR. ✅ +- Сквозной (global) ADR не требуется — локальное аддитивное решение по прецеденту. ✅ + +## Качество кода (ось 3) — PASS + +- Чистый, network-free, unit-тестируемый хелпер; docstrings на всех новых/изменённых функциях. ✅ +- never-raise контракт соблюдён (`try/except` в рендере; создание задачи не падает). ✅ +- BC: все добавленные параметры аддитивны с дефолтами; единственный прочий потребитель сигнатуры + (`_docs_spy` в `test_serial_gate_branch.py`) синхронно обновлён под аддитивный `description=None`. ✅ +- **Багфикс-регресс (ORCH-019 / BR-4) — фиксатор присутствует:** TC-01 настоящий red до фикса + (`_render_business_request` отсутствовал + `_create_initial_docs` хардкодил `TBD` на `main`), + green после; усилен TC-04 (бьёт реальный баг-путь `_create_initial_docs`). ✅ +- Тесты содержательные: 7 TC покрывают регресс, fallback (4 параметра), оба пути, schema-BC, + идемпотентность 422, multiline/спецсимволы verbatim, анти-регресс гейтов. ✅ +- **Личная проверка:** `test_orch119_business_request.py` + `test_serial_gate_branch.py` — 13 passed; + `test_stage_engine.py` — 52 passed (изолированно); полный прогон — 2215 passed. ✅ + +## Документация (ось 4) — PASS + +Изменён `src/` → проверка обязательна. Документация обновлена в том же PR: +- **`CHANGELOG.md`** — подробная запись ORCH-119 (AC-8). ✅ +- **ADR-001** (`06-adr/ADR-001-source-backed-business-request-doc.md`) + **`08-data-requirements.md`** + (документирует схему `tasks.description`) — присутствуют, исчерпывающи. ✅ +- **API** — изменений нет (ТЗ §4) → таблица API в `docs/architecture/README.md` обновления не + требует. ✅ +- **Стадии/QG** — не тронуты → `docs/architecture/README.md` / `internals.md` обновления не + требуют. ✅ +- **Витрина `docs/overview/` (ORCH-011):** `tech-agents.md` по-прежнему верно указывает + `00-business-request.md` как вход аналитика; `tech-data-model.md` описывает `tasks` + концептуально (без перечня колонок) — добавление колонки его не устаревает; смена **тела** + артефакта (внутренняя деталь генерации) описанных стадий/гейтов/агентов/способностей не меняет. + Обновление не требуется. ✅ +- **`README.md` «Известные ограничения» (ORCH-079):** проверено — TBD-баг `00-business-request.md` + **не** числится среди 3 открытых ограничений (Telegram-48h / intra-repo deps / batch-автоном) — + закрывать/снимать нечего, обновление README не требуется. ✅ +- **Центрального schema-дока колонок `tasks` нет** — конвенция проекта: per-task скалярные колонки + документируются ADR задачи + data-requirements + CHANGELOG (так же оформлены `tasks.title` / + `tasks.track` / `tasks.cancelled_at`). ORCH-119 ей следует. ✅ + +**Итог по документации:** при изменении `src/` документация обновлена в том же PR — P0 «`src/` +изменён, документация не обновлена» **не наступает**. + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- Нет. + +### P3 — Nice-to-have (не блокирует) +- [ ] **`CHANGELOG.md` over-claim после rebase.** Хвост записи ORCH-119 описывает «Дополнительно в + том же PR починена тест-гермеичность `tests/test_orch123_staging_runner_exec.py`…». Этот файл + **отсутствует** в diff против `origin/main` — фикс (`fresh_db` пинит `repos_dir`) уже в `main` + (проверено: `git show origin/main:tests/test_orch123_staging_runner_exec.py` уже содержит пин). + Описывает изменение вне фактического скоупа PR (артефакт rebase). Безвредно (ядро ORCH-119 + задокументировано точно), но для аккуратности — подрезать хвост. На корректность фикса не влияет. +- [ ] `tests/test_orch119_business_request.py` мутирует `os.environ` (`ORCH_DB_PATH`/ + `ORCH_REPOS_DIR`) на уровне модуля — добавляет к общей import-pollution-поверхности сьюта. + Соответствует существующей конвенции многих тест-файлов проекта (не новый грех), на корректность + не влияет. +- [ ] `_render_business_request`: `try/except Exception` вокруг `(description or "").strip()` — + `.strip()` на строке не бросает, ветка недостижима. Намеренный never-break «belt-and-suspenders» в + стиле кодбейза; оставить как есть допустимо. + +## Документация + +**Статус: обновлена в том же PR — достаточно.** `CHANGELOG.md` (AC-8) + ADR-001 + +`08-data-requirements.md` (схема `tasks.description`) — присутствуют и исчерпывающи. Витрина +`docs/overview/`, обзорный `README.md` («Известные ограничения»), +`PIPELINE_DOCS.md`/`HANDOFF_PROTOCOL.md` проверены явно — не устаревают (артефакт остаётся +информационным, продюсер/владелец/гейт-статус не изменились; TBD-баг не числился среди открытых +ограничений). Центрального schema-дока колонок `tasks` нет — аддитивная колонка документирована по +конвенции (ADR + data-requirements + CHANGELOG, как `tasks.title`/`track`/`cancelled_at`). +**Необновлённой обязательной документации не найдено.** (Единственный документ-нюанс — P3-over-claim +в `CHANGELOG.md` про вне-скоупный `test_orch123`, не блокирует.) + +## Вердикт + +**APPROVED.** Нет P0/P1/P2. Все AC/FR/ADR удовлетворены; реализация — точное зеркало одобренного +прецедента `tasks.title` с нулевым касанием конвейерной машинерии и сохранёнными инвариантами +ORCH-088/ORCH-053; обязательный регресс-тест-фиксатор присутствует (genuine red→green, проверено +против `origin/main`); полный прогон зелёный (2215 passed). Документация синхронна. Готово к стадии +`testing`. diff --git a/docs/work-items/ORCH-119/13-test-report.md b/docs/work-items/ORCH-119/13-test-report.md new file mode 100644 index 0000000..c1171a4 --- /dev/null +++ b/docs/work-items/ORCH-119/13-test-report.md @@ -0,0 +1,40 @@ +--- +result: PASS +work_item: ORCH-119 +stage: testing +author_agent: test-runner +status: success +created_at: 2026-06-17 +model_used: n/a +exit_code: 0 +smoke: ok +--- + +# Test Gate Log (deterministic runner, ORCH-116) + +pytest exit-code `0` -> `result: PASS` (smoke: ok). + +Вердикт зафиксирован детерминированным test-раннером (ORCH-116), не LLM. PASS/FAIL = exit-код `pytest` + read-only smoke (`/health`, `/status`, `/queue` + блок `serial_gate`). + +pytest stdout (tail): +``` +............................................. [ 65%] +........................................................................ [ 68%] +........................................................................ [ 71%] +........................................................................ [ 74%] +........................................................................ [ 78%] +........................................................................ [ 81%] +........................................................................ [ 84%] +........................................................................ [ 87%] +........................................................................ [ 91%] +........................................................................ [ 94%] +........................................................................ [ 97%] +....................................................... [100%] +=============================== warnings summary =============================== +src/config.py:8 + /repos/_wt/orchestrator/feature_ORCH-119-bug-00-business-request-md-is-/src/config.py:8: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/ + class Settings(BaseSettings): + +-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html +2215 passed, 1 warning in 105.47s (0:01:45) +``` diff --git a/docs/work-items/ORCH-119/14-deploy-log.md b/docs/work-items/ORCH-119/14-deploy-log.md new file mode 100644 index 0000000..257db5d --- /dev/null +++ b/docs/work-items/ORCH-119/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-119 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-119/15-staging-log.md b/docs/work-items/ORCH-119/15-staging-log.md new file mode 100644 index 0000000..cc8e4cb --- /dev/null +++ b/docs/work-items/ORCH-119/15-staging-log.md @@ -0,0 +1,46 @@ +--- +staging_status: SUCCESS +work_item: ORCH-119 +stage: deploy-staging +author_agent: staging-runner +status: success +created_at: 2026-06-17 +model_used: n/a +exit_code: 0 +base_url: http://localhost:8501 +--- + +# Staging Gate Log (deterministic runner, ORCH-115) + +Staging suite exit-code `0` -> `staging_status: SUCCESS`. + +Вердикт зафиксирован детерминированным staging-раннером (ORCH-115), не LLM. infra-tolerance (ORCH-061) уже учтена внутри `staging_check.py` — раннер её не пересуживает. + +INFRA-WAIVED lines (ORCH-061, copied for observability): +- INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green) + +Staging suite stdout (tail): +``` + (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + · waiting... (waiting for analyst job in queue) + ✗ FAIL C9b Analyst job enqueued in staging queue + +[CLEANUP] + · CLEANUP: no branch to delete + ✓ PASS CLEANUP: deleted Plane issue 0c163a41-a2a8-4884-b1f7-77017bce8d50 (HTTP 204) + · CLEANUP DB: no task row found for plane_id=0c163a41-a2a8-4884-b1f7-77017bce8d50 + · CLEANUP DB dedup: no such table: events_dedup + +============================================================ + RESULT: 8/10 checks PASS + REAL failed : none + SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] +============================================================ + · tolerance: staging_infra_tolerance_enabled=True +INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green) +VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green +``` diff --git a/docs/work-items/ORCH-119/17-security-report.md b/docs/work-items/ORCH-119/17-security-report.md new file mode 100644 index 0000000..688a4f5 --- /dev/null +++ b/docs/work-items/ORCH-119/17-security-report.md @@ -0,0 +1,29 @@ +--- +security_status: PASS +secrets_found: 0 +deps_blocking: 0 +deps_warning: 8 +deps_audit_degraded: false +--- +# Security Report — ORCH-119 + +Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше. + +## Verdict +clean: 0 secrets, 0 blocking CVE(s) + +## Secrets +- None + +## Dependencies (blocking) +- None + +## Dependencies (warning) +- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3 +- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1 +- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0 +- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2 +- `starlette==0.38.6` — GHSA-wqp7-x3pw-xc5r severity=UNKNOWN fix=1.1.0 +- `starlette==0.38.6` — GHSA-x746-7m8f-x49c severity=UNKNOWN fix=1.1.0 +- `starlette==0.38.6` — GHSA-82w8-qh3p-5jfq severity=UNKNOWN fix=1.3.1 +- `starlette==0.38.6` — GHSA-jp82-jpqv-5vv3 severity=UNKNOWN fix=1.3.0 diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 643a4e5..65258de 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -512,7 +512,8 @@ class AgentLauncher: return None def _materialize_deferred_branch( - self, repo: str, branch: str, work_item_id: str | None, title: str | None + self, repo: str, branch: str, work_item_id: str | None, title: str | None, + description: str | None = None, ) -> None: """ORCH-088 (ADR-001 D1): create the deferred Gitea branch + initial docs. @@ -524,6 +525,12 @@ class AgentLauncher: Both are idempotent (409/422 -> no-op) so a re-claim after a restart is safe. A transient Gitea error PROPAGATES so the caller (_spawn) fails the launch and the queue worker requeues the job for a later tick (never a half-cut state). + + ORCH-119 (FR-3 / AC-3): ``description`` (read from the tasks row by ``_spawn``, + durable since task creation) is passed through to ``_create_initial_docs`` so + the artifact materialised at claim carries the real request text, not ``TBD``. + The branch-cut MOMENT/CONDITION are unchanged — only the data source is enriched + (ORCH-088 anti-stale-base invariant preserved, NFR-3). """ import asyncio from ..webhooks.plane import _create_gitea_branch, _create_initial_docs @@ -535,7 +542,9 @@ class AgentLauncher: ) asyncio.run(_create_gitea_branch(repo, branch)) if work_item_id: - asyncio.run(_create_initial_docs(repo, branch, work_item_id, name)) + asyncio.run( + _create_initial_docs(repo, branch, work_item_id, name, description) + ) def _spawn(self, agent: str, repo: str, task_content: str = None, task_id: int = None, job_id: int = None) -> int: @@ -556,9 +565,14 @@ class AgentLauncher: raise FileNotFoundError(f"Repo not found: {local_repo_path}") # Determine branch (needed before we touch the worktree / task file). + # ORCH-119: also read the durable `description` so the deferred branch + # materialisation (path B) renders the real request text into + # 00-business-request.md instead of `TBD`. No network call (read from the + # tasks row) -> the hot claim path stays network-free (NFR-4). _br_row = ( get_db().execute( - "SELECT branch, work_item_id, title FROM tasks WHERE id=?", (task_id,) + "SELECT branch, work_item_id, title, description FROM tasks WHERE id=?", + (task_id,), ).fetchone() if task_id else None ) @@ -580,7 +594,7 @@ class AgentLauncher: _applies = False if _applies: self._materialize_deferred_branch( - repo, agent_branch, _br_row[1], _br_row[2] + repo, agent_branch, _br_row[1], _br_row[2], _br_row[3] ) # ORCH-41: resolve the Plane project uuid for this repo so per-project diff --git a/src/db.py b/src/db.py index 3567eef..410561f 100644 --- a/src/db.py +++ b/src/db.py @@ -123,6 +123,16 @@ def init_db(): # ("🛠️ ET-012 · "). Populated from the Plane work-item name at task # creation; falls back to the work_item_id when absent. Idempotent ALTER. _ensure_column(conn, "tasks", "title", "TEXT") + # ORCH-119 (08-data-requirements.md): durable source-backed Plane-issue + # `description` (plain-text, preferably description_stripped). Mirrors tasks.title + # 1:1 — additive, idempotent (_ensure_column is a no-op once present) -> safe on + # the live shared prod DB (enduro untouched). Written inside the atomic INSERT in + # create_task_atomic so it is race-safe vs the ORCH-053 anti-dup claim (no window + # where the task exists but the description is missing). The deferred branch cut + # (path B, ORCH-088, dominates on self-hosting) reads it from the tasks row at + # claim time and renders it into 00-business-request.md instead of the historic + # hardcoded `TBD`. NULL for pre-existing rows -> renders the safe fallback marker. + _ensure_column(conn, "tasks", "description", "TEXT") # Telegram live tracker: "BRD review" is the only HUMAN gate time — the delta # between "BRD ready / approve requested" and the analysis->architecture # advance (human flipped Plane to Approved). Persisted on the task so the @@ -662,6 +672,7 @@ def create_task_atomic( branch: str, stage: str, title: str, + description: str | None = None, ) -> tuple[dict, bool]: """ORCH-053 (AC-4): atomically claim creation of a task for a plane_id. @@ -676,6 +687,14 @@ def create_task_atomic( * ``created=False`` -> a task for this plane_id already existed (the other racer won); ``row`` is the existing task and the caller must NOT duplicate the follow-up work. + + ORCH-119 (ADR-001 D1): ``description`` (the source-backed Plane-issue text) is + persisted durable INSIDE this same atomic INSERT — never a separate UPDATE — so + there is no race window (ORCH-053) where the task exists but the description is + missing. The parameter is additive with a default so the other callers (e.g. the + F-2 reconciler) stay backward-compatible (NULL description -> render falls back to + a safe marker). The deferred branch cut (path B, ORCH-088) reads it from the row + at claim time. """ with _CREATE_TASK_LOCK: conn = get_db() @@ -688,9 +707,11 @@ def create_task_atomic( return dict(existing), False cur = conn.execute( "INSERT INTO tasks " - "(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) " - "VALUES (?, ?, ?, ?, ?, ?, ?)", - (plane_id, work_item_id, repo, branch, stage, plane_id, title), + "(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title, " + "description) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + (plane_id, work_item_id, repo, branch, stage, plane_id, title, + description), ) conn.commit() row = conn.execute( diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index 7ae720c..8af3aee 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -657,8 +657,12 @@ async def start_pipeline(data: dict, project_id: str = ""): # process-wide lock. If the F-2 reconciler and this live webhook race on the # same plane_id, exactly one wins (created=True); the loser sees the existing # task and returns WITHOUT creating a second branch / worktree / analyst job. + # ORCH-119 (FR-3 / AC-3): persist `description` DURABLE inside the same atomic + # INSERT (mirror of `title`) so the deferred branch cut (path B, ORCH-088 — dominates + # on self-hosting) can read it from the tasks row at claim time and render the real + # request text into 00-business-request.md instead of the hardcoded `TBD`. task_row, created = create_task_atomic( - plane_id, work_item_id, repo, branch, "analysis", name + plane_id, work_item_id, repo, branch, "analysis", name, description ) if not created: logger.info( @@ -726,8 +730,10 @@ async def start_pipeline(data: dict, project_id: str = ""): return # Create initial docs structure via Gitea API (create file) + # ORCH-119 (FR-2 / AC-2): direct path A — pass the source-backed `description` + # so the artifact is created with the real request text in one call. try: - await _create_initial_docs(repo, branch, work_item_id, name) + await _create_initial_docs(repo, branch, work_item_id, name, description) except Exception as e: logger.error(f"Failed to create initial docs: {e}") else: @@ -934,15 +940,59 @@ async def _create_gitea_branch(repo: str, branch: str): logger.info(f"Created branch '{branch}' in {owner}/{repo}") -async def _create_initial_docs(repo: str, branch: str, work_item_id: str, name: str): - """Create initial business request doc in the feature branch.""" +# ORCH-119 (FR-4 / AC-4): explicit safe marker used when the source description is +# empty / whitespace / None / unreadable — NOT the historic bare `TBD` bug. +_BUSINESS_REQUEST_FALLBACK = "_(описание отсутствует в источнике)_" + + +def _render_business_request( + work_item_id: str, name: str, description: str | None +) -> str: + """ORCH-119 (FR-1 / BR-1): render the body of ``00-business-request.md`` from the + source-backed Plane-issue ``description``. + + Pure & network-free so it is unit-testable without Gitea (TC-01/TC-02/TC-06). The + Description section carries the ACTUAL request text plain-text and verbatim — + multi-line content and markdown special chars are preserved (the doc is + informational and NOT gate-parsed, NFR-2); only surrounding whitespace is trimmed + for the emptiness check. Empty / whitespace / None / any failure degrades to an + explicit safe marker (``_BUSINESS_REQUEST_FALLBACK``) instead of the historic bare + ``TBD`` (FR-4 / AC-4); never raises. The header (``# Business Request: {name}``) + and ``Work Item ID`` are unchanged. + """ + try: + body = (description or "").strip() + if not body: + body = _BUSINESS_REQUEST_FALLBACK + except Exception: # noqa: BLE001 - never let rendering break task creation + body = _BUSINESS_REQUEST_FALLBACK + return ( + f"# Business Request: {name}\n\n" + f"Work Item ID: {work_item_id}\n\n" + f"## Description\n\n{body}\n" + ) + + +async def _create_initial_docs( + repo: str, branch: str, work_item_id: str, name: str, + description: str | None = None, +): + """Create initial business request doc in the feature branch. + + ORCH-119: the Description section now carries the source-backed Plane-issue + ``description`` (rendered via ``_render_business_request``) instead of the historic + hardcoded ``TBD``. ``description`` is additive with a default so existing callers / + a re-claim stay backward-compatible; empty/None degrades to a safe marker. + Idempotent: Gitea 422 (file already exists) -> no-op, the previously written body + is NOT overwritten (AC-6 / TC-06). + """ owner = settings.gitea_owner file_path = f"docs/work-items/{work_item_id}/00-business-request.md" url = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/contents/{file_path}" headers = {"Authorization": f"token {settings.gitea_token}"} import base64 - content = f"# Business Request: {name}\n\nWork Item ID: {work_item_id}\n\n## Description\n\nTBD\n" + content = _render_business_request(work_item_id, name, description) encoded = base64.b64encode(content.encode()).decode() payload = { diff --git a/tests/test_orch119_business_request.py b/tests/test_orch119_business_request.py new file mode 100644 index 0000000..6f06519 --- /dev/null +++ b/tests/test_orch119_business_request.py @@ -0,0 +1,263 @@ +"""ORCH-119: source-backed ``00-business-request.md`` (fix the hardcoded ``TBD``). + +The Description section of ``00-business-request.md`` must carry the ACTUAL Plane-issue +``description`` instead of the historic hardcoded literal ``TBD``. Because the +self-hosting ``orchestrator`` repo cuts its branch lazily at analyst-job claim (the +deferred path B, ORCH-088), the description must be DURABLE-persisted on the ``tasks`` +row at creation time (mirror of ``tasks.title``) so it survives the gap between task +creation and claim. + +These tests are network-free: the Gitea ``contents`` POST (httpx) and the deferred +branch-cut coroutines are mocked; ``create_task_atomic`` runs against a real isolated +SQLite DB. Mapping to ``04-test-plan.yaml``: + + * TC-01 — MANDATORY regression: render contains the real description, not ``TBD``. + * TC-02 — fallback: empty/whitespace/None -> explicit safe marker, never raises. + * TC-03 — deferred path B: description persisted + rendered at materialisation. + * TC-04 — direct path A: ``_create_initial_docs`` writes the real description. + * TC-05 — schema backward-compat: ``_ensure_column`` is additive + idempotent. + * TC-06 — data integrity: multi-line markdown preserved; Gitea 422 -> no-op. + * TC-07 — anti-regression: gates / STAGE_TRANSITIONS / QG_CHECKS unchanged. +""" + +import asyncio +import base64 +import os +import tempfile +from types import SimpleNamespace + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch119_business_request.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db, create_task_atomic # noqa: E402 +from src.webhooks import plane # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + + +# --------------------------------------------------------------------------- +# TC-01 (MANDATORY regression): the rendered body carries the real request +# text, NOT the historic hardcoded ``TBD``. Red before the fix +# (``_render_business_request`` did not exist / body was ``TBD``). +# --------------------------------------------------------------------------- +def test_tc01_render_contains_real_description(): + desc = "Users need source-backed business requests captured from the Plane issue." + out = plane._render_business_request("ORCH-119", "Source-backed request", desc) + + # The real request text reaches the artifact body... + assert desc in out + # ...the header / Work Item ID are still present (unchanged contract)... + assert "# Business Request: Source-backed request" in out + assert "Work Item ID: ORCH-119" in out + # ...and the Description body is NOT the bare ``TBD`` bug. + body = out.split("## Description", 1)[1] + assert "TBD" not in body + + +# --------------------------------------------------------------------------- +# TC-02 (fallback): empty / whitespace / None description -> explicit safe +# marker (never the bare ``TBD`` bug), and the renderer never raises. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("desc", ["", " ", "\n\t \n", None]) +def test_tc02_fallback_marker_no_raise(desc): + out = plane._render_business_request("ORCH-119", "Name", desc) + assert "описание отсутствует в источнике" in out + body = out.split("## Description", 1)[1] + assert "TBD" not in body + + +# --------------------------------------------------------------------------- +# TC-03 (deferred path B / self-hosting): description is persisted DURABLE on +# the tasks row at creation and rendered into the artifact when the +# deferred branch is materialised at claim (launcher). +# --------------------------------------------------------------------------- +def test_tc03_deferred_path_persists_and_renders(monkeypatch): + desc = "A durable source-backed description for the deferred path B (claim-time cut)." + row, created = create_task_atomic( + "plane-b", "ORCH-201", "orchestrator", + "feature/ORCH-201-x", "analysis", "Title B", desc, + ) + assert created + + # Durable: description survives in the tasks row (readable without the network). + got = get_db().execute( + "SELECT description FROM tasks WHERE id=?", (row["id"],) + ).fetchone() + assert got[0] == desc + + captured = {} + + async def _branch_spy(repo, branch): + captured["branch_cut"] = (repo, branch) + + async def _docs_spy(repo, branch, work_item_id, name, description=None): + captured["description"] = description + + # _materialize_deferred_branch imports these names from webhooks.plane at call + # time, so patching the source module attributes intercepts them. + monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy) + monkeypatch.setattr(plane, "_create_initial_docs", _docs_spy) + + from src.agents.launcher import AgentLauncher + AgentLauncher()._materialize_deferred_branch( + "orchestrator", "feature/ORCH-201-x", "ORCH-201", "Title B", desc + ) + + assert captured.get("branch_cut") == ("orchestrator", "feature/ORCH-201-x") + assert captured.get("description") == desc + + +# --------------------------------------------------------------------------- +# TC-04 (direct path A / serial gate not applicable): _create_initial_docs +# writes the real description into the Gitea contents body. +# --------------------------------------------------------------------------- +def test_tc04_direct_path_renders_description(monkeypatch): + desc = "Direct path A description that must reach the artifact body verbatim." + captured = {} + + class _Resp: + status_code = 201 + + def raise_for_status(self): + pass + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None, headers=None, timeout=None): + captured["content"] = base64.b64decode(json["content"]).decode() + return _Resp() + + monkeypatch.setattr( + plane, "httpx", SimpleNamespace(AsyncClient=lambda *a, **k: _Client()) + ) + + asyncio.run( + plane._create_initial_docs("orchestrator", "feature/x", "ORCH-119", "Name", desc) + ) + + assert desc in captured["content"] + assert "## Description\n\nTBD\n" not in captured["content"] + + +# --------------------------------------------------------------------------- +# TC-05 (schema backward-compat): _ensure_column adds tasks.description on a +# pre-existing table without it; idempotent on re-run; creation works. +# --------------------------------------------------------------------------- +def test_tc05_schema_backward_compat(monkeypatch, tmp_path): + db_path = str(tmp_path / "bc.db") + monkeypatch.setattr(_db.settings, "db_path", db_path) + + # Simulate an OLD tasks table WITHOUT the description column. + conn = _db.get_db() + conn.executescript( + """ + CREATE TABLE tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plane_id TEXT, work_item_id TEXT, repo TEXT NOT NULL, + branch TEXT, stage TEXT DEFAULT 'created', plane_issue_id TEXT, title TEXT + ); + """ + ) + conn.commit() + conn.close() + + # init_db must add the column idempotently and never fail. + init_db() + cols = [r[1] for r in _db.get_db().execute("PRAGMA table_info(tasks)").fetchall()] + assert "description" in cols + + init_db() # re-run: _ensure_column is a no-op (idempotent) + + # Task creation works and persists the description. + row, created = create_task_atomic( + "p1", "ORCH-1", "orchestrator", "b", "analysis", "T", "a real description" + ) + assert created + got = get_db().execute( + "SELECT description FROM tasks WHERE id=?", (row["id"],) + ).fetchone() + assert got[0] == "a real description" + + +# --------------------------------------------------------------------------- +# TC-06 (data integrity): multi-line markdown with special chars is preserved +# verbatim (no truncation/escaping); Gitea 422 (file exists) -> no-op +# (single create attempt, body NOT overwritten). +# --------------------------------------------------------------------------- +def test_tc06_multiline_preserved_and_idempotent_422(monkeypatch): + desc = ( + "# Heading\n\n- bullet with `inline code`\n" + "- second *italic* and __bold__\n\n" + "Paragraph with special chars: <>&\"' and a trailing word." + ) + out = plane._render_business_request("ORCH-119", "N", desc) + assert desc in out # preserved verbatim, no truncation/escaping + + calls = [] + + class _Resp: + status_code = 422 # Gitea: file already exists + + def raise_for_status(self): + raise AssertionError("422 must be a no-op, never raised") + + class _Client: + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, json=None, headers=None, timeout=None): + calls.append(json) + return _Resp() + + monkeypatch.setattr( + plane, "httpx", SimpleNamespace(AsyncClient=lambda *a, **k: _Client()) + ) + + asyncio.run( + plane._create_initial_docs("orchestrator", "b", "ORCH-119", "N", desc) + ) + # Exactly one create attempt; no follow-up PUT/overwrite of the existing body. + assert len(calls) == 1 + + +# --------------------------------------------------------------------------- +# TC-07 (anti-regression): the pipeline machinery is untouched — +# 00-business-request.md stays informational (not gate-parsed). +# --------------------------------------------------------------------------- +def test_tc07_gates_unchanged(): + from src.stages import STAGE_TRANSITIONS + from src.qg.checks import QG_CHECKS + + # Stage graph intact. + for st in ("created", "analysis", "architecture", "development", + "review", "testing", "deploy-staging", "deploy", "done"): + assert st in STAGE_TRANSITIONS + + # The named checks still exist with their canonical names. + for chk in ("check_analysis_complete", "check_architecture_done", + "check_ci_green", "check_tests_passed"): + assert chk in QG_CHECKS + + # 00-business-request.md is informational: no check is keyed on it. + assert not any("business" in k.lower() for k in QG_CHECKS) diff --git a/tests/test_serial_gate_branch.py b/tests/test_serial_gate_branch.py index b4aeb96..1d36110 100644 --- a/tests/test_serial_gate_branch.py +++ b/tests/test_serial_gate_branch.py @@ -68,7 +68,9 @@ async def _drive_start_pipeline(monkeypatch, gate_applies: bool): async def _branch_spy(repo, branch): branch_calls.append((repo, branch)) - async def _docs_spy(repo, branch, wi, name): + # ORCH-119: _create_initial_docs gained an additive `description` arg; the spy + # accepts it so the serial-gate invariant assertions below stay 1:1. + async def _docs_spy(repo, branch, wi, name, description=None): docs_calls.append((repo, branch, wi, name)) monkeypatch.setattr(plane, "_create_gitea_branch", _branch_spy)