Files
orchestrator/docs/work-items/ORCH-020/06-adr/ADR-001-task-estimation-status-trigger.md
claude-bot 6c204548a7
All checks were successful
CI / test (push) Successful in 1m14s
architect(ET): auto-commit from architect run_id=798
2026-06-17 21:16:50 +03:00

28 KiB
Raw Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-020 architecture architect proposed 2026-06-17 claude-opus-4-8

ADR-001: Оценка задачи как side-механизм, запускаемый операторским Plane-статусом «Оценка», с детерминированной эвристикой по истории

Work Item: ORCH-020 — Оценка задачи (прогноз стоимости/времени/токенов/story points), запускаемая статусом «Оценка» Стадия: architecture Сквозная регистрация: docs/architecture/adr/adr-0054-task-estimation-status-trigger.md (решение кросс-каттинговое: новый член семейства операторских action-статусов + новая аддитивная таблица + новый primitive записи в Plane + новый leaf).

Статус

Proposed

Контекст

BRD/TRZ (01-brd.md/02-trz.md, ревизия после REJECT 2026-06-17) требуют: оператор массово переводит backlog-задачи в выделенный Plane-статус «Оценка», по каждой оркестратор прогнозирует стоимость / время / токены / сложность (story points {1,2,3,5,8}), пишет прогноз в Plane-поле estimate_point, публикует в Plane-комменте и пункте «Оценка» Telegram-карточки, сохраняет в леджер прогноз↔факт и возвращает issue в Backlog; пере-оценка повтором идемпотентна; по завершении задачи факт пишется в point. Шаг 2 (адаптивный выбор модели) — вне объёма (заказчик: «Модели не выбираем и не меняем»).

Факты, сверенные с кодом (не изобретать):

  • Семейство операторских action-статусов уже существует. webhooks/plane.py::handle_issue_updated (строки 163181) разбирает STOP (ORCH-090) и Confirm Deploy (ORCH-059) через proj_states.get("<key>"); оба намеренно отсутствуют в plane_sync._DEFAULT_STATES (fail-closed) и сопоставляются именем через _PLANE_NAME_TO_KEY (src/plane_sync.py:131). Статус «Оценка» — третий представитель того же семейства.
  • Массовость «бесплатна»: Plane multi-select → N независимых issue.updated-вебхуков; спец-batch-кода не нужно.
  • Фактура для калибровки накоплена: usage.task_usage_summary(task_id) (src/usage.py:834) агрегирует токены/стоимость per-task из agent_runs; тайминги — tasks.created_at/updated_at, agent_runs.started_at/finished_at. Колонка tasks.track (ORCH-019) различает full/bug.
  • Запись в Plane идёт через guard ORCH-117: все три примитива записи (update_issue_state/add_comment/ _set_issue_state_direct) проходят _guard_allows_write (src/plane_sync.py:847) — из тест/worktree-процесса запись в боевой проект физически заблокирована.
  • estimate-система Plane не настроена на момент анализа; estimate_point — FK на estimate-point estimate-системы, point — целочисленное поле issue. В src/ нет кода работы с Plane-estimate (net-new интеграция).
  • leaf-паттерн платформы (serial_gate/coverage_gate/bug_fast_track/lessons): never-raise, kill-switch *_enabled, скоуп *_repos (пусто → self-hosting only через qg.checks.is_self_hosting_repo), read-only блок в GET /queue, applies(repo) локально и ПЕРВЫМ.
  • Хук done-факта: блок if next_stage == "done" в stage_engine.advance_stage (src/stage_engine.py:521) — единственная авторитетная точка перехода в терминал.

Инвариант (NFR-1/NFR-3): оценка — наблюдатель/продюсер, не Quality Gate и не переход стадии. STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict-ключи / схемы существующих таблиц — байт-в-байт. Горячий путь resolve_agent_model/resolve_agent_effort/_spawn — не трогается.

Решение

Сводка

Вводим новый операторский Plane-статус «Оценка» как третий член семейства action-статусов (STOP/Confirm Deploy) — fail-closed .get("estimate")-ветка в handle_issue_updated, делегирующая новому leaf-модулю src/estimator.py (never-raise, kill-switch, скоуп). Механизм прогноза — детерминированная эвристика по истории завершённых задач (чистые функции, без LLM-вызова): прогноз = средние токены/время/стоимость похожих done-задач того же репо/трека, story-points — чистая функция-бакетизатор. Прогноз пишется в Plane (estimate_point + коммент), Telegram-карточку и новую аддитивную таблицу task_estimates (UPSERT по work_item_id); затем issue возвращается в Backlog. По завершении задачи факт (из usage.py) пишется в point и в леджер. Всё — аддитивно, под флагами, fail-safe, без касания control-path.

