From 2855f9a0edaa5d27d69580667168585fb5401788 Mon Sep 17 00:00:00 2001 From: jrob5756 Date: Wed, 7 Oct 2026 16:29:04 -0400 Subject: [PATCH] fix(mcp): refresh floating registry defaults at startup --- changelog.d/595.fixed.md | 3 + docs/mcp-server.md | 8 + .../mcp-server/conductor-mcp.design.md | 19 +- .../projects/mcp-server/conductor-mcp.plan.md | 28 +- src/conductor/mcp/serve/catalogue.py | 66 +++-- tests/test_mcp/test_serve_catalogue.py | 267 +++++++++++++++--- 6 files changed, 303 insertions(+), 88 deletions(-) create mode 100644 changelog.d/595.fixed.md diff --git a/changelog.d/595.fixed.md b/changelog.d/595.fixed.md new file mode 100644 index 00000000..e1afd601 --- /dev/null +++ b/changelog.d/595.fixed.md @@ -0,0 +1,3 @@ +**MCP registry defaults**: server startup now checks for a newer default-branch +commit even with a warm cache, while retaining the recorded SHA as a fallback +when ref resolution fails. diff --git a/docs/mcp-server.md b/docs/mcp-server.md index 2f37e28a..1df50a03 100644 --- a/docs/mcp-server.md +++ b/docs/mcp-server.md @@ -112,6 +112,14 @@ narrows which *registries* are even considered before the ladder runs. `--workflow-dir ` (repeatable) adds workflows from a local directory alongside, or instead of, any registry. +At every server start, configured GitHub registries check the current default +branch and pin the catalogue to its commit SHA. Content already cached at that +SHA is reused; if default-ref resolution fails, the server falls back to the +last recorded SHA (subject to cache source validation). A running catalogue +stays frozen at its startup SHA and tool list: restart the server to adopt +registry changes. `conductor doctor mcp` uses an offline, cache-only snapshot, +not the online default-ref check performed by server startup. + The startup summary the server prints to stderr (see [Startup Summary](#startup-summary)) names every workflow it *does* expose, its source registry, and its pinned identity — a workflow diff --git a/docs/projects/mcp-server/conductor-mcp.design.md b/docs/projects/mcp-server/conductor-mcp.design.md index 31d13e6a..498bfff9 100644 --- a/docs/projects/mcp-server/conductor-mcp.design.md +++ b/docs/projects/mcp-server/conductor-mcp.design.md @@ -322,8 +322,9 @@ independent of building a server — see DD0 for the empirical result. a registry is large. 8. **G8** — Every exposed workflow is pinned to an immutable identity at session start, and drift is reported rather than silently applied. -9. **G9** — Server startup does not depend on the network when the registry - cache is warm. +9. **G9** — A warm registry cache supports an explicitly cache-only build + without network access and a fallback for failed online ref resolution; + normal server startup checks floating defaults online (issue #595). 10. **G10** — Every governance control a workflow author declared still applies when the caller is a model: gates gate, budgets cap, schemas validate. The server adds a caller, not an exemption. @@ -377,7 +378,7 @@ independent of building a server — see DD0 for the empirical result. | ID | Requirement | |---|---| -| NFR1 | Cold-start to first `tools/list` response ≤ 2s with a warm registry cache, with **zero network I/O**. | +| NFR1 | Cold-start to first `tools/list` response ≤ 2s with a warm registry cache in an explicitly cache-only build, with **zero network I/O**. **Issue #595 amendment:** ordinary online server startup re-resolves each GitHub registry's floating default ref even with a warm cache; this network resolution is outside the schema-resolution deadline. On resolution failure, a recorded SHA provides a cached fallback. | | NFR2 | A workflow whose schema cannot be resolved is exposed with a permissive schema and a description saying so — never silently dropped. | | NFR3 | No tool **accepts** a filesystem path, URL, or registry source as a parameter. (Returning a path *outward*, as DD12's `resource_link`s do, is the opposite direction and is not constrained by this rule.) | | NFR4 | Any YAML-authored text reaching a tool `description` is sanitized and length-capped. | @@ -479,7 +480,10 @@ achievable: 2. **SHA-keyed parse cache** — a parsed, normalized tool definition stored beside the existing mirror under `$CONDUCTOR_HOME/cache/registries//_meta//`. A SHA-keyed - entry is immutable, so a warm cache makes startup a no-network operation. + entry is immutable, so a warm cache avoids re-parsing or fetching at the + selected SHA. Online startup still checks the default ref first; explicitly + cache-only catalogue builds use the recorded pointer with no network I/O + (issue #595). This reuses `registry/cache.py`'s existing layout, sentinel convention, and `CACHE_LAYOUT_VERSION` invalidation rather than inventing a second cache. 3. **Fetch and parse**, under a startup deadline. On failure — including the @@ -1449,8 +1453,11 @@ terminal record alongside its event log, DD13), `registry/index.py` and them. **Performance.** Startup is the sensitive path (hosts respawn stdio servers -aggressively). Warm cache: local reads only, no network (NFR1). Cold cache: one -index fetch plus one fetch per unresolved workflow, under a deadline, with +aggressively). A warm cache permits zero-network explicitly cache-only builds, +and supplies a fallback when online default-ref resolution fails; ordinary +online startup still resolves the default branch before reusing any SHA-keyed +index or schema cache (issue #595). Cold cache: one index fetch plus one fetch +per unresolved workflow, with a deadline for schema resolution and degraded-schema fallback. Per-invocation cost is one process fork plus the existing three-stage health gate — the same cost `conductor run --web-bg` already pays. `conductor_run_status` on a live run is a bounded tail read of diff --git a/docs/projects/mcp-server/conductor-mcp.plan.md b/docs/projects/mcp-server/conductor-mcp.plan.md index a48c4cc7..796acdf5 100644 --- a/docs/projects/mcp-server/conductor-mcp.plan.md +++ b/docs/projects/mcp-server/conductor-mcp.plan.md @@ -73,7 +73,7 @@ and test churn are accepted. Consequences propagated: **R2 — A floating registry ref resolves offline through a `_meta/_refs/.json` pointer in `registry/cache.py`.** *(affects E5)* -NFR1 ("zero network I/O" to first `tools/list` on a warm cache) and DD6 (every +NFR1 ("zero network I/O" in explicitly cache-only builds on a warm cache) and DD6 (every exposed workflow pinned to an immutable identity) were **unsatisfiable together** against the current code. Verified: `registry/cache.py` keys everything by SHA (`_meta//`), and the only way to learn what a floating ref (`latest`, @@ -88,8 +88,11 @@ fallback has a checkout to choose (`AGENTS.md`, *Git-backed plugin sources*: "without a record of what a floating ref last meant, the offline fallback has no checkout to choose"). The registry cache gains the same shape, written on every successful online resolution and read when a caller declares the network -off-limits. Startup is then genuinely zero-network on a warm cache, and DD6's -interval drift re-check is what refreshes the pointer. +off-limits. **Issue #595 amendment:** explicitly cache-only builds are +zero-network on a warm cache; ordinary online startup re-resolves the default +branch and refreshes the pointer on success. Resolution failure falls back to +the recorded SHA. Interval drift checks only report drift; they do not refresh +the pointer or mutate an existing catalogue. --- @@ -190,7 +193,8 @@ about run state: an offline-answerable registry, and a typed `mcp:` block. **Exit criteria** - [ ] A warm registry cache answers "what is this workflow's `input:` and `mcp:` block, at what SHA" with **zero** network calls, proven by a test - that patches `registry/github.py` to raise on any call (NFR1, G9, R2). + that patches `registry/github.py` to raise on any call in an explicitly + cache-only build (NFR1, G9, R2); online startup checks the ref first. - [ ] A floating ref resolves offline through the `_meta/_refs/` pointer, and a cold cache still resolves online exactly as today (R2). - [ ] `workflow.mcp:` parses, validates, and is reported by `conductor @@ -205,7 +209,8 @@ No protocol, no process launching — pure functions over the registry. **Exit criteria** - [ ] `build_catalogue(...)` returns an immutable catalogue from a fixture - registry in under 2s with the network patched to raise (NFR1). + registry in under 2s with the network patched to raise and + `allow_network=False` (NFR1); default online startup re-resolves refs. - [ ] The four-rung exposure ladder (`--deny` > `--allow` > `mcp.expose` > default-on) is exercised in every ordering that distinguishes the rungs (FR2, DD4). @@ -511,9 +516,9 @@ single-forward-pass constraint is untouched. ### E5 — Registry index fields, offline ref pointer, and parse cache (P4, NFR1, G9, R2) — DONE (completed 2026-08-22) -**Goal.** Make the catalogue answerable from a warm cache with zero network -I/O — the three-tier schema ladder's first two tiers (*Key Components → 1*) — -and close the NFR1/DD6 conflict R2 resolves. +**Goal.** Make an explicitly cache-only catalogue answerable from a warm cache +with zero network I/O — the three-tier schema ladder's first two tiers +(*Key Components → 1*) — and close the NFR1/DD6 conflict R2 resolves. **Prerequisites.** E6 (for `McpConfig`, which E5-T1 imports); otherwise independent of E1–E4. @@ -536,7 +541,7 @@ API, so a floating ref cannot be resolved offline — the gap R2 closes. | E5-T5 | TEST | Index round-trip with and without the new fields; an old index loads unchanged; a new index loads on a build that ignores the fields. Ref pointer write/read, slug safety, and atomicity. Parse-cache hit avoids re-parse; `CACHE_LAYOUT_VERSION` bump invalidates. **The load-bearing test:** with every function in `registry/github.py` patched to raise, a warm cache still resolves a GitHub registry's workflows to schemas and SHAs (NFR1, G9, R2). | `tests/test_registry/test_index.py`, `tests/test_registry/test_cache.py` | DONE | **Acceptance criteria** -- [x] A warm cache answers "input schema + `mcp:` block + pinned SHA" for every workflow with the network patched to raise, including for a floating ref. +- [x] An explicitly cache-only build answers "input schema + `mcp:` block + pinned SHA" from a warm cache with the network patched to raise, including for a floating ref. Ordinary online startup re-resolves that ref first (issue #595). - [x] Existing registries and indexes work unchanged. - [x] A cold cache still resolves online exactly as today, and writes the pointer as a side effect. @@ -603,11 +608,12 @@ process launching. | E7-T6 | IMPL | `build_catalogue(...)`: enumerate → filter by the four-rung ladder (`--deny` > `--allow` > `mcp.expose` > default-on, with `--registry` selecting the candidate set one level above it) → resolve schemas through the three-tier ladder under a startup deadline → pin → sanitize → qualify collisions → decide direct-tools vs discovery. Return an immutable `Catalogue`. Reject a workflow whose `input:` collides with `_wait_seconds`, logging the reason per FR10. On any parse failure — including the `${VAR}`-missing and parent-directory `!file` cases from P4 — expose with `{"type": "object"}` and an explanatory description (NFR2). | `src/conductor/mcp/serve/catalogue.py` | DONE | | E7-T7 | TEST | Naming and sanitizing: slug charset and length, prefixing, both-sides collision qualification, control-character stripping, length cap. | `tests/test_mcp/test_serve_naming.py` | DONE | | E7-T8 | TEST | Tool generation: all five input types; `required`/`default`/`description` survive; `_wait_seconds` present and documented; no `outputSchema`; a workflow declaring `_wait_seconds` is rejected. | `tests/test_mcp/test_serve_toolgen.py` | DONE | -| E7-T9 | TEST | Catalogue: every ladder ordering that distinguishes a rung (`--deny` beats `--allow`; `--allow` overrides `mcp.expose: false`; `--registry` excludes non-candidates entirely); the schema ladder's three tiers; NFR1 (network patched to raise, warm cache, under 2s); NFR2 for both parse-failure modes; the discovery threshold decision. | `tests/test_mcp/test_serve_catalogue.py` | DONE | +| E7-T9 | TEST | Catalogue: every ladder ordering that distinguishes a rung (`--deny` beats `--allow`; `--allow` overrides `mcp.expose: false`; `--registry` excludes non-candidates entirely); the schema ladder's three tiers; NFR1 (network patched to raise, warm cache, `allow_network=False`, under 2s); NFR2 for both parse-failure modes; the discovery threshold decision. Online builds re-resolve floating defaults (issue #595). | `tests/test_mcp/test_serve_catalogue.py` | DONE | | E7-T10 | TEST | Pinning: SHA for GitHub, content hash for path, drift detected and reported without the catalogue changing. | `tests/test_mcp/test_serve_pinning.py` | DONE | **Acceptance criteria** -- [x] A frozen catalogue is built from a fixture registry with zero network I/O. +- [x] A frozen catalogue is built from a fixture registry with zero network I/O + in cache-only mode; ordinary online startup re-resolves floating refs. - [x] The exposure ladder behaves exactly as FR2 specifies in every distinguishing case. - [x] No workflow is ever silently dropped for an environmental reason. - [x] Two registries publishing one slug yield two qualified names. diff --git a/src/conductor/mcp/serve/catalogue.py b/src/conductor/mcp/serve/catalogue.py index 080e1852..be01c845 100644 --- a/src/conductor/mcp/serve/catalogue.py +++ b/src/conductor/mcp/serve/catalogue.py @@ -11,9 +11,9 @@ 1. Enumerate the registries selected by ``--registry`` (one level above the exposure ladder — a registry outside this set is never a candidate, full stop). -2. For each registry, load its index — for a GitHub registry, through the - E5 offline-capable ref-pointer + SHA-keyed cache path, so a warm cache - never touches the network (NFR1). +2. For each registry, load its index — online GitHub builds resolve the + current default ref before consulting the SHA-keyed cache; explicitly + cache-only builds use the recorded ref pointer without network I/O. 3. Filter each workflow through the four-rung exposure ladder (``--deny`` > ``--allow`` > ``mcp.expose`` > default-on, DD4). 4. Resolve each surviving workflow's schema through the three-tier ladder @@ -72,9 +72,9 @@ logger = logging.getLogger(__name__) -# NFR1: cold-start to first `tools/list` response <= 2s with a warm cache. # This bounds tier-3 (fetch-and-parse) attempts specifically -- a tier-1 or -# tier-2 hit never reaches this check. A workflow whose tier-3 resolution +# tier-2 hit never reaches this check. Online default-ref resolution is not +# covered by this deadline. A workflow whose tier-3 resolution # would blow the deadline degrades (NFR2) rather than stalling startup. DEFAULT_SCHEMA_RESOLUTION_DEADLINE_SECONDS = 2.0 @@ -276,11 +276,12 @@ def build_catalogue( ``registry.config.load_config()`` (``~/.conductor/registries.toml``); overridable so tests and embedders can supply a fixture set without touching disk-backed configuration. - allow_network: When ``False``, every registry resolution is - answered entirely from the local cache — no GitHub API calls - — matching NFR1. A cache miss under this constraint degrades - that workflow (or, for a whole unreachable registry, skips - it) rather than raising. + allow_network: When ``True`` (default), GitHub registries resolve + their current default branch on every build, then reuse the + SHA-keyed index cache when valid. A resolution failure falls + back to the recorded pointer. When ``False``, resolution is + cache-only with no GitHub API calls; a missing index skips the + registry rather than aborting the build. schema_resolution_deadline: Wall-clock seconds, from this call's start, budgeted for tier-3 (fetch-and-parse) schema resolution. Exceeding it degrades the remaining unresolved @@ -550,8 +551,8 @@ def _validated_cached_index( def _resolve_registry_index( registry_name: str, entry: RegistryEntry, *, allow_network: bool ) -> tuple[RegistryIndex, str | None]: - """Load a registry's index, honoring the E5 offline/warm-cache path - for GitHub registries. Returns ``(index, sha)`` — ``sha`` is ``None`` + """Load a registry's index, resolving online or using the E5 cache-only + path for GitHub registries. Returns ``(index, sha)`` — ``sha`` is ``None`` for path registries, which have no ref/SHA concept. Reuses ``registry/cache.py``'s E5 primitives directly (its own module @@ -561,25 +562,26 @@ def _resolve_registry_index( if entry.type == RegistryType.path: return load_index(entry), None - # NFR1: consult the warm cache first -- network is used only on a cold - # miss (no recorded ref pointer yet, or the index isn't cached for the - # pointer's SHA), even when allow_network=True. Resolving the ref - # online unconditionally would touch the network on every build, warm - # cache or not. + # An online build checks the floating default on every start. Only a + # resolution error falls back to the last recorded SHA; an index fetch + # failure for a newly resolved SHA must not publish an older catalogue. if allow_network: + using_fallback = False try: - offline_sha = registry_cache._resolve_sha_offline(registry_name, None) + resolved_ref = resolve_ref(entry, None) + sha = materialize_to_sha(entry, resolved_ref) except RegistryError: - offline_sha = None - if offline_sha is not None: - meta = registry_cache._meta_dir(registry_name, offline_sha) - cached_index = _validated_cached_index(meta, registry_name, entry, offline_sha) - if cached_index is not None: - return cached_index, offline_sha - - resolved_ref = resolve_ref(entry, None) - sha = materialize_to_sha(entry, resolved_ref) - registry_cache._write_ref_pointer(registry_name, None, sha) + sha = registry_cache._read_ref_pointer(registry_name, None) + if sha is None: + raise + using_fallback = True + logger.warning( + "Could not resolve default ref for registry %r; falling back to recorded SHA %s", + registry_name, + sha, + ) + else: + registry_cache._write_ref_pointer(registry_name, None, sha) else: sha = registry_cache._resolve_sha_offline(registry_name, None) @@ -588,6 +590,14 @@ def _resolve_registry_index( if cached_index is not None: return cached_index, sha + if allow_network and using_fallback: + metadata = registry_cache._read_source_metadata(meta) + if metadata is not None and not registry_cache._metadata_matches(metadata, entry, sha): + raise RegistryError( + f"Recorded SHA {sha} for registry {registry_name!r} has cached metadata " + "from a different source; refusing to use that index." + ) + if not allow_network: raise RegistryError( f"Registry {registry_name!r} index at {sha[:12]} is not available in the " diff --git a/tests/test_mcp/test_serve_catalogue.py b/tests/test_mcp/test_serve_catalogue.py index b884f2e1..1693ce5f 100644 --- a/tests/test_mcp/test_serve_catalogue.py +++ b/tests/test_mcp/test_serve_catalogue.py @@ -9,12 +9,16 @@ import textwrap import time from pathlib import Path +from unittest.mock import Mock import pytest from conductor.mcp.serve.catalogue import build_catalogue from conductor.mcp.serve.options import ServeOptions +from conductor.registry import cache as registry_cache from conductor.registry.config import RegistriesConfig, RegistryEntry, RegistryType +from conductor.registry.errors import RegistryError +from conductor.registry.index import RegistryIndex from tests.test_mcp.conftest import ( patch_github_network_to_raise, populate_github_warm_cache, @@ -23,6 +27,7 @@ ) _FAKE_SHA = "c" * 40 +_NEW_SHA = "d" * 40 _REVIEW_PR_YAML = """\ workflow: @@ -602,8 +607,8 @@ def test_tier3_fetch_and_parse_from_mirrored_file( def test_zero_network_build_completes_well_under_two_seconds( self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """NFR1: cold-start to first `tools/list` response <= 2s with a - warm registry cache, with zero network I/O.""" + """NFR1: a cache-only build with a warm registry cache completes + within the startup budget without network I/O.""" index_yaml = textwrap.dedent( """\ workflows: @@ -648,47 +653,6 @@ def test_unresolved_floating_ref_registry_is_skipped_not_fatal( catalogue = build_catalogue(ServeOptions(), registries_config=config, allow_network=False) assert catalogue.entries == () - def test_default_allow_network_path_uses_warm_cache_without_touching_network( - self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: - """NFR1: the *default* production path (``allow_network=True``, - the caller-facing default) must consult the warm cache first and - never resolve the ref online when the cache already answers -- - not just the explicit ``allow_network=False`` path exercised - above.""" - index_yaml = textwrap.dedent( - """\ - workflows: - qa-bot: - description: Simple Q&A - path: workflows/qa-bot.yaml - input: - question: - type: string - required: true - mcp: - mode: sync - """ - ) - populate_github_warm_cache( - conductor_home, - registry_name="official", - sha=_FAKE_SHA, - registry_source="myorg/workflows", - index_yaml=index_yaml, - ) - patch_github_network_to_raise(monkeypatch) - - entry = RegistryEntry(type=RegistryType.github, source="myorg/workflows") - config = _registries_config(official=entry) - - # No `allow_network=` override here -- this is the default an - # operator gets from `conductor mcp serve`. - catalogue = build_catalogue(ServeOptions(), registries_config=config) - - assert len(catalogue.entries) == 1 - assert catalogue.entries[0].resolution_tier == "index" - def test_repointed_registry_source_does_not_serve_stale_cached_index( self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -728,6 +692,223 @@ def test_repointed_registry_source_does_not_serve_stale_cached_index( assert catalogue.entries == () +# --------------------------------------------------------------------------- +# Online GitHub default-ref resolution (#595) +# --------------------------------------------------------------------------- + + +class TestOnlineDefaultRef: + _SOURCE = "myorg/workflows" + _A_INDEX = """\ +workflows: + old: + description: Old workflow + path: workflows/old.yaml + input: {} + mcp: {} +""" + _B_INDEX = """\ +workflows: + new: + description: New workflow + path: workflows/new.yaml + input: + answer: + type: number + required: true + mcp: {} +""" + + def _config(self, source: str = _SOURCE) -> RegistriesConfig: + return _registries_config(official=RegistryEntry(type=RegistryType.github, source=source)) + + def _warm( + self, home: Path, sha: str = _FAKE_SHA, index: str = _A_INDEX, source: str = _SOURCE + ) -> None: + populate_github_warm_cache( + home, + registry_name="official", + sha=sha, + registry_source=source, + index_yaml=index, + ) + + def _resolvers(self, monkeypatch: pytest.MonkeyPatch, sha: str = _NEW_SHA) -> tuple[Mock, Mock]: + patch_github_network_to_raise(monkeypatch) + ref = Mock(return_value="main") + materialize = Mock(return_value=sha) + monkeypatch.setattr("conductor.mcp.serve.catalogue.resolve_ref", ref) + monkeypatch.setattr("conductor.mcp.serve.catalogue.materialize_to_sha", materialize) + return ref, materialize + + @pytest.mark.parametrize("cached", [True, False]) + def test_stale_pointer_refreshes_index_and_pin( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch, cached: bool + ) -> None: + self._warm(conductor_home) + if cached: + self._warm(conductor_home, _NEW_SHA, self._B_INDEX) + # The fixture updates the pointer; restore the stale default. + registry_cache._write_ref_pointer("official", None, _FAKE_SHA) + ref, materialize = self._resolvers(monkeypatch) + new_index = RegistryIndex.model_validate( + { + "workflows": { + "new": { + "description": "New workflow", + "path": "workflows/new.yaml", + "input": {"answer": {"type": "number", "required": True}}, + "mcp": {}, + } + } + } + ) + load = Mock(return_value=new_index) + monkeypatch.setattr("conductor.mcp.serve.catalogue.load_index", load) + + catalogue = build_catalogue(ServeOptions(), registries_config=self._config()) + + ref.assert_called_once_with(self._config().registries["official"], None) + materialize.assert_called_once_with(self._config().registries["official"], "main") + assert [item.workflow for item in catalogue.entries] == ["new"] + assert catalogue.entries[0].tool.inputSchema["properties"]["answer"]["type"] == "number" + assert catalogue.entries[0].pin.value == _NEW_SHA + assert registry_cache._read_ref_pointer("official", None) == _NEW_SHA + if cached: + load.assert_not_called() + else: + load.assert_called_once_with(self._config().registries["official"], ref=_NEW_SHA) + + def test_unchanged_sha_resolves_twice_but_reuses_index( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._warm(conductor_home) + ref, materialize = self._resolvers(monkeypatch, _FAKE_SHA) + load = Mock(side_effect=AssertionError("warm index downloaded")) + monkeypatch.setattr("conductor.mcp.serve.catalogue.load_index", load) + + catalogue = build_catalogue(ServeOptions(), registries_config=self._config()) + + assert [item.workflow for item in catalogue.entries] == ["old"] + ref.assert_called_once() + materialize.assert_called_once() + load.assert_not_called() + + def test_cold_cache_persists_pointer_for_offline_build( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._resolvers(monkeypatch) + load = Mock( + return_value=RegistryIndex.model_validate( + {"workflows": {"new": {"path": "workflows/new.yaml", "input": {}, "mcp": {}}}} + ) + ) + monkeypatch.setattr("conductor.mcp.serve.catalogue.load_index", load) + online = build_catalogue(ServeOptions(), registries_config=self._config()) + assert online.entries[0].pin.value == _NEW_SHA + assert registry_cache._read_ref_pointer("official", None) == _NEW_SHA + + monkeypatch.setattr( + "conductor.mcp.serve.catalogue.resolve_ref", + Mock(side_effect=AssertionError("offline resolver called")), + ) + offline = build_catalogue( + ServeOptions(), registries_config=self._config(), allow_network=False + ) + assert offline.entries[0].pin.value == _NEW_SHA + load.assert_called_once() + + @pytest.mark.parametrize("stage", ["resolve", "materialize"]) + def test_failed_resolution_uses_warm_pointer_without_rewriting( + self, + conductor_home: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + stage: str, + ) -> None: + self._warm(conductor_home) + ref, materialize = self._resolvers(monkeypatch) + error = RegistryError("remote unavailable") + (ref if stage == "resolve" else materialize).side_effect = error + write = Mock(side_effect=AssertionError("pointer rewritten on failure")) + monkeypatch.setattr(registry_cache, "_write_ref_pointer", write) + + catalogue = build_catalogue(ServeOptions(), registries_config=self._config()) + + assert catalogue.entries[0].pin.value == _FAKE_SHA + assert catalogue.failed_registries == () + assert "official" in caplog.text and _FAKE_SHA in caplog.text + assert registry_cache._read_ref_pointer("official", None) == _FAKE_SHA + write.assert_not_called() + + @pytest.mark.parametrize("pointer", ["missing", "malformed"]) + @pytest.mark.parametrize("stage", ["resolve", "materialize"]) + def test_invalid_pointer_preserves_resolution_error( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch, pointer: str, stage: str + ) -> None: + if pointer == "malformed": + path = registry_cache._ref_pointer_path("official", None) + path.parent.mkdir(parents=True) + path.write_text('{"sha": "not-a-sha"}', encoding="utf-8") + ref, materialize = self._resolvers(monkeypatch) + (ref if stage == "resolve" else materialize).side_effect = RegistryError( + "remote unavailable" + ) + + catalogue = build_catalogue(ServeOptions(), registries_config=self._config()) + + assert catalogue.entries == () + assert len(catalogue.failed_registries) == 1 + assert "remote unavailable" in catalogue.failed_registries[0].reason + assert "network access is not permitted" not in catalogue.failed_registries[0].reason + + def test_fallback_rejects_cached_index_from_different_source( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._warm(conductor_home) + ref, _ = self._resolvers(monkeypatch) + ref.side_effect = RegistryError("remote unavailable") + load = Mock(side_effect=AssertionError("fetched index for a different source")) + monkeypatch.setattr("conductor.mcp.serve.catalogue.load_index", load) + catalogue = build_catalogue( + ServeOptions(), registries_config=self._config("other/repository") + ) + assert catalogue.entries == () + assert len(catalogue.failed_registries) == 1 + assert "different source" in catalogue.failed_registries[0].reason + assert registry_cache._read_ref_pointer("official", None) == _FAKE_SHA + load.assert_not_called() + + def test_new_sha_index_failure_does_not_rollback( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._warm(conductor_home) + self._resolvers(monkeypatch) + monkeypatch.setattr( + "conductor.mcp.serve.catalogue.load_index", + Mock(side_effect=RegistryError("new index unavailable")), + ) + catalogue = build_catalogue(ServeOptions(), registries_config=self._config()) + assert catalogue.entries == () + assert "new index unavailable" in catalogue.failed_registries[0].reason + assert registry_cache._read_ref_pointer("official", None) == _NEW_SHA + + def test_new_build_does_not_mutate_earlier_catalogue( + self, conductor_home: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + self._warm(conductor_home) + self._warm(conductor_home, _NEW_SHA, self._B_INDEX) + registry_cache._write_ref_pointer("official", None, _FAKE_SHA) + self._resolvers(monkeypatch, _FAKE_SHA) + first = build_catalogue(ServeOptions(), registries_config=self._config()) + self._resolvers(monkeypatch, _NEW_SHA) + second = build_catalogue(ServeOptions(), registries_config=self._config()) + assert [item.workflow for item in first.entries] == ["old"] + assert first.entries[0].pin.value == _FAKE_SHA + assert [item.workflow for item in second.entries] == ["new"] + assert second.entries[0].pin.value == _NEW_SHA + + # --------------------------------------------------------------------------- # Partial tier-1 index metadata (FR2/DD4) # ---------------------------------------------------------------------------