Files
orchestrator/tests/test_lessons.py
claude-bot 7d21625d84 feat(lessons): machine lessons-journal — additive table + observer leaf (ORCH-098)
Step 1 ("Foundation", F2) of the self-improvement epic: formalise free-text
"lessons" from memory/ into a machine-readable `lessons` table — the foundation
for the future retrospective agent (E2), the RICE prioritiser (E3) and Стрим.

- src/lessons.py: pure never-raise observer leaf (record/get/update/snapshot),
  kill-switch only, NO repo scope (observer-only; records about any repo incl.
  enduro; repo cut on the read side). Slug-convention constants.
- src/db.py: additive idempotent `lessons` table in init_db() (+3 indexes);
  nullable attribution columns from the start (NFR-6, _ensure_column forward-safe);
  helpers record_lesson/get_lessons/update_lesson/lessons_snapshot/
  lessons_recent_dup_exists (auto-dedup window).
- 4 auto-detectors (best-effort, source="auto", deduped): gate_failure
  (_handle_qg_failure_rollbacks), merge_hold (_handle_merge_verify HOLD),
  transient_retry (launcher._finalize_transient budget-exhaustion), deploy_degraded
  (post-deploy DEGRADED -> set_repo_freeze).
- src/main.py: GET /lessons, POST /lessons, POST /lessons/{id} + read-only
  `lessons` block in GET /queue; off-switch -> {"enabled": false}.
- src/config.py: lessons_enabled / lessons_query_limit_default / lessons_dedup_window_s.
- tests/test_lessons.py: TC-01..TC-12 (unit + integration), all green.
- Docs: CLAUDE.md, docs/architecture/README.md (component + schema + API), CHANGELOG.

Invariant: the journal is an OBSERVER, not a Quality Gate — STAGE_TRANSITIONS /
QG_CHECKS / check_* / machine-verdict / existing table schemas are byte-for-byte
untouched; enduro not affected. never-raise on every public fn + injection.

Refs: ORCH-098
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:40:17 +03:00

397 lines
18 KiB
Python