D1 — Механизм прогноза: детерминированная эвристика по истории, без LLM (NFR-4, NFR-5; решение Q открытого вопроса TRZ §NFR-4)

Это главное архитектурное решение, которое TRZ явно делегировал архитектору.

  • Выбор: чистая детерминированная эвристика (in-process, без сетевого LLM-вызова и без субпроцесса). Прогноз вычисляется парой индексированных SQL-чтений + чистыми функциями за микросекунды.
  • Почему не LLM-оценщик / не гибрид (на этом шаге):
    1. NFR-5 (массовость). Multi-select десятков задач → десятки почти одновременных вебхуков. LLM-вызов на оценку умножился бы на N и конкурировал бы за единственный транспорт LLM (launcher._spawn) с боевыми агентами, рискуя замедлить обслуживание других проектов (enduro) из общего прода.
    2. NFR-4 (стоимость ≪ ценности). Opus-вызов на каждую из десятков backlog-задач — это реальные $ за саму оценку; эвристика бесплатна.
    3. Политика ORCH-118 (determinization-roadmap). Платформа целенаправленно сокращает avoidable LLM-пути (llm-usage-policy.md: «LLM — только где нужно настоящее суждение»). Оценка размера по истории — деривируемая из tool-сигналов величина, не требующая суждения LLM. Вводить здесь новый LLM-путь прямо противоречит действующей политике.
    4. Воспроизводимость/тестируемость. Детерминированный бакетизатор покрывается unit-тестами на границах (AC-2 / TC-09), чего LLM не даёт.
  • Стек-расширяемость (BR-6 содержания, без реализации сейчас): контракт estimator.estimate(work_item_id, description|issue, repo) -> {forecast_tokens, forecast_seconds, forecast_cost_usd, story_points}граница расширения. Будущий гибридный LLM-рефайнер (если когда-нибудь понадобится) встраивается ЗА этой границей без изменения вызывающих. Сейчас LLM-рефайнер не строится (Шаг 2 / выбор модели вне объёма, AC-11).

D2 — Модель прогноза: средние по «похожим» завершённым задачам + bootstrap (BR-1, BR-2)

  • «Похожие» = тот же repo И тот же track (full/bug, ORCH-019) среди задач со stage='done'. Трек — дешёвый, уже хранимый, осмысленный разрез сложности (багфикс короче полного цикла).
  • Источник фактуры (read-only): тонкий агрегат db.completed_task_stats(repo, track) -> {n, mean_tokens, mean_cost_usd, mean_seconds} поверх agent_runs (токены/стоимость, как task_usage_summary, но сгруппировано по завершённым задачам) и tasks (время = updated_at - created_at, отсечка аномалий по estimator_wall_cap_s, зеркало tracker_brd_review_cap_s ORCH-087). usage.py переиспользуется read-only.
  • Прогноз = средние по выборке. forecast_tokens = mean_tokens, forecast_cost_usd = mean_cost_usd, forecast_seconds = mean_seconds.
  • Bootstrap (пустая/малая история): n < estimator_min_samples (дефолт 3) → значения берутся из конфиг-дефолтов estimator_bootstrap_{tokens,cost_usd,seconds} (или смешиваются с имеющейся выборкой — деталь реализации; разработчику разрешена линейная интерполяция). Никогда не исключение (AC-1/TC-10).
  • Сигналы описания (опц., v1 — не обязательны): длина текста постановки / наличие метки Bug могут скорректировать выбор трека; в v1 достаточно repo+track. Расширение — за границей D1.

D3 — Бакетизатор story points: чистая функция, конфигурируемые пороги (BR-3, AC-2)

  • Чистая функция estimator.story_points_for(forecast) -> int ∈ {1,2,3,5,8}. Первичный сигнал — forecast_cost_usd (прямая ось «сколько будет стоить», запрошенная заказчиком; легко ре-калибруется конфигом при смене тарифа/провайдера ORCH-13).
  • Пороги — конфиг estimator_sp_cost_thresholds (CSV из 4 возрастающих кат-оффов t1,t2,t3,t5), семантика <= по возрастанию: cost ≤ t1 → 1 · ≤ t2 → 2 · ≤ t3 → 3 · ≤ t5 → 5 · иначе → 8. Дефолты (bootstrap, подлежат калибровке): 0.50, 2.00, 5.00, 12.00 ($).
  • Семантика шкалы (фиксирована, BR-3/FR-T4): 1 docs/label/config · 2 небольшой фикс · 3 средняя · 5 сложная (код+тесты) · 8 эпик/разбивать. Значения вне {1,2,3,5,8} не выдаются.
  • Факт-story-points считаются той же функцией по фактической стоимости (консистентность прогноз↔факт).
  • Калибровка порогов — задача петли ORCH-8 поверх леджера D7; пороги конфигурируемы именно ради этого.

