Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/repowise/cli/commands/init_cmd/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,13 @@ async def _index_with_resume() -> Any:
# this run, so persistence/state below treat it as index-only. So does
# `no_provider`: there is no model to do the work either way.
effective_index_only = index_only or cost_declined or no_provider
# The generation phase rebuilds the embedder, and that second build can
# degrade even when the header probe was clean (issue #1369). The phase
# records it on ``result``; fold it into the run record so state.json's
# ``degraded`` list reports it just like a header-probe degradation would.
gen_embedder_degraded = getattr(result, "embedder_degraded", None)
if gen_embedder_degraded:
run_warnings.append(gen_embedder_degraded)
print_phase_header(
console,
total_phases,
Expand Down
25 changes: 25 additions & 0 deletions packages/cli/src/repowise/cli/commands/init_cmd/generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
count_stub_fallbacks,
is_stub_fallback,
)
from repowise.core.providers.embedding.base import KeylessEmbedder

__all__ = [
"COST_GATE_USD",
Expand Down Expand Up @@ -285,6 +286,30 @@ def run_repo_generation(
vector_store: Any = build_vector_store(repo_path, embedder_impl)
result.vector_store = vector_store

# The generation phase rebuilds the embedder, and that second build can
# degrade even when the header probe was clean (Ollama stops mid-run, a key
# is revoked between the header and generation). ``build_embedder`` warns on
# stderr, which a human watching sees — but a scripted run only has the
# persisted state record, and the run would otherwise write mock vectors
# with nothing in ``state.json`` saying so. Record the degradation here, on
# the same object the callers persist, so a degraded generation-phase
# embedder is reported exactly like the header probe's.
#
# ``mock`` is the keyless default and reaching it is not a failure, so it is
# only reported when a real backend was requested and silently downgraded.
requested_real = embedder_name_resolved not in ("mock", "ollama")
degraded_embedder = isinstance(embedder_impl, KeylessEmbedder) and (
requested_real or embedder_name_resolved == "ollama"
)
if degraded_embedder:
result.embedder_degraded = (
f"Configured embedder {embedder_name_resolved!r} could not be built, "
"so this run wrote keyless (mock) vectors and has no semantic search. "
"Fix the key or endpoint and run `repowise reindex` to restore it."
)
else:
result.embedder_degraded = None

deterministic = bool(getattr(gen_config, "deterministic", False))

# Cost tracker backed by the real DB so every LLM call is persisted to the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,13 @@ def _render_from_templates() -> None:
state["model"] = provider.model_name
if repo_phase_timings:
state["phase_timings"] = repo_phase_timings
# The generation phase rebuilds the embedder, and that second build can
# degrade even when the header probe was clean (issue #1369). It records the
# degradation on ``result``; persist it here so an agent-driven workspace run
# sees it in state.json after the terminal is gone, like the single-repo flow.
gen_embedder_degraded = getattr(result, "embedder_degraded", None)
if gen_embedder_degraded:
state["degraded"] = [gen_embedder_degraded]
kg = getattr(result, "knowledge_graph_result", None)
if kg is not None:
state["knowledge_graph"] = build_kg_state(kg)
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/cli/test_init_failure_reporting.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,3 +547,93 @@ def test_run_repo_generation_uses_exact_job_id_when_provided(tmp_path, monkeypat
)

assert getattr(result, "failed_page_ids", None) == ["file_page:target_failure.ts"]


class TestGenerationPhaseEmbedderDegradation:
"""Issue #1369: a generation-phase embedder rebuild can degrade silently.

The init header probes the embedder and warns on degradation, but the
generation phase rebuilds it. If that second build fails — Ollama goes
down mid-run, a key is revoked between header and generation — the run
writes mock (keyless) vectors and a scripted run would record a clean
state.json with nothing saying so. ``run_repo_generation`` must record the
degradation on ``result`` so the callers fold it into ``state.json``.
"""

def _run(self, tmp_path, monkeypatch, *, result, requested: str) -> None:
monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.console.print", lambda *a, **k: None
)
monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.run_async",
lambda coro: [_page("file_page:lib/c.ts")],
)
monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation._enrich_knowledge_graph",
lambda **kw: None,
)
monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.flush_cost_tracker", lambda t: None
)
run_repo_generation(
repo_path=tmp_path,
result=result,
provider=SimpleNamespace(provider_name="mock", model_name="mock-model"),
gen_config=SimpleNamespace(max_concurrency=1, deterministic=False),
concurrency=1,
embedder_name_resolved=requested,
resume=False,
verbose=False,
)

@staticmethod
def _result() -> SimpleNamespace:
return SimpleNamespace(
repo_name="test_repo",
parsed_files=[],
source_map={},
graph_builder=MagicMock(),
repo_structure=MagicMock(),
git_meta_map={},
)

def test_real_embedder_degradation_is_recorded(self, tmp_path, monkeypatch):
from repowise.core.providers.embedding.base import KeylessEmbedder

monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.build_embedder",
lambda *a, **k: KeylessEmbedder(),
)
result = self._result()
self._run(tmp_path, monkeypatch, result=result, requested="ollama")

reason = getattr(result, "embedder_degraded", None)
assert reason is not None
assert "ollama" in reason
assert "keyless" in reason

def test_keyless_default_stays_silent(self, tmp_path, monkeypatch):
"""``mock`` is the keyless default; reaching it is not a failure."""
from repowise.core.providers.embedding.base import KeylessEmbedder

monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.build_embedder",
lambda *a, **k: KeylessEmbedder(),
)
result = self._result()
self._run(tmp_path, monkeypatch, result=result, requested="mock")

assert getattr(result, "embedder_degraded", None) is None

def test_healthy_rebuild_stays_silent(self, tmp_path, monkeypatch):
"""A real embedder that builds clean is not degraded."""
from repowise.core.providers.embedding.base import MockEmbedder

monkeypatch.setattr(
"repowise.cli.commands.init_cmd.generation.build_embedder",
lambda *a, **k: MockEmbedder(),
)
result = self._result()
self._run(tmp_path, monkeypatch, result=result, requested="openai")

assert getattr(result, "embedder_degraded", None) is None
Loading