Files
orchestrator/tests/test_tracker_effort_time.py
claude-bot a7b27f2235 fix(notifications): tracker orphan cleanup + effort-in-line + honest done-time (ORCH-087)
Устраняет «замёрзшие» осиротевшие карточки 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>
2026-06-09 10:06:17 +03:00

184 lines
7.7 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 "Всего"