Устраняет «замёрзшие» осиротевшие карточки live-трекера и доделывает строку
стадии/итоговое время.
G1 — зачистка сирот: аддитивный леджер tracker_messages(task_id, message_id,
created_at, deleted_at) + хелперы add/get_open/mark_deleted в src/db.py. bump
теперь удаляет ВСЕ незакрытые mid задачи (а не только скаляр
tasks.tracker_message_id, сохранён как BC-указатель). Новый mid в леджер только
при успешном send (BR-6); transient-delete остаётся для ретрая; «already
gone»/>48ч закрывается. Корень бага — скалярный учёт, терявший ссылку при
гонке/delete-fail+send-ok (ADR-001 G0).
G3 — deploy-цикл: ключ confirm_deploy в _LIVE_BRANCH_LABELS (без base-alias).
BR-EFF — эффорт в строке: колонка agent_runs.effort (_ensure_column,
идемпотентно), стамп фактического resolve_agent_effort в launcher._spawn в
момент запуска; рендер `· {model} · {effort}`, пустой → суффикс опускается.
BR-G5 — честное время: done-строка `⏱️ Агенты Σ · твоё {review~cap} · общее с
ожиданием {wall}` — три независимых подписанных метрики; кап
tracker_brd_review_cap_s (ORCH_TRACKER_BRD_REVIEW_CAP_S, дефолт 2ч, маркер ~).
Инварианты: STAGE_TRANSITIONS/QG_CHECKS/стадии без изменений; миграции
аддитивны/идемпотентны (enduro не трогается); never-raise,
disable_notification, plane_issue_link (ORCH-067), disable_web_page_preview
(ORCH-080) сохранены; src/reconciler.py не эродирован (ORCH-086 на месте).
Тесты: tests/test_notifications_orphans.py (TC-01..05 + never-raise),
tests/test_tracker_effort_time.py (TC-06/11..15 + confirm_deploy),
tests/test_launcher.py::TestEffortStamp (TC-09/10). Доки: CLAUDE.md
(§Нотификации), docs/architecture/README.md (Notifications), CHANGELOG.md.
Refs: ORCH-087
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.7 KiB
Python
184 lines
7.7 KiB
Python
"""ORCH-087: effort-in-stage-line (BR-EFF), honest done-time (BR-G5),
|
||
deterministic stage labels (G2) and deploy-cycle label (G3).
|
||
|
||
Telegram/Plane fully isolated (render is pure DB). Covers TC-06, TC-11..TC-15
|
||
and the confirm_deploy live-overlay label.
|
||
"""
|
||
|
||
import os
|
||
import tempfile
|
||
|
||
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||
|
||
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_eff_time.db")
|
||
os.environ["ORCH_DB_PATH"] = _test_db
|
||
|
||
import pytest # noqa: E402
|
||
|
||
import src.db as db_module # noqa: E402
|
||
from src.db import init_db, get_db # noqa: E402
|
||
from src import notifications as N # noqa: E402
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def setup_db(monkeypatch):
|
||
monkeypatch.setattr(db_module.settings, "db_path", _test_db, raising=False)
|
||
if os.path.exists(_test_db):
|
||
os.unlink(_test_db)
|
||
init_db()
|
||
# No live overlay in render-only tests unless a test opts in.
|
||
monkeypatch.setattr(N._get_settings(), "tracker_live_status", False, raising=False)
|
||
yield
|
||
if os.path.exists(_test_db):
|
||
os.unlink(_test_db)
|
||
|
||
|
||
def _mk_task(stage="development", wid="ORCH-087", title="eff/time test",
|
||
brd_start=None, brd_end=None, created=None, updated=None):
|
||
conn = get_db()
|
||
cur = conn.execute(
|
||
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, "
|
||
"brd_review_started_at, brd_review_ended_at) VALUES (?,?,?,?,?,?,?,?)",
|
||
("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, title,
|
||
brd_start, brd_end),
|
||
)
|
||
tid = cur.lastrowid
|
||
if created or updated:
|
||
conn.execute(
|
||
"UPDATE tasks SET created_at=COALESCE(?, created_at), "
|
||
"updated_at=COALESCE(?, updated_at) WHERE id=?",
|
||
(created, updated, tid),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
return tid
|
||
|
||
|
||
def _mk_run(tid, agent, started, finished, *, effort=None, model="tokenator/claude-opus-4-8",
|
||
in_tok=10, out_tok=5, cost=0.0, exit_code=0):
|
||
conn = get_db()
|
||
conn.execute(
|
||
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at, "
|
||
"exit_code, input_tokens, output_tokens, cost_usd, model, effort) "
|
||
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||
(tid, agent, started, finished, exit_code, in_tok, out_tok, cost, model, effort),
|
||
)
|
||
conn.commit()
|
||
conn.close()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# G2: plane_status_label deterministic for every stage (TC-06)
|
||
# --------------------------------------------------------------------------- #
|
||
def test_plane_status_label_all_stages():
|
||
"""TC-06/AC-2.2: every stage maps to its own label; deploy -> Awaiting Deploy."""
|
||
cases = {
|
||
"created": "To Analyse",
|
||
"analysis": "Analysis",
|
||
"architecture": "Architecture",
|
||
"development": "Development",
|
||
"review": "Code-Review",
|
||
"testing": "Testing",
|
||
"done": "Done",
|
||
}
|
||
for stage, expected in cases.items():
|
||
assert N.plane_status_label({"stage": stage}) == expected
|
||
deploy = N.plane_status_label({"stage": "deploy"})
|
||
assert "Awaiting Deploy" in deploy
|
||
# In Review derives from the brd-clock on the analysis stage.
|
||
in_review = N.plane_status_label(
|
||
{"stage": "analysis", "brd_review_started_at": "2026-06-04 10:00:00",
|
||
"brd_review_ended_at": None}
|
||
)
|
||
assert "In Review" in in_review
|
||
|
||
|
||
def test_confirm_deploy_label_registered():
|
||
"""G3/AC-3.x: the deploy-cycle gains a confirm_deploy overlay label."""
|
||
assert "confirm_deploy" in N._LIVE_BRANCH_LABELS
|
||
assert "Confirm Deploy" in N._LIVE_BRANCH_LABELS["confirm_deploy"]
|
||
# confirm_deploy is a REAL dedicated status -> no base-alias suppression.
|
||
assert "confirm_deploy" not in N._LIVE_BRANCH_BASE
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# BR-EFF: effort rendered next to the model (TC-11, TC-12)
|
||
# --------------------------------------------------------------------------- #
|
||
@pytest.mark.parametrize("agent,label,effort", [
|
||
("developer", "Разработка", "xhigh"),
|
||
("tester", "Тестирование", "medium"),
|
||
("deployer", "Внедрение", "medium"),
|
||
("analyst", "Анализ", "high"),
|
||
("architect", "Архитектура", "high"),
|
||
("reviewer", "Код ревью", "high"),
|
||
])
|
||
def test_stage_line_shows_effort(agent, label, effort):
|
||
"""TC-11/AC-E.2,AC-E.3: stage line shows '· model · effort' for each role."""
|
||
tid = _mk_task(stage="done")
|
||
_mk_run(tid, agent, "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort=effort)
|
||
text = N.render_task_tracker(tid)
|
||
line = [ln for ln in text.splitlines() if ln.startswith(f"✅ {label}")][0]
|
||
assert line.rstrip().endswith(f"opus-4-8 · {effort}")
|
||
|
||
|
||
def test_stage_line_omits_empty_effort():
|
||
"""TC-12/AC-E.4: NULL effort -> suffix omitted, render does not crash."""
|
||
tid = _mk_task(stage="analysis")
|
||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort=None)
|
||
text = N.render_task_tracker(tid)
|
||
line = [ln for ln in text.splitlines() if ln.startswith("✅ Анализ")][0]
|
||
# Ends at the model (no trailing effort segment).
|
||
assert line.rstrip().endswith("opus-4-8")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# BR-G5: honest done-time (TC-13, TC-14, TC-15)
|
||
# --------------------------------------------------------------------------- #
|
||
def test_done_review_time_capped():
|
||
"""TC-13/AC-5.1: a ~6h open brd_review window is NOT shown as ~6h."""
|
||
# 6h review window (10:00 -> 16:00) with default 2h cap.
|
||
tid = _mk_task(
|
||
stage="done",
|
||
brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 16:00:00",
|
||
created="2026-06-04 09:00:00", updated="2026-06-04 16:30:00",
|
||
)
|
||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:30:00", effort="high")
|
||
text = N.render_task_tracker(tid)
|
||
time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0]
|
||
# Capped to ~2h (120м), marked with '~'; the raw 360m is NOT shown as твоё.
|
||
assert "твоё ~120м" in time_line
|
||
assert "твоё 360м" not in time_line
|
||
|
||
|
||
def test_done_review_time_under_cap_uncapped():
|
||
"""AC-5.1: a normal short review window is shown verbatim (no '~')."""
|
||
tid = _mk_task(
|
||
stage="done",
|
||
brd_start="2026-06-04 10:00:00", brd_end="2026-06-04 10:08:00",
|
||
created="2026-06-04 09:00:00", updated="2026-06-04 10:30:00",
|
||
)
|
||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:30:00", effort="high")
|
||
text = N.render_task_tracker(tid)
|
||
time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0]
|
||
assert "твоё 8м" in time_line
|
||
assert "~" not in time_line
|
||
|
||
|
||
def test_done_time_line_labels_and_agent_sum():
|
||
"""TC-14,TC-15/AC-5.2,AC-5.3: agents=Σ runs; wall labelled 'общее с ожиданием'."""
|
||
tid = _mk_task(
|
||
stage="done",
|
||
created="2026-06-04 09:00:00", updated="2026-06-04 11:00:00", # wall 120m
|
||
)
|
||
# Two runs: 10m + 6m = 16m of agent time.
|
||
_mk_run(tid, "analyst", "2026-06-04 09:00:00", "2026-06-04 09:10:00", effort="high")
|
||
_mk_run(tid, "deployer", "2026-06-04 10:50:00", "2026-06-04 10:56:00", effort="medium")
|
||
text = N.render_task_tracker(tid)
|
||
time_line = [ln for ln in text.splitlines() if ln.startswith("⏱")][0]
|
||
# agents = 16m (exact Σ), wall = 120m labelled as "общее с ожиданием".
|
||
assert "Агенты 16м" in time_line # Агенты 16м
|
||
assert "общее с ожиданием 120м" in time_line # общее с ожиданием 120м
|
||
# wall (120m) != agents (16m) -> not presented as a sum.
|
||
assert "Всего" not in time_line # no old "Всего"
|