D4 — Триггер: fail-closed ветка estimate, взаимоисключение жестов (BR-T1, BR-T5, AC-T1, AC-T5)

  • plane_sync._PLANE_NAME_TO_KEY["Оценка"] = "estimate"; ключ estimate НЕ добавляется в _DEFAULT_STATES (fail-closed, как stop/confirm_deploy). На доске без статуса proj_states.get("estimate") is None → ветка инертна (нет KeyError, нет оценки).
  • В handle_issue_updated — отдельная ветка estimate_state = proj_states.get("estimate"); if estimate_state and new_state == estimate_state: await handle_estimate(...). Размещение: сразу после ветки stop (раннее, рядом с прочими .get-action-статусами). Корректность взаимоисключения обеспечена различием UUID статусов (а не порядком); порядок выбран для читаемости. Ветка не алиасит STOP/to_analyse/confirm_deploy/approved/rejected.

D5 — handle_estimate: анти-disruption, авто-возврат, анти-loop (BR-T2, BR-T6, FR-T2, FR-T5, AC-T2, AC-T6)

  • handle_estimate(data, project_id) резолвит plane_id/work_item_id; repo — по проекту (projects.get_project_by_repo/реестр). Исполнение off-loop через asyncio.to_thread (зеркало handle_stop), т.к. ядро синхронно и делает сетевые Plane-вызовы. Контракт never-raise.
  • Guard-цепочка (каждый — no-op-with-log при невыполнении):
    1. estimator.applies(repo) — kill-switch + скоуп, локально и ПЕРВЫМ (без сети при выключенном флаге);
    2. анти-disruption (BR-T6): issue с pipeline-задачей, у которой есть активный job (db.has_active_job_for_task(task_id), src/db.py:1323) → no-op + лог (не выдёргивать in-flight работу). Backlog-issue (нет задачи) или терминальная/idle-задача → оценка допустима.
  • Далее: estimator.estimate(...) → запись прогноза (D6/D7/D8) → set_issue_backlog(work_item) (D6).
  • Анти-loop: backlog не совпадает ни с одной триггер-веткой → входящий «state→Backlog» webhook — no-op-эхо. Возврат best-effort: сбой записи статуса не роняет флоу (прогноз уже записан).

D6 — Запись в Plane: estimate_point (FK) + point (int) + коммент + Backlog (BR-7, BR-8, FR-T7, NFR-6, NFR-7)

Новые write-хелперы в plane_sync.py, все через _guard_allows_write (ORCH-117), все never-raise:

  • set_issue_backlog(work_item)get_project_states(pid)["backlog"]_set_issue_state_direct (ключ backlog уже в _DEFAULT_STATES). Зеркало set_issue_done/set_issue_in_review.
  • set_issue_point(work_item, value:int) — PATCH {"point": int(value)} (легаси целочисленное поле, устойчиво — не зависит от estimate-системы). Это запись факта (BR-8).
  • set_issue_estimate_point(work_item, value) — резолв estimate-point UUID через новый get_project_estimate_points(project_id) (GET project → estimate id → GET estimate-points → map value→uuid, TTL-кэш по образцу get_project_states/ORCH-068), затем PATCH {"estimate_point": <uuid>}. Это запись прогноза (BR-7).
  • fail-safe (NFR-7): estimate-система не настроена / значение вне системы / поле отсутствует / 4xx → best-effort пропуск + лог, не падение. point устойчивее estimate_point (сырой int), но оба best-effort.
  • Комментadd_comment с прогнозом (стоимость/время/токены/story points), author="stream".
  • Прогноз пишется в estimate_point, факт — в point; поля не перепутаны; факт не перезаписывает estimate_point (AC-3/AC-4).

