mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
204 lines
6.6 KiB
Python
204 lines
6.6 KiB
Python
"""M23 — durable playbook index-state tracking + startup reconcile.
|
|||
|
|
|
||
|
|
An Ollama restart mid-approval-burst left an APPROVED playbook's
|
||
|
|
``index_approved`` step swallowed (logged + dropped), so the row was
|
||
|
|
APPROVED in the DB but absent from the PLAYBOOKS RAG corpus — the Auditor
|
||
|
|
saw "approved" while agents never surfaced the procedure. The fix is a
|
||
|
|
durable ``indexed_ok`` flag set ONLY on a successful embed, plus a startup
|
||
|
|
reconcile that re-indexes APPROVED-but-``indexed_ok=False`` rows.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.config import settings
|
||
|
|
from roboco.models.base import PlaybookStatus
|
||
|
|
from roboco.services.optimal_brain.indexes.base import IngestResult
|
||
|
|
from roboco.services.playbook import PlaybookService
|
||
|
|
|
||
|
|
|
||
|
|
def _approved_playbook(*, indexed_ok: bool = False) -> Any:
|
||
|
|
pb = MagicMock()
|
||
|
|
pb.id = uuid4()
|
||
|
|
pb.status = PlaybookStatus.APPROVED.value
|
||
|
|
pb.title = "Roll back a bad migration"
|
||
|
|
pb.problem = "A migration shipped data loss."
|
||
|
|
pb.procedure = "1. pg_restore the snapshot.\n2. Re-apply forward migrations."
|
||
|
|
pb.tags = ["backend"]
|
||
|
|
pb.team = "backend"
|
||
|
|
pb.scope = "org"
|
||
|
|
pb.indexed_ok = indexed_ok
|
||
|
|
pb.indexed_at = None
|
||
|
|
return pb
|
||
|
|
|
||
|
|
|
||
|
|
def _session_returning(playbooks: list[Any]) -> Any:
|
||
|
|
session = MagicMock()
|
||
|
|
result = MagicMock()
|
||
|
|
scalars = MagicMock()
|
||
|
|
scalars.all.return_value = playbooks
|
||
|
|
result.scalars.return_value = scalars
|
||
|
|
session.execute = AsyncMock(return_value=result)
|
||
|
|
session.flush = AsyncMock()
|
||
|
|
session.commit = AsyncMock()
|
||
|
|
session.rollback = AsyncMock()
|
||
|
|
return session
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_reconcile_reindexes_unindexed_approved(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""Reconcile finds APPROVED-but-indexed_ok=False rows and re-indexes them,
|
||
|
|
stamping ``indexed_ok=True`` + ``indexed_at`` only on a successful embed."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
pb = _approved_playbook(indexed_ok=False)
|
||
|
|
session = _session_returning([pb])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
optimal = MagicMock()
|
||
|
|
optimal.index_playbook = AsyncMock(
|
||
|
|
return_value=IngestResult(doc_id=str(pb.id), chunk_count=3, success=True)
|
||
|
|
)
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.optimal.get_optimal_service",
|
||
|
|
AsyncMock(return_value=optimal),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
count = await svc.reconcile_unindexed_approved()
|
||
|
|
|
||
|
|
assert count == 1
|
||
|
|
optimal.index_playbook.assert_awaited_once()
|
||
|
|
assert pb.indexed_ok is True
|
||
|
|
assert pb.indexed_at is not None
|
||
|
|
session.commit.assert_awaited_once()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_reconcile_skips_when_org_memory_disabled(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""org_memory_enabled=False ⇒ the index is inert; reconcile no-ops."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", False)
|
||
|
|
session = _session_returning([_approved_playbook()])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
count = await svc.reconcile_unindexed_approved()
|
||
|
|
|
||
|
|
assert count == 0
|
||
|
|
session.execute.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_reconcile_rolls_back_on_index_exception(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""A per-playbook failure is rolled back; the loop continues (best-effort)."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
pb = _approved_playbook(indexed_ok=False)
|
||
|
|
session = _session_returning([pb])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
optimal = MagicMock()
|
||
|
|
optimal.index_playbook = AsyncMock(side_effect=RuntimeError("ollama still down"))
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.optimal.get_optimal_service",
|
||
|
|
AsyncMock(return_value=optimal),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
count = await svc.reconcile_unindexed_approved()
|
||
|
|
|
||
|
|
assert count == 0
|
||
|
|
assert pb.indexed_ok is False
|
||
|
|
session.rollback.assert_awaited_once()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_index_approved_failure_does_not_stamp_indexed_ok(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""``index_approved`` sets ``indexed_ok=True`` ONLY on a successful embed —
|
||
|
|
a failed embed (the embedder returned success=False) leaves the row
|
||
|
|
APPROVED-but-unindexed for the reconcile to retry."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
pb = _approved_playbook(indexed_ok=False)
|
||
|
|
session = _session_returning([])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
optimal = MagicMock()
|
||
|
|
optimal.index_playbook = AsyncMock(
|
||
|
|
return_value=IngestResult(
|
||
|
|
doc_id=str(pb.id), chunk_count=0, success=False, error="ollama down"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.optimal.get_optimal_service",
|
||
|
|
AsyncMock(return_value=optimal),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc.index_approved(pb)
|
||
|
|
|
||
|
|
optimal.index_playbook.assert_awaited_once()
|
||
|
|
assert pb.indexed_ok is False
|
||
|
|
assert pb.indexed_at is None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_index_approved_exception_does_not_stamp_indexed_ok(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""An exception out of ``index_playbook`` is swallowed (best-effort) and
|
||
|
|
``indexed_ok`` stays False — the row remains a reconcile candidate."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
pb = _approved_playbook(indexed_ok=False)
|
||
|
|
session = _session_returning([])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
optimal = MagicMock()
|
||
|
|
optimal.index_playbook = AsyncMock(side_effect=RuntimeError("embedder crashed"))
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.optimal.get_optimal_service",
|
||
|
|
AsyncMock(return_value=optimal),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc.index_approved(pb) # must not raise
|
||
|
|
|
||
|
|
assert pb.indexed_ok is False
|
||
|
|
assert pb.indexed_at is None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_index_approved_success_stamps_indexed_ok(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
"""A successful embed stamps ``indexed_ok=True`` + ``indexed_at`` and
|
||
|
|
flushes so the caller's commit persists the flag."""
|
||
|
|
monkeypatch.setattr(settings, "org_memory_enabled", True)
|
||
|
|
pb = _approved_playbook(indexed_ok=False)
|
||
|
|
session = _session_returning([])
|
||
|
|
svc = PlaybookService(session)
|
||
|
|
|
||
|
|
optimal = MagicMock()
|
||
|
|
optimal.index_playbook = AsyncMock(
|
||
|
|
return_value=IngestResult(doc_id=str(pb.id), chunk_count=3, success=True)
|
||
|
|
)
|
||
|
|
with (
|
||
|
|
patch(
|
||
|
|
"roboco.services.optimal.get_optimal_service",
|
||
|
|
AsyncMock(return_value=optimal),
|
||
|
|
),
|
||
|
|
):
|
||
|
|
await svc.index_approved(pb)
|
||
|
|
|
||
|
|
assert pb.indexed_ok is True
|
||
|
|
assert pb.indexed_at is not None
|
||
|
|
session.flush.assert_awaited_once()
|