fix: journals/learnings never reached the RAG corpus + git-readonly slug 404s (#339)

* fix(rag): per-index chunk floors — journals and learnings were never indexed

The global 200-char garbage floor (sized for code/doc chunks) discarded
every templated journal note and most distilled org-memory lessons,
silently: ingest returned success with zero chunks, so agent journals
and learnings were never retrievable via RAG. IndexConfig now carries a
per-type min_chunk_length (journals 40, learnings 80, others unchanged).

* fix(mcp): git-readonly tools default project_slug from the container env

Agents 404ed /api/git/status with 'Project not found: roboco' — the
tools made the LLM supply the slug and six doc examples taught a slug
that matches no registered project. The tools now fall back to the
ROBOCO_PROJECT_SLUG the orchestrator already injects, and the stale
examples are corrected.

* feat(rag): startup backfill re-ingests zero-chunk journals and learnings

Before the per-index chunk-floor fix, ingest() returned success with
chunk_count=0 for undersized content: every historical journal entry and
distilled learning below the (then-global) 200-char floor was durably
recorded in journal_entries but silently never got a chunks_journals /
chunks_learnings row, and no exception meant the existing dead-letter
(rag_index_failures) never saw it either.

Extends the startup reconcile (roboco/api/app.py _reconcile_rag_indexes)
with a new pass: backfill_unindexed_journals (roboco/services/
rag_index_failures.py) queries journal_entries for rows missing from each
vector table and re-ingests them through the same live code paths
(_reindex_journal_entry / record_learning).

Journals and learnings are backfilled independently since a LEARNING entry
can clear the (lower) JOURNALS floor while still failing the (higher)
LEARNINGS floor — a learning's doc_source is a content hash, not the entry
id, so presence there is checked by hashing each candidate the same way
LearningsIndexPlugin.record_learning does and batch-querying chunks_learnings
for those exact sources.

Bounded to 200 rows per pass per boot (converges over restarts on a larger
backlog) and best-effort per row (one failure never aborts the pass). Rows
still under the current floor are excluded by a length filter in the SELECT
so they are never retried forever, and private entries are excluded from
the JOURNALS pass exactly like the live indexing path.

* test(rag): scope backfill assertions to their own rows

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-08 16:03:00 +02:00
committed by GitHub
co-authored by Renn F
parent 0e9f21de69
commit 4fb0059556
15 changed files with 999 additions and 45 deletions
+7 -4
View File
@@ -12,10 +12,13 @@ There is **no** "roboco_git_commit / _push / _create_pr / _merge_pr / _checkout"
| `roboco_git_diff` | View changes |
```python
status = roboco_git_status(project_slug="roboco")
diff = roboco_git_diff(project_slug="roboco")
log = roboco_git_log(project_slug="roboco", branch="feature/backend/a1b2c3d4")
branches = roboco_git_branch_list(project_slug="roboco")
# project_slug is optional — omit it and your own project is used
# (from this agent's environment). Pass it explicitly only to inspect
# a different project than the one you're assigned to.
status = roboco_git_status()
diff = roboco_git_diff()
log = roboco_git_log(branch="feature/backend/a1b2c3d4")
branches = roboco_git_branch_list()
```
## Branch Lifecycle — automatic
+3 -3
View File
@@ -15,7 +15,7 @@
roboco_kb_search(
query="rate limiting redis",
top_k=5,
project="roboco",
project="roboco-api",
index_types=["code", "docs"]
)
```
@@ -71,14 +71,14 @@ roboco_docs_read(path="backend/api/endpoints.md")
# Index code (PM, Developer)
roboco_kb_index_code(
sources=["src/**/*.py"],
project="roboco"
project="roboco-api"
)
# Index docs (PM, Documenter) - for bulk/explicit indexing
# Note: roboco_docs_write() auto-indexes when writing
roboco_kb_index_docs(
sources=["docs/**/*.md"],
project="roboco"
project="roboco-api"
)
```
+7 -5
View File
@@ -16,10 +16,12 @@ A task carries its project linkage; you don't look it up with a tool. The task o
Read-only git inspection is available through the `roboco-git-readonly` MCP server (developers and QA):
```python
roboco_git_status(project_slug="roboco")
roboco_git_log(project_slug="roboco")
roboco_git_diff(project_slug="roboco")
roboco_git_branch_list(project_slug="roboco")
# project_slug is optional on all four — omit it and your own project
# is used (from this agent's environment).
roboco_git_status()
roboco_git_log()
roboco_git_diff()
roboco_git_branch_list()
```
There is **no** `roboco_git_commit / _push / _checkout / _create_pr / _merge_pr` tool. Commits go through the `commit` content tool (auto- prefixed with `[task-id]`, auto-pushed by the choreographer); PRs open at `open_pr` time; merges are a PM `complete` operation.
@@ -29,7 +31,7 @@ There is **no** `roboco_git_commit / _push / _checkout / _create_pr / _merge_pr`
To learn how a project's codebase is laid out or how a subsystem works, query the knowledge base rather than a project tool:
```python
roboco_kb_search(query="rate limiting redis", project="roboco",
roboco_kb_search(query="rate limiting redis", project="roboco-api",
index_types=["code", "documentation"])
roboco_ask_mentor(question="How is auth wired up in this project?")
```
+5 -4
View File
@@ -57,8 +57,9 @@ You don't push or create a PR yourself. The choreographer pushed the commit duri
```python
# Read-only inspection (any role) — roboco-git-readonly MCP
status = roboco_git_status(project_slug="roboco")
log = roboco_git_log(project_slug="roboco", branch="feature/backend/a1b2c3d4--def67890")
diff = roboco_git_diff(project_slug="roboco")
branches = roboco_git_branch_list(project_slug="roboco")
# project_slug is optional — omit it and your own project is used.
status = roboco_git_status()
log = roboco_git_log(branch="feature/backend/a1b2c3d4--def67890")
diff = roboco_git_diff()
branches = roboco_git_branch_list()
```
+1 -1
View File
@@ -26,7 +26,7 @@ It searches ALL knowledge sources and supports follow-up questions.
roboco_kb_search(
query="rate limiting redis implementation",
top_k=5, # Results to return
project="roboco", # Optional project filter
project="roboco-api", # Optional project filter
index_types=["code", "docs"] # Filter by type
)
```
+4 -3
View File
@@ -17,9 +17,10 @@ give_me_work()
# guard at pass/fail time)
claim_review(task_id="<task>")
# 3. Inspect the diff
roboco_git_diff(project_slug="roboco")
roboco_git_log(project_slug="roboco", branch="<dev's branch>")
# 3. Inspect the diff (project_slug is optional — omit it and your
# own project is used)
roboco_git_diff()
roboco_git_log(branch="<dev's branch>")
# 4. Run the relevant suite
# Backend: uv run pytest && uv run ruff check . && uv run mypy roboco/
+18 -2
View File
@@ -67,7 +67,7 @@ from roboco.services.extraction import ExtractionPipeline, ExtractionService
from roboco.services.learning import get_learning_service
from roboco.services.optimal import close_optimal_service, get_optimal_service
from roboco.services.playbook import PlaybookService
from roboco.services.rag_index_failures import reclaim_due
from roboco.services.rag_index_failures import backfill_unindexed_journals, reclaim_due
from roboco.services.settings import apply_persisted_feature_flags
from roboco.services.transcription import TranscriptionService
@@ -125,10 +125,26 @@ async def _reclaim_rag_index_failures(app: FastAPI) -> None:
logger.warning("RAG index dead-letter reclaim failed; continuing", error=str(e))
async def _backfill_unindexed_journals(app: FastAPI) -> None:
"""Re-index journal/learning entries silently zero-chunked before the
per-index chunk-floor fix (see ``backfill_unindexed_journals``'s
docstring). Best-effort: a failure here never blocks startup the rows
stay and the next startup retries them. Skipped when RAG is disabled.
"""
if app.state.optimal is None:
return
try:
await backfill_unindexed_journals(app.state.optimal)
except Exception as e:
logger.warning("Journal/learning RAG backfill failed; continuing", error=str(e))
async def _reconcile_rag_indexes(app: FastAPI) -> None:
"""Run both RAG index reconcile passes (playbooks + dead-letter reclaim)."""
"""Run all RAG index reconcile passes: playbooks, dead-letter reclaim,
and the journals/learnings zero-chunk backfill."""
await _reconcile_unindexed_playbooks(app)
await _reclaim_rag_index_failures(app)
await _backfill_unindexed_journals(app)
@asynccontextmanager
+2 -2
View File
@@ -14,8 +14,8 @@ Workspace Structure:
+-- [git repo files]
Example:
/data/workspaces/roboco/backend/be-dev-1/
/data/workspaces/roboco/backend/be-dev-2/
/data/workspaces/roboco-api/backend/be-dev-1/
/data/workspaces/roboco-api/backend/be-dev-2/
This allows multiple agents to work on the same project in parallel,
each on their own branch, without file conflicts.
+49 -12
View File
@@ -70,6 +70,27 @@ def _get(path: str, params: dict[str, Any]) -> dict[str, Any]:
return result
_PROJECT_SLUG_ENV = "ROBOCO_PROJECT_SLUG"
def _resolve_project_slug(project_slug: str | None) -> str | dict[str, Any]:
"""Resolve the slug to query: explicit arg wins, else the orchestrator's
env-injected slug for this agent's own project. Returns an error dict
(never raises) when neither is available.
"""
slug = project_slug or os.environ.get(_PROJECT_SLUG_ENV)
if not slug:
return {
"error": "missing_project_slug",
"detail": (
"no project_slug given and "
f"{_PROJECT_SLUG_ENV} is not set in this agent's environment — "
"pass project_slug explicitly"
),
}
return slug
def _cap_diff(result: dict[str, Any]) -> dict[str, Any]:
"""Truncate an oversized diff for context embedding; annotate the cut."""
diff = result.get("diff")
@@ -85,35 +106,43 @@ def _cap_diff(result: dict[str, Any]) -> dict[str, Any]:
@mcp.tool()
def roboco_git_status(project_slug: str) -> dict[str, Any]:
def roboco_git_status(project_slug: str | None = None) -> dict[str, Any]:
"""Read-only: current git status of your workspace.
Args:
project_slug: Project slug (e.g. "roboco").
project_slug: Optional omit it and your own project is used
(from this agent's environment).
Returns:
Current branch, staged/unstaged/untracked files, ahead/behind counts.
"""
return _get("/api/git/status", {"project_slug": project_slug})
slug = _resolve_project_slug(project_slug)
if isinstance(slug, dict):
return slug
return _get("/api/git/status", {"project_slug": slug})
@mcp.tool()
def roboco_git_log(
project_slug: str,
project_slug: str | None = None,
limit: int = 10,
branch: str | None = None,
) -> dict[str, Any]:
"""Read-only: recent commits on the named branch (default: current).
Args:
project_slug: Project slug.
project_slug: Optional omit it and your own project is used
(from this agent's environment).
limit: Number of commits to return (max 50).
branch: Branch to inspect; defaults to the current checked-out branch.
Returns:
List of commits with hash, short_hash, message, author, date.
"""
params: dict[str, Any] = {"project_slug": project_slug, "limit": limit}
slug = _resolve_project_slug(project_slug)
if isinstance(slug, dict):
return slug
params: dict[str, Any] = {"project_slug": slug, "limit": limit}
if branch is not None:
params["branch"] = branch
return _get("/api/git/log", params)
@@ -121,21 +150,25 @@ def roboco_git_log(
@mcp.tool()
def roboco_git_diff(
project_slug: str,
project_slug: str | None = None,
staged: bool = False,
file_path: str | None = None,
) -> dict[str, Any]:
"""Read-only: diff of your workspace against the index.
Args:
project_slug: Project slug.
project_slug: Optional omit it and your own project is used
(from this agent's environment).
staged: If True, show staged changes; otherwise show unstaged.
file_path: Optional path to scope the diff to a single file.
Returns:
Diff text plus files_changed count.
"""
params: dict[str, Any] = {"project_slug": project_slug, "staged": staged}
slug = _resolve_project_slug(project_slug)
if isinstance(slug, dict):
return slug
params: dict[str, Any] = {"project_slug": slug, "staged": staged}
if file_path is not None:
params["file_path"] = file_path
return _cap_diff(_get("/api/git/diff", params))
@@ -143,21 +176,25 @@ def roboco_git_diff(
@mcp.tool()
def roboco_git_branch_list(
project_slug: str,
project_slug: str | None = None,
include_remote: bool = False,
) -> dict[str, Any]:
"""Read-only: list local (and optionally remote) branches.
Args:
project_slug: Project slug.
project_slug: Optional omit it and your own project is used
(from this agent's environment).
include_remote: If True, also include remote-tracking branches.
Returns:
Branches with current branch marked.
"""
slug = _resolve_project_slug(project_slug)
if isinstance(slug, dict):
return slug
return _get(
"/api/git/branches",
{"project_slug": project_slug, "include_remote": include_remote},
{"project_slug": slug, "include_remote": include_remote},
)
+20 -4
View File
@@ -41,18 +41,30 @@ def build_doc_source(*, kind: str, id_: str | None) -> str | None:
_MIN_CHUNK_LENGTH = 200
# Per-index-type override of _MIN_CHUNK_LENGTH. Journal/learning entries are
# short by design (templated notes, distilled Problem->Approach->Gotcha
# lessons) — the global 200-char floor discarded every one of them as
# "garbage" (raw_count=1 every time), so org-memory/journal retrieval never
# had anything to find. Every other index keeps the default.
_MIN_CHUNK_LENGTH_BY_TYPE: dict[IndexType, int] = {
IndexType.JOURNALS: 40,
IndexType.LEARNINGS: 80,
}
def _filter_quality_chunks(raw_chunks: list[Any]) -> list[Any]:
def _filter_quality_chunks(
raw_chunks: list[Any], min_chunk_length: int = _MIN_CHUNK_LENGTH
) -> list[Any]:
"""Drop tiny chunks and chunks that are mostly markdown formatting."""
kept: list[Any] = []
for chunk in raw_chunks:
text = chunk.text.strip()
if len(text) < _MIN_CHUNK_LENGTH:
if len(text) < min_chunk_length:
continue
non_formatting = (
text.replace("```", "").replace("---", "").replace("#", "").strip()
)
if len(non_formatting) < _MIN_CHUNK_LENGTH // 2:
if len(non_formatting) < min_chunk_length // 2:
continue
kept.append(chunk)
return kept
@@ -70,6 +82,7 @@ class IndexConfig:
embedding_model: str = "qwen3-embedding:0.6b"
llm_model: str = "glm-5.2:cloud"
llm_base_url: str = "http://roboco-ollama:11434/v1"
min_chunk_length: int = _MIN_CHUNK_LENGTH
@classmethod
def from_settings(cls, index_type: IndexType) -> "IndexConfig":
@@ -90,6 +103,9 @@ class IndexConfig:
embedding_model=settings.default_embedding_model,
llm_model=settings.local_llm_model,
llm_base_url=settings.local_llm_base_url,
min_chunk_length=_MIN_CHUNK_LENGTH_BY_TYPE.get(
index_type, _MIN_CHUNK_LENGTH
),
)
@@ -435,7 +451,7 @@ class BaseIndexPlugin(ABC):
embedder = self._require_embedder
raw_chunks: list[Chunk] = chunker.chunk_document(doc)
chunks = _filter_quality_chunks(raw_chunks)
chunks = _filter_quality_chunks(raw_chunks, self.config.min_chunk_length)
if not chunks:
logger.warning(
"All chunks filtered as garbage",
+210 -2
View File
@@ -8,24 +8,43 @@ reclaims due rows with backoff — on success the row is deleted, on failure
``attempts`` bumps and ``next_retry_at`` advances. Best-effort throughout: a
persist failure never blocks the caller's commit, and a reclaim failure never
blocks startup.
A second, unrelated problem this module also repairs: before the per-index
chunk-floor fix, ``ingest()`` returned success with ``chunk_count=0`` for
undersized content (journal reflections, distilled learnings) no exception
was ever raised, so those rows never reached the dead-letter above and never
got a vector row either. ``backfill_unindexed_journals`` re-ingests that
silent-failure history straight from ``journal_entries`` (see its docstring).
"""
from __future__ import annotations
import hashlib
import logging
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import UUID
from sqlalchemy import delete, func, select
from sqlalchemy import delete, func, select, text
from roboco.db.base import get_db_context
from roboco.db.tables import RagIndexFailureTable
from roboco.models.optimal import IndexJournalEntryParams
from roboco.models.optimal import IndexJournalEntryParams, IndexType
from roboco.services.optimal_brain.indexes.base import IndexConfig
logger = logging.getLogger(__name__)
# Backoff ceiling: 1m, 2m, 4m, ... capped at 1h.
_MAX_BACKOFF_SECONDS = 3600
_BASE_BACKOFF_SECONDS = 60
# Per-pass cap on the startup backfill (below): bounded so a large historical
# backlog can't stall startup. The candidate query already excludes rows below
# the CURRENT floor (they would zero-chunk again), so anything left over past
# the cap is picked up on the next restart — it converges, it may just take
# more than one boot for a very large backlog.
_BACKFILL_CAP = 200
def _backoff(attempts: int) -> timedelta:
seconds = min(_BASE_BACKOFF_SECONDS * (2 ** (attempts - 1)), _MAX_BACKOFF_SECONDS)
@@ -206,8 +225,197 @@ async def _reindex_completion_learning(optimal: Any, payload: dict[str, Any]) ->
)
def _learning_source(content: str) -> str:
"""Derive a learning's doc_source exactly as record_learning does.
Mirrors ``LearningsIndexPlugin.record_learning`` (learnings.py): the
doc_id is a hash of the raw content, not the entry id, so presence in
``chunks_learnings`` can't be joined by primary key — this lets the
backfill below compute the same source and check for it directly.
"""
content_hash = hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:16]
return f"roboco://learnings/lrn-{content_hash}"
async def _backfill_journals(optimal: Any) -> tuple[int, int]:
"""Re-ingest non-private journal entries with no ``chunks_journals`` row.
Candidates are entries at/above the JOURNALS floor (a still-undersized
entry would zero-chunk again excluded here so it's never retried
forever) whose ``roboco://journals/<id>`` source has zero chunk rows.
Returns ``(processed, still_missing)`` for this capped batch.
"""
if not optimal.is_index_registered(IndexType.JOURNALS):
return 0, 0
floor = IndexConfig.from_settings(IndexType.JOURNALS).min_chunk_length
async with get_db_context() as session:
rows = (
await session.execute(
text(
"""
SELECT je.id, je.content, je.type, j.agent_id, je.task_id, je.tags
FROM journal_entries je
JOIN journals j ON j.id = je.journal_id
LEFT JOIN chunks_journals cj
ON cj.source = 'roboco://journals/' || je.id::text
WHERE cj.source IS NULL
AND je.is_private = false
AND length(je.content) >= :floor
ORDER BY je.created_at
LIMIT :cap
"""
),
{"floor": floor, "cap": _BACKFILL_CAP},
)
).all()
processed = 0
for row in rows:
try:
await _reindex_journal_entry(
optimal,
{
"content": row.content,
"entry_type": row.type,
"entry_id": str(row.id),
"agent_id": str(row.agent_id) if row.agent_id else None,
"task_id": str(row.task_id) if row.task_id else None,
"tags": list(row.tags or []),
"is_private": False,
},
)
processed += 1
except Exception as e:
logger.warning(
"RAG backfill: journal re-index failed (best-effort), "
f"entry_id={row.id}, error={e}"
)
return processed, len(rows) - processed
async def _backfill_learnings(optimal: Any) -> tuple[int, int]:
"""Re-ingest LEARNING journal entries with no matching ``chunks_learnings`` row.
A learning's doc_source is a content hash, not the entry id, so presence
can't be joined in SQL — hash each candidate (``_learning_source``) and
batch-check ``chunks_learnings`` for those exact sources. Independent of
:func:`_backfill_journals`: a LEARNING entry can pass the (lower) JOURNALS
floor but fail the (higher) LEARNINGS floor, so it may already have a
``chunks_journals`` row while still missing here.
"""
if not optimal.is_index_registered(IndexType.LEARNINGS):
return 0, 0
floor = IndexConfig.from_settings(IndexType.LEARNINGS).min_chunk_length
async with get_db_context() as session:
rows = (
await session.execute(
text(
"""
SELECT je.id, je.content, j.agent_id, je.task_id, je.tags,
je.is_private
FROM journal_entries je
JOIN journals j ON j.id = je.journal_id
WHERE je.type = 'learning' AND length(je.content) >= :floor
ORDER BY je.created_at
LIMIT :cap
"""
),
{"floor": floor, "cap": _BACKFILL_CAP},
)
).all()
if not rows:
return 0, 0
sources = [_learning_source(row.content) for row in rows]
existing = {
row[0]
for row in (
await session.execute(
text(
"SELECT source FROM chunks_learnings "
"WHERE source = ANY(CAST(:sources AS text[]))"
),
{"sources": sources},
)
).all()
}
from roboco.services.optimal_brain.indexes.learnings import (
RecordLearningParams as _JournalLearningParams,
)
processed = 0
missing = 0
for row, source in zip(rows, sources, strict=True):
if source in existing:
continue
missing += 1
try:
await optimal.record_learning(
_JournalLearningParams(
content=row.content,
category="journal_learning",
agent_id=row.agent_id,
task_id=row.task_id,
shareable=not row.is_private,
tags=list(row.tags or []),
)
)
processed += 1
except Exception as e:
logger.warning(
"RAG backfill: learning re-index failed (best-effort), "
f"entry_id={row.id}, error={e}"
)
return processed, missing - processed
async def backfill_unindexed_journals(optimal: Any) -> dict[str, int]:
"""Re-ingest journal/learning entries silently zero-chunked before the
per-index chunk-floor fix.
``ingest()`` used to return success with ``chunk_count=0`` for undersized
content, so historical journal entries and their derived learnings were
durably recorded in ``journal_entries`` but never landed a row in
``chunks_journals`` / ``chunks_learnings`` invisible to RAG search
though the dead-letter above only ever covered rows that raised, not rows
that silently zero-chunked.
Each pass is independently gated (skipped if that index's plugin never
initialized), capped per boot, and best-effort per row one failing row
never blocks the rest. Best-effort at this level too: a hard failure
(e.g. a lost DB connection) never blocks startup, and it converges over
restarts since a successfully re-indexed row simply stops matching the
candidate query.
"""
try:
j_processed, j_remaining = await _backfill_journals(optimal)
except Exception as e:
logger.warning(f"RAG backfill: journals pass failed; continuing, error={e}")
j_processed, j_remaining = 0, 0
try:
l_processed, l_remaining = await _backfill_learnings(optimal)
except Exception as e:
logger.warning(f"RAG backfill: learnings pass failed; continuing, error={e}")
l_processed, l_remaining = 0, 0
if j_processed or l_processed:
logger.info(
"RAG backfill: re-indexed historical zero-chunk entries "
f"(journals processed={j_processed} remaining={j_remaining}, "
f"learnings processed={l_processed} remaining={l_remaining})"
)
return {
"journals_processed": j_processed,
"journals_remaining": j_remaining,
"learnings_processed": l_processed,
"learnings_remaining": l_remaining,
}
__all__ = [
"_serialize_journal_payload",
"backfill_unindexed_journals",
"count_failures",
"persist_failure",
"reclaim_due",
+3 -3
View File
@@ -13,9 +13,9 @@ parallel development without conflicts:
[git repo files]
Example:
/data/workspaces/roboco/backend/be-dev-1/
/data/workspaces/roboco/backend/be-dev-2/
/data/workspaces/roboco/frontend/fe-dev-1/
/data/workspaces/roboco-api/backend/be-dev-1/
/data/workspaces/roboco-api/backend/be-dev-2/
/data/workspaces/roboco-api/frontend/fe-dev-1/
"""
import asyncio
@@ -0,0 +1,79 @@
"""project_slug defaults to ROBOCO_PROJECT_SLUG when omitted on every
roboco-git-readonly tool agents no longer need to guess a slug, and the
RAG-taught literal "roboco" example (a non-existent slug) can't 404 them.
"""
from __future__ import annotations
import importlib
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
import types
@pytest.fixture
def git_module(monkeypatch: pytest.MonkeyPatch) -> types.ModuleType:
monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000042")
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.delenv("ROBOCO_PROJECT_SLUG", raising=False)
import roboco.mcp.git_readonly as srv
importlib.reload(srv)
return srv
def test_resolve_omitted_falls_back_to_env(
git_module: types.ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ROBOCO_PROJECT_SLUG", "roboco-api")
assert git_module._resolve_project_slug(None) == "roboco-api"
assert git_module._resolve_project_slug("") == "roboco-api"
def test_resolve_supplied_passes_through(
git_module: types.ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ROBOCO_PROJECT_SLUG", "roboco-api")
assert git_module._resolve_project_slug("roboco-panel") == "roboco-panel"
def test_resolve_neither_present_returns_clear_error(
git_module: types.ModuleType,
) -> None:
result = git_module._resolve_project_slug(None)
assert isinstance(result, dict)
assert result["error"] == "missing_project_slug"
assert "ROBOCO_PROJECT_SLUG" in result["detail"]
def _mock_response(payload: dict) -> MagicMock:
resp = MagicMock()
resp.raise_for_status.return_value = None
resp.json.return_value = payload
return resp
def test_git_status_omitted_slug_uses_env(
git_module: types.ModuleType, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setenv("ROBOCO_PROJECT_SLUG", "roboco-api")
with patch("httpx.Client") as client_cls:
client = client_cls.return_value.__enter__.return_value
client.get.return_value = _mock_response({"branch": "main"})
git_module.roboco_git_status()
_, kwargs = client.get.call_args
assert kwargs["params"]["project_slug"] == "roboco-api"
def test_git_status_missing_both_returns_error_without_http_call(
git_module: types.ModuleType,
) -> None:
with patch("httpx.Client") as client_cls:
result = git_module.roboco_git_status()
client_cls.assert_not_called()
assert result["error"] == "missing_project_slug"
@@ -0,0 +1,86 @@
"""Per-index-type chunk-length floor — journals/learnings are short by
design (templated notes, distilled lessons) and were always discarded by
the global 200-char garbage filter ("All chunks filtered as garbage",
raw_count=1 on every journal write). Journals get a 40-char floor,
learnings an 80-char floor; every other index keeps the 200-char default.
"""
from __future__ import annotations
from roboco.models.optimal import IndexType
from roboco.services.optimal_brain.indexes.base import (
_MIN_CHUNK_LENGTH,
IndexConfig,
_filter_quality_chunks,
)
from roboco.services.optimal_brain.text_chunker import Chunk
# Named constants — ruff PLR2004 forbids magic-number comparisons.
_JOURNALS_FLOOR = 40
_LEARNINGS_FLOOR = 80
_DEFAULT_FLOOR = _MIN_CHUNK_LENGTH # 200
def _chunk(text: str) -> Chunk:
return Chunk(text=text, source="roboco://journals/entry-1")
# ---------------------------------------------------------------------------
# IndexConfig.from_settings — per-index-type floor
# ---------------------------------------------------------------------------
def test_journals_config_lowers_floor_to_40() -> None:
assert IndexConfig.from_settings(IndexType.JOURNALS).min_chunk_length == (
_JOURNALS_FLOOR
)
def test_learnings_config_lowers_floor_to_80() -> None:
assert IndexConfig.from_settings(IndexType.LEARNINGS).min_chunk_length == (
_LEARNINGS_FLOOR
)
def test_other_index_keeps_default_floor() -> None:
assert IndexConfig.from_settings(IndexType.DOCUMENTATION).min_chunk_length == (
_DEFAULT_FLOOR
)
assert IndexConfig.from_settings(IndexType.CODE).min_chunk_length == _DEFAULT_FLOOR
assert IndexConfig(persist_dir="/tmp/idx").min_chunk_length == _DEFAULT_FLOOR
# ---------------------------------------------------------------------------
# _filter_quality_chunks — the actual gate
# ---------------------------------------------------------------------------
def test_120_char_journal_chunk_passes_journals_floor() -> None:
text = (
"Reflected on task rate-limiter-fix: fixed the off-by-one boundary "
"check in the 429 threshold and added a regression test."
)
assert len(text) >= _JOURNALS_FLOOR * 3
kept = _filter_quality_chunks([_chunk(text)], min_chunk_length=_JOURNALS_FLOOR)
assert len(kept) == 1
def test_150_char_chunk_still_fails_default_200_floor() -> None:
text = "x" * (_DEFAULT_FLOOR - 50)
kept = _filter_quality_chunks([_chunk(text)], min_chunk_length=_DEFAULT_FLOOR)
assert kept == []
def test_default_index_behavior_unchanged_no_arg() -> None:
"""Omitting min_chunk_length keeps the historical 200-char behavior."""
short = _chunk("x" * (_DEFAULT_FLOOR - 50))
long_enough = _chunk("x" * (_DEFAULT_FLOOR + 50))
assert _filter_quality_chunks([short]) == []
assert _filter_quality_chunks([long_enough]) == [long_enough]
def test_markdown_only_chunk_still_filtered_at_lowered_floor() -> None:
"""A 40-char chunk that's mostly ``` / --- / # formatting is still junk."""
text = "#" * (_JOURNALS_FLOOR // 2) + "-" * (_JOURNALS_FLOOR // 2)
kept = _filter_quality_chunks([_chunk(text)], min_chunk_length=_JOURNALS_FLOOR)
assert kept == []
+505
View File
@@ -0,0 +1,505 @@
"""DB-backed tests for the journals/learnings zero-chunk RAG backfill.
Before the per-index chunk-floor fix, ``ingest()`` returned success with
``chunk_count=0`` for undersized content, so historical journal entries and
learnings were durably recorded in ``journal_entries`` but never landed a row
in the vector store. These tests exercise the REAL SQL against a real
Postgres (`db_session`, see tests/conftest.py) the JOIN/floor/ANY(...)
logic can't be verified with a mocked session. ``optimal`` itself is mocked
(index_journal_entry / record_learning) since the embedder is out of scope.
Run with: ROBOCO_TEST_DB_PORT=55432 ROBOCO_TEST_DB_USER=renzof pytest ...
``db_session`` rolls back at the end of each test, so nothing THIS file seeds
leaks between its own tests. But the DB itself is session-scoped for the
whole pytest run, and other test files commit real journal/learning rows
against it (e.g. via gateway note-writing tests) a full-suite / CI run can
start this file's tests with a non-empty ``journal_entries`` table. Every
assertion below is therefore scoped to the specific rows a test creates
(by entry_id or by a uniquified content string), never to a global
processed/remaining count except the cap test, which measures the
pre-existing "stray" candidate count first and sizes the cap around it.
"""
from __future__ import annotations
import hashlib
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from roboco.db.tables import AgentTable, JournalEntryTable, JournalTable
from roboco.models.base import AgentRole, AgentStatus, JournalEntryType, Team
from roboco.services import rag_index_failures as rif
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
if TYPE_CHECKING:
from roboco.models.optimal import IndexJournalEntryParams
from roboco.services.optimal_brain.indexes.learnings import RecordLearningParams
# Journals floor is 40, learnings floor is 80 (see optimal_brain/indexes/base.py).
_LONG_CONTENT = "x" * 90 # clears both floors
_MID_CONTENT = "y" * 50 # clears JOURNALS (40) but not LEARNINGS (80)
_SHORT_CONTENT = "short" # clears neither floor
_TEST_CAP = 2 # how many of THIS test's own rows the cap test expects to fit
def _unique_content(base: str) -> str:
"""A floor-clearing content string that can't collide with another
test's stray committed row, so hash/content-keyed presence checks stay
deterministic under DB pollution."""
return f"{base}-{uuid4().hex[:8]}"
@pytest_asyncio.fixture(scope="session", loop_scope="session", autouse=True)
async def _chunks_tables(_test_database_url: str) -> None:
"""Create minimal chunks_journals/chunks_learnings tables once.
VectorStore normally provisions these (id, content, source, embedding,
metadata, tsv); the backfill queries only touch ``source``, so a minimal
shape is enough. Created via a throwaway engine so the DDL commits
outside any per-test rolled-back transaction.
"""
engine = create_async_engine(_test_database_url, future=True)
try:
async with engine.begin() as conn:
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS chunks_journals "
"(id serial primary key, source text not null)"
)
)
await conn.execute(
text(
"CREATE TABLE IF NOT EXISTS chunks_learnings "
"(id serial primary key, source text not null)"
)
)
finally:
await engine.dispose()
@pytest.fixture(autouse=True)
def _redirect_db_context(
monkeypatch: pytest.MonkeyPatch, db_session: AsyncSession
) -> None:
"""Make get_db_context() (used inside the backfill functions) reuse the
test's own session/transaction, so uncommitted seed rows are visible
(read-your-own-writes) without needing an explicit commit + rollback of
the whole test DB's shared tables between tests."""
class _Ctx:
async def __aenter__(self) -> AsyncSession:
return db_session
async def __aexit__(self, *_a: object) -> None:
return None
monkeypatch.setattr(rif, "get_db_context", _Ctx)
@pytest_asyncio.fixture
async def _journal(db_session: AsyncSession) -> UUID:
"""Seed one agent + its journal; returns the journal id."""
agent = AgentTable(
id=uuid4(),
name="Backfill Test Agent",
slug=f"backfill-{uuid4().hex[:8]}",
role=AgentRole.DEVELOPER,
team=Team.BACKEND,
status=AgentStatus.ACTIVE,
model_config={},
system_prompt="x",
capabilities=[],
permissions={},
metrics={},
)
db_session.add(agent)
await db_session.flush()
journal = JournalTable(id=uuid4(), agent_id=agent.id)
db_session.add(journal)
await db_session.flush()
return UUID(str(journal.id))
async def _seed_entry(
db_session: AsyncSession,
journal_id: UUID,
content: str,
*,
entry_type: JournalEntryType = JournalEntryType.GENERAL,
is_private: bool = False,
) -> UUID:
entry = JournalEntryTable(
id=uuid4(),
journal_id=journal_id,
type=entry_type,
title="t",
content=content,
is_private=is_private,
tags=[],
)
db_session.add(entry)
await db_session.flush()
return UUID(str(entry.id))
async def _insert_chunk(db_session: AsyncSession, table: str, source: str) -> None:
await db_session.execute(
text(f"INSERT INTO {table} (source) VALUES (:s)"), {"s": source}
)
def _optimal(**overrides: Any) -> MagicMock:
optimal = MagicMock()
optimal.is_index_registered = MagicMock(return_value=True)
optimal.index_journal_entry = AsyncMock()
optimal.record_learning = AsyncMock()
for key, value in overrides.items():
setattr(optimal, key, value)
return optimal
# ---------------------------------------------------------------------------
# _backfill_journals
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_journals_selects_only_zero_chunk_entries(
db_session: AsyncSession, _journal: UUID
) -> None:
"""An entry with an existing chunks_journals row is left alone; one with
none is re-ingested. Scoped to these two entry_ids since a polluted DB
may carry other zero-chunk stray rows the pass also processes."""
missing_id = await _seed_entry(db_session, _journal, _LONG_CONTENT)
present_id = await _seed_entry(db_session, _journal, _LONG_CONTENT)
await _insert_chunk(
db_session, "chunks_journals", f"roboco://journals/{present_id}"
)
optimal = _optimal()
await rif._backfill_journals(optimal)
called_ids = {
c.args[0].entry_id for c in optimal.index_journal_entry.await_args_list
}
assert missing_id in called_ids
assert present_id not in called_ids
@pytest.mark.asyncio
async def test_backfill_journals_excludes_private_entries(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A private entry is never a candidate — it's deliberately excluded
from the shared JOURNALS corpus, not a pending backlog item."""
private_id = await _seed_entry(db_session, _journal, _LONG_CONTENT, is_private=True)
optimal = _optimal()
await rif._backfill_journals(optimal)
called_ids = {
c.args[0].entry_id for c in optimal.index_journal_entry.await_args_list
}
assert private_id not in called_ids
@pytest.mark.asyncio
async def test_backfill_journals_excludes_sub_floor_entries(
db_session: AsyncSession, _journal: UUID
) -> None:
"""Content still under the JOURNALS floor would zero-chunk again — the
SELECT excludes it so it is never retried forever."""
short_id = await _seed_entry(db_session, _journal, _SHORT_CONTENT)
optimal = _optimal()
await rif._backfill_journals(optimal)
called_ids = {
c.args[0].entry_id for c in optimal.index_journal_entry.await_args_list
}
assert short_id not in called_ids
@pytest.mark.asyncio
async def test_backfill_journals_respects_cap(
db_session: AsyncSession, _journal: UUID, monkeypatch: pytest.MonkeyPatch
) -> None:
"""The per-boot cap bounds how many rows a single pass fetches.
A polluted DB's stray zero-chunk rows sort first (earliest created_at)
and spend part of the cap before this test's own rows do. So: measure
the stray count with an unbounded pass first (mocked nothing is
actually written to chunks_journals), then size the cap to exactly
`strays + _TEST_CAP` and seed one row more than that budget covers.
Only _TEST_CAP of this test's own rows can then fit.
"""
monkeypatch.setattr(rif, "_BACKFILL_CAP", 1_000_000)
strays, _ = await rif._backfill_journals(_optimal())
own_ids = [
await _seed_entry(db_session, _journal, _LONG_CONTENT)
for _ in range(_TEST_CAP + 1)
]
monkeypatch.setattr(rif, "_BACKFILL_CAP", strays + _TEST_CAP)
optimal = _optimal()
processed, _remaining = await rif._backfill_journals(optimal)
assert processed == strays + _TEST_CAP
called_ids = {
c.args[0].entry_id for c in optimal.index_journal_entry.await_args_list
}
assert len(called_ids & set(own_ids)) == _TEST_CAP
@pytest.mark.asyncio
async def test_backfill_journals_tolerates_failing_row(
db_session: AsyncSession, _journal: UUID
) -> None:
"""One row's re-index failure never aborts the rest of the pass — the
fake ingest rejects THIS test's own fail_id specifically (by entry_id),
so the proof holds regardless of how many stray rows are also in play."""
fail_id = await _seed_entry(db_session, _journal, _LONG_CONTENT)
ok_id = await _seed_entry(db_session, _journal, _LONG_CONTENT)
succeeded: set[UUID] = set()
async def _index(params: IndexJournalEntryParams) -> None:
if params.entry_id == fail_id:
raise RuntimeError("ollama down")
succeeded.add(params.entry_id)
optimal = _optimal(index_journal_entry=AsyncMock(side_effect=_index))
await rif._backfill_journals(optimal)
called_ids = {
c.args[0].entry_id for c in optimal.index_journal_entry.await_args_list
}
assert fail_id in called_ids
assert ok_id in succeeded
assert fail_id not in succeeded
@pytest.mark.asyncio
async def test_backfill_journals_noop_when_index_not_registered(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A JOURNALS plugin that never initialized is skipped, not errored."""
await _seed_entry(db_session, _journal, _LONG_CONTENT)
optimal = _optimal(is_index_registered=MagicMock(return_value=False))
processed, remaining = await rif._backfill_journals(optimal)
assert (processed, remaining) == (0, 0)
optimal.index_journal_entry.assert_not_awaited()
# ---------------------------------------------------------------------------
# _backfill_learnings
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_learnings_selects_only_missing_from_chunks_learnings(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A LEARNING entry already present in chunks_journals (lower floor) can
still be missing from chunks_learnings (higher floor) the two passes
check presence independently. Content is uniquified so this test's own
row can't be shadowed by a stray row of identical content."""
content = _unique_content(_LONG_CONTENT)
entry_id = await _seed_entry(
db_session, _journal, content, entry_type=JournalEntryType.LEARNING
)
# Already indexed into JOURNALS — irrelevant to the LEARNINGS check.
await _insert_chunk(db_session, "chunks_journals", f"roboco://journals/{entry_id}")
optimal = _optimal()
await rif._backfill_learnings(optimal)
calls_by_content = {
c.args[0].content: c.args[0] for c in optimal.record_learning.await_args_list
}
assert content in calls_by_content
assert calls_by_content[content].shareable is True
@pytest.mark.asyncio
async def test_backfill_learnings_skips_already_present(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A learning whose hashed source already has a chunks_learnings row is
left alone."""
content = _unique_content(_LONG_CONTENT)
await _seed_entry(
db_session, _journal, content, entry_type=JournalEntryType.LEARNING
)
await _insert_chunk(db_session, "chunks_learnings", rif._learning_source(content))
optimal = _optimal()
await rif._backfill_learnings(optimal)
called_contents = {
c.args[0].content for c in optimal.record_learning.await_args_list
}
assert content not in called_contents
@pytest.mark.asyncio
async def test_backfill_learnings_excludes_sub_floor_entries(
db_session: AsyncSession, _journal: UUID
) -> None:
"""Content between the JOURNALS floor and the (higher) LEARNINGS floor
would zero-chunk again in LEARNINGS excluded so it's never retried
forever. The length-based SQL filter excludes it structurally, so this
holds regardless of any other candidate rows in play."""
await _seed_entry(
db_session, _journal, _MID_CONTENT, entry_type=JournalEntryType.LEARNING
)
optimal = _optimal()
await rif._backfill_learnings(optimal)
called_contents = {
c.args[0].content for c in optimal.record_learning.await_args_list
}
assert _MID_CONTENT not in called_contents
@pytest.mark.asyncio
async def test_backfill_learnings_shareable_reflects_privacy(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A private learning is still recorded, just non-shareable — mirrors
the live _schedule_rag_index path."""
content = _unique_content(_LONG_CONTENT)
await _seed_entry(
db_session,
_journal,
content,
entry_type=JournalEntryType.LEARNING,
is_private=True,
)
optimal = _optimal()
await rif._backfill_learnings(optimal)
calls_by_content = {
c.args[0].content: c.args[0] for c in optimal.record_learning.await_args_list
}
assert content in calls_by_content
assert calls_by_content[content].shareable is False
@pytest.mark.asyncio
async def test_backfill_learnings_tolerates_failing_row(
db_session: AsyncSession, _journal: UUID
) -> None:
"""One row's re-index failure never aborts the rest of the pass — the
fake ingest rejects THIS test's own fail_content specifically (by
content), so the proof holds regardless of stray rows also in play."""
fail_content = _unique_content(f"{_LONG_CONTENT}-fail")
ok_content = _unique_content(f"{_LONG_CONTENT}-ok")
await _seed_entry(
db_session, _journal, fail_content, entry_type=JournalEntryType.LEARNING
)
await _seed_entry(
db_session, _journal, ok_content, entry_type=JournalEntryType.LEARNING
)
succeeded: set[str] = set()
async def _record(params: RecordLearningParams) -> None:
if params.content == fail_content:
raise RuntimeError("ollama down")
succeeded.add(params.content)
optimal = _optimal(record_learning=AsyncMock(side_effect=_record))
await rif._backfill_learnings(optimal)
called_contents = {
c.args[0].content for c in optimal.record_learning.await_args_list
}
assert fail_content in called_contents
assert ok_content in succeeded
assert fail_content not in succeeded
@pytest.mark.asyncio
async def test_backfill_learnings_noop_when_index_not_registered(
db_session: AsyncSession, _journal: UUID
) -> None:
"""A LEARNINGS plugin that never initialized is skipped, not errored."""
await _seed_entry(
db_session, _journal, _LONG_CONTENT, entry_type=JournalEntryType.LEARNING
)
optimal = _optimal(is_index_registered=MagicMock(return_value=False))
processed, remaining = await rif._backfill_learnings(optimal)
assert (processed, remaining) == (0, 0)
optimal.record_learning.assert_not_awaited()
# ---------------------------------------------------------------------------
# _learning_source — must match LearningsIndexPlugin.record_learning exactly,
# or the presence check can never find what the live path indexed.
# ---------------------------------------------------------------------------
def test_learning_source_matches_plugin_hash() -> None:
content = "a distilled lesson"
expected_hash = hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[
:16
]
assert rif._learning_source(content) == f"roboco://learnings/lrn-{expected_hash}"
# ---------------------------------------------------------------------------
# backfill_unindexed_journals — orchestration + isolation between passes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_backfill_unindexed_journals_isolates_pass_failures(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A hard failure in one pass (e.g. lost DB connection) doesn't prevent
the other pass from running."""
monkeypatch.setattr(
rif, "_backfill_journals", AsyncMock(side_effect=RuntimeError("db down"))
)
learnings_mock = AsyncMock(return_value=(3, 1))
monkeypatch.setattr(rif, "_backfill_learnings", learnings_mock)
result = await rif.backfill_unindexed_journals(MagicMock())
assert result == {
"journals_processed": 0,
"journals_remaining": 0,
"learnings_processed": 3,
"learnings_remaining": 1,
}
learnings_mock.assert_awaited_once()
@pytest.mark.asyncio
async def test_backfill_unindexed_journals_returns_combined_counts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Happy path: both passes run and their counts are combined."""
monkeypatch.setattr(rif, "_backfill_journals", AsyncMock(return_value=(2, 0)))
monkeypatch.setattr(rif, "_backfill_learnings", AsyncMock(return_value=(1, 0)))
result = await rif.backfill_unindexed_journals(MagicMock())
assert result == {
"journals_processed": 2,
"journals_remaining": 0,
"learnings_processed": 1,
"learnings_remaining": 0,
}