"""ORCH-098 / TC-01..TC-12: the machine lessons-journal (src/lessons.py + db + wiring).
Contract under test (ADR-001 §7 / acceptance-criteria):
* the `lessons` table is additive + idempotent and carries the NULLABLE
attribution columns (attribution / target_repo / target_domain) from the start;
* record() inserts a row (auto/manual) and returns its id; auto records are
deduped in a window, manual records are never deduped;
* never-raise: a failing DB -> None/[]/{}/False, never an exception into the caller;
* kill-switch off -> record/get/update/snapshot inert (no DB access);
* get_lessons filters by type/status/repo/work_item + LIMIT + ORDER BY id DESC;
* update_lesson mutates fields + stamps updated_at; unknown id is safe;
* auto-record wiring: a QG rollback to development writes a `gate_failure` lesson;
a launcher transient-budget-exhaustion writes a `transient_retry` lesson; a
failing journal never breaks the hot path;
* the HTTP endpoints (GET /lessons, POST /lessons, POST /lessons/{id}) and the
GET /queue `lessons` block behave + honour the kill-switch;
* pipeline invariants (STAGE_TRANSITIONS / QG_CHECKS) are structurally untouched.
"""
import os
import tempfile
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_lessons.db")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import pytest # noqa: E402
import src.db as db # noqa: E402
from src import config as cfg # noqa: E402
from src import lessons # noqa: E402
_REPO = "orchestrator"
_WI = "ORCH-098"
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
"""Isolated tmp SQLite DB + journal ON by default."""
dbfile = tmp_path / "lessons.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
monkeypatch.setattr(cfg.settings, "lessons_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "lessons_query_limit_default", 100, raising=False)
monkeypatch.setattr(cfg.settings, "lessons_dedup_window_s", 3600, raising=False)
db.init_db()
yield
def _columns():
conn = db.get_db()
try:
return {r[1] for r in conn.execute("PRAGMA table_info(lessons)").fetchall()}
finally:
conn.close()
# ===========================================================================
# TC-01 — additive + idempotent table with all BR-1 fields
# ===========================================================================
def test_tc01_table_idempotent_and_fields():
# Double init must not raise nor duplicate.
db.init_db()
db.init_db()
cols = _columns()
for f in (
"id", "created_at", "updated_at", "lesson_type", "work_item_id", "task_id",
"stage", "agent", "repo", "root_cause", "suggestion", "status", "related_task",
):
assert f in cols, f"missing column {f}"
# No existing table mutated: tasks/jobs still present and unchanged in shape.
conn = db.get_db()
try:
tabs = {
r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
finally:
conn.close()
assert {"tasks", "jobs", "agent_runs", "lessons"} <= tabs
# ===========================================================================
# TC-02 — attribution columns present from the start, nullable, set later
# ===========================================================================
def test_tc02_attribution_columns_nullable_and_settable():
cols = _columns()
assert {"attribution", "target_repo", "target_domain"} <= cols
# A record WITHOUT attribution is accepted (NULL).
lid = lessons.record(lessons.LessonType.DEPLOY_DEGRADED, work_item_id=_WI, repo=_REPO)
assert lid is not None
rows = lessons.get(work_item_id=_WI)
assert rows[0]["attribution"] is None
# Attribution can be filled in later via update.
assert lessons.update(
lid, attribution=lessons.Attribution.PLATFORM,
target_repo=_REPO, target_domain=lessons.Domain.RELIABILITY,
) is True
rows = lessons.get(work_item_id=_WI)
assert rows[0]["attribution"] == "platform"
assert rows[0]["target_domain"] == "reliability"
# ===========================================================================
# TC-03 — record() inserts and returns id, created_at filled, source honoured
# ===========================================================================
def test_tc03_record_inserts_and_returns_id():
lid = lessons.record(
lessons.LessonType.GATE_FAILURE, work_item_id=_WI, task_id=7, stage="review",
agent="reviewer", repo=_REPO, root_cause="REQUEST_CHANGES", source="auto",
)
assert isinstance(lid, int) and lid > 0
rows = lessons.get(work_item_id=_WI)
assert len(rows) == 1
r = rows[0]
assert r["lesson_type"] == "gate_failure"
assert r["task_id"] == 7
assert r["agent"] == "reviewer"
assert r["source"] == "auto"
assert r["status"] == "new"
assert r["created_at"]
# A manual record with a different (work_item, type) -> distinct row.
lid2 = lessons.record("custom_manual", work_item_id="ORCH-1", source="manual")
assert lid2 is not None and lid2 != lid
# ===========================================================================
# TC-04 — never-raise: a failing DB -> safe defaults, no exception
# ===========================================================================
def test_tc04_never_raise_on_db_error(monkeypatch):
def boom(*a, **k):
raise RuntimeError("db down")
monkeypatch.setattr(db, "record_lesson", boom)
monkeypatch.setattr(db, "lessons_recent_dup_exists", lambda *a, **k: False)
monkeypatch.setattr(db, "get_lessons", boom)
monkeypatch.setattr(db, "update_lesson", boom)
monkeypatch.setattr(db, "lessons_snapshot", boom)
assert lessons.record("gate_failure", work_item_id=_WI) is None
assert lessons.get(work_item_id=_WI) == []
assert lessons.update(1, status="closed") is False
snap = lessons.snapshot()
assert snap == {"enabled": True} # never-raise -> minimal dict, no exception
# ===========================================================================
# TC-05 — kill-switch: lessons_enabled=False -> inert, no DB access
# ===========================================================================
def test_tc05_kill_switch_inert(monkeypatch):
monkeypatch.setattr(cfg.settings, "lessons_enabled", False, raising=False)
def fail(*a, **k):
raise AssertionError("DB must NOT be touched when kill-switch is off")
monkeypatch.setattr(db, "record_lesson", fail)
monkeypatch.setattr(db, "get_lessons", fail)
monkeypatch.setattr(db, "update_lesson", fail)
monkeypatch.setattr(db, "lessons_snapshot", fail)
assert lessons.record("gate_failure", work_item_id=_WI) is None
assert lessons.get(work_item_id=_WI) == []
assert lessons.update(1, status="closed") is False
assert lessons.snapshot() == {"enabled": False}
# ===========================================================================
# TC-06 — get_lessons filters + limit + ORDER BY id DESC
# ===========================================================================
def test_tc06_filters_limit_order():
# Seed rows directly via the DB helper (bypasses the leaf's auto-dedup).
for i in range(5):
db.record_lesson(
lesson_type="gate_failure", work_item_id=f"ORCH-{i}", repo=_REPO,
status="new", source="auto",
)
db.record_lesson(lesson_type="merge_hold", work_item_id="ORCH-X", repo="enduro-trails",
status="closed", source="auto")
# Filter by type.
gf = db.get_lessons(lesson_type="gate_failure")
assert len(gf) == 5 and all(r["lesson_type"] == "gate_failure" for r in gf)
# Filter by status.
assert len(db.get_lessons(status="closed")) == 1
# Filter by repo.
assert len(db.get_lessons(repo="enduro-trails")) == 1
# Filter by work_item.
assert len(db.get_lessons(work_item_id="ORCH-3")) == 1
# LIMIT.
assert len(db.get_lessons(lesson_type="gate_failure", limit=2)) == 2
# ORDER BY id DESC (newest first).
allr = db.get_lessons(limit=100)
got_ids = [r["id"] for r in allr]
assert got_ids == sorted(got_ids, reverse=True)
# ===========================================================================
# TC-07 — update_lesson mutates + stamps updated_at; unknown id safe
# ===========================================================================
def test_tc07_update_and_unknown_id():
lid = db.record_lesson(lesson_type="deploy_degraded", work_item_id=_WI, repo=_REPO,
status="new", source="auto")
before = db.get_lessons(work_item_id=_WI)[0]
assert before["updated_at"] is None
ok = db.update_lesson(
lid, status="in_progress", attribution="both", target_repo=_REPO,
target_domain="reliability", related_task="ORCH-200",
)
assert ok is True
after = db.get_lessons(work_item_id=_WI)[0]
assert after["status"] == "in_progress"
assert after["attribution"] == "both"
assert after["related_task"] == "ORCH-200"
assert after["updated_at"] is not None
# Unknown id -> no row changed, no raise.
assert db.update_lesson(999999, status="closed") is False
# Empty update (no recognised fields) -> False, safe.
assert db.update_lesson(lid) is False
# ===========================================================================
# TC-07b — auto dedup vs manual always-writes (D4)
# ===========================================================================
def test_tc07b_auto_dedup_and_manual_passthrough():
a = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto")
b = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto")
assert a is not None and b is None # second auto deduped in-window
# Manual is never deduped.
m1 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="manual")
m2 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="manual")
assert m1 is not None and m2 is not None and m1 != m2
# Window=0 disables dedup.
import src.config as c
c.settings.lessons_dedup_window_s = 0
c2 = lessons.record("transient_retry", work_item_id=_WI, stage="deploy", source="auto")
assert c2 is not None
c.settings.lessons_dedup_window_s = 3600
# ===========================================================================
# TC-08 — wiring: QG rollback to development writes a gate_failure lesson
# ===========================================================================
def test_tc08_gate_failure_autorecord(monkeypatch):
from src import stage_engine as se
# All side-effecting DB / notifier / plane ops on the rollback path are patched
# to no-ops; only the lessons block reaches the (real tmp) DB — so we assert the
# WIRING (rolled_back_to -> gate_failure lesson) without standing up a full task.
for name in ("notify_stage_change", "plane_notify_stage", "send_telegram",
"set_issue_in_progress", "plane_add_comment", "update_task_stage"):
monkeypatch.setattr(se, name, lambda *a, **k: None, raising=False)
monkeypatch.setattr(se, "extract_test_failures", lambda *a, **k: "", raising=False)
monkeypatch.setattr(se, "_developer_retry_count", lambda *a, **k: 0, raising=False)
monkeypatch.setattr(se, "enqueue_job", lambda *a, **k: 123, raising=False)
result = se.AdvanceResult()
se._handle_qg_failure_rollbacks(
99, "testing", _REPO, "ORCH-098", "feature/ORCH-098-fnd",
agent="tester", qg_name="check_tests_passed", reason="2 failed", result=result,
)
assert result.rolled_back_to == "development"
rows = db.get_lessons(lesson_type="gate_failure", work_item_id="ORCH-098")
assert len(rows) == 1
r = rows[0]
assert r["stage"] == "testing"
assert r["agent"] == "tester"
assert r["repo"] == _REPO
assert r["source"] == "auto"
assert r["detail"] == "check_tests_passed"
# ===========================================================================
# TC-09 — wiring: launcher transient-budget-exhaustion writes a lesson;
# a failing journal never breaks the hot path
# ===========================================================================
def test_tc09_transient_autorecord_and_never_raise(monkeypatch):
from src.agents import launcher as lmod
launcher = lmod.AgentLauncher()
monkeypatch.setattr(launcher, "_notify_failed", lambda *a, **k: None)
monkeypatch.setattr(launcher, "_record_outcome", lambda *a, **k: None)
monkeypatch.setattr(cfg.settings, "transient_max_attempts", 3, raising=False)
job_id = db.enqueue_job("developer", _REPO, "task", task_id=42)
job = {"transient_attempts": 3, "task_id": 42, "repo": _REPO}
# Budget exhausted (tattempts >= tmax) -> the failed branch records the lesson.
launcher._finalize_transient(job_id, "developer", 1, 99, job, retry_after=None)
rows = db.get_lessons(lesson_type="transient_retry")
assert len(rows) == 1
assert rows[0]["repo"] == _REPO
assert rows[0]["agent"] == "developer"
assert rows[0]["source"] == "auto"
# never-raise in the hot path: a failing record must not break finalisation.
def boom(*a, **k):
raise RuntimeError("journal down")
monkeypatch.setattr(db, "record_lesson", boom)
monkeypatch.setattr(db, "lessons_recent_dup_exists", lambda *a, **k: False)
job_id2 = db.enqueue_job("developer", _REPO, "task2", task_id=43)
job2 = {"transient_attempts": 3, "task_id": 43, "repo": _REPO}
# Must NOT raise even though the journal insert blows up.
launcher._finalize_transient(job_id2, "developer", 1, 99, job2, retry_after=None)
# ===========================================================================
# TC-10 — GET /lessons + GET /queue block; reads do not mutate
# ===========================================================================
def test_tc10_get_endpoints(monkeypatch):
from fastapi.testclient import TestClient
import src.main as main
db.record_lesson(lesson_type="gate_failure", work_item_id=_WI, repo=_REPO,
status="new", source="auto")
db.record_lesson(lesson_type="merge_hold", work_item_id="ORCH-2", repo="enduro-trails",
status="closed", source="auto")
client = TestClient(main.app)
r = client.get("/lessons")
assert r.status_code == 200
body = r.json()
assert body["enabled"] is True
assert len(body["lessons"]) == 2
# Filters.
r = client.get("/lessons", params={"type": "gate_failure"})
assert len(r.json()["lessons"]) == 1
r = client.get("/lessons", params={"repo": "enduro-trails"})
assert len(r.json()["lessons"]) == 1
r = client.get("/lessons", params={"limit": 1})
assert len(r.json()["lessons"]) == 1
# Reads do not mutate.
assert db.lessons_snapshot()["total"] == 2
# GET /queue carries the read-only lessons block.
q = client.get("/queue")
assert q.status_code == 200
assert "lessons" in q.json()
assert q.json()["lessons"]["enabled"] is True
assert q.json()["lessons"]["total"] == 2
# ===========================================================================
# TC-11 — POST /lessons (manual) + POST /lessons/{id} (update); kill-switch
# ===========================================================================
def test_tc11_post_endpoints_and_killswitch(monkeypatch):
from fastapi.testclient import TestClient
import src.main as main
client = TestClient(main.app)
# Manual create with attribution.
r = client.post("/lessons", json={
"lesson_type": "process_gap", "work_item_id": _WI, "repo": _REPO,
"attribution": "platform", "target_domain": "quality", "root_cause": "manual note",
})
assert r.status_code == 200
lid = r.json()["id"]
assert isinstance(lid, int)
rows = db.get_lessons(work_item_id=_WI)
assert rows[0]["source"] == "manual"
assert rows[0]["attribution"] == "platform"
# Missing lesson_type -> error, no row.
r = client.post("/lessons", json={"work_item_id": "X"})
assert r.json()["ok"] is False
# Update via POST /lessons/{id}.
r = client.post(f"/lessons/{lid}", json={"status": "closed", "related_task": "ORCH-300"})
assert r.json()["ok"] is True
assert db.get_lessons(work_item_id=_WI)[0]["status"] == "closed"
# Kill-switch off -> endpoints report {"enabled": false}.
monkeypatch.setattr(cfg.settings, "lessons_enabled", False, raising=False)
assert client.get("/lessons").json() == {"enabled": False, "lessons": []}
assert client.post("/lessons", json={"lesson_type": "x"}).json() == {"enabled": False}
assert client.post(f"/lessons/{lid}", json={"status": "new"}).json() == {"enabled": False}
# ===========================================================================
# TC-12 — pipeline invariants structurally untouched
# ===========================================================================
def test_tc12_pipeline_invariants_untouched():
from src.stages import STAGE_TRANSITIONS
from src.qg.checks import QG_CHECKS
# The journal must not have added/removed a stage edge or a QG check.
assert "development" in STAGE_TRANSITIONS
assert "deploy" in STAGE_TRANSITIONS
# machine-verdict QG checks still registered (sample of the canon set).
for name in ("check_ci_green", "check_tests_passed", "check_coverage_gate"):
assert name in QG_CHECKS
# The journal is NOT a quality gate — no check named after it.
assert not any("lesson" in k.lower() for k in QG_CHECKS)