docs(operations): add user FAQ for STOP cancellation status (ORCH-108)
All checks were successful
CI / test (push) Successful in 1m15s
CI / test (pull_request) Successful in 1m16s

Create docs/operations/FAQ_STOP.md — a user-facing "вопрос → ответ" FAQ for
Plane board users explaining the STOP status: what it does, how to cancel a
task, step-by-step consequences (agent stops → jobs cancelled → working branch
and worktree removed → task → cancelled → Telegram+Plane; docs artifacts
preserved, main/prod untouched), deferred cancellation in the critical
merge/deploy window, explicit "STOP does NOT revert merged/deployed code"
(revert is a separate task), restart only via "To Analyse" from scratch, no-op
causes, where to observe the result, and STOP/Approved/Confirm Deploy disambig.

docs-only: src/**, STAGE_TRANSITIONS, QG_CHECKS, check_*, machine-verdict keys
and the DB schema are byte-for-byte untouched. STOP behaviour source of truth
remains ORCH-090 (adr-0026); the FAQ documents and links to it (link-first:
machine details markers/lease/tombstone given by reference, not duplicated).

Add two-way cross-links: docs/overview/business.md (Сценарий 6) and
docs/overview/tech-pipeline.md (Отмена: STOP → cancelled) → FAQ; FAQ → overview
+ ADR ORCH-090. Structure guarded by deterministic anti-drift test
tests/test_faq_stop_doc.py (offline, no network/LLM/subprocess; mirrors
tests/test_lite_setup_doc.py): existence + 8 section anchors + fact bricks +
cross-links + claim-level negative scan (sentence-level, not bare substrings,
so it never false-fails on correctly negating phrases — TR-3, with a
non-evergreen self-check). Full pytest tests/ green (2227 passed).

Refs: ORCH-108

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:50:58 +03:00
parent e97111dc74
commit dbd8df6eb2
6 changed files with 403 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
Work item: ORCH-126
Work item: ORCH-108
Repo: orchestrator
Branch: feature/ORCH-126-bug-queued-job-can-keep-stale-
Branch: feature/ORCH-108-19c40858
Stage: development

View File

@@ -3,6 +3,26 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **FAQ по статусу STOP для пользователя доски** (ORCH-108, `docs`): создан пользовательский
FAQ `docs/operations/FAQ_STOP.md` в формате «вопрос → ответ» — что делает STOP, как отменить
задачу, что происходит пошагово (агент останавливается → job'ы снимаются → рабочая ветка и
worktree удаляются → задача → `cancelled` → Telegram+Plane; **docs-артефакты сохраняются**,
`main`/прод не трогаются), отложенная отмена в критичном окне (merge/deploy), явное «STOP **не
откатывает** влитый в `main`/прод код» (revert — отдельная задача), перезапуск только через
«To Analyse» с нуля, причины no-op («ничего не произошло»), где увидеть результат, и разведение
STOP/Approved/Confirm Deploy. **docs-only:** `src/**` / `STAGE_TRANSITIONS` / `QG_CHECKS` /
`check_*` / machine-verdict / схема БД — байт-в-байт не тронуты; поведение STOP — источник истины
ORCH-090 (`adr-0026`), FAQ его лишь документирует и ссылается, не форкая (link-first: машинные
детали маркеры/lease/тумбстон — только ссылками). Добавлены двусторонние перекрёстные ссылки:
витрина `docs/overview/business.md` (Сценарий 6) и обзор `docs/overview/tech-pipeline.md`
(«Отмена: STOP → `cancelled`») → FAQ; FAQ → обзор + ADR ORCH-090. Структуру FAQ закрывает
детерминированный анти-дрейф тест `tests/test_faq_stop_doc.py` (offline, без сети/LLM/subprocess;
образец `tests/test_lite_setup_doc.py`): существование + 8 секций-якорей + факты-«кирпичи» +
кросс-ссылки + **негативный скан запрещённых утверждений на уровне предложений, а не голых
подстрок** (не фолзит на корректно отрицающих фразах — TR-3, проверено non-evergreen-самочеком).
**Норматив сопровождения:** правишь поведение STOP (`src/cancel.py` / `cancel_task` / маршрут
`stop`) → обнови `docs/operations/FAQ_STOP.md` в том же PR. ADR:
`docs/work-items/ORCH-108/06-adr/ADR-001-faq-stop-placement-and-anti-drift.md`.
- **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`.

View File

@@ -0,0 +1,87 @@
# FAQ: отмена задачи через статус STOP
Эта страница — для пользователя доски Plane. Она объясняет простыми словами, что делает статус
**STOP**, как им безопасно остановить задачу и чего от него ждать. Читать код для этого не нужно.
Технические детали механизма — в
[инженерном обзоре конвейера](../overview/tech-pipeline.md#отмена-stop--cancelled) и в
[ADR ORCH-090](../work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md) (источник истины
поведения). Эта страница их **не дублирует**, а пересказывает «для человека» и ссылается на них.
## Что делает статус STOP?
STOP — это «кнопка отмены» задачи. Перевод задачи в статус STOP останавливает работу агента,
снимает задачу с очереди, прибирает рабочие материалы (ветку и worktree) и помечает задачу
отменённой (`cancelled`). Нажимать его безопасно даже посреди конвейера.
## Как отменить задачу?
Переведите issue в статус **STOP** на доске Plane — так же, как меняете любой другой статус.
Предусловие: на доске должен быть заведён статус **STOP** (группа `cancelled`). Если его нет, STOP
не сработает (см. раздел «Я нажал STOP, но ничего не произошло — почему?»).
## Что происходит, когда я нажимаю STOP?
По шагам:
1. Активный агент **останавливается** (мягкая остановка процесса).
2. Все **job'ы** этой задачи в очереди снимаются и больше не перезапускаются.
3. Рабочая **ветка** задачи и её **worktree** удаляются. Ветка `main` и прод-контейнер при этом
никогда не трогаются.
4. Задача переходит в терминальное состояние **`cancelled`**.
5. Приходит уведомление в **Telegram** («🛑 … задача ОТМЕНЕНА (STOP)») и **комментарий в Plane**.
При этом **документы задачи** (бизнес-запрос, анализ, ТЗ, ADR и т.д.) **сохраняются** — удаляются
только рабочая ветка и worktree, не история документов.
## Что если задача в этот момент сливается или деплоится?
Если STOP пришёл во время **необратимого шага** (слияние в `main` или выкладка в прод), отмена
**аккуратно откладывается** до честного завершения этого шага. Вы увидите уведомление вида
«⏸️ … отмена ОТЛОЖЕНА». Ветка `main` и прод при этом не трогаются; как только шаг честно
завершится, отмена применяется автоматически.
Иными словами: STOP **не прерывает** уже начатый необратимый шаг на полпути — он дожидается его
честного завершения, а затем отменяет задачу.
## Откатит ли STOP уже выложенный код?
**Нет.** STOP сбрасывает **незавершённый прогресс** задачи (рабочую ветку, worktree, очередь), но
**не откатывает** код, который уже влит в `main` или выложен в прод. Откат уже выложенного —
это отдельная задача (revert), и STOP её **не делает**.
## Как перезапустить отменённую задачу?
Отменённую задачу **нельзя «продолжить с середины»**. Чтобы начать заново, переведите её в статус
**«To Analyse»** — задача будет создана **с нуля**: новая ветка от актуального `main`, новый анализ
и полный проход конвейера.
## Я нажал STOP, но ничего не произошло — почему?
Вероятные причины:
- На доске **нет статуса STOP** — переход не распознаётся (безопасный no-op). Заведите статус STOP
(группа `cancelled`) и попробуйте снова.
- Задача **уже завершена или уже отменена** — повторный STOP ничего не меняет (это нормально,
идемпотентный no-op, задача не ломается).
- Отмена **отключена для репозитория** настройкой оператора (`stop_status_enabled` /
`stop_status_repos`) — обратитесь к оператору.
## Где увидеть, что задача отменена?
- **Карточка задачи в Telegram** покажет «🛑 … ОТМЕНЕНА (STOP)».
- В **Plane** появится комментарий об отмене.
- Оператор может увидеть отмену на служебной странице состояния `GET /queue` — в блоке `stop`.
## STOP, Approved и Confirm Deploy — в чём разница?
Это разные управляющие статусы, их легко перепутать:
- **STOP** — *отменить* задачу и сбросить её незавершённый прогресс.
- **Approved** — *одобрить* артефакт анализа (двигает задачу дальше по конвейеру); деплой он **не**
запускает.
- **Confirm Deploy** — *подтвердить* выкладку в прод.
Подробнее об управляющих статусах и их семантике — в
[инженерном обзоре конвейера](../overview/tech-pipeline.md). Эта страница описывает только STOP.

View File

@@ -97,6 +97,7 @@
Передумали — переводите задачу в статус «STOP»: работа агента останавливается, ветка и
рабочие материалы прибираются, задача помечается отменённой. Если задача в этот момент в
необратимой фазе выкладки — отмена аккуратно откладывается до её честного завершения.
Подробнее — [FAQ по статусу STOP](../operations/FAQ_STOP.md).
---

View File

@@ -126,6 +126,8 @@ freeze, ни объявленные зависимости. Свежесть б
(идёт слияние/выкладка) — отмена откладывается и применяется после честного завершения шага.
STOP никогда не трогает `main` и прод-контейнер.
Пользовательская инструкция «как этим пользоваться» — [FAQ по статусу STOP](../operations/FAQ_STOP.md).
## Статусная модель Plane: индикация ≠ управление
Статусы в Plane — слой **индикации**: они показывают человеку осмысленную картину хода задачи,

291
tests/test_faq_stop_doc.py Normal file
View File

@@ -0,0 +1,291 @@
"""ORCH-108 (FR-6 / AC-1…AC-11): анти-дрейф контур пользовательского FAQ по STOP.
Структурные проверки golden source `docs/operations/FAQ_STOP.md` (ADR-001 D3,
образец — `tests/test_lite_setup_doc.py`): док существует и адресован пользователю
(TC-01); присутствуют все 8 обязательных секций-якорей FR-2 (TC-02); несёт ключевые
факты-«кирпичи» о последствиях STOP (TC-03), об отложенной отмене (TC-04), о том что
STOP не откатывает прод-код (TC-05) и о перезапуске через «To Analyse» с нуля (TC-06);
негативный скан запрещённых **утверждений** на уровне предложений, а НЕ голых подстрок
(TC-07, ключевой нюанс D3 — иначе тест краснел бы на корректно отрицающих фразах);
двусторонние перекрёстные ссылки витрина/обзор ⇄ FAQ ⇄ ADR ORCH-090 (TC-08);
docs-only / детерминированность самого теста — без сети/LLM/subprocess (TC-09).
Детерминировано: только парсинг файлов (read_text), без сети/LLM/subprocess.
Поведение STOP в рантайме реализовано и протестировано в ORCH-090 — эта задача его
НЕ меняет (docs-only), здесь проверяется лишь структурная целостность документации.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
FAQ = REPO_ROOT / "docs/operations/FAQ_STOP.md"
BUSINESS = REPO_ROOT / "docs/overview/business.md"
TECH_PIPELINE = REPO_ROOT / "docs/overview/tech-pipeline.md"
# Обязательные секции FR-2: распознаваемая (нормализованная, регистронезависимая)
# подстрока заголовка `## ` — стабильный якорь, толерантный к полировке пунктуации.
SECTION_ANCHORS: tuple[str, ...] = (
"что делает статус stop", # (1) назначение
"как отменить задачу", # (2) как отменить
"что происходит, когда я нажимаю stop", # (3) пошагово
"сливается или деплоится", # (4) отложенная отмена
"откатит ли stop уже выложенный код", # (5) не откатывает прод
"как перезапустить отменённую задачу", # (6) перезапуск с нуля
"ничего не произошло", # (7) no-op причины
"где увидеть, что задача отменена", # (8) где увидеть результат
)
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _faq_text() -> str:
assert FAQ.is_file(), "docs/operations/FAQ_STOP.md отсутствует (AC-1)"
text = FAQ.read_text(encoding="utf-8")
assert text.strip(), "FAQ_STOP.md пуст (AC-1)"
return text
def _headers() -> list[str]:
"""Нормализованные (lowercase) тексты markdown-заголовков `#`/`##`/…."""
return [
re.sub(r"^#+\s*", "", line).strip().lower()
for line in _faq_text().splitlines()
if line.lstrip().startswith("#")
]
def _body_sentences() -> list[str]:
"""Предложения тела FAQ БЕЗ заголовков и без code-fence-блоков.
Заголовки этого FAQ — вопросы («Откатит ли STOP…?», «Что если…?») и по
построению не несут утверждений; негативный скан утверждений (TC-07)
исключает их, чтобы не краснеть на легитимных вопросах-якорях (D3/TR-3).
"""
lines: list[str] = []
in_fence = False
for raw in _faq_text().splitlines():
stripped = raw.strip()
if stripped.startswith("```"):
in_fence = not in_fence
continue
if in_fence or stripped.startswith("#"):
continue
lines.append(raw)
body = " ".join(lines)
return [s.strip().lower() for s in re.split(r"[.!?\n]+", body) if s.strip()]
_NEGATION = re.compile(r"\b(?:не|нет|никогда|нельзя|без)\b", re.IGNORECASE)
# Опасные claim-триггеры (D3): ЕСЛИ предложение тела матчит триггер, оно ОБЯЗАНО
# нести отрицание рядом, иначе это запрещённое FR-5 утверждение. Скан на уровне
# предложений-утверждений, НЕ голых подстрок (заголовки-вопросы исключены).
_CLAIM_TRIGGERS: tuple[tuple[str, re.Pattern[str]], ...] = (
# «STOP откатывает прод/влитый код» — должно встречаться только с отрицанием.
("откат прод-кода", re.compile(r"откат\w*", re.IGNORECASE)),
# «можно продолжить отменённую с середины».
("продолжить с середины", re.compile(r"продолж\w*[^.]{0,40}середин", re.IGNORECASE)),
# «STOP трогает main / делает force-push».
("трогает main / force-push", re.compile(r"трога\w*|force[\s-]?push", re.IGNORECASE)),
# «STOP мгновенно убивает/прерывает идущий деплой/слияние».
(
"мгновенно убивает деплой",
re.compile(r"(?:мгновенн|немедленн)\w*[^.]{0,40}(?:убива|прерыва|деплой|слиян|выклад)", re.IGNORECASE),
),
)
def _forbidden_claim_violations(sentences: list[str]) -> list[str]:
"""Предложения, которые делают запрещённое утверждение без отрицания рядом."""
violations: list[str] = []
for name, trigger in _CLAIM_TRIGGERS:
for sent in sentences:
if trigger.search(sent) and not _NEGATION.search(sent):
violations.append(f"[{name}] {sent[:120]}")
return violations
# ---------------------------------------------------------------------------
# TC-01: FAQ существует, непустой, адресован пользователю (AC-1).
# ---------------------------------------------------------------------------
def test_faq_exists_user_oriented_with_h1():
text = _faq_text()
headers = _headers()
assert headers, "в FAQ_STOP.md нет ни одного заголовка"
assert "stop" in headers[0], f"H1 FAQ не про STOP: {headers[0]!r} (AC-1)"
# Вводный абзац «для кого/зачем» (тон пользовательский, без требования читать код).
intro = text.split("\n##", 1)[0].lower()
assert "для пользователя доски plane" in intro, (
"нет вводного абзаца «для кого» (AC-1) — FAQ должен адресоваться пользователю"
)
assert "не нужно" in intro and "код" in intro, (
"вводный абзац не фиксирует пользовательский тон «читать код не нужно» (AC-1)"
)
# ---------------------------------------------------------------------------
# TC-02: присутствуют все 8 обязательных секций-якорей FR-2 (AC-2).
# ---------------------------------------------------------------------------
def test_all_mandatory_sections_present():
headers_blob = " || ".join(_headers())
missing = [a for a in SECTION_ANCHORS if a not in headers_blob]
assert not missing, f"в FAQ_STOP.md отсутствуют обязательные секции FR-2 (AC-2): {missing}"
# ---------------------------------------------------------------------------
# TC-03: пошаговые последствия и сохранность docs (AC-3).
# ---------------------------------------------------------------------------
def test_step_consequences_and_docs_preserved():
low = _faq_text().lower()
bricks = {
"остановка агента": "останав",
"снятие job'ов": "job",
"удаление рабочей ветки": "ветк",
"удаление worktree": "worktree",
"переход в cancelled": "cancelled",
"уведомление telegram": "telegram",
"комментарий plane": "plane",
"docs сохраняются": "сохран",
"main/прод не трогаются": "main",
}
missing = [name for name, token in bricks.items() if token not in low]
assert not missing, f"раздел «что происходит» не несёт фактов (AC-3): {missing}"
# ---------------------------------------------------------------------------
# TC-04: критичное окно — отложенная отмена + main/прод не трогаются (AC-4).
# ---------------------------------------------------------------------------
def test_deferred_cancel_in_critical_window():
low = _faq_text().lower()
assert "отлож" in low, "нет факта отложенной отмены в критичном окне (AC-4)"
# «main/прод не трогаются» — явное отрицание рядом с критичным окном.
assert "не трогают" in low or "не трогаются" in low, (
"нет явного «main/прод не трогаются» (AC-4)"
)
# ---------------------------------------------------------------------------
# TC-05: STOP ≠ откат прод-кода — явный отрицательный ответ (AC-5).
# ---------------------------------------------------------------------------
def test_stop_does_not_revert_prod():
low = _faq_text().lower()
assert "не откатывает" in low, "нет явного «не откатывает» прод/влитый код (AC-5)"
assert "revert" in low or "отдельная задача" in low, (
"не сказано, что revert выложенного — отдельная задача (AC-5)"
)
# ---------------------------------------------------------------------------
# TC-06: перезапуск — «To Analyse» с нуля, без «продолжить с середины» (AC-6).
# ---------------------------------------------------------------------------
def test_restart_via_to_analyse_from_scratch():
text = _faq_text()
low = text.lower()
assert "to analyse" in low, "перезапуск не через «To Analyse» (AC-6)"
assert "с нуля" in low, "не сказано, что задача создаётся «с нуля» (AC-6)"
# «продолжить с середины» допустимо только в отрицающем предложении.
for sent in _body_sentences():
if "продолж" in sent and "середин" in sent:
assert _NEGATION.search(sent), (
f"обещание продолжить отменённую задачу с середины (AC-6): {sent[:120]!r}"
)
# ---------------------------------------------------------------------------
# TC-07a: «ничего не произошло» и идемпотентность — причины no-op (AC-7).
# ---------------------------------------------------------------------------
def test_noop_reasons_present():
low = _faq_text().lower()
assert "stop_status_enabled" in low and "stop_status_repos" in low, (
"не перечислены настройки отключения отмены (AC-7)"
)
assert "уже" in low and ("заверш" in low or "отмен" in low), (
"не описан идемпотентный no-op для уже терминальной задачи (AC-7)"
)
# ---------------------------------------------------------------------------
# TC-07b: негативный скан запрещённых утверждений (claim-level, не подстроки).
# Инвариант D3: НЕ фолзит на верном FAQ; обязан краснеть на реально ложном (AC-9).
# ---------------------------------------------------------------------------
def test_no_forbidden_claims_in_real_faq():
violations = _forbidden_claim_violations(_body_sentences())
assert not violations, (
"FAQ_STOP.md содержит запрещённые FR-5 утверждения без отрицания (AC-9):\n"
+ "\n".join(violations)
)
def test_forbidden_claim_scanner_is_not_evergreen():
"""Подсаженные ложные утверждения ОБЯЗАНЫ ловиться (паттерн ORCH-101/102:
сканер не может молча стать вечнозелёным). Зеркало
test_secret_heuristic_is_not_evergreen из test_lite_setup_doc.py."""
bad = [
"stop откатывает уже выложенный в прод код автоматически",
"отменённую задачу можно продолжить с середины конвейера",
"stop делает force-push в main для зачистки",
"stop мгновенно убивает идущий деплой на полпути",
]
caught = _forbidden_claim_violations(bad)
assert len(caught) >= 4, f"сканер не ловит подсаженные ложные утверждения: {caught}"
# И обратно: корректные ОТРИЦАЮЩИЕ предложения НЕ должны ловиться (анти-TR-3).
good = [
"stop не откатывает код, уже влитый в main или выложенный в прод",
"отменённую задачу нельзя продолжить с середины",
"ветка main и прод никогда не трогаются",
]
assert _forbidden_claim_violations(good) == [], (
"сканер ложно краснеет на корректно отрицающих предложениях (TR-3)"
)
# ---------------------------------------------------------------------------
# TC-08: двусторонние перекрёстные ссылки витрина/обзор ⇄ FAQ ⇄ ADR (AC-8).
# ---------------------------------------------------------------------------
def test_overview_docs_link_to_faq():
assert "FAQ_STOP.md" in BUSINESS.read_text(encoding="utf-8"), (
"docs/overview/business.md (Сценарий 6) не ссылается на FAQ_STOP.md (AC-8)"
)
assert "FAQ_STOP.md" in TECH_PIPELINE.read_text(encoding="utf-8"), (
"docs/overview/tech-pipeline.md (Отмена: STOP → cancelled) не ссылается на FAQ_STOP.md (AC-8)"
)
def test_faq_links_back_to_overview_and_adr():
text = _faq_text()
assert "tech-pipeline.md" in text, "FAQ не ссылается на инженерный обзор (AC-8)"
assert "ORCH-090" in text and "ADR-001-stop-cancel-task.md" in text, (
"FAQ не ссылается на ADR ORCH-090 как источник истины (AC-8)"
)
# link-first: машинные детали не дублируются (даются ссылкой), а не копируются.
low = text.lower()
for machine_detail in ("тумбстон", "merge-lease", "_ensure_column"):
assert machine_detail not in low, (
f"FAQ дублирует машинную деталь {machine_detail!r} вместо ссылки (AC-8/D4)"
)
# ---------------------------------------------------------------------------
# TC-09: детерминированность самого теста — docs-only, без сети/LLM/subprocess.
# ---------------------------------------------------------------------------
def test_this_module_is_deterministic_offline():
"""Самочек контракта D3: тест-модуль не импортирует сеть/subprocess/LLM и не
тянет рантайм src/ — читает только файлы docs/ (AC-10, AC-11)."""
# Скан реальных import-стейтментов по началу строки (а не подстрок — иначе
# тест ловил бы собственные строковые литералы из этого же списка).
banned = ("subprocess", "socket", "requests", "httpx", "urllib")
offenders: list[str] = []
for line in Path(__file__).read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not (stripped.startswith("import ") or stripped.startswith("from ")):
continue
module = stripped.split()[1].split(".", 1)[0]
if module in banned or module == "src":
offenders.append(stripped)
assert not offenders, (
f"анти-дрейф тест FAQ нарушает контракт детерминированности (сеть/subprocess/"
f"рантайм src): {offenders}"
)