Устраняет «замёрзшие» осиротевшие карточки 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>
223 lines
9.0 KiB
Python
223 lines
9.0 KiB
Python
"""ORCH-087 (BR-G1): tracker_messages ledger — no orphaned cards in bump mode.
|
|
|
|
The scalar tasks.tracker_message_id only ever knew the LAST mid, so any lost
|
|
reference (delete-fail+send-ok, race, restart) orphaned older cards forever. The
|
|
additive tracker_messages ledger lets every bump delete ALL still-open mids, not
|
|
just the last one. These tests model the dominant orphan generators (vopros 2 in
|
|
ADR-001) with Telegram fully mocked (no network).
|
|
|
|
Covers TC-01..TC-05 / AC-1.2, AC-1.3, AC-X.1.
|
|
"""
|
|
|
|
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_orphans.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
|
|
import pytest # noqa: E402
|
|
|
|
import src.db as db_module # noqa: E402
|
|
from src.db import ( # noqa: E402
|
|
init_db, get_db, get_tracker_message_id, set_tracker_message_id,
|
|
add_tracker_message, get_open_tracker_messages, mark_tracker_message_deleted,
|
|
)
|
|
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()
|
|
# Keep the render cheap & deterministic (no real Telegram / Plane).
|
|
monkeypatch.setattr(N, "render_task_tracker", lambda task_id: "CARD")
|
|
_bump_mode(monkeypatch)
|
|
yield
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
def _bump_mode(monkeypatch):
|
|
monkeypatch.setattr(N._get_settings(), "tracker_mode", "bump", raising=False)
|
|
|
|
|
|
def _mk_task(stage="development", wid="ORCH-087"):
|
|
conn = get_db()
|
|
cur = conn.execute(
|
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
("p1", wid, "orchestrator", "feature/ORCH-087-x", stage, "orphan test"),
|
|
)
|
|
tid = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return tid
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# ledger helpers (direct DB contract)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_ledger_add_get_mark(monkeypatch):
|
|
"""add -> open set; mark_deleted -> drops out; INSERT OR IGNORE idempotent."""
|
|
tid = _mk_task()
|
|
add_tracker_message(tid, 10)
|
|
add_tracker_message(tid, 11)
|
|
add_tracker_message(tid, 10) # duplicate -> ignored, no resurrection
|
|
assert get_open_tracker_messages(tid) == [10, 11]
|
|
mark_tracker_message_deleted(tid, 10)
|
|
assert get_open_tracker_messages(tid) == [11]
|
|
# re-add of a deleted mid is ignored (PK exists) -> stays deleted.
|
|
add_tracker_message(tid, 10)
|
|
assert get_open_tracker_messages(tid) == [11]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-01: bump deletes ALL known open mids, not just the last
|
|
# --------------------------------------------------------------------------- #
|
|
def test_bump_deletes_all_open_mids(monkeypatch):
|
|
"""TC-01/AC-1.2: every still-open card is deleted on the next bump."""
|
|
tid = _mk_task()
|
|
# Three orphans accumulated in the ledger from earlier desyncs.
|
|
for m in (100, 101, 102):
|
|
add_tracker_message(tid, m)
|
|
set_tracker_message_id(tid, 102) # scalar only knows the last one
|
|
|
|
deleted = []
|
|
monkeypatch.setattr(N, "delete_telegram",
|
|
lambda mid: deleted.append(mid) or True)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: 200)
|
|
|
|
N.update_task_tracker(tid)
|
|
|
|
assert sorted(deleted) == [100, 101, 102] # ALL open mids deleted
|
|
# Old ones marked gone; only the new card is open.
|
|
assert get_open_tracker_messages(tid) == [200]
|
|
assert get_tracker_message_id(tid) == 200
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-02: send -> None keeps the ledger/pointer intact (BR-6 / R-3)
|
|
# --------------------------------------------------------------------------- #
|
|
def test_send_none_keeps_ledger_and_pointer(monkeypatch):
|
|
"""TC-02/AC-1.3: send fails -> no new mid recorded, pointer not wiped."""
|
|
tid = _mk_task()
|
|
add_tracker_message(tid, 100)
|
|
set_tracker_message_id(tid, 100)
|
|
|
|
# delete fails transiently so 100 stays open (alive); send returns None.
|
|
monkeypatch.setattr(N, "delete_telegram", lambda mid: False)
|
|
sends = []
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False:
|
|
sends.append(1) or None)
|
|
|
|
N.update_task_tracker(tid) # must not raise
|
|
|
|
assert len(sends) == 1 # exactly one attempt
|
|
assert get_tracker_message_id(tid) == 100 # pointer preserved
|
|
assert get_open_tracker_messages(tid) == [100] # 100 still tracked for retry
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-03: delete False -> stays open; "already gone" -> dropped
|
|
# --------------------------------------------------------------------------- #
|
|
def test_delete_transient_stays_open_gone_dropped(monkeypatch):
|
|
"""TC-03: transient-delete mid retried next bump; gone mid excluded."""
|
|
tid = _mk_task()
|
|
add_tracker_message(tid, 100) # will fail transiently -> stays
|
|
add_tracker_message(tid, 101) # will be 'gone' (True) -> dropped
|
|
|
|
def _del(mid):
|
|
return mid != 100 # 100 -> False (transient), 101 -> True (gone)
|
|
|
|
monkeypatch.setattr(N, "delete_telegram", _del)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: 300)
|
|
|
|
N.update_task_tracker(tid)
|
|
|
|
# 100 still open (retry), 101 marked deleted, 300 new card open.
|
|
assert set(get_open_tracker_messages(tid)) == {100, 300}
|
|
assert get_tracker_message_id(tid) == 300
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-04: rapid repeats / race -> one live card, <=1 send per call
|
|
# --------------------------------------------------------------------------- #
|
|
def test_repeated_bumps_converge_to_one_card(monkeypatch):
|
|
"""TC-04/AC-X.1: repeated bumps self-heal to exactly one open card."""
|
|
tid = _mk_task()
|
|
|
|
seq = iter([501, 502, 503, 504])
|
|
sends_per_call = []
|
|
|
|
def _send(text, disable_notification=False):
|
|
sends_per_call.append(1)
|
|
return next(seq)
|
|
|
|
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
|
monkeypatch.setattr(N, "send_telegram", _send)
|
|
|
|
for _ in range(4):
|
|
before = len(sends_per_call)
|
|
N.update_task_tracker(tid)
|
|
assert len(sends_per_call) - before == 1 # <=1 send per call
|
|
|
|
# After the last bump only the newest card is open; all earlier deleted.
|
|
assert get_open_tracker_messages(tid) == [504]
|
|
assert get_tracker_message_id(tid) == 504
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# TC-05: ledger survives a "restart" (read from DB) -> old cards cleaned
|
|
# --------------------------------------------------------------------------- #
|
|
def test_ledger_survives_restart(monkeypatch):
|
|
"""TC-05/AC-1.3: mids persisted in DB are cleaned on the next bump."""
|
|
tid = _mk_task()
|
|
# Simulate a previous process that created two cards but lost the scalar to
|
|
# one of them (orphan): both are in the ledger though.
|
|
add_tracker_message(tid, 700)
|
|
add_tracker_message(tid, 701)
|
|
set_tracker_message_id(tid, 701) # scalar lost 700
|
|
|
|
deleted = []
|
|
monkeypatch.setattr(N, "delete_telegram",
|
|
lambda mid: deleted.append(mid) or True)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False: 800)
|
|
|
|
# "Fresh process" reads the ledger straight from the DB.
|
|
N.update_task_tracker(tid)
|
|
|
|
assert sorted(deleted) == [700, 701] # the orphan 700 is reaped too
|
|
assert get_open_tracker_messages(tid) == [800]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# never-raise on ledger/DB explosion
|
|
# --------------------------------------------------------------------------- #
|
|
def test_bump_never_raises_on_ledger_error(monkeypatch):
|
|
"""AC-X.2: a ledger read blowing up does not break the bump path."""
|
|
tid = _mk_task()
|
|
monkeypatch.setattr(N, "get_open_tracker_messages",
|
|
lambda task_id: (_ for _ in ()).throw(RuntimeError("db")),
|
|
raising=False)
|
|
# Even if the import-bound name is used, force the failure via db module too.
|
|
monkeypatch.setattr(db_module, "get_open_tracker_messages",
|
|
lambda task_id: (_ for _ in ()).throw(RuntimeError("db")),
|
|
raising=False)
|
|
sent = []
|
|
monkeypatch.setattr(N, "delete_telegram", lambda mid: True)
|
|
monkeypatch.setattr(N, "send_telegram",
|
|
lambda text, disable_notification=False:
|
|
sent.append(1) or 900)
|
|
# Must not raise; still sends the fresh card.
|
|
N.update_task_tracker(tid)
|
|
assert sent == [1]
|