146 lines
5.3 KiB
Python
146 lines
5.3 KiB
Python
"""ORCH-016 / TC-24 + TC-25 + AC-14: DB fallback for the duration line.
|
|
|
|
When build_status_comment is called WITHOUT an explicit duration_s but with a
|
|
task_id, it must:
|
|
- read the last finished agent_runs row for (task_id, agent),
|
|
- compute (julianday(finished_at) - julianday(started_at)) * 86400 in seconds,
|
|
- format it via fmt_duration and inject the «Длительность: …» line.
|
|
|
|
Failure modes (DB locked / row missing / NULL finished_at / negative diff) must
|
|
NEVER raise; they simply suppress the duration line and let the rest of the
|
|
comment publish.
|
|
"""
|
|
|
|
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_orch016_duration_fallback.db")
|
|
os.environ["ORCH_DB_PATH"] = _test_db
|
|
|
|
import pytest # noqa: E402
|
|
|
|
from src import db as db_module # noqa: E402
|
|
from src.db import init_db, get_db # noqa: E402
|
|
from src import usage as U # 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()
|
|
yield
|
|
if os.path.exists(_test_db):
|
|
os.unlink(_test_db)
|
|
|
|
|
|
def _insert_run(task_id, agent, *, seconds_ago_start=None, finished=True):
|
|
"""Insert an agent_runs row with controllable timestamps."""
|
|
conn = get_db()
|
|
if seconds_ago_start is None:
|
|
conn.execute(
|
|
"INSERT INTO agent_runs (task_id, agent) VALUES (?, ?)",
|
|
(task_id, agent),
|
|
)
|
|
else:
|
|
if finished:
|
|
conn.execute(
|
|
"INSERT INTO agent_runs (task_id, agent, started_at, finished_at) "
|
|
"VALUES (?, ?, datetime('now', ?), datetime('now'))",
|
|
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"INSERT INTO agent_runs (task_id, agent, started_at) "
|
|
"VALUES (?, ?, datetime('now', ?))",
|
|
(task_id, agent, f"-{seconds_ago_start} seconds"),
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-24: explicit duration_s missing -> DB lookup populates the line.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc24_fallback_reads_agent_runs_for_last_finished():
|
|
_insert_run(7, "reviewer", seconds_ago_start=240)
|
|
secs = U.get_agent_duration(7, "reviewer")
|
|
# SQLite's julianday math can be off by a second on either side.
|
|
assert secs is not None and abs(secs - 240) <= 1, secs
|
|
|
|
html = U.build_status_comment("reviewer", task_id=7)
|
|
assert any(
|
|
s in html for s in (
|
|
"Длительность: 4m 00s",
|
|
"Длительность: 4m 01s",
|
|
"Длительность: 3m 59s",
|
|
)
|
|
), html
|
|
|
|
|
|
def test_tc24_fallback_picks_last_run_when_multiple():
|
|
_insert_run(11, "developer", seconds_ago_start=120)
|
|
_insert_run(11, "developer", seconds_ago_start=10)
|
|
secs = U.get_agent_duration(11, "developer")
|
|
assert secs is not None and abs(secs - 10) <= 1, secs
|
|
|
|
|
|
def test_tc24_no_row_returns_none():
|
|
assert U.get_agent_duration(999, "tester") is None
|
|
|
|
|
|
def test_tc24_finished_at_null_returns_none():
|
|
_insert_run(13, "tester", seconds_ago_start=100, finished=False)
|
|
assert U.get_agent_duration(13, "tester") is None
|
|
|
|
|
|
def test_tc24_missing_args_returns_none():
|
|
assert U.get_agent_duration(None, "tester") is None
|
|
assert U.get_agent_duration(7, "") is None
|
|
assert U.get_agent_duration(0, "tester") is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TC-25: read failure -> logged at debug, NO exception, comment still ships.
|
|
# ---------------------------------------------------------------------------
|
|
def test_tc25_db_read_failure_no_raise(monkeypatch, caplog):
|
|
"""A locked / broken DB must not crash the status comment hot path."""
|
|
import logging
|
|
|
|
def _boom():
|
|
raise RuntimeError("simulated DB outage")
|
|
|
|
monkeypatch.setattr(U, "get_db", _boom)
|
|
with caplog.at_level(logging.DEBUG, logger="orchestrator.usage"):
|
|
assert U.get_agent_duration(1, "developer") is None
|
|
# build_status_comment must still publish (no duration line, no crash).
|
|
html = U.build_status_comment("developer", task_id=1, repo="r", branch="b")
|
|
assert "Длительность" not in html
|
|
assert "\U0001f4bb Developer" in html
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sanity: explicit duration_s wins over DB fallback (no SELECT at all).
|
|
# ---------------------------------------------------------------------------
|
|
def test_explicit_duration_wins_over_db_fallback(monkeypatch):
|
|
called = {"n": 0}
|
|
real = U.get_agent_duration
|
|
|
|
def _spy(task_id, agent):
|
|
called["n"] += 1
|
|
return real(task_id, agent)
|
|
|
|
monkeypatch.setattr(U, "get_agent_duration", _spy)
|
|
_insert_run(5, "architect", seconds_ago_start=300)
|
|
|
|
html = U.build_status_comment(
|
|
"architect", task_id=5, duration_s=12, repo="r", branch="b",
|
|
)
|
|
assert "Длительность: 12s" in html
|
|
# Explicit value supplied -> DB fallback is short-circuited.
|
|
assert called["n"] == 0
|