28 KiB
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(строки 163–181) разбирает 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-оценщик / не гибрид (на этом шаге):
- NFR-5 (массовость). Multi-select десятков задач → десятки почти одновременных вебхуков. LLM-вызов
на оценку умножился бы на N и конкурировал бы за единственный транспорт LLM (
launcher._spawn) с боевыми агентами, рискуя замедлить обслуживание других проектов (enduro) из общего прода. - NFR-4 (стоимость ≪ ценности). Opus-вызов на каждую из десятков backlog-задач — это реальные $ за саму оценку; эвристика бесплатна.
- Политика ORCH-118 (determinization-roadmap). Платформа целенаправленно сокращает avoidable
LLM-пути (
llm-usage-policy.md: «LLM — только где нужно настоящее суждение»). Оценка размера по истории — деривируемая из tool-сигналов величина, не требующая суждения LLM. Вводить здесь новый LLM-путь прямо противоречит действующей политике. - Воспроизводимость/тестируемость. Детерминированный бакетизатор покрывается unit-тестами на границах (AC-2 / TC-09), чего LLM не даёт.
- NFR-5 (массовость). Multi-select десятков задач → десятки почти одновременных вебхуков. LLM-вызов
на оценку умножился бы на N и конкурировал бы за единственный транспорт 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_sORCH-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):
1docs/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 при невыполнении):
estimator.applies(repo)— kill-switch + скоуп, локально и ПЕРВЫМ (без сети при выключенном флаге);- анти-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 →estimateid → GET estimate-points → mapvalue→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, envORCH_ESTIMATOR_ENABLED),estimator_repos(CSV, envORCH_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-карточка)