D7 — Персистентность: аддитивная task_estimates, UPSERT по work_item_id (BR-10, FR-T9, NFR-8, AC-T4, AC-12)

  • Новая аддитивная таблица task_estimates (CREATE TABLE IF NOT EXISTS в init_db(), паттерн coverage_baseline/lessons/transition_lease), UNIQUE(work_item_id) для идемпотентного UPSERT. Полная схема, типы, индексы — 08-data-requirements.md.
  • Хелперы db.record_estimate(**) (UPSERT прогноза по work_item_id), db.set_actual(work_item_id, ...) (запись факта+дельты), db.get_estimate(work_item_id), db.estimates_snapshot().
  • Ключ — work_item_id (на момент оценки task_id может быть NULL — issue на бэклоге, строки tasks ещё нет). task_id заполняется позже, когда оценённый issue входит в пайплайн (best-effort).
  • Существующие таблицы — не изменяются (NFR-8).

D8 — Поверхности отображения: Plane-коммент + пункт «Оценка» в Telegram-карточке (BR-9, FR-T8, AC-5, AC-6)

  • Plane-коммент — D6.
  • Telegram-карточка — пункт «Оценка» (время · токены · стоимость) в рендере общей карточки (notifications.update_task_tracker), читается из task_estimates по work_item_id; never-raise; пустой прогноз → пункт опускается; инвариант «одна карточка на задачу» (ORCH-087) не нарушается; HTML-data-слоты экранируются html.escape ровно один раз (канон ORCH-095).
  • Замечание о времени появления строки: карточка существует у pipeline-задачи; если оценка сделана на бэклоге до старта пайплайна — строка «Оценка» появится при первом рендере карточки после старта (task_estimates хранится по work_item_id, переживает старт). Приемлемо и задокументировано.

D9 — Запись факта на done (BR-8, FR-T7, AC-4)

  • Тонкая best-effort врезка estimator.record_actual_on_done(task_id, repo, work_item_id) в stage_engine.advance_stage в существующем блоке if next_stage == "done" (src/stage_engine.py:521), ПОСЛЕ terminal-sync, в своём try/except (never-raise; зеркало release-merge-lease-врезки рядом).
  • Считает факт из usage.task_usage_summary(task_id) + тайминги → story_points_for(actual)db.set_actual(...) + set_issue_point(work_item, actual_sp). Не перезаписывает estimate_point.
  • STAGE_TRANSITIONS/гейт check_deploy_status/machine-verdict — не трогаются (врезка после решения о переходе, не влияет на него).

D10 — Толерантность к массовости (NFR-5, AC-T3)

  • Сглаживание встроено в выбор D1: детерминированная эвристика без LLM/субпроцесса → per-issue ядро O(1) (пара индексированных чтений). Доминирующая стоимость — несколько ограниченных Plane HTTP-раундов на issue, исполняемых off-loop (to_thread).
  • Новой очереди НЕ вводим: очередь jobs/max_concurrency — для агентов (control-path); оценка не занимает её слот (NFR-3). Опциональный простой in-process семафор estimator_max_inflight (дефолт «щедрый», эффективно off) — конфиг-семя на случай измеренной перегрузки; в v1 не активничает.
  • Один webhook не гасит остальные (N независимых вызовов).

D11 — leaf-инварианты, флаги, наблюдаемость (NFR-2, NFR-3, FR-T10, AC-9)

  • Leaf src/estimator.py (never-raise, паттерн bug_fast_track/coverage_gate): импортирует только config (+ лениво db/usage/plane_sync/notifications/qg.checks), не импортирует stage_engine/ launcher. Публичные: applies(repo), estimate(...), story_points_for(...), record_actual_on_done(...), snapshot().
  • Флаги (config.py, дефолты безопасные): estimator_enabled (kill-switch, env ORCH_ESTIMATOR_ENABLED), estimator_repos (CSV, env ORCH_ESTIMATOR_REPOS; пусто → self-hosting only), + тюнинг estimator_min_samples, estimator_bootstrap_tokens/cost_usd/seconds, estimator_sp_cost_thresholds, estimator_wall_cap_s, estimator_max_inflight.
  • applies(repo) локально и ПЕРВЫМ → выключенный флаг = нулевой сетевой оверхед, нулевая регрессия для enduro/orchestrator.
  • Наблюдаемость: read-only блок estimator в GET /queue (флаг/скоуп + счётчики прогнозов/записей в Plane/возвратов-в-Backlog/фактов); при невозможности записи в Plane — лог-warning.

D12 — Опциональные программные эндпоинты (TRZ §4, AC-7)

  • POST /estimate?work_item=<id>, POST /estimate/backlog, GET /estimate?work_item=<id>то же ядро estimator.estimate(...), идемпотентны. Удобство/диагностика, не основной триггер. Их отсутствие не нарушает приёмку. Не преподносить как единственный способ запуска.

