"""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