Альтернативы

  • LLM-оценщик (отдельный вызов на задачу) / гибрид — отвергнуто на этом шаге: нарушает NFR-4 (стоимость самой оценки), NFR-5 (массовость конкурирует за единственный LLM-транспорт), и политику ORCH-118 (avoidable LLM control/consultation path). Граница estimate() оставляет место под будущий гибрид без переписывания вызывающих.
  • Авто-оценка каждой задачи на start_pipeline — отвергнуто: это модель, которую заказчик явно отклонил (REJECT 2026-06-17). Оценка — операторский on-demand жест.
  • Новый массовый POST /estimate-batch как основной путь — отвергнуто: массовость даёт сам Plane multi-select (N вебхуков); batch-эндпоинт — лишний код и второй источник истины.
  • Отдельная стадия/ребро STAGE_TRANSITIONS для оценки — отвергнуто: нарушает NFR-1; оценка не есть переход стадии. Side-механизм по образцу STOP/Confirm Deploy.
  • Бакетизация по токенам вместо стоимости — рассмотрено: токены модель-независимы, но заказчик мыслит осью «сколько стоит». Выбрана стоимость с конфигурируемыми порогами (ре-калибруемыми при ORCH-13); переключение сигнала — локальная правка за чистой функцией.
  • Хранение оценки в tasks колонками — отвергнуто: на момент оценки строки tasks может не быть (бэклог); ключ по work_item_id в отдельной таблице корректнее (NFR-8, аддитивность).

Последствия

  • + Оператор видит прогноз (стоимость/время/токены/story points) до отправки задачи в работу; массовая оценка одним multi-select; пере-оценка идемпотентна; фундамент петли калибровки (ORCH-8) заложен (леджер).
  • + Нулевая нагрузка на LLM-транспорт и нулевая $-стоимость самой оценки; bulk-безопасно; полностью детерминировано и тестируемо; согласовано с determinization-политикой ORCH-118.
  • + Control-path/гейты/горячий путь не тронуты; enduro и текущий orchestrator при выключенном флаге / на доске без статуса — нулевая регрессия.
  • Точность прогноза на холодном старте ограничена (мало истории) → митигейшн: bootstrap-дефолты + петля калибровки порогов поверх леджера (BR-10). Пороги story-points — начальные, подлежат калибровке.
  • Net-new интеграция с Plane-estimate API (estimate_point — FK) добавляет инфра-предусловие (estimate-система с Fibonacci) и хрупкость записи → митигейшн: best-effort/fail-safe (NFR-7), устойчивый point (raw int) для факта, точная спека — 07-infra-requirements.md.
  • (масштаб) Это аддитивный leaf по устоявшемуся паттерну (как serial_gate/coverage_gate/lessons), без новой стадии, без правки существующих таблиц, без смены БД-движка. arch:major-change не требуется.
  • Откат: ORCH_ESTIMATOR_ENABLED=false → весь модуль инертен (статус «Оценка» не обрабатывается, нет записей в Plane/карточку/таблицу; конвейер байт-в-байт до ORCH-020). Доп. откат «на уровне доски» — не создавать статус «Оценка» (fail-closed). Таблица task_estimates остаётся (аддитивна, безвредна).

Ссылки

  • BRD: docs/work-items/ORCH-020/01-brd.md
  • TRZ: docs/work-items/ORCH-020/02-trz.md
  • Acceptance: docs/work-items/ORCH-020/03-acceptance-criteria.md
  • Сквозной ADR: docs/architecture/adr/adr-0054-task-estimation-status-trigger.md
  • Инфра/данные/риски: 07-infra-requirements.md, 08-data-requirements.md, 10-tech-risks.md
  • Сверено по коду: src/plane_sync.py (_PLANE_NAME_TO_KEY/_DEFAULT_STATES/_guard_allows_write/write-хелперы), src/webhooks/plane.py (handle_issue_updated/handle_stop), src/usage.py:834 (task_usage_summary), src/db.py (has_active_job_for_task/_ensure_column/leaf-DDL-паттерн), src/stage_engine.py:521 (next_stage=="done"), src/bug_fast_track.py (applies/label-аппарат), src/qg/checks.py (is_self_hosting_repo)
  • Прецеденты: ORCH-090 (STOP), ORCH-059 (Confirm Deploy), ORCH-117 (write-guard), ORCH-019 (track), ORCH-118 (LLM-политика), ORCH-098 (leaf-таблица), ORCH-087/095 (Telegram-карточка)