From 1c21263a375d0e606f33bcb7d939634fbba32213 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:16:55 +0300 Subject: [PATCH 01/11] fix(provider): classify failures and stop repeated provider calls --- src/contextforge/models/__init__.py | 14 ++ src/contextforge/models/ollama.py | 8 +- src/contextforge/models/openai_compatible.py | 60 +++++++- src/contextforge/models/providers.py | 140 ++++++++++++++++++- tests/test_model_providers.py | 79 ++++++++++- tests/test_openai_compatible.py | 36 +++-- 6 files changed, 313 insertions(+), 24 deletions(-) diff --git a/src/contextforge/models/__init__.py b/src/contextforge/models/__init__.py index 4a4d0f1..6d91fa2 100644 --- a/src/contextforge/models/__init__.py +++ b/src/contextforge/models/__init__.py @@ -45,11 +45,18 @@ ModelRequest, ModelResponse, ModelUsage, + ProviderAuthenticationError, + ProviderAuthorizationError, ProviderCancelledError, ProviderCapabilities, + ProviderCircuitOpenError, ProviderConfiguration, ProviderConfigurationError, ProviderDiagnostic, + ProviderMissingCredentialError, + ProviderModelNotFoundError, + ProviderQuotaError, + ProviderRateLimitError, ProviderRequestError, ProviderRuntime, ProviderTimeoutError, @@ -124,10 +131,17 @@ "OpenAICompatibleModelProvider", "OpenAICompatibleTransport", "ProviderCancelledError", + "ProviderAuthenticationError", + "ProviderAuthorizationError", "ProviderCapabilities", + "ProviderCircuitOpenError", "ProviderConfiguration", "ProviderConfigurationError", "ProviderDiagnostic", + "ProviderModelNotFoundError", + "ProviderMissingCredentialError", + "ProviderQuotaError", + "ProviderRateLimitError", "ProviderRequestError", "ProviderRuntime", "ProviderTimeoutError", diff --git a/src/contextforge/models/ollama.py b/src/contextforge/models/ollama.py index e600460..a4cadf0 100644 --- a/src/contextforge/models/ollama.py +++ b/src/contextforge/models/ollama.py @@ -19,6 +19,8 @@ ModelRequest, ModelResponse, ModelUsage, + ProviderAuthenticationError, + ProviderAuthorizationError, ProviderCapabilities, ProviderConfiguration, ProviderConfigurationError, @@ -378,7 +380,11 @@ def _raise_for_ollama_http_error(status: int, data: bytes) -> None: raise StructuredOutputSchemaUnsupportedError( "Ollama rejected the structured output schema" ) - if status in {400, 401, 403, 404, 422}: + if status == 401: + raise ProviderAuthenticationError("Ollama rejected authentication (HTTP 401)") + if status == 403: + raise ProviderAuthorizationError("Ollama rejected authorization (HTTP 403)") + if status in {400, 404, 422}: raise ProviderRequestError(f"Ollama rejected the request (HTTP {status})") raise ProviderUnavailableError(f"Ollama returned HTTP status {status}") diff --git a/src/contextforge/models/openai_compatible.py b/src/contextforge/models/openai_compatible.py index b96a17c..33b940a 100644 --- a/src/contextforge/models/openai_compatible.py +++ b/src/contextforge/models/openai_compatible.py @@ -21,10 +21,15 @@ ModelRequest, ModelResponse, ModelUsage, + ProviderAuthenticationError, + ProviderAuthorizationError, ProviderCancelledError, ProviderCapabilities, ProviderConfiguration, ProviderConfigurationError, + ProviderModelNotFoundError, + ProviderQuotaError, + ProviderRateLimitError, ProviderRequestError, ProviderRuntime, ProviderTimeoutError, @@ -712,15 +717,21 @@ def _raise_for_status( if 200 <= status < 300: return detail = _safe_error_detail(response.body) + error_code = _safe_error_code(response.body) lowered = "" if detail is None else detail.casefold() + classified = "" if error_code is None else error_code.casefold() suffix = "" if detail is None else f": {detail}" - if status in {401, 403}: - raise ProviderRequestError( - f"OpenAI-compatible authentication failed with HTTP {status}{suffix}" + if status == 401: + raise ProviderAuthenticationError( + "OpenAI-compatible provider rejected authentication (HTTP 401)" + ) + if status == 403: + raise ProviderAuthorizationError( + "OpenAI-compatible provider rejected authorization (HTTP 403)" ) if status == 404 and operation == "chat completion": - raise ProviderRequestError( - f"model ID {model_id!r} was not found (HTTP 404){suffix}" + raise ProviderModelNotFoundError( + f"model ID {model_id!r} was not found (HTTP 404)" ) if ( status in {400, 422} @@ -773,7 +784,25 @@ def _raise_for_status( raise ProviderRequestError( f"OpenAI-compatible server rejected the request (HTTP {status}){suffix}" ) - if status in {408, 429} or 500 <= status < 600: + if status == 408: + raise ProviderTimeoutError("OpenAI-compatible provider returned HTTP 408") + if status == 429 and any( + marker in f"{classified} {lowered}" + for marker in ( + "insufficient_quota", + "quota exceeded", + "quota_exceeded", + "billing hard limit", + "billing_hard_limit", + "credits exhausted", + ) + ): + raise ProviderQuotaError("OpenAI-compatible provider quota is exhausted") + if status == 429: + raise ProviderRateLimitError( + "OpenAI-compatible provider rate limit was reached" + ) + if 500 <= status < 600: raise ProviderUnavailableError( f"OpenAI-compatible server failed {operation} with HTTP {status}{suffix}" ) @@ -802,6 +831,25 @@ def _safe_error_detail(data: bytes) -> str | None: return None +def _safe_error_code(data: bytes) -> str | None: + """Read only a bounded provider error classifier, never an arbitrary body.""" + + try: + payload = json.loads(data.decode("utf-8", errors="strict")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(payload, dict): + return None + error = payload.get("error") + candidate = error.get("code") if isinstance(error, dict) else payload.get("code") + if not isinstance(candidate, str): + return None + normalized = candidate.strip() + if not re.fullmatch(r"[A-Za-z0-9_.-]{1,128}", normalized): + return None + return normalized + + def _redact_error[ErrorType: (ProviderRequestError, ProviderUnavailableError)]( error: ErrorType, secrets: Sequence[str] ) -> ErrorType: diff --git a/src/contextforge/models/providers.py b/src/contextforge/models/providers.py index 9792918..6a7c549 100644 --- a/src/contextforge/models/providers.py +++ b/src/contextforge/models/providers.py @@ -40,6 +40,7 @@ MAX_PROVIDER_TIMEOUT_SECONDS = 600.0 MAX_PROVIDER_CONCURRENCY = 8 MAX_PROVIDER_RETRIES = 2 +DEFAULT_PROVIDER_CIRCUIT_FAILURE_THRESHOLD = 3 RETRY_DELAYS_SECONDS = (0.25, 1.0) MAX_UNTRUSTED_SOURCE_BYTES = 1_000_000 MAX_REQUEST_SOURCE_BYTES = 4_000_000 @@ -225,7 +226,7 @@ def load_credential( source = os.environ if environment is None else environment value = source.get(self.credential_env) if value is None or not value: - raise ProviderConfigurationError( + raise ProviderMissingCredentialError( f"credential environment variable {self.credential_env!r} is not set" ) return SecretStr(value) @@ -809,6 +810,7 @@ class ModelProviderError(RuntimeError): """Base provider failure with stable retry classification.""" retry_classification = RetryClassification.NON_RETRYABLE + provider_wide = False def __init__( self, message: str, *, diagnostic: ProviderDiagnostic | None = None @@ -818,6 +820,7 @@ def __init__( self.provider_capability_calls = 0 self.transport_attempts = 1 self.total_provider_http_calls = 1 + self.circuit_opened = False super().__init__(message) def add_http_accounting( @@ -944,6 +947,52 @@ class ProviderUnavailableError(ModelProviderError): retry_classification = RetryClassification.RETRYABLE +class ProviderRequestError(ModelProviderError): + """Raised for a non-retryable provider request failure.""" + + +class ProviderRateLimitError(ProviderUnavailableError): + """Raised for a transient provider rate limit.""" + + +class ProviderAuthenticationError(ProviderRequestError): + """Raised when provider credentials are absent, expired, or rejected.""" + + provider_wide = True + + +class ProviderMissingCredentialError(ProviderAuthenticationError): + """Raised when a configured credential reference has no usable value.""" + + +class ProviderAuthorizationError(ProviderRequestError): + """Raised when valid credentials cannot perform the configured operation.""" + + provider_wide = True + + +class ProviderQuotaError(ProviderRequestError): + """Raised when provider quota or billing capacity is exhausted.""" + + provider_wide = True + + +class ProviderModelNotFoundError(ProviderRequestError): + """Raised when the configured model identity is unavailable.""" + + provider_wide = True + + +class ProviderCircuitOpenError(ModelProviderError): + """Raised before dispatch after the shared provider circuit opens.""" + + provider_wide = True + + def __init__(self, message: str) -> None: + super().__init__(message) + self.circuit_opened = True + + class ProviderCancelledError(ModelProviderError): """Raised when explicit or task cancellation stops provider work.""" @@ -951,9 +1000,7 @@ class ProviderCancelledError(ModelProviderError): class ProviderConfigurationError(ModelProviderError): """Raised for a non-retryable local provider configuration failure.""" - -class ProviderRequestError(ModelProviderError): - """Raised for a non-retryable provider request failure.""" + provider_wide = True class ModelProvider(Protocol): @@ -1066,6 +1113,7 @@ def __init__( environment: Mapping[str, str] | None = None, clock: Callable[[], float] = time.monotonic, retry_delays: Sequence[float] = RETRY_DELAYS_SECONDS, + circuit_failure_threshold: int = DEFAULT_PROVIDER_CIRCUIT_FAILURE_THRESHOLD, ) -> None: self.configuration = configuration self._environment = environment @@ -1081,9 +1129,62 @@ def __init__( raise ValueError("retry_delays must contain finite non-negative numbers") if configuration.retry_limit and not self._retry_delays: raise ValueError("retry_delays must not be empty when retries are enabled") + if ( + type(circuit_failure_threshold) is not int + or circuit_failure_threshold < 1 + or circuit_failure_threshold > 100 + ): + raise ValueError("circuit_failure_threshold must be between 1 and 100") self._semaphore = asyncio.Semaphore(configuration.concurrency_limit) + self._circuit_lock = asyncio.Lock() + self._circuit_failure_threshold = circuit_failure_threshold + self._consecutive_failure_key: str | None = None + self._consecutive_failure_count = 0 + self._circuit_error: tuple[str, str] | None = None self._closed = False + async def _raise_if_circuit_open(self) -> None: + async with self._circuit_lock: + if self._circuit_error is None: + return + code, message = self._circuit_error + raise ProviderCircuitOpenError( + f"provider circuit is open after {code}: {message}" + ) + + async def _record_success(self) -> None: + async with self._circuit_lock: + if self._circuit_error is None: + self._consecutive_failure_key = None + self._consecutive_failure_count = 0 + + async def _record_final_failure(self, error: ModelProviderError) -> None: + if isinstance(error, (ProviderCancelledError, ProviderCircuitOpenError)): + return + code, message = provider_error_details(error) + async with self._circuit_lock: + if self._circuit_error is not None: + return + if error.provider_wide: + self._circuit_error = (code, message) + error.circuit_opened = True + return + if classify_retry(error) is not RetryClassification.RETRYABLE: + self._consecutive_failure_key = None + self._consecutive_failure_count = 0 + return + key = ( + f"{self.configuration.provider_id}:{self.configuration.model_id}:{code}" + ) + if key == self._consecutive_failure_key: + self._consecutive_failure_count += 1 + else: + self._consecutive_failure_key = key + self._consecutive_failure_count = 1 + if self._consecutive_failure_count >= self._circuit_failure_threshold: + self._circuit_error = (code, message) + error.circuit_opened = True + async def execute( self, request: ModelRequest, @@ -1095,7 +1196,12 @@ async def execute( if self._closed: raise ProviderRequestError("provider is closed") - credential = self.configuration.load_credential(self._environment) + await self._raise_if_circuit_open() + try: + credential = self.configuration.load_credential(self._environment) + except ModelProviderError as exc: + await self._record_final_failure(exc) + raise secrets = () if credential is None else (credential.get_secret_value(),) started = self._clock() timeout = min( @@ -1344,6 +1450,7 @@ def counter_data() -> dict[str, Any]: timeout=timeout, ) try: + await self._raise_if_circuit_open() transport_attempts += 1 total_http_calls += 1 raw = await _await_bounded( @@ -1528,6 +1635,7 @@ def counter_data() -> dict[str, Any]: usage=raw.usage, ) progress.complete(message="Provider request completed.") + await self._record_success() return ModelResponse( normalized_json=accepted.normalized_json, value=accepted.value, @@ -1912,6 +2020,7 @@ def counter_data() -> dict[str, Any]: ) else: progress.fail(message=message) + await self._record_final_failure(error) raise error async def close(self) -> None: @@ -1937,6 +2046,20 @@ def provider_error_details(error: BaseException) -> tuple[str, str]: return "provider_timeout", "provider request timed out" if isinstance(error, ProviderCancelledError): return "cancelled", "provider request was cancelled" + if isinstance(error, ProviderCircuitOpenError): + return "provider_circuit_open", "provider circuit breaker is open" + if isinstance(error, ProviderMissingCredentialError): + return "missing_credential", "provider credential is not configured" + if isinstance(error, ProviderAuthenticationError): + return "authentication_failed", "provider authentication failed" + if isinstance(error, ProviderAuthorizationError): + return "authorization_failed", "provider authorization failed" + if isinstance(error, ProviderQuotaError): + return "quota_exhausted", "provider quota is exhausted" + if isinstance(error, ProviderModelNotFoundError): + return "model_not_found", "configured model was not found" + if isinstance(error, ProviderRateLimitError): + return "rate_limited", "provider rate limit was reached" if isinstance(error, ContextWindowExceededError): return ( "context_window_exceeded", @@ -3544,9 +3667,16 @@ def _redacted_provider_error( "ModelUsage", "MissingRequiredFieldIssue", "ProviderCancelledError", + "ProviderAuthenticationError", + "ProviderAuthorizationError", + "ProviderCircuitOpenError", "ProviderCapabilities", "ProviderConfiguration", "ProviderConfigurationError", + "ProviderModelNotFoundError", + "ProviderMissingCredentialError", + "ProviderQuotaError", + "ProviderRateLimitError", "ProviderDiagnostic", "ProviderRequestError", "ProviderRuntime", diff --git a/tests/test_model_providers.py b/tests/test_model_providers.py index f346ab0..8e7a02b 100644 --- a/tests/test_model_providers.py +++ b/tests/test_model_providers.py @@ -20,9 +20,12 @@ ModelResponse, ModelUsage, OllamaModelProvider, + ProviderAuthenticationError, ProviderCancelledError, + ProviderCircuitOpenError, ProviderConfiguration, ProviderConfigurationError, + ProviderMissingCredentialError, ProviderRequestError, ProviderTimeoutError, ProviderTransportResponse, @@ -576,6 +579,70 @@ async def exercise() -> FakeModelProvider: assert provider.call_count == 3 +def test_provider_circuit_opens_after_three_matching_transient_failures() -> None: + async def exercise() -> FakeModelProvider: + provider = FakeModelProvider( + _configuration(), + scripts=[ProviderUnavailableError("offline")] * 3 + [_valid_json()], + ) + for _ in range(3): + with pytest.raises(ProviderUnavailableError): + await provider.complete_structured(_request()) + with pytest.raises(ProviderCircuitOpenError) as captured: + await provider.complete_structured(_request()) + assert captured.value.circuit_opened is True + return provider + + provider = asyncio.run(exercise()) + + assert provider.call_count == 3 + + +def test_provider_success_resets_transient_circuit_sequence() -> None: + async def exercise() -> FakeModelProvider: + provider = FakeModelProvider( + _configuration(), + scripts=[ + ProviderUnavailableError("offline"), + ProviderUnavailableError("offline"), + _valid_json(), + ProviderUnavailableError("offline"), + ProviderUnavailableError("offline"), + _valid_json(), + ], + ) + for _ in range(2): + with pytest.raises(ProviderUnavailableError): + await provider.complete_structured(_request()) + await provider.complete_structured(_request()) + for _ in range(2): + with pytest.raises(ProviderUnavailableError): + await provider.complete_structured(_request()) + await provider.complete_structured(_request()) + return provider + + provider = asyncio.run(exercise()) + + assert provider.call_count == 6 + + +def test_terminal_provider_failure_opens_circuit_immediately() -> None: + async def exercise() -> FakeModelProvider: + provider = FakeModelProvider( + _configuration(), + scripts=[ProviderAuthenticationError("expired"), _valid_json()], + ) + with pytest.raises(ProviderAuthenticationError): + await provider.complete_structured(_request()) + with pytest.raises(ProviderCircuitOpenError): + await provider.complete_structured(_request()) + return provider + + provider = asyncio.run(exercise()) + + assert provider.call_count == 1 + + def test_environment_credential_loading_and_secret_redaction() -> None: secret = "sensitive-provider-value" configuration = ProviderConfiguration( @@ -623,11 +690,17 @@ def test_missing_environment_credential_reference_is_non_retryable() -> None: configuration = _configuration(credential_env="MISSING_TEST_TOKEN") provider = FakeModelProvider(configuration, scripts=[_valid_json()], environment={}) - with pytest.raises(ProviderConfigurationError) as captured: - asyncio.run(provider.complete_structured(_request())) + async def exercise() -> ProviderMissingCredentialError: + with pytest.raises(ProviderMissingCredentialError) as captured: + await provider.complete_structured(_request()) + with pytest.raises(ProviderCircuitOpenError): + await provider.complete_structured(_request()) + return captured.value + + error = asyncio.run(exercise()) assert provider.call_count == 0 - assert classify_retry(captured.value) is RetryClassification.NON_RETRYABLE + assert classify_retry(error) is RetryClassification.NON_RETRYABLE def test_secret_values_are_not_persisted_in_contextforge_index(tmp_path: Path) -> None: diff --git a/tests/test_openai_compatible.py b/tests/test_openai_compatible.py index 51ad3cd..400a957 100644 --- a/tests/test_openai_compatible.py +++ b/tests/test_openai_compatible.py @@ -21,9 +21,14 @@ ModelUsage, OpenAICompatibleHTTPResponse, OpenAICompatibleModelProvider, + ProviderAuthenticationError, + ProviderAuthorizationError, ProviderCancelledError, ProviderConfiguration, ProviderConfigurationError, + ProviderModelNotFoundError, + ProviderQuotaError, + ProviderRateLimitError, ProviderRequestError, ProviderTimeoutError, ProviderUnavailableError, @@ -301,7 +306,7 @@ async def exercise() -> None: asyncio.run(exercise()) -def test_http_404_reports_model_error_body_when_safe() -> None: +def test_http_404_is_a_terminal_typed_model_error() -> None: call_count = 0 async def transport( @@ -323,7 +328,7 @@ async def transport( async def exercise() -> None: provider = OpenAICompatibleModelProvider(_configuration(), transport=transport) - with pytest.raises(ProviderRequestError, match="model was unloaded"): + with pytest.raises(ProviderRequestError, match="model ID"): await provider.complete_structured(_request()) asyncio.run(exercise()) @@ -446,7 +451,7 @@ async def exercise() -> ModelResponse: ) -def test_auth_error_redacts_loaded_credential_and_keeps_safe_body() -> None: +def test_auth_error_omits_loaded_credential_and_provider_body() -> None: secret = "credential-that-must-not-leak" async def transport( @@ -471,7 +476,6 @@ async def exercise() -> None: with pytest.raises(ProviderRequestError) as captured: await provider.complete_structured(_request()) assert secret not in str(captured.value) - assert "[REDACTED]" in str(captured.value) assert "HTTP 401" in str(captured.value) asyncio.run(exercise()) @@ -790,11 +794,13 @@ async def exercise_close() -> None: @pytest.mark.parametrize( ("status", "operation", "error_type", "message"), [ - (403, "model diagnostics", ProviderRequestError, "authentication"), + (401, "model diagnostics", ProviderAuthenticationError, "authentication"), + (403, "model diagnostics", ProviderAuthorizationError, "authorization"), (404, "model diagnostics", ProviderRequestError, "model diagnostics"), (418, "chat completion", ProviderRequestError, "chat completion"), - (408, "chat completion", ProviderUnavailableError, "HTTP 408"), - (429, "chat completion", ProviderUnavailableError, "HTTP 429"), + (404, "chat completion", ProviderModelNotFoundError, "model ID"), + (408, "chat completion", ProviderTimeoutError, "HTTP 408"), + (429, "chat completion", ProviderRateLimitError, "rate limit"), (500, "chat completion", ProviderUnavailableError, "HTTP 500"), (422, "chat completion", ProviderRequestError, "request"), ], @@ -814,6 +820,18 @@ def test_http_status_classification( ) +def test_http_quota_is_terminal_and_distinct_from_rate_limit() -> None: + response = OpenAICompatibleHTTPResponse( + status=429, + body=b'{"error":{"code":"insufficient_quota","message":"limit"}}', + ) + + with pytest.raises(ProviderQuotaError): + openai_module._raise_for_status( + response, operation="chat completion", model_id="exact/model" + ) + + @pytest.mark.parametrize( "body", [ @@ -884,10 +902,10 @@ async def exercise() -> None: transport=auth_transport, environment={"LM_STUDIO_API_KEY": secret}, ) - with pytest.raises(ProviderRequestError) as captured: + with pytest.raises(ProviderAuthorizationError) as captured: await authenticated.list_models() assert secret not in str(captured.value) - assert "[REDACTED]" in str(captured.value) + assert "HTTP 403" in str(captured.value) asyncio.run(exercise()) From 2e0088ec8482742bdf982240927c2c8151dac6a1 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:17:24 +0300 Subject: [PATCH 02/11] feat(index): add bounded failure policies and JSONL progress --- src/contextforge/application.py | 79 +++++-- src/contextforge/cli/intelligence_commands.py | 42 +++- src/contextforge/cli/progress.py | 10 +- src/contextforge/intelligence/__init__.py | 6 + src/contextforge/intelligence/indexer.py | 11 + src/contextforge/intelligence/models.py | 10 + src/contextforge/intelligence/semantics.py | 192 +++++++++++++----- tests/test_cli_intelligence.py | 95 ++++++++- tests/test_semantic_analysis.py | 114 +++++++++++ 9 files changed, 478 insertions(+), 81 deletions(-) diff --git a/src/contextforge/application.py b/src/contextforge/application.py index 6ab890b..6a3bc87 100644 --- a/src/contextforge/application.py +++ b/src/contextforge/application.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import hashlib import json import uuid from contextlib import suppress @@ -76,6 +75,7 @@ load_file_semantic_analysis, load_manifest, load_repository_overview, + normalize_analyzer_identity, write_manifest, ) from contextforge.intelligence.models import AnalyzerIdentity @@ -107,6 +107,10 @@ class MissingIndexError(ApplicationError): """Raised when an operation explicitly requires an active index.""" +class IndexSourceChangedError(ApplicationError): + """Raised when repository identity changes before atomic publication.""" + + class ArtifactReadError(ApplicationError): """Raised when a portable handoff cannot be read or validated.""" @@ -197,6 +201,8 @@ async def build_repository_index( update_only: bool = False, concurrency: int = 2, fail_on_error: bool = False, + fail_fast: bool = False, + max_failures: int | None = None, force_reanalyze: bool = False, max_files: int | None = None, semantic_max_output_tokens: int = 1024, @@ -205,9 +211,17 @@ async def build_repository_index( progress: ProgressObserver | None = None, operation_id: str | None = None, parent_operation_id: str | None = None, + cancellation: asyncio.Event | None = None, ) -> IndexBuildReport: """Build/update all index phases while retaining a prior pointer on failure.""" + if fail_fast and max_failures is not None: + raise ValueError("fail_fast and max_failures cannot be used together") + if max_failures is not None and ( + type(max_failures) is not int or max_failures <= 0 + ): + raise ValueError("max_failures must be a positive integer or None") + effective_max_failures = 1 if fail_fast else max_failures reporter = _progress_reporter( "repository.index.update" if update_only else "repository.index.build", progress, @@ -244,12 +258,14 @@ async def build_repository_index( update_only=update_only, concurrency=concurrency, fail_on_error=fail_on_error, + max_failures=effective_max_failures, force_reanalyze=force_reanalyze, max_files=max_files, semantic_max_output_tokens=semantic_max_output_tokens, recover_stale_lock=recover_stale_lock, confirm_unknown_lock=confirm_unknown_lock, progress=reporter, + cancellation=cancellation, ) except BaseException as exc: _report_terminal_exception(reporter, exc) @@ -264,7 +280,12 @@ async def build_repository_index( raise reporter.complete( message="Repository index build completed.", - metadata={"partial": report.partial}, + metadata={ + "generation_id": report.manifest.generation_id, + "snapshot_digest": report.manifest.build.source_snapshot_digest, + "index_schema": report.manifest.schema_versions.index_schema_version, + "partial": report.partial, + }, ) _persist_application_diagnostic( repository_root, @@ -285,16 +306,19 @@ async def _build_repository_index( update_only: bool, concurrency: int, fail_on_error: bool, + max_failures: int | None, force_reanalyze: bool, max_files: int | None, semantic_max_output_tokens: int, recover_stale_lock: bool, confirm_unknown_lock: bool, progress: ProgressReporter, + cancellation: asyncio.Event | None, ) -> IndexBuildReport: """Implement index construction under the public progress boundary.""" root = Path(repository_root).expanduser().resolve(strict=True) + _raise_if_index_cancelled(cancellation) initialize_index(root) previous: IndexManifest | None try: @@ -318,7 +342,8 @@ async def _build_repository_index( phase_weight=scan_end, activity=ProgressActivity.ACTIVE, ) - snapshot = scan_repository(root) + snapshot = await asyncio.to_thread(scan_repository, root) + _raise_if_index_cancelled(cancellation) progress.report( "scan", "Repository scan completed.", @@ -370,10 +395,12 @@ async def _build_repository_index( unit_type="files", activity=ProgressActivity.ACTIVE, ) - structural = build_structural_index( + structural = await asyncio.to_thread( + build_structural_index, snapshot, lock, previous_manifest=previous, + cancellation=cancellation, ) progress.report( "structural_index", @@ -454,11 +481,13 @@ def observe_semantic(event: ProgressEvent) -> None: max_files=max_files, max_output_tokens=semantic_max_output_tokens, fail_on_error=fail_on_error, + max_failures=max_failures, force_reanalyze=force_reanalyze, resume=not force_reanalyze, progress=observe_semantic, ), previous_manifest=previous, + cancellation=cancellation, ) semantic_event = progress.last_event if semantic_event is not None: @@ -529,6 +558,7 @@ def observe_semantic(event: ProgressEvent) -> None: maps_start, 93.0, phase_prefix="repository_maps" ), ), + cancellation=cancellation, ) map_fallback = any(item.status == "fallback" for item in maps.outcomes) progress.report( @@ -575,6 +605,14 @@ def observe_semantic(event: ProgressEvent) -> None: phase_percent=100, phase_weight=3 if model_enabled else 15, ) + _raise_if_index_cancelled(cancellation) + current_snapshot = await asyncio.to_thread(scan_repository, root) + if calculate_source_snapshot_digest( + current_snapshot + ) != calculate_source_snapshot_digest(snapshot): + raise IndexSourceChangedError( + "repository source identity changed before index publication" + ) progress.report( "validation", "Validating the active index generation.", @@ -764,7 +802,8 @@ def _inspect_repository_index( or analysis.schema_version != SEMANTIC_SCHEMA_VERSION or ( analysis.record_kind != "deterministic_metadata_interpretation" - and analysis.semantic_analyzer != expected + and normalize_analyzer_identity(analysis.semantic_analyzer) + != expected ) ): stale.add(path) @@ -1171,13 +1210,8 @@ def _semantic_identity( return None return AnalyzerIdentity( analyzer_id=(GENERIC_SEMANTIC_ANALYZER_ID if generic else SEMANTIC_ANALYZER_ID), - analyzer_version=_model_dependent_analyzer_version( - ( - GENERIC_SEMANTIC_ANALYZER_VERSION - if generic - else SEMANTIC_ANALYZER_VERSION - ), - configuration, + analyzer_version=( + GENERIC_SEMANTIC_ANALYZER_VERSION if generic else SEMANTIC_ANALYZER_VERSION ), analysis_prompt_version=SEMANTIC_PROMPT_VERSION, response_schema_version=SEMANTIC_SCHEMA_VERSION, @@ -1188,16 +1222,6 @@ def _semantic_identity( ) -def _model_dependent_analyzer_version( - analyzer_version: str, configuration: ProviderConfiguration -) -> str: - if configuration.provider_id != "openai-compatible": - return analyzer_version - canonical = configuration.endpoint.rstrip("/") - digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest() - return f"{analyzer_version}+base.{digest}" - - def _manifest_model_identity( manifest: IndexManifest, configuration: ProviderConfiguration | None, @@ -1294,6 +1318,11 @@ def _workflow_snapshot( return cast(ProjectSnapshot, source) +def _raise_if_index_cancelled(cancellation: asyncio.Event | None) -> None: + if cancellation is not None and cancellation.is_set(): + raise asyncio.CancelledError + + def _report_terminal_exception( reporter: ProgressReporter, error: BaseException ) -> None: @@ -1358,6 +1387,11 @@ def _diagnostic_error_code(error: BaseException | None) -> str | None: return None if isinstance(error, ModelProviderError): return provider_error_details(error)[0] + typed_code = getattr(error, "error_code", None) + if isinstance(typed_code, str): + return typed_code + if isinstance(error, IndexSourceChangedError): + return "source_identity_changed" run_record = getattr(error, "run_record", None) value = getattr(run_record, "failure_code", None) if isinstance(value, str): @@ -1423,6 +1457,7 @@ def _reject_json_constant(value: str) -> None: "ApplicationError", "ArtifactReadError", "IndexBuildReport", + "IndexSourceChangedError", "IndexStatusReport", "MAX_HANDOFF_BYTES", "MissingIndexError", diff --git a/src/contextforge/cli/intelligence_commands.py b/src/contextforge/cli/intelligence_commands.py index 91420a5..2c02c52 100644 --- a/src/contextforge/cli/intelligence_commands.py +++ b/src/contextforge/cli/intelligence_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import sys from contextlib import suppress from enum import StrEnum from pathlib import Path @@ -60,6 +61,8 @@ def _index_operation( json_repair_attempts: int | None, max_output_tokens: int | None, fail_on_error: bool, + fail_fast: bool, + max_failures: int | None, force_reanalyze: bool, max_files: int | None, local_only: bool, @@ -67,8 +70,15 @@ def _index_operation( confirm_unknown_lock: bool, progress_mode: ProgressMode, ) -> None: + if fail_fast and max_failures is not None: + _exit_with_error( + "--fail-fast and --max-failures cannot be used together", code=2 + ) provider: ModelProvider | None = None - progress = CLIProgressRenderer(progress_mode) + progress = CLIProgressRenderer( + progress_mode, + stream=sys.stdout if progress_mode is ProgressMode.JSONL else None, + ) try: project = load_project_configuration(path, config_path=config) provider_configuration = resolve_provider_configuration( @@ -100,6 +110,8 @@ def _index_operation( update_only=update_only, concurrency=effective_concurrency, fail_on_error=fail_on_error, + fail_fast=fail_fast, + max_failures=max_failures, force_reanalyze=force_reanalyze, max_files=max_files, semantic_max_output_tokens=( @@ -132,7 +144,8 @@ def _index_operation( with suppress(ModelProviderError): asyncio.run(provider.close()) - typer.echo(_render_build_summary(report), nl=False) + if progress_mode is not ProgressMode.JSONL: + typer.echo(_render_build_summary(report), nl=False) @index_app.command("build") @@ -205,6 +218,21 @@ def build_index( help="Keep the prior active generation on any model-analysis failure.", ), ] = False, + fail_fast: Annotated[ + bool, + typer.Option( + "--fail-fast", + help="Stop after the first model-analysis failure.", + ), + ] = False, + max_failures: Annotated[ + int | None, + typer.Option( + "--max-failures", + min=1, + help="Stop after this many model-analysis failures.", + ), + ] = None, force_reanalyze: Annotated[ bool, typer.Option( @@ -242,7 +270,7 @@ def build_index( ProgressMode, typer.Option( "--progress", - help="Progress rendering: auto, always when safe, or never.", + help="Progress rendering: auto, always, never, or JSONL on stdout.", case_sensitive=False, ), ] = ProgressMode.AUTO, @@ -262,6 +290,8 @@ def build_index( json_repair_attempts=json_repair_attempts, max_output_tokens=max_output_tokens, fail_on_error=fail_on_error, + fail_fast=fail_fast, + max_failures=max_failures, force_reanalyze=force_reanalyze, max_files=max_files, local_only=local_only, @@ -310,6 +340,8 @@ def update_index( typer.Option("--max-output-tokens", min=96, max=32_768), ] = None, fail_on_error: Annotated[bool, typer.Option("--fail-on-error")] = False, + fail_fast: Annotated[bool, typer.Option("--fail-fast")] = False, + max_failures: Annotated[int | None, typer.Option("--max-failures", min=1)] = None, force_reanalyze: Annotated[bool, typer.Option("--force-reanalyze")] = False, max_files: Annotated[int | None, typer.Option("--max-files", min=1)] = None, local_only: Annotated[bool, typer.Option("--local-only")] = False, @@ -321,7 +353,7 @@ def update_index( ProgressMode, typer.Option( "--progress", - help="Progress rendering: auto, always when safe, or never.", + help="Progress rendering: auto, always, never, or JSONL on stdout.", case_sensitive=False, ), ] = ProgressMode.AUTO, @@ -341,6 +373,8 @@ def update_index( json_repair_attempts=json_repair_attempts, max_output_tokens=max_output_tokens, fail_on_error=fail_on_error, + fail_fast=fail_fast, + max_failures=max_failures, force_reanalyze=force_reanalyze, max_files=max_files, local_only=local_only, diff --git a/src/contextforge/cli/progress.py b/src/contextforge/cli/progress.py index ee7e647..3b53466 100644 --- a/src/contextforge/cli/progress.py +++ b/src/contextforge/cli/progress.py @@ -39,6 +39,7 @@ class ProgressMode(StrEnum): AUTO = "auto" ALWAYS = "always" NEVER = "never" + JSONL = "jsonl" class CLIProgressRenderer: @@ -84,7 +85,7 @@ def __init__( self._unicode = self._supports_unicode(self._console.encoding) self._spinner = Spinner("dots" if self._unicode else "line", style="cyan") self._dynamic = ( - self.mode is not ProgressMode.NEVER + self.mode not in {ProgressMode.NEVER, ProgressMode.JSONL} and self._console.is_terminal and self._is_interactive(self._stdout) and self._is_interactive(self._stream) @@ -124,6 +125,8 @@ def rendering_mode(self) -> str: if self.mode is ProgressMode.NEVER: return "disabled" + if self.mode is ProgressMode.JSONL: + return "jsonl" return "dynamic" if self._dynamic else "discrete" def __call__(self, event: ProgressEvent) -> None: @@ -132,6 +135,11 @@ def __call__(self, event: ProgressEvent) -> None: with self._state_lock: if self.mode is ProgressMode.NEVER or self._closed: return + if self.mode is ProgressMode.JSONL: + self._stream.write(event.model_dump_json() + "\n") + self._stream.flush() + self._event = event + return if self._started is None: self._started = self._clock() request_key = (event.current_item, event.current_attempt) diff --git a/src/contextforge/intelligence/__init__.py b/src/contextforge/intelligence/__init__.py index 6679c01..2007a40 100644 --- a/src/contextforge/intelligence/__init__.py +++ b/src/contextforge/intelligence/__init__.py @@ -93,6 +93,7 @@ SchemaVersionMetadata, SemanticStatus, calculate_index_statistics, + normalize_analyzer_identity, validate_portable_relative_path, ) from contextforge.intelligence.polyglot import ( @@ -127,8 +128,10 @@ SEMANTIC_SYSTEM_INSTRUCTIONS, SemanticAnalysisError, SemanticAnalysisOptions, + SemanticFailureLimitError, SemanticFileOutcome, SemanticIndexBuildResult, + SemanticProviderCircuitError, SemanticRoute, SemanticWorkPlan, SemanticWorkPlanItem, @@ -247,9 +250,11 @@ "SchemaVersionMetadata", "SemanticAnalysisError", "SemanticAnalysisOptions", + "SemanticFailureLimitError", "SemanticConfidence", "SemanticFileOutcome", "SemanticIndexBuildResult", + "SemanticProviderCircuitError", "SemanticRoute", "SemanticWorkPlan", "SemanticWorkPlanItem", @@ -276,6 +281,7 @@ "build_repository_overview", "calculate_generation_id", "calculate_index_statistics", + "normalize_analyzer_identity", "calculate_source_snapshot_digest", "canonical_json_bytes", "clean_generated_index", diff --git a/src/contextforge/intelligence/indexer.py b/src/contextforge/intelligence/indexer.py index ecf58b8..8d862f7 100644 --- a/src/contextforge/intelligence/indexer.py +++ b/src/contextforge/intelligence/indexer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import hashlib from dataclasses import dataclass from pathlib import Path @@ -67,6 +68,7 @@ def build_structural_index( *, max_source_bytes: int = DEFAULT_CODEMAP_SOURCE_LIMIT, previous_manifest: IndexManifest | None = None, + cancellation: asyncio.Event | None = None, ) -> StructuralIndexBuildResult: """Extract, resolve, and atomically persist facts without semantic analysis.""" @@ -85,6 +87,7 @@ def build_structural_index( reused: list[str] = [] all_records_valid = previous is not None for project_file in sorted(snapshot.files, key=lambda item: item.path): + _raise_if_cancelled(cancellation) state = previous_states.get(project_file.path) code_map = _reuse_code_map(lock, previous, state, project_file) if code_map is None: @@ -122,10 +125,12 @@ def build_structural_index( generation_path=generation, ) + _raise_if_cancelled(cancellation) code_maps = resolve_relationships(tuple(base_maps)) states: list[IndexedFileState] = [] record_digests: list[tuple[str, str]] = [] for code_map in code_maps: + _raise_if_cancelled(cancellation) content = serialize_code_map(code_map) location = _record_location(code_map.path) digest = write_index_record(lock, location, content) @@ -190,6 +195,7 @@ def build_structural_index( previous.generation_id if previous is not None else None ), ) + _raise_if_cancelled(cancellation) manifest = build_index_manifest( build=build, files=states, @@ -310,6 +316,11 @@ def _validate_build_inputs( raise ValueError("snapshot root does not match the locked repository") +def _raise_if_cancelled(cancellation: asyncio.Event | None) -> None: + if cancellation is not None and cancellation.is_set(): + raise asyncio.CancelledError + + def _optional_manifest(lock: IndexWriteLock) -> IndexManifest | None: try: return load_manifest(lock.layout.repository_root) diff --git a/src/contextforge/intelligence/models.py b/src/contextforge/intelligence/models.py index f758b9e..03a90ab 100644 --- a/src/contextforge/intelligence/models.py +++ b/src/contextforge/intelligence/models.py @@ -34,6 +34,7 @@ ] _IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:/+@-]{0,127}$") +_LEGACY_ENDPOINT_SUFFIX = re.compile(r"\+base\.[0-9a-f]{64}$") class IndexModel(BaseModel): @@ -339,3 +340,12 @@ def analyzer_identity_key(identity: AnalyzerIdentity) -> tuple[str, ...]: model.provider_id if model is not None else "", model.model_id if model is not None else "", ) + + +def normalize_analyzer_identity(identity: AnalyzerIdentity) -> AnalyzerIdentity: + """Remove the legacy transport-endpoint suffix from analyzer provenance.""" + + version = _LEGACY_ENDPOINT_SUFFIX.sub("", identity.analyzer_version) + if version == identity.analyzer_version: + return identity + return identity.model_copy(update={"analyzer_version": version}) diff --git a/src/contextforge/intelligence/semantics.py b/src/contextforge/intelligence/semantics.py index 43534b3..fd83d51 100644 --- a/src/contextforge/intelligence/semantics.py +++ b/src/contextforge/intelligence/semantics.py @@ -39,6 +39,7 @@ ModelIdentity, SemanticStatus, analyzer_identity_key, + normalize_analyzer_identity, ) from contextforge.intelligence.semantic_models import ( SEMANTIC_SCHEMA_VERSION, @@ -242,6 +243,28 @@ class SemanticAnalysisError(RuntimeError): """Raised when semantic analysis cannot safely publish the requested result.""" +class SemanticFailureLimitError(SemanticAnalysisError): + """Raised after the configured number of semantic units fail.""" + + def __init__(self, failure_count: int, diagnostic: AnalysisDiagnostic) -> None: + self.failure_count = failure_count + self.error_code = diagnostic.code + self.safe_reason = diagnostic.message + super().__init__( + f"semantic failure limit reached after {failure_count} file(s): " + f"{diagnostic.message}" + ) + + +class SemanticProviderCircuitError(SemanticAnalysisError): + """Raised when a provider-wide or repeated transient failure opens the circuit.""" + + def __init__(self, error_code: str, safe_reason: str) -> None: + self.error_code = error_code + self.safe_reason = safe_reason + super().__init__(f"provider circuit opened: {safe_reason}") + + class StaleStructuralIndexError(SemanticAnalysisError): """Raised when semantics are requested without current deterministic facts.""" @@ -274,6 +297,7 @@ class SemanticAnalysisOptions: max_chunks_per_file: int = 64 max_requests_per_file: int = 64 max_files: int | None = None + max_failures: int | None = None fail_on_error: bool = False resume: bool = True force_reanalyze: bool = False @@ -307,6 +331,10 @@ def __post_init__(self) -> None: type(self.max_files) is not int or self.max_files <= 0 ): raise ValueError("max_files must be a positive integer or None") + if self.max_failures is not None and ( + type(self.max_failures) is not int or self.max_failures <= 0 + ): + raise ValueError("max_failures must be a positive integer or None") if self.max_source_bytes_per_request > self.max_request_bytes: raise ValueError("source byte limit cannot exceed request byte limit") if ( @@ -454,11 +482,27 @@ def publish(self) -> None: self._lifecycle = "published" self.reporter.complete(message="Semantic generation published atomically.") - def abort(self, *, cancelled: bool = False) -> None: + def abort( + self, + *, + cancelled: bool = False, + cancelled_units: int = 0, + unstarted_units: int = 0, + ) -> None: + metadata: dict[str, JsonValue] = { + "route_totals": cast(dict[str, JsonValue], self.plan.route_totals), + "cancelled_units": cancelled_units, + "unstarted_units": unstarted_units, + } if cancelled: - self.reporter.cancel(message="Semantic analysis cancelled.") + self.reporter.cancel( + message="Semantic analysis cancelled.", metadata=metadata + ) else: - self.reporter.fail(message="Semantic analysis failed before publication.") + self.reporter.fail( + message="Semantic analysis failed before publication.", + metadata=metadata, + ) def _emit(self, message: str, *, current_item: str | None = None) -> None: total_weight = sum(self._weights.values()) @@ -595,19 +639,17 @@ async def build_semantic_index( load_file_code_map(snapshot.root, item.path, manifest=structural) for item in structural.files ) - provider_id, model_id, base_url_sha256 = _provider_identity(provider) + provider_id, model_id = _provider_identity(provider) rich_analyzer = _semantic_analyzer( active_options, provider_id, model_id, - base_url_sha256, analysis_route="rich_model_analysis", ) generic_analyzer = _semantic_analyzer( active_options, provider_id, model_id, - base_url_sha256, analysis_route="generic_model_analysis", ) options_digest = _analysis_options_digest(active_options) @@ -616,6 +658,10 @@ async def build_semantic_index( reusable_manifests = _reuse_manifests( snapshot.root, structural, previous_manifest=previous_manifest ) + identity_migration_needed = any( + normalize_analyzer_identity(item) != item + for item in structural.semantic_analyzers + ) analyses: dict[str, FileSemanticAnalysis] = {} outcomes: dict[str, SemanticFileOutcome] = {} @@ -818,7 +864,11 @@ async def build_semantic_index( plan_item.path, code=diagnostic.code, message=diagnostic.message ) - if not selected_stale and _manifest_matches_planned_semantics(structural, analyses): + if ( + not selected_stale + and not identity_migration_needed + and _manifest_matches_planned_semantics(structural, analyses) + ): tracker.publish() return SemanticIndexBuildResult( manifest=structural, @@ -943,41 +993,92 @@ async def analyze_one( }, ) _emit_status(active_options, project_file.path, "failed") + if isinstance(exc, ModelProviderError) and exc.circuit_opened: + raise SemanticProviderCircuitError(code, message) from exc return project_file.path, None, diagnostic, False task_results: list[ tuple[str, _AnalysisWork | None, AnalysisDiagnostic | None, bool] ] = [] - for offset in range(0, len(selected_stale), active_options.max_concurrency): - batch = selected_stale[offset : offset + active_options.max_concurrency] - tasks = [ - asyncio.create_task( - analyze_one( - project_file, - code_map, - state, - route, - expected_analyzer, - expected_digest, - ) - ) - for ( + failure_count = 0 + pending: set[ + asyncio.Task[tuple[str, _AnalysisWork | None, AnalysisDiagnostic | None, bool]] + ] = set() + next_work = 0 + + def schedule_available() -> None: + nonlocal next_work + while len(pending) < active_options.max_concurrency and next_work < len( + selected_stale + ): + ( project_file, code_map, state, route, expected_analyzer, expected_digest, - ) in batch - ] - try: - task_results.extend(await asyncio.gather(*tasks)) - except BaseException: - for task in tasks: - task.cancel() - await asyncio.gather(*tasks, return_exceptions=True) - tracker.abort(cancelled=True) - raise + ) = selected_stale[next_work] + next_work += 1 + pending.add( + asyncio.create_task( + analyze_one( + project_file, + code_map, + state, + route, + expected_analyzer, + expected_digest, + ) + ) + ) + + schedule_available() + try: + while pending: + done, pending = await asyncio.wait( + pending, return_when=asyncio.FIRST_COMPLETED + ) + limit_diagnostic: AnalysisDiagnostic | None = None + for task in done: + result = task.result() + task_results.append(result) + _, work, diagnostic, _ = result + if work is not None: + continue + assert diagnostic is not None + failure_count += 1 + if ( + active_options.max_failures is not None + and failure_count >= active_options.max_failures + and limit_diagnostic is None + ): + limit_diagnostic = diagnostic + if limit_diagnostic is not None: + cancelled_units = len(pending) + for unfinished in pending: + unfinished.cancel() + await asyncio.gather(*pending, return_exceptions=True) + tracker.abort( + cancelled_units=cancelled_units, + unstarted_units=len(selected_stale) - next_work, + ) + raise SemanticFailureLimitError(failure_count, limit_diagnostic) + schedule_available() + except BaseException as exc: + cancelled_units = len(pending) + for task in pending: + task.cancel() + await asyncio.gather(*pending, return_exceptions=True) + if not isinstance(exc, SemanticFailureLimitError): + tracker.abort( + cancelled=isinstance( + exc, (asyncio.CancelledError, ProviderCancelledError) + ), + cancelled_units=cancelled_units, + unstarted_units=len(selected_stale) - next_work, + ) + raise failures: list[AnalysisDiagnostic] = [] for path, work, diagnostic, resumed in task_results: @@ -2527,7 +2628,6 @@ def _semantic_analyzer( options: SemanticAnalysisOptions, provider_id: str, model_id: str, - base_url_sha256: str | None, *, analysis_route: Literal["rich_model_analysis", "generic_model_analysis"], ) -> AnalyzerIdentity: @@ -2543,7 +2643,7 @@ def _semantic_analyzer( ) return AnalyzerIdentity( analyzer_id=analyzer_id, - analyzer_version=_connection_bound_version(analyzer_version, base_url_sha256), + analyzer_version=analyzer_version, analysis_prompt_version=options.prompt_version, response_schema_version=SEMANTIC_SCHEMA_VERSION, model_identity=ModelIdentity( @@ -2553,7 +2653,7 @@ def _semantic_analyzer( ) -def _provider_identity(provider: ModelProvider) -> tuple[str, str, str | None]: +def _provider_identity(provider: ModelProvider) -> tuple[str, str]: provider_id = provider.provider_id configuration = getattr(provider, "configuration", None) model_id = getattr(configuration, "model_id", None) @@ -2561,23 +2661,7 @@ def _provider_identity(provider: ModelProvider) -> tuple[str, str, str | None]: raise SemanticAnalysisError( "semantic provider must expose stable provider and model identity" ) - endpoint = getattr(configuration, "endpoint", None) - base_url_sha256 = None - if provider_id == "openai-compatible": - if not isinstance(endpoint, str): - raise SemanticAnalysisError( - "OpenAI-compatible provider must expose a stable base URL identity" - ) - base_url_sha256 = hashlib.sha256( - endpoint.rstrip("/").encode("utf-8") - ).hexdigest() - return provider_id, model_id, base_url_sha256 - - -def _connection_bound_version(version: str, base_url_sha256: str | None) -> str: - if base_url_sha256 is None: - return version - return f"{version}+base.{base_url_sha256}" + return provider_id, model_id def _validate_response_identity( @@ -2630,7 +2714,7 @@ def _analysis_matches( and analysis.language == state.language == code_map.language and analysis.fact_record_sha256 == _required_fact_digest(state) and analysis.codemap_analyzer == code_map.analyzer - and analysis.semantic_analyzer == analyzer + and normalize_analyzer_identity(analysis.semantic_analyzer) == analyzer and analysis.analysis_options_digest == options_digest ) @@ -2663,7 +2747,7 @@ def _find_reusable_analysis( if analysis.schema_version == SEMANTIC_SCHEMA_VERSION and _analysis_matches( analysis, state, code_map, analyzer, options_digest ): - return analysis + return analysis.model_copy(update={"semantic_analyzer": analyzer}) return None @@ -2796,9 +2880,11 @@ def _bounded_error_message(error: BaseException) -> str: "SEMANTIC_PROMPT_VERSION", "SEMANTIC_SYSTEM_INSTRUCTIONS", "SemanticAnalysisError", + "SemanticFailureLimitError", "SemanticAnalysisOptions", "SemanticFileOutcome", "SemanticIndexBuildResult", + "SemanticProviderCircuitError", "SemanticRoute", "SemanticWorkPlan", "SemanticWorkPlanItem", diff --git a/tests/test_cli_intelligence.py b/tests/test_cli_intelligence.py index 47b54c2..427fd70 100644 --- a/tests/test_cli_intelligence.py +++ b/tests/test_cli_intelligence.py @@ -1,3 +1,4 @@ +import asyncio import json from pathlib import Path from typing import Any, cast @@ -6,9 +7,14 @@ import pytest from typer.testing import CliRunner, Result +import contextforge.application as application_module import contextforge.cli.context_commands as context_cli import contextforge.cli.intelligence_commands as index_cli -from contextforge.application import render_context_suggestion +from contextforge.application import ( + IndexSourceChangedError, + build_repository_index, + render_context_suggestion, +) from contextforge.cli.main import app from contextforge.discovery import ( CompletenessWarning, @@ -22,6 +28,7 @@ from contextforge.discovery.renderers import DiscoveryResultFormat from contextforge.intelligence import IndexManifestNotFoundError, load_manifest from contextforge.models import FakeModelProvider, ProviderConfiguration +from contextforge.progress import ProgressEvent, ProgressStatus runner = CliRunner() TERMINAL_WIDTH = 140 @@ -230,6 +237,44 @@ def test_index_provider_failure_preserves_previous_active_generation( assert load_manifest(tmp_path) == previous +def test_index_rechecks_snapshot_before_atomic_publication( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "app.py" + _write(tmp_path, "app.py", "VALUE = 1\n") + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + ) + ) + previous = load_manifest(tmp_path) + source.write_text("VALUE = 2\n", encoding="utf-8") + original = cast(Any, application_module).build_structural_index + + def mutate_after_structural(*args: Any, **kwargs: Any) -> Any: + result = original(*args, **kwargs) + source.write_text("VALUE = 3\n", encoding="utf-8") + return result + + monkeypatch.setattr( + application_module, "build_structural_index", mutate_after_structural + ) + + with pytest.raises(IndexSourceChangedError): + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + update_only=True, + ) + ) + + assert load_manifest(tmp_path) == previous + + def test_index_cancellation_maps_to_130( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -281,6 +326,54 @@ def test_progress_never_suppresses_stderr_and_preserves_json_stdout( assert json.loads(suggested.stdout)["mode"] == "hybrid" +def test_index_jsonl_progress_is_a_clean_schema_three_stream(tmp_path: Path) -> None: + _write(tmp_path, "app.py", "VALUE = 1\n") + + result = _invoke( + "--log-level", + "quiet", + "index", + "build", + str(tmp_path), + "--provider", + "none", + "--progress", + "jsonl", + ) + + assert result.exit_code == 0, result.output + assert result.stderr == "" + events = [ + ProgressEvent.model_validate_json(line) for line in result.stdout.splitlines() + ] + assert events + assert all(event.schema_version == 3 for event in events) + assert [event.sequence for event in events] == list(range(len(events))) + assert events[-1].status is ProgressStatus.COMPLETED + assert events[-1].metadata["generation_id"] == load_manifest(tmp_path).generation_id + assert events[-1].metadata["snapshot_digest"] + assert events[-1].metadata["index_schema"] == 2 + assert events[-1].metadata["partial"] is False + assert "\x1b[" not in result.stdout + assert "Status:" not in result.stdout + + +def test_index_rejects_ambiguous_failure_policy(tmp_path: Path) -> None: + result = _invoke( + "index", + "build", + str(tmp_path), + "--provider", + "none", + "--fail-fast", + "--max-failures", + "2", + ) + + assert result.exit_code == 2 + assert "cannot be used together" in _plain(result.stderr) + + def _invoke_focused_suggestion(tmp_path: Path, *arguments: str) -> Result: _write(tmp_path, "app.py", "def run():\n return 1\n") return _invoke( diff --git a/tests/test_semantic_analysis.py b/tests/test_semantic_analysis.py index bf82411..9921c18 100644 --- a/tests/test_semantic_analysis.py +++ b/tests/test_semantic_analysis.py @@ -19,6 +19,7 @@ SemanticAnalysisError, SemanticAnalysisOptions, SemanticConfidence, + SemanticFailureLimitError, SemanticIndexBuildResult, SourceRange, StaleStructuralIndexError, @@ -954,6 +955,72 @@ def test_unchanged_analysis_is_reused_without_provider_calls(tmp_path: Path) -> assert second.generation_path == first.generation_path +def test_loopback_endpoint_change_does_not_invalidate_semantics(tmp_path: Path) -> None: + snapshot = _snapshot_with_facts(tmp_path, {"app.txt": "service notes\n"}) + first_provider = FakeModelProvider( + ProviderConfiguration( + provider_id="fake", + endpoint="fake://127.0.0.1:1234", + model_id="exact/model", + retry_limit=0, + ), + responder=_valid_response, + ) + first = _build_semantics(snapshot, first_provider, run_id="endpoint-first") + second_provider = FakeModelProvider( + ProviderConfiguration( + provider_id="fake", + endpoint="fake://127.0.0.1:9999", + model_id="exact/model", + retry_limit=0, + ), + responder=_valid_response, + ) + + second = _build_semantics(snapshot, second_provider, run_id="endpoint-second") + + assert first_provider.call_count == 1 + assert second_provider.call_count == 0 + assert second.manifest == first.manifest + assert "+base." not in second.analyses[0].semantic_analyzer.analyzer_version + + +def test_legacy_endpoint_identity_is_republished_without_model_calls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + snapshot = _snapshot_with_facts(tmp_path, {"app.txt": "service notes\n"}) + original = semantics_module._semantic_analyzer + + def legacy_analyzer(*args: Any, **kwargs: Any) -> Any: + identity = original(*args, **kwargs) + return identity.model_copy( + update={"analyzer_version": identity.analyzer_version + "+base." + "a" * 64} + ) + + monkeypatch.setattr(semantics_module, "_semantic_analyzer", legacy_analyzer) + legacy = _build_semantics(snapshot, _provider(), run_id="legacy-identity") + monkeypatch.setattr(semantics_module, "_semantic_analyzer", original) + migration_provider = _provider() + + migrated = _build_semantics(snapshot, migration_provider, run_id="migrate-identity") + stable_provider = _provider() + stable = _build_semantics(snapshot, stable_provider, run_id="stable-identity") + + assert migration_provider.call_count == stable_provider.call_count == 0 + assert migrated.manifest.generation_id != legacy.manifest.generation_id + assert stable.manifest == migrated.manifest + assert all( + "+base." not in item.analyzer_version + for item in migrated.manifest.semantic_analyzers + ) + assert ( + "+base." + not in load_file_semantic_analysis( + tmp_path, "app.txt" + ).semantic_analyzer.analyzer_version + ) + + def test_semantic_persistence_is_deterministic_across_repository_roots( tmp_path: Path, ) -> None: @@ -1096,6 +1163,53 @@ def test_fail_on_error_keeps_prior_valid_generation_active(tmp_path: Path) -> No assert load_manifest(tmp_path) == structural +@pytest.mark.parametrize( + ("concurrency", "failure_limit", "expected_calls"), + [(1, 1, 1), (1, 2, 2), (2, 1, 2)], +) +def test_failure_limit_stops_scheduling_and_keeps_active_generation( + tmp_path: Path, + concurrency: int, + failure_limit: int, + expected_calls: int, +) -> None: + snapshot = _snapshot_with_facts( + tmp_path, {f"{name}.txt": f"{name}\n" for name in "abcde"} + ) + active = load_manifest(tmp_path) + events: list[ProgressEvent] = [] + scripts: list[Any] = [ + ProviderRequestError("rejected") for _ in range(failure_limit) + ] + if concurrency > 1: + scripts.insert(1, FakeScript(ProviderRequestError("late"), delay_seconds=1)) + provider = _provider(concurrency=concurrency, scripts=scripts) + + with ( + acquire_index_lock(tmp_path, "semantic-failure-limit") as lock, + pytest.raises(SemanticFailureLimitError), + ): + asyncio.run( + build_semantic_index( + snapshot, + lock, + provider, + options=SemanticAnalysisOptions( + max_concurrency=concurrency, + max_failures=failure_limit, + progress=events.append, + ), + ) + ) + + assert provider.call_count == expected_calls + assert load_manifest(tmp_path) == active + assert events[-1].status.value == "failed" + assert events[-1].failed_units == failure_limit + assert cast(int, events[-1].metadata["unstarted_units"]) > 0 + assert cast(int, events[-1].metadata["cancelled_units"]) == concurrency - 1 + + def test_interrupted_build_resumes_only_validated_checkpoints(tmp_path: Path) -> None: snapshot = _snapshot_with_facts(tmp_path, {"a.py": "pass\n", "b.py": "pass\n"}) cancellation = asyncio.Event() From f65ab55bf3216de986531a92e4a47a0493c1ba2c Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:17:43 +0300 Subject: [PATCH 03/11] feat(bridge): add tracked index jobs --- .../contextforge-bridge-v2.schema.json | 242 ++++++++++++++ src/contextforge/bridge/models.py | 32 ++ src/contextforge/bridge/protocol.py | 5 +- src/contextforge/bridge/server.py | 311 +++++++++++++++++- tests/test_bridge.py | 271 ++++++++++++++- 5 files changed, 846 insertions(+), 15 deletions(-) create mode 100644 docs/schemas/contextforge-bridge-v2.schema.json diff --git a/docs/schemas/contextforge-bridge-v2.schema.json b/docs/schemas/contextforge-bridge-v2.schema.json new file mode 100644 index 0000000..c501cb9 --- /dev/null +++ b/docs/schemas/contextforge-bridge-v2.schema.json @@ -0,0 +1,242 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contextforge.dev/schemas/contextforge-bridge-v2.schema.json", + "title": "ContextForge trusted-local bridge protocol v2", + "description": "Bridge v1 frames plus opt-in tracked index jobs and correlated progress notifications.", + "oneOf": [ + { "$ref": "contextforge-bridge-v1.schema.json" }, + { "$ref": "#/$defs/helloV2Request" }, + { "$ref": "#/$defs/helloV2Success" }, + { "$ref": "#/$defs/indexRequest" }, + { "$ref": "#/$defs/indexSuccess" }, + { "$ref": "#/$defs/indexFailure" }, + { "$ref": "#/$defs/progressNotification" } + ], + "$defs": { + "id": { + "oneOf": [ + { "type": "string", "minLength": 1, "maxLength": 200 }, + { "type": "integer" } + ] + }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "timeout": { "type": "integer", "minimum": 1, "maximum": 900000 }, + "helloV2Request": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/id" }, + "method": { "const": "hello" }, + "params": { + "type": "object", + "additionalProperties": false, + "required": ["protocol_version"], + "properties": { + "protocol_version": { "const": "2.0" }, + "client_name": { "type": "string", "minLength": 1, "maxLength": 200 }, + "timeout_ms": { "$ref": "#/$defs/timeout" } + } + } + } + }, + "helloV2Success": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/id" }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["protocol_version", "contextforge_version", "supported_protocol_versions", "capabilities", "workspace", "policy"], + "properties": { + "protocol_version": { "const": "2.0" }, + "contextforge_version": { "type": "string" }, + "supported_protocol_versions": { "type": "array", "items": { "type": "string" }, "uniqueItems": true }, + "capabilities": { "type": "object" }, + "workspace": { "type": "object" }, + "policy": { "type": "object" } + } + } + } + }, + "indexRequest": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/id" }, + "method": { "const": "index" }, + "params": { + "type": "object", + "additionalProperties": false, + "required": ["action", "expected_snapshot_digest"], + "properties": { + "action": { "enum": ["build", "update"] }, + "expected_snapshot_digest": { "$ref": "#/$defs/sha256" }, + "provider": { "type": "string", "minLength": 1, "maxLength": 128 }, + "model": { "type": "string", "minLength": 1, "maxLength": 128 }, + "base_url": { "type": "string", "minLength": 1, "maxLength": 2000 }, + "concurrency": { "type": "integer", "minimum": 1, "maximum": 8 }, + "request_timeout": { "type": "number", "minimum": 1, "maximum": 600 }, + "context_window": { "type": "integer", "minimum": 1024, "maximum": 2000000 }, + "json_repair_attempts": { "type": "integer", "minimum": 0, "maximum": 10 }, + "max_output_tokens": { "type": "integer", "minimum": 96, "maximum": 32768 }, + "fail_on_error": { "type": "boolean" }, + "fail_fast": { "type": "boolean" }, + "max_failures": { "type": "integer", "minimum": 1 }, + "force_reanalyze": { "type": "boolean" }, + "max_files": { "type": "integer", "minimum": 1 }, + "local_only": { "type": "boolean" }, + "recover_stale_lock": { "type": "boolean" }, + "confirm_unknown_lock": { "type": "boolean" }, + "timeout_ms": { "$ref": "#/$defs/timeout" } + } + } + } + }, + "indexSuccess": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "result"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/id" }, + "result": { + "type": "object", + "additionalProperties": false, + "required": ["action", "generation_id", "snapshot_digest", "index_schema", "partial", "statistics"], + "properties": { + "action": { "enum": ["build", "update"] }, + "generation_id": { "$ref": "#/$defs/sha256" }, + "snapshot_digest": { "$ref": "#/$defs/sha256" }, + "index_schema": { "type": "integer", "minimum": 1 }, + "partial": { "type": "boolean" }, + "statistics": { "type": "object" } + } + } + } + }, + "indexFailure": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "id", "error"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/id" }, + "error": { + "type": "object", + "additionalProperties": false, + "required": ["code", "message", "data"], + "properties": { + "code": { "enum": [-32001, -32008, -32009, -32010, -32011, -32012, -32013] }, + "message": { "type": "string" }, + "data": { + "type": "object", + "additionalProperties": false, + "required": ["code", "error_code", "phase", "reason", "retryable", "operation_id"], + "properties": { + "code": { + "enum": [ + "SOURCE_IDENTITY_CHANGED", + "INDEX_BUILD_FAILED", + "PROVIDER_FAILURE", + "PROVIDER_CONFIGURATION_ERROR", + "FAILURE_LIMIT_REACHED", + "PROVIDER_CIRCUIT_OPEN", + "INDEX_STORAGE_ERROR", + "INDEX_LOCKED" + ] + }, + "error_code": { "type": "string", "minLength": 1, "maxLength": 128 }, + "phase": { "type": "string", "minLength": 1, "maxLength": 128 }, + "reason": { "type": "string", "minLength": 1, "maxLength": 1000 }, + "retryable": { "type": "boolean" }, + "operation_id": { "type": "string", "minLength": 1, "maxLength": 128 } + } + } + } + } + } + }, + "progressNotification": { + "type": "object", + "additionalProperties": false, + "required": ["jsonrpc", "method", "params"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { "const": "$/progress" }, + "params": { + "type": "object", + "additionalProperties": false, + "required": ["request_id", "event"], + "properties": { + "request_id": { "$ref": "#/$defs/id" }, + "event": { + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "operation_id", "operation_type", "phase_id", "message", "percentage", "status", "sequence", "overall_percent", "phase_label", "phase_percent", "processed_units", "failed_units", "elapsed_seconds"], + "properties": { + "schema_version": { "const": 3 }, + "operation_id": { "type": "string" }, + "operation_type": { "type": "string" }, + "phase_id": { "type": "string" }, + "message": { "type": "string" }, + "completed": { "type": "number" }, + "total": { "type": ["number", "null"] }, + "percentage": { "type": "number", "minimum": 0, "maximum": 100 }, + "status": { "enum": ["running", "completed", "failed", "cancelled"] }, + "top_level_operation_id": { "type": ["string", "null"] }, + "parent_operation_id": { "type": ["string", "null"] }, + "metadata": { "type": "object" }, + "sequence": { "type": "integer", "minimum": 0 }, + "indeterminate": { "type": "boolean" }, + "overall_percent": { "type": "number" }, + "phase_label": { "type": "string" }, + "phase_percent": { "type": "number" }, + "phase_weight": { "type": "number" }, + "completed_units": { "type": "number" }, + "total_units": { "type": ["number", "null"] }, + "unit_type": { "type": "string" }, + "current_item": { "type": ["string", "null"] }, + "last_completed_item": { "type": ["string", "null"] }, + "last_failed_item": { "type": ["string", "null"] }, + "active_items": { "type": "array", "items": { "type": "string" } }, + "active_item_count": { "type": "integer" }, + "reused_units": { "type": "integer" }, + "skipped_units": { "type": "integer" }, + "failed_units": { "type": "integer" }, + "elapsed_seconds": { "type": "number" }, + "activity": { "enum": ["idle", "active", "waiting"] }, + "planned_units": { "type": "integer" }, + "processed_units": { "type": "integer" }, + "succeeded_units": { "type": "integer" }, + "fallback_units": { "type": "integer" }, + "active_units": { "type": "integer" }, + "current_attempt": { "type": ["integer", "null"] }, + "max_attempts": { "type": ["integer", "null"] }, + "lifecycle_state": { "type": "string" }, + "safe_error_code": { "type": ["string", "null"] }, + "safe_error_message": { "type": ["string", "null"] }, + "request_elapsed_seconds": { "type": "number" }, + "operation_elapsed_seconds": { "type": "number" }, + "analyzer_kind": { "type": ["string", "null"] }, + "estimated_input_tokens": { "type": ["integer", "null"] }, + "output_token_budget": { "type": ["integer", "null"] }, + "input_truncated": { "type": "boolean" }, + "configured_context_window": { "type": ["integer", "null"] }, + "schema_overhead_tokens": { "type": ["integer", "null"] }, + "safety_margin_tokens": { "type": ["integer", "null"] }, + "estimated_total_tokens": { "type": ["integer", "null"] } + } + } + } + } + } + } + } +} diff --git a/src/contextforge/bridge/models.py b/src/contextforge/bridge/models.py index 1ffea60..9e114d0 100644 --- a/src/contextforge/bridge/models.py +++ b/src/contextforge/bridge/models.py @@ -38,6 +38,37 @@ class SnapshotParams(BridgeParams): pass +class IndexParams(BridgeParams): + """Bridge 2 parameters for one atomic tracked index job.""" + + action: Literal["build", "update"] + expected_snapshot_digest: Sha256 + provider: str | None = Field(default=None, min_length=1, max_length=128) + model: str | None = Field(default=None, min_length=1, max_length=128) + base_url: str | None = Field(default=None, min_length=1, max_length=2_000) + concurrency: int | None = Field(default=None, ge=1, le=8, strict=True) + request_timeout: float | None = Field(default=None, ge=1, le=600) + context_window: int | None = Field( + default=None, ge=1_024, le=2_000_000, strict=True + ) + json_repair_attempts: int | None = Field(default=None, ge=0, le=10, strict=True) + max_output_tokens: int | None = Field(default=None, ge=96, le=32_768, strict=True) + fail_on_error: bool = False + fail_fast: bool = False + max_failures: int | None = Field(default=None, ge=1, strict=True) + force_reanalyze: bool = False + max_files: int | None = Field(default=None, ge=1, strict=True) + local_only: bool = False + recover_stale_lock: bool = False + confirm_unknown_lock: bool = False + + @model_validator(mode="after") + def validate_failure_policy(self) -> IndexParams: + if self.fail_fast and self.max_failures is not None: + raise ValueError("fail_fast and max_failures cannot be used together") + return self + + class DiscoverParams(BridgeParams): expected_snapshot_digest: Sha256 task: str = Field(min_length=1, max_length=20_000) @@ -152,6 +183,7 @@ class ShutdownParams(BridgeParams): "ExpandParams", "ExpansionOperation", "HelloParams", + "IndexParams", "PackageParams", "ReadParams", "ShutdownParams", diff --git a/src/contextforge/bridge/protocol.py b/src/contextforge/bridge/protocol.py index b629e7b..f49fdfa 100644 --- a/src/contextforge/bridge/protocol.py +++ b/src/contextforge/bridge/protocol.py @@ -2,10 +2,11 @@ from typing import Final, Literal -BRIDGE_PROTOCOL_VERSION: Final[Literal["1.1"]] = "1.1" -SUPPORTED_BRIDGE_PROTOCOL_VERSIONS: Final[tuple[Literal["1.0", "1.1"], ...]] = ( +BRIDGE_PROTOCOL_VERSION: Final[Literal["2.0"]] = "2.0" +SUPPORTED_BRIDGE_PROTOCOL_VERSIONS: Final[tuple[Literal["1.0", "1.1", "2.0"], ...]] = ( "1.0", "1.1", + "2.0", ) __all__ = ["BRIDGE_PROTOCOL_VERSION", "SUPPORTED_BRIDGE_PROTOCOL_VERSIONS"] diff --git a/src/contextforge/bridge/server.py b/src/contextforge/bridge/server.py index 9b9b0e2..c6554f3 100644 --- a/src/contextforge/bridge/server.py +++ b/src/contextforge/bridge/server.py @@ -9,6 +9,7 @@ import os import unicodedata from collections import OrderedDict +from contextlib import suppress from dataclasses import dataclass from pathlib import Path from typing import Any, BinaryIO, TextIO @@ -16,7 +17,12 @@ from pydantic import BaseModel, ValidationError from contextforge._metadata import __version__ -from contextforge.application import inspect_repository_index +from contextforge.application import ( + ApplicationError, + IndexSourceChangedError, + build_repository_index, + inspect_repository_index, +) from contextforge.context import ( ContextLimitError, ContextReaderError, @@ -45,14 +51,33 @@ ) from contextforge.discovery.tools import TOOL_INPUT_MODELS from contextforge.intelligence import ( + INDEX_SCHEMA_VERSION, + MANIFEST_SCHEMA_VERSION, + RECORD_SCHEMA_VERSION, + GlobalMapAnalysisError, + IndexLockError, + IndexStorageError, + SemanticAnalysisError, + SemanticFailureLimitError, + SemanticProviderCircuitError, calculate_source_snapshot_digest, canonical_json_bytes, load_file_code_map, load_file_semantic_analysis, load_manifest, ) +from contextforge.models import ( + ModelProvider, + ModelProviderError, + ProviderConfigurationError, + RetryClassification, + classify_retry, + provider_error_details, +) +from contextforge.progress import PROGRESS_SCHEMA_VERSION, ProgressEvent from contextforge.project_config import ( ProjectConfigError, + create_model_provider, load_project_configuration, resolve_provider_configuration, ) @@ -65,6 +90,7 @@ ExpandParams, ExpansionOperation, HelloParams, + IndexParams, PackageParams, ReadParams, ShutdownParams, @@ -93,11 +119,18 @@ SHUTTING_DOWN = -32005 INCOMPATIBLE_PROTOCOL_VERSION = -32006 PROTOCOL_NEGOTIATION_REQUIRED = -32007 +INDEX_BUILD_FAILED = -32008 +PROVIDER_FAILURE = -32009 +FAILURE_LIMIT_REACHED = -32010 +PROVIDER_CIRCUIT_OPEN = -32011 +INDEX_STORAGE_FAILURE = -32012 +INDEX_LOCKED = -32013 _METHOD_MODELS: dict[str, type[BaseModel]] = { "hello": HelloParams, "status": StatusParams, "snapshot": SnapshotParams, + "index": IndexParams, "discover": DiscoverParams, "expand": ExpandParams, "read": ReadParams, @@ -432,7 +465,7 @@ async def _process_request( }, ) timeout_ms = getattr(params, "timeout_ms", None) - operation = self._dispatch(method, params, cancellation) + operation = self._dispatch(method, request_id, params, cancellation) if timeout_ms is None: result = await operation else: @@ -513,7 +546,11 @@ async def _process_request( self._active.pop(_id_key(request_id), None) async def _dispatch( - self, method: str, raw: BaseModel, cancellation: asyncio.Event + self, + method: str, + request_id: str | int, + raw: BaseModel, + cancellation: asyncio.Event, ) -> dict[str, Any]: if cancellation.is_set(): raise asyncio.CancelledError @@ -523,6 +560,16 @@ async def _dispatch( return await self._status(_require_type(raw, StatusParams), cancellation) if method == "snapshot": return await self._snapshot(cancellation) + if method == "index": + if self._protocol_version != "2.0": + raise BridgeFault( + METHOD_NOT_FOUND, + "METHOD_NOT_FOUND", + "The requested method is not supported by this protocol version.", + ) + return await self._index( + request_id, _require_type(raw, IndexParams), cancellation + ) if method == "discover": return await self._discover( _require_type(raw, DiscoverParams), cancellation @@ -543,6 +590,7 @@ async def _dispatch( ) def _hello(self) -> dict[str, Any]: + bridge_v2 = self._protocol_version == "2.0" return { "protocol_version": self._protocol_version or BRIDGE_PROTOCOL_VERSION, "supported_protocol_versions": list(SUPPORTED_BRIDGE_PROTOCOL_VERSIONS), @@ -552,6 +600,7 @@ def _hello(self) -> dict[str, Any]: "hello", "status", "snapshot", + *(["index"] if bridge_v2 else []), "discover", "expand", "read", @@ -565,17 +614,42 @@ def _hello(self) -> dict[str, Any]: "concurrent_requests": True, "serialized_responses": True, "max_message_bytes": MAX_JSONRPC_MESSAGE_BYTES, - "expansion_candidates": self._protocol_version == "1.1", + "expansion_candidates": self._protocol_version in {"1.1", "2.0"}, + "tracked_index_jobs": bridge_v2, + "progress_notifications": bridge_v2, + "schemas": { + "index": { + "current": INDEX_SCHEMA_VERSION, + "readable": [1, INDEX_SCHEMA_VERSION], + }, + "manifest": { + "current": MANIFEST_SCHEMA_VERSION, + "readable": [1, MANIFEST_SCHEMA_VERSION], + }, + "record": { + "current": RECORD_SCHEMA_VERSION, + "readable": [1, RECORD_SCHEMA_VERSION], + }, + "progress": { + "current": PROGRESS_SCHEMA_VERSION, + "readable": [1, 2, PROGRESS_SCHEMA_VERSION], + }, + "context_package": {"current": 1, "readable": [1]}, + }, }, "workspace": { "identity": self.workspace_identity, }, "policy": { - "repository_access": "read_only_verified_snapshot", - "external_data": "disabled", + "repository_access": ( + "verified_snapshot_and_atomic_index_write" + if bridge_v2 + else "read_only_verified_snapshot" + ), + "external_data": "provider_policy" if bridge_v2 else "disabled", "portable_paths_only": True, "source_writes": False, - "index_mutation": False, + "index_mutation": bridge_v2, "shell": False, "subprocess_execution": False, }, @@ -630,7 +704,7 @@ async def _status( "lock_status": report.lock_status, }, } - if self._protocol_version == "1.1": + if self._protocol_version in {"1.1", "2.0"}: result["index"]["coverage"] = await asyncio.to_thread(self._index_coverage) return result @@ -650,6 +724,146 @@ async def _snapshot(self, cancellation: asyncio.Event) -> dict[str, Any]: } return response + async def _index( + self, + request_id: str | int, + params: IndexParams, + cancellation: asyncio.Event, + ) -> dict[str, Any]: + operation_id = ( + "bridge-index-" + + hashlib.sha256( + f"{type(request_id).__name__}:{request_id}".encode() + ).hexdigest()[:24] + ) + provider: ModelProvider | None = None + last_event: ProgressEvent | None = None + progress_tail: asyncio.Task[None] | None = None + + def observe(event: ProgressEvent) -> None: + nonlocal last_event, progress_tail + last_event = event + previous = progress_tail + + async def publish() -> None: + if previous is not None: + await previous + await self._require_writer().write( + { + "jsonrpc": JSONRPC_VERSION, + "method": "$/progress", + "params": { + "request_id": request_id, + "event": event.model_dump(mode="json"), + }, + } + ) + + progress_tail = asyncio.create_task(publish()) + + async def flush_progress() -> None: + if progress_tail is not None: + await progress_tail + + try: + snapshot = await asyncio.to_thread(scan_repository, self.workspace) + current_digest = calculate_source_snapshot_digest(snapshot) + if current_digest != params.expected_snapshot_digest: + raise _index_bridge_fault( + IndexSourceChangedError( + "repository source identity differs from expected snapshot" + ), + last_event, + operation_id, + ) + try: + project = load_project_configuration(self.workspace) + configuration = resolve_provider_configuration( + project, + provider=params.provider, + model=params.model, + base_url=params.base_url, + concurrency=params.concurrency, + timeout_seconds=params.request_timeout, + operation_timeout_seconds=params.request_timeout, + context_window=params.context_window, + json_repair_attempts=params.json_repair_attempts, + local_only=True if params.local_only else None, + ) + if configuration is not None: + provider = create_model_provider(configuration) + except (ProjectConfigError, ValueError): + raise _index_bridge_fault( + ProviderConfigurationError( + "provider configuration could not be resolved" + ), + last_event, + operation_id, + ) from None + concurrency = ( + configuration.concurrency_limit + if configuration is not None + else ( + project.models.concurrency_limit + if params.concurrency is None + else params.concurrency + ) + ) + report = await build_repository_index( + self.workspace, + provider=provider, + provider_configuration=configuration, + update_only=params.action == "update", + concurrency=concurrency, + fail_on_error=params.fail_on_error, + fail_fast=params.fail_fast, + max_failures=params.max_failures, + force_reanalyze=params.force_reanalyze, + max_files=params.max_files, + semantic_max_output_tokens=( + project.models.semantic_max_output_tokens + if params.max_output_tokens is None + else params.max_output_tokens + ), + recover_stale_lock=params.recover_stale_lock, + confirm_unknown_lock=params.confirm_unknown_lock, + progress=observe, + operation_id=operation_id, + cancellation=cancellation, + ) + await flush_progress() + self._snapshot_digest = report.manifest.build.source_snapshot_digest + self._preparations.clear() + return { + "action": params.action, + "generation_id": report.manifest.generation_id, + "snapshot_digest": report.manifest.build.source_snapshot_digest, + "index_schema": report.manifest.schema_versions.index_schema_version, + "partial": report.partial, + "statistics": report.manifest.statistics.model_dump(mode="json"), + } + except asyncio.CancelledError: + await flush_progress() + raise + except BridgeFault: + await flush_progress() + raise + except ( + ApplicationError, + GlobalMapAnalysisError, + IndexStorageError, + ModelProviderError, + ProjectConfigError, + SemanticAnalysisError, + ValueError, + ) as exc: + await flush_progress() + raise _index_bridge_fault(exc, last_event, operation_id) from None + finally: + if provider is not None: + with suppress(ModelProviderError): + await provider.close() + async def _discover( self, params: DiscoverParams, cancellation: asyncio.Event ) -> dict[str, Any]: @@ -721,7 +935,7 @@ async def _expand( "made_progress": result.made_progress, "budget_usage": result.budget_usage.model_dump(mode="json"), } - if self._protocol_version == "1.1": + if self._protocol_version in {"1.1", "2.0"}: response["candidates"] = self._register_expansion_candidates( snapshot, preparation, params.operation, result.data ) @@ -1246,6 +1460,85 @@ def _validation_details(exc: ValidationError | ValueError) -> list[dict[str, Any return [{"location": [], "message": str(exc), "type": "value_error"}] +def _index_bridge_fault( + error: BaseException, + event: ProgressEvent | None, + operation_id: str, +) -> BridgeFault: + error_code = "index_build_failed" + reason = "ContextForge could not complete the index operation." + retryable = False + current: BaseException | None = error + seen: set[int] = set() + while current is not None and id(current) not in seen: + seen.add(id(current)) + if isinstance(current, ModelProviderError): + error_code, reason = provider_error_details(current) + retryable = ( + classify_retry(current) is RetryClassification.RETRYABLE + and not current.circuit_opened + ) + break + typed_code = getattr(current, "error_code", None) + safe_reason = getattr(current, "safe_reason", None) + if isinstance(typed_code, str) and isinstance(safe_reason, str): + error_code = typed_code + reason = safe_reason[:1_000] + break + current = current.__cause__ + rpc_code = INDEX_BUILD_FAILED + typed_rpc_code = "INDEX_BUILD_FAILED" + if isinstance(error, IndexSourceChangedError): + rpc_code = SOURCE_IDENTITY_CHANGED + typed_rpc_code = "SOURCE_IDENTITY_CHANGED" + error_code = "source_identity_changed" + reason = "Repository source identity changed before index publication." + retryable = True + elif isinstance(error, (ProjectConfigError, ProviderConfigurationError)): + rpc_code = PROVIDER_FAILURE + typed_rpc_code = "PROVIDER_CONFIGURATION_ERROR" + error_code = "provider_configuration_error" + reason = "Project provider configuration is invalid." + elif isinstance(error, SemanticFailureLimitError): + rpc_code = FAILURE_LIMIT_REACHED + typed_rpc_code = "FAILURE_LIMIT_REACHED" + error_code = "failure_limit_reached" + reason = "The configured semantic failure limit was reached." + elif isinstance(error, SemanticProviderCircuitError): + rpc_code = PROVIDER_CIRCUIT_OPEN + typed_rpc_code = "PROVIDER_CIRCUIT_OPEN" + error_code = "provider_circuit_open" + reason = "The provider circuit breaker opened during indexing." + retryable = False + elif isinstance(error, ModelProviderError): + rpc_code = PROVIDER_FAILURE + typed_rpc_code = "PROVIDER_FAILURE" + elif isinstance(error, IndexLockError): + rpc_code = INDEX_LOCKED + typed_rpc_code = "INDEX_LOCKED" + error_code = "index_lock_unavailable" + reason = "Another writer owns the index lock or lock recovery is required." + retryable = True + elif isinstance(error, IndexStorageError): + rpc_code = INDEX_STORAGE_FAILURE + typed_rpc_code = "INDEX_STORAGE_ERROR" + error_code = "index_storage_error" + reason = "ContextForge could not safely access index storage." + retryable = True + return BridgeFault( + rpc_code, + typed_rpc_code, + "ContextForge index operation failed.", + data={ + "error_code": error_code, + "phase": "initialize" if event is None else event.phase_id, + "reason": reason, + "retryable": retryable, + "operation_id": operation_id, + }, + ) + + __all__ = [ "BRIDGE_PROTOCOL_VERSION", "JSONRPC_VERSION", diff --git a/tests/test_bridge.py b/tests/test_bridge.py index e3eea8e..9d5ae9e 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -19,9 +19,12 @@ BridgeSelectionItem, CancelParams, DiscoverParams, + IndexParams, ReadParams, ) from contextforge.intelligence import acquire_index_lock, build_structural_index +from contextforge.models import ProviderAuthenticationError +from contextforge.progress import ProgressEvent, ProgressStatus from contextforge.repositories import scan_repository @@ -42,6 +45,23 @@ def test_bridge_protocol_schema_is_closed_and_matches_v1() -> None: assert "tool_name" not in expand["properties"] assert schema["$defs"]["discoverResult"]["additionalProperties"] is False + v2 = json.loads( + (root / "docs/schemas/contextforge-bridge-v2.schema.json").read_text( + encoding="utf-8" + ) + ) + assert ( + v2["$defs"]["indexRequest"]["properties"]["params"]["additionalProperties"] + is False + ) + progress_event = v2["$defs"]["progressNotification"]["properties"]["params"][ + "properties" + ]["event"] + assert progress_event["properties"]["schema_version"] == {"const": 3} + assert v2["$defs"]["progressNotification"]["properties"]["method"] == { + "const": "$/progress" + } + def test_bridge_status_reports_structural_index_coverage(tmp_path: Path) -> None: (tmp_path / "parsed.py").write_text("def run():\n return 1\n", encoding="utf-8") @@ -121,6 +141,19 @@ def wait(self, count: int) -> list[dict[str, Any]]: chunks = list(self.chunks[:count]) return [cast(dict[str, Any], json.loads(chunk)) for chunk in chunks] + def wait_for_id(self, request_id: str | int) -> dict[str, Any]: + deadline = time.monotonic() + 10 + with self._condition: + while True: + for chunk in self.chunks: + frame = cast(dict[str, Any], json.loads(chunk)) + if frame.get("id") == request_id: + return frame + remaining = deadline - time.monotonic() + if remaining <= 0: + raise AssertionError("timed out waiting for bridge response") + self._condition.wait(remaining) + class _Harness: def __init__( @@ -201,7 +234,11 @@ async def exercise() -> None: hello = (await harness.response(1))[0] assert hello["jsonrpc"] == "2.0" assert hello["result"]["protocol_version"] == "1.0" - assert hello["result"]["supported_protocol_versions"] == ["1.0", "1.1"] + assert hello["result"]["supported_protocol_versions"] == [ + "1.0", + "1.1", + "2.0", + ] assert hello["result"]["capabilities"]["model_free_discovery"] is True assert hello["result"]["policy"]["source_writes"] is False assert "shell" in hello["result"]["policy"] @@ -234,13 +271,13 @@ async def exercise() -> None: assert missing["error"]["data"]["code"] == "INVALID_PARAMS" harness.input.send( - _request("incompatible", "hello", {"protocol_version": "2.0"}) + _request("incompatible", "hello", {"protocol_version": "3.0"}) ) incompatible = (await harness.response(3))[-1] assert incompatible["error"]["data"] == { "code": "INCOMPATIBLE_PROTOCOL_VERSION", - "requested_protocol_version": "2.0", - "supported_protocol_versions": ["1.0", "1.1"], + "requested_protocol_version": "3.0", + "supported_protocol_versions": ["1.0", "1.1", "2.0"], } harness.input.send(_request("compatible", "hello", {"protocol_version": "1.0"})) @@ -255,6 +292,223 @@ async def exercise() -> None: asyncio.run(exercise()) +def test_bridge_v1_does_not_expose_index_mutation(tmp_path: Path) -> None: + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "1.1"})) + hello = (await harness.response(1))[-1] + assert "index" not in hello["result"]["capabilities"]["methods"] + assert hello["result"]["policy"]["index_mutation"] is False + + harness.input.send( + _request( + "index-v1", + "index", + { + "action": "build", + "expected_snapshot_digest": "0" * 64, + "provider": "none", + }, + ) + ) + rejected = await asyncio.to_thread(harness.output.wait_for_id, "index-v1") + assert rejected["error"]["data"]["code"] == "METHOD_NOT_FOUND" + await harness.close() + + asyncio.run(exercise()) + + +def test_bridge_v2_build_update_and_correlated_progress(tmp_path: Path) -> None: + (tmp_path / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) + hello = (await harness.response(1))[-1]["result"] + capabilities = hello["capabilities"] + assert capabilities["tracked_index_jobs"] is True + assert capabilities["progress_notifications"] is True + assert capabilities["schemas"] == { + "index": {"current": 2, "readable": [1, 2]}, + "manifest": {"current": 2, "readable": [1, 2]}, + "record": {"current": 2, "readable": [1, 2]}, + "progress": {"current": 3, "readable": [1, 2, 3]}, + "context_package": {"current": 1, "readable": [1]}, + } + assert "index" in capabilities["methods"] + digest = await _snapshot(harness, 2) + + for action in ("build", "update"): + request_id = f"index-{action}" + harness.input.send( + _request( + request_id, + "index", + { + "action": action, + "expected_snapshot_digest": digest, + "provider": "none", + }, + ) + ) + response = await asyncio.to_thread(harness.output.wait_for_id, request_id) + assert response["result"]["action"] == action + assert response["result"]["snapshot_digest"] == digest + assert response["result"]["index_schema"] == 2 + assert response["result"]["partial"] is False + progress = [ + frame + for frame in await harness.response(len(harness.output.chunks)) + if frame.get("method") == "$/progress" + and frame["params"]["request_id"] == request_id + ] + assert progress + events = [ + ProgressEvent.model_validate(frame["params"]["event"]) + for frame in progress + ] + assert events[-1].status is ProgressStatus.COMPLETED + assert ( + events[-1].metadata["generation_id"] + == response["result"]["generation_id"] + ) + await harness.close() + + asyncio.run(exercise()) + + +def test_bridge_v2_index_cancellation_is_cooperative( + tmp_path: Path, monkeypatch: Any +) -> None: + started = asyncio.Event() + + async def blocked_build(*args: object, **kwargs: object) -> None: + del args + cancellation = cast(asyncio.Event, kwargs["cancellation"]) + started.set() + while not cancellation.is_set(): + await asyncio.sleep(0) + raise asyncio.CancelledError + + monkeypatch.setattr(bridge_module, "build_repository_index", blocked_build) + + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) + await harness.response(1) + digest = await _snapshot(harness, 2) + harness.input.send( + _request( + "slow-index", + "index", + { + "action": "build", + "expected_snapshot_digest": digest, + "provider": "none", + }, + ) + ) + await asyncio.wait_for(started.wait(), timeout=1) + harness.input.send( + { + "jsonrpc": "2.0", + "method": "$/cancelRequest", + "params": {"id": "slow-index"}, + } + ) + response = await asyncio.to_thread(harness.output.wait_for_id, "slow-index") + assert response["error"]["data"]["code"] == "REQUEST_CANCELLED" + await harness.close() + + asyncio.run(exercise()) + + +def test_bridge_v2_index_errors_are_typed_and_safe( + tmp_path: Path, monkeypatch: Any +) -> None: + async def authentication_failure(*args: object, **kwargs: object) -> None: + del args, kwargs + raise ProviderAuthenticationError( + "Bearer top-secret at http://user:pass@example.invalid" + ) + + monkeypatch.setattr(bridge_module, "build_repository_index", authentication_failure) + + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) + await harness.response(1) + digest = await _snapshot(harness, 2) + harness.input.send( + _request( + "provider-error", + "index", + { + "action": "build", + "expected_snapshot_digest": digest, + "provider": "none", + }, + ) + ) + response = await asyncio.to_thread(harness.output.wait_for_id, "provider-error") + data = response["error"]["data"] + assert response["error"]["code"] == bridge_module.PROVIDER_FAILURE + assert data["code"] == "PROVIDER_FAILURE" + assert data["error_code"] == "authentication_failed" + assert data["phase"] == "initialize" + assert data["reason"] == "provider authentication failed" + assert data["retryable"] is False + assert data["operation_id"].startswith("bridge-index-") + assert "secret" not in json.dumps(response) + + harness.input.send( + _request( + "snapshot-drift", + "index", + { + "action": "build", + "expected_snapshot_digest": "0" * 64, + "provider": "none", + }, + ) + ) + drift = await asyncio.to_thread(harness.output.wait_for_id, "snapshot-drift") + drift_data = drift["error"]["data"] + assert drift["error"]["code"] == bridge_module.SOURCE_IDENTITY_CHANGED + assert drift_data["code"] == "SOURCE_IDENTITY_CHANGED" + assert drift_data["error_code"] == "source_identity_changed" + assert drift_data["retryable"] is True + + harness.input.send( + _request( + "configuration-error", + "index", + { + "action": "build", + "expected_snapshot_digest": digest, + "provider": "openai-compatible", + "model": "exact/model", + "base_url": "http://user:top-secret@example.invalid/v1", + }, + ) + ) + configuration = await asyncio.to_thread( + harness.output.wait_for_id, "configuration-error" + ) + configuration_data = configuration["error"]["data"] + assert configuration["error"]["code"] == bridge_module.PROVIDER_FAILURE + assert configuration_data["code"] == "PROVIDER_CONFIGURATION_ERROR" + assert configuration_data["error_code"] == "provider_configuration_error" + assert "top-secret" not in json.dumps(configuration) + await harness.close() + + asyncio.run(exercise()) + + def test_bridge_rejects_malformed_oversized_unknown_and_invalid_requests( tmp_path: Path, ) -> None: @@ -1126,6 +1380,15 @@ async def failure_exercise() -> None: def test_bridge_parameter_models_reject_noncanonical_values() -> None: + with pytest.raises(ValidationError): + IndexParams.model_validate( + { + "action": "build", + "expected_snapshot_digest": "0" * 64, + "fail_fast": True, + "max_failures": 2, + } + ) with pytest.raises(ValidationError): DiscoverParams.model_validate_json( json.dumps( From f5388938671b44551af5296b57db79edd104a0d4 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:18:03 +0300 Subject: [PATCH 04/11] fix(index): stabilize analyzer identity across endpoints --- src/contextforge/intelligence/manifest.py | 4 +- src/contextforge/intelligence/maps.py | 80 ++++++++++++++--------- tests/test_intelligence_manifest.py | 15 ++--- tests/test_repository_maps.py | 31 +++++++++ 4 files changed, 88 insertions(+), 42 deletions(-) diff --git a/src/contextforge/intelligence/manifest.py b/src/contextforge/intelligence/manifest.py index a3395ce..eaf60ac 100644 --- a/src/contextforge/intelligence/manifest.py +++ b/src/contextforge/intelligence/manifest.py @@ -15,6 +15,7 @@ SchemaVersionMetadata, analyzer_identity_key, calculate_index_statistics, + normalize_analyzer_identity, validate_portable_relative_path, ) from contextforge.repositories import ProjectFile, ProjectSnapshot @@ -183,7 +184,8 @@ def identify_stale_analysis( and ( invalidate_all or not _source_matches(indexed, current_by_path[indexed.path]) - or indexed.analyzer != expected_analyzer + or normalize_analyzer_identity(indexed.analyzer) + != normalize_analyzer_identity(expected_analyzer) ) ) diff --git a/src/contextforge/intelligence/maps.py b/src/contextforge/intelligence/maps.py index 752527b..dd8fd74 100644 --- a/src/contextforge/intelligence/maps.py +++ b/src/contextforge/intelligence/maps.py @@ -48,6 +48,7 @@ IndexModel, ModelIdentity, analyzer_identity_key, + normalize_analyzer_identity, ) from contextforge.intelligence.semantic_models import ( EvidenceReference, @@ -336,8 +337,8 @@ async def build_repository_maps( ) overview = build_repository_overview(current, code_maps) semantic_analyses = _load_available_semantics(snapshot.root, current) - provider_id, model_id, base_url_sha256 = _provider_identity(provider) - analyzer = _global_analyzer(active_options, provider_id, model_id, base_url_sha256) + provider_id, model_id = _provider_identity(provider) + analyzer = _global_analyzer(active_options, provider_id, model_id) options_digest = _options_digest(active_options) source_interpretations_digest = _file_interpretations_digest(current) previous_records = _try_load_global_records(snapshot.root, current) @@ -362,20 +363,40 @@ async def build_repository_maps( source_interpretations_digest, ) ): + normalized_architecture = old_architecture.model_copy( + update={"analyzer": analyzer} + ) + normalized_features = old_features.model_copy(update={"analyzer": analyzer}) + migrated = ( + normalized_architecture != old_architecture + or normalized_features != old_features + ) + generation_path = lock.layout.generations / current.generation_id + manifest = current + if migrated: + generation_path = _publish_global_records( + lock, + current, + overview, + normalized_architecture, + normalized_features, + analyzer, + ) + manifest = load_manifest(snapshot.root) return GlobalMapBuildResult( - manifest=current, + manifest=manifest, overview=overview, - architecture=old_architecture, - features=old_features, + architecture=normalized_architecture, + features=normalized_features, outcomes=( GlobalMapOutcome("architecture", "reused", 0), GlobalMapOutcome("features", "reused", 0), ), - generation_path=lock.layout.generations / current.generation_id, + generation_path=generation_path, request_count=0, package_summary_count=0, group_summary_count=0, - published=False, + published=migrated, ) required_model_calls = _required_model_calls(code_maps, active_options) @@ -397,6 +418,10 @@ async def build_repository_maps( except ProviderCancelledError: raise except (ModelProviderError, GlobalMapAnalysisError, ValueError) as exc: + if isinstance(exc, ModelProviderError) and exc.circuit_opened: + raise GlobalMapAnalysisError( + "provider circuit opened during repository map hierarchy" + ) from exc if active_options.fail_on_error: raise GlobalMapAnalysisError( "repository map hierarchy failed; index not published" @@ -477,6 +502,10 @@ async def build_repository_maps( except ProviderCancelledError: raise except (ModelProviderError, GlobalMapAnalysisError, ValueError) as exc: + if isinstance(exc, ModelProviderError) and exc.circuit_opened: + raise GlobalMapAnalysisError( + "provider circuit opened during architecture map analysis" + ) from exc diagnostic = _failure_diagnostic("architecture-map-failed", exc) outcomes.append(GlobalMapOutcome("architecture", "failed", 1, diagnostic)) @@ -504,6 +533,10 @@ async def build_repository_maps( except ProviderCancelledError: raise except (ModelProviderError, GlobalMapAnalysisError, ValueError) as exc: + if isinstance(exc, ModelProviderError) and exc.circuit_opened: + raise GlobalMapAnalysisError( + "provider circuit opened during feature map analysis" + ) from exc diagnostic = _failure_diagnostic("feature-map-failed", exc) outcomes.append(GlobalMapOutcome("features", "failed", 1, diagnostic)) @@ -1955,11 +1988,13 @@ def _publish_global_records( interpretations_digest=interpretations_digest, previous_generation_id=current.generation_id, ) - semantic_analyzers = current.semantic_analyzers + semantic_analyzers = tuple( + normalize_analyzer_identity(item) for item in current.semantic_analyzers + ) if analyzer is not None: semantic_analyzers = tuple( sorted( - set((*current.semantic_analyzers, analyzer)), + set((*semantic_analyzers, analyzer)), key=analyzer_identity_key, ) ) @@ -2116,13 +2151,10 @@ def _global_analyzer( options: GlobalMapAnalysisOptions, provider_id: str, model_id: str, - base_url_sha256: str | None, ) -> AnalyzerIdentity: return AnalyzerIdentity( analyzer_id=GLOBAL_MAP_ANALYZER_ID, - analyzer_version=_connection_bound_version( - GLOBAL_MAP_ANALYZER_VERSION, base_url_sha256 - ), + analyzer_version=GLOBAL_MAP_ANALYZER_VERSION, analysis_prompt_version=options.prompt_version, response_schema_version=GLOBAL_MAP_SCHEMA_VERSION, model_identity=ModelIdentity( @@ -2183,12 +2215,12 @@ def _map_inputs_match( value.source_snapshot_digest == manifest.build.source_snapshot_digest and value.facts_digest == manifest.build.facts_digest and value.source_interpretations_digest == source_interpretations_digest - and value.analyzer == analyzer + and normalize_analyzer_identity(value.analyzer) == analyzer and value.analysis_options_digest == options_digest ) -def _provider_identity(provider: ModelProvider) -> tuple[str, str, str | None]: +def _provider_identity(provider: ModelProvider) -> tuple[str, str]: provider_id = provider.provider_id configuration = getattr(provider, "configuration", None) model_id = getattr(configuration, "model_id", None) @@ -2196,23 +2228,7 @@ def _provider_identity(provider: ModelProvider) -> tuple[str, str, str | None]: raise GlobalMapAnalysisError( "global map provider must expose stable provider and model identity" ) - endpoint = getattr(configuration, "endpoint", None) - base_url_sha256 = None - if provider_id == "openai-compatible": - if not isinstance(endpoint, str): - raise GlobalMapAnalysisError( - "OpenAI-compatible provider must expose a stable base URL identity" - ) - base_url_sha256 = hashlib.sha256( - endpoint.rstrip("/").encode("utf-8") - ).hexdigest() - return provider_id, model_id, base_url_sha256 - - -def _connection_bound_version(version: str, base_url_sha256: str | None) -> str: - if base_url_sha256 is None: - return version - return f"{version}+base.{base_url_sha256}" + return provider_id, model_id def _validate_build_inputs(snapshot: ProjectSnapshot, lock: IndexWriteLock) -> None: diff --git a/tests/test_intelligence_manifest.py b/tests/test_intelligence_manifest.py index d4d2900..cdfb71e 100644 --- a/tests/test_intelligence_manifest.py +++ b/tests/test_intelligence_manifest.py @@ -341,7 +341,7 @@ def test_invalid_build_options_digest_is_rejected() -> None: ) -def test_openai_compatible_base_url_identity_change_invalidates_model_records() -> None: +def test_legacy_endpoint_suffixes_are_equivalent_model_identities() -> None: project_file = _file("app.py", "pass") first = _analyzer().model_copy( update={ @@ -359,12 +359,9 @@ def test_openai_compatible_base_url_identity_change_invalidates_model_records() ) manifest = _manifest((project_file,), analyzer=first) - assert ( - identify_stale_analysis( - manifest, - (project_file,), - expected_analyzer=changed, - build_options_digest=_sha("options"), - ) - == manifest.files + assert not identify_stale_analysis( + manifest, + (project_file,), + expected_analyzer=changed, + build_options_digest=_sha("options"), ) diff --git a/tests/test_repository_maps.py b/tests/test_repository_maps.py index f67bec8..b0c0fb8 100644 --- a/tests/test_repository_maps.py +++ b/tests/test_repository_maps.py @@ -7,6 +7,7 @@ import pytest from pydantic import ValidationError +import contextforge.intelligence.maps as maps_module from contextforge.intelligence import ( ArchitectureMap, GlobalMapAnalysisError, @@ -281,6 +282,36 @@ def test_one_module_hierarchical_maps_persist_and_reuse_without_source_prompt( ) +def test_legacy_endpoint_identity_is_republished_for_maps_without_model_calls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + snapshot = _facts(tmp_path, {"main.py": "def main():\n return 1\n"}) + original = maps_module._global_analyzer + + def legacy_analyzer(*args: Any, **kwargs: Any) -> Any: + identity = original(*args, **kwargs) + return identity.model_copy( + update={"analyzer_version": identity.analyzer_version + "+base." + "b" * 64} + ) + + monkeypatch.setattr(maps_module, "_global_analyzer", legacy_analyzer) + legacy = _maps(snapshot, _Responder(), run_id="legacy-map-identity") + monkeypatch.setattr(maps_module, "_global_analyzer", original) + responder = _Responder() + + migrated = _maps(snapshot, responder, run_id="migrate-map-identity") + + assert responder.requests == [] + assert migrated.published is True + assert migrated.manifest.generation_id != legacy.manifest.generation_id + assert migrated.architecture is not None + assert "+base." not in migrated.architecture.analyzer.analyzer_version + assert all( + "+base." not in item.analyzer_version + for item in migrated.manifest.semantic_analyzers + ) + + def test_multi_package_hierarchy_entry_adapter_core_and_deterministic_order( tmp_path: Path, ) -> None: From 7919fcbaffc61e1d2c4f39019ccb79192ec4182b Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:18:32 +0300 Subject: [PATCH 05/11] docs(bridge): document host integration contracts --- CHANGELOG.md | 27 ++++++ README.md | 24 +++-- docs/architecture/model-providers.md | 19 +++- docs/architecture/progress-reporting.md | 8 ++ docs/decisions/003-host-managed-index-jobs.md | 71 ++++++++++++++ docs/guides/bridge.md | 96 +++++++++++++++---- src/contextforge/cli/main.py | 15 +-- 7 files changed, 228 insertions(+), 32 deletions(-) create mode 100644 docs/decisions/003-host-managed-index-jobs.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 16bb097..4d73462 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ and Python distribution versions follow PEP 440. ## [Unreleased] +### Added + +- Added independent `--fail-fast` and `--max-failures N` index policies, a + bounded semantic scheduler, and a job-scoped provider circuit breaker. +- Added `--progress jsonl` for clean, flushed `ProgressEvent` schema 3 streams + from `index build` and `index update`. +- Added opt-in Bridge 2.0 tracked `build`/`update` jobs, correlated + `$/progress` notifications, cooperative cancellation, schema capabilities, + and a normative Bridge 2 JSON Schema. Bridge 1.0/1.1 remain supported. + +### Changed + +- Classified authentication, authorization, missing credential, quota, rate + limit, model, configuration, timeout, and service failures so terminal + provider-wide failures are not retried per file. +- Made semantic and repository-map analyzer identity depend on provider/model + and analysis contracts rather than an OpenAI-compatible endpoint. Legacy + `+base.` records migrate on update without model calls. + +### Fixed + +- Revalidate the repository snapshot immediately before atomic index + publication and preserve the prior active generation on cancellation, + failure limits, provider circuit opening, or source drift. +- Return safe typed Bridge index errors without provider bodies, credentialed + URLs, absolute paths, tracebacks, or exception representations. + ## [0.5.1] - 2026-09-05 ### Added diff --git a/README.md b/README.md index e7d75e3..0e5a75a 100644 --- a/README.md +++ b/README.md @@ -28,9 +28,9 @@ commands.

> [!IMPORTANT] -> ContextForge is pre-alpha software. Version `0.5.1` adds verified polyglot -> declarations, resumable semantic coverage, bridge protocol 1.1, and hardened -> exact-symbol discovery. Discovery benchmarking is experimental and +> ContextForge is pre-alpha software. The unreleased API adds bounded failure +> policies, JSONL progress, and opt-in Bridge 2 tracked index jobs while the +> package version remains `0.5.1`. Discovery benchmarking is experimental and > its results should be reviewed alongside the recorded provider, model, > configuration, and source snapshot. @@ -49,7 +49,8 @@ commands. - **Explicit trust boundaries.** ContextForge does not edit repository source, execute repository code, expose shell tools, or mutate Git state. - **Automation-friendly output.** Structured results stay on stdout while - progress and diagnostics stay on stderr. + normal progress and diagnostics stay on stderr; index jobs can opt into a + pure schema-3 JSONL progress stream on stdout. ## Representative workflow @@ -185,7 +186,7 @@ mutating operations. | `contextforge diagnostics config [PATH]` | Explain effective configuration | Read-only | | `contextforge diagnostics provider [PATH]` | Show provider policy without probing it | Read-only | | `contextforge mcp serve [PATH]` | Run the local read-only stdio MCP server | Read-only session | -| `contextforge bridge --stdio --workspace PATH` | Run persistent JSON-RPC bridge v1 | Verified read-only workspace session | +| `contextforge bridge --stdio --workspace PATH` | Run negotiated JSON-RPC Bridge 1 or 2 | V1 read-only; V2 may atomically mutate only the index | | `contextforge benchmark discovery PATH` | Run manifest-driven discovery benchmarks | Repository/index read-only; experimental | Global diagnostic options are `--log-level`, `--log-format`, `--log-file`, @@ -193,9 +194,20 @@ repeatable `--log-component`, `--no-log-file`, `--no-color`, and `-v`/`-vv`. Detailed syntax, defaults, streams, side effects, mistakes, and examples are in the [Wiki CLI reference](https://github.com/waterflane/ContextForge/wiki/CLI-Overview). The local integration contract is documented in the -[bridge v1 guide](docs/guides/bridge.md), with a runnable +[bridge integration guide](docs/guides/bridge.md), with a runnable [generic client](examples/generic_bridge_client.py). +Long model-backed index jobs can stop issuing new work with `--fail-fast` or +`--max-failures N`. Existing `--fail-on-error` semantics are unchanged: without +one of those limits ContextForge finishes the workload and declines publication +if any semantic unit failed. Hosts that launch the CLI can consume full +`ProgressEvent` schema 3 objects with `--progress jsonl`: + +```bash +contextforge index update . --provider openai-compatible \ + --model exact/model-id --progress jsonl --max-failures 3 +``` + ## Configuration Project configuration is closed, versioned TOML. Resolution order is: diff --git a/docs/architecture/model-providers.md b/docs/architecture/model-providers.md index eef079d..bb9770a 100644 --- a/docs/architecture/model-providers.md +++ b/docs/architecture/model-providers.md @@ -271,9 +271,12 @@ identity. Arbitrary parsed JSON is never accepted. No model name is supplied by default. The base URL is configurable with `[models].base_url` or CLI `--base-url`. -Changing it changes the credential-free SHA-256 suffix on semantic and -repository-map analyzer identity versions, invalidating model-dependent records -without changing the persisted provider/model schema. +It is transport configuration, not analyzer identity: changing a temporary +loopback port does not invalidate model-dependent records. Analyzer identity is +derived from analyzer, prompt and response-schema versions plus provider/model. +Legacy versions ending in `+base.` compare as their neutral identity +and are republished without that suffix during the next update, without a model +call. An optional bearer token is loaded only through the configured `credential_env` name. Authentication failures, safe structured error bodies, missing model IDs, malformed envelopes, structured-output rejection, @@ -281,6 +284,16 @@ unavailability, timeout, and cancellation are translated to the shared typed provider errors. The adapter uses the same bounded retry runtime as Ollama and accepts an injectable async HTTP transport for offline tests. +The shared runtime distinguishes terminal provider-wide failures from transient +ones. Authentication, authorization, missing credentials, quota/billing +exhaustion, missing models, and invalid configuration open the job-scoped +circuit after the first final result and are not retried for each file. Rate +limits, timeouts, and service unavailability retain bounded request retries; +three consecutive exhausted failures with the same safe code and +provider/model identity open the circuit. A success resets that sequence. +OpenAI-compatible HTTP 429 responses use bounded structured `error.code` and +message fields to distinguish quota exhaustion from transient rate limiting. + ## Troubleshooting local structured providers `request exceeds the available context size` means the configured window is diff --git a/docs/architecture/progress-reporting.md b/docs/architecture/progress-reporting.md index dd3bce7..c139cb4 100644 --- a/docs/architecture/progress-reporting.md +++ b/docs/architecture/progress-reporting.md @@ -159,6 +159,14 @@ stderr receives coalesced, non-ANSI records only for meaningful phase, percentage, item, counter, or terminal changes. `never` suppresses rendering; `always` never forces terminal controls onto an unsafe redirected stream. +For `contextforge index build|update`, `--progress jsonl` changes stdout into a +pure UTF-8 JSONL stream. Every line is the complete `ProgressEvent` schema 3 +object and is flushed immediately; Rich rendering and the human build summary +are suppressed. Diagnostics remain on stderr. The final event carries the +generation ID, source snapshot digest, index schema, and partial flag. When a +failure limit or cancellation stops semantic scheduling, terminal event +metadata also reports cancelled and not-yet-started units. + Direct stderr and existing stderr logging handlers are routed through the same live console while it is active, then restored on the single stop path. This prints diagnostics above the panel instead of leaving a duplicate frame. All diff --git a/docs/decisions/003-host-managed-index-jobs.md b/docs/decisions/003-host-managed-index-jobs.md new file mode 100644 index 0000000..5b0a10c --- /dev/null +++ b/docs/decisions/003-host-managed-index-jobs.md @@ -0,0 +1,71 @@ +# ADR-003: Host-managed index jobs and stable provider identity + +## Status + +Accepted for the unreleased ContextForge API following 0.5.1. + +## Context + +Local hosts need to build and update ContextForge indexes without duplicating +writer-lock, staging, publication, progress, timeout, and cancellation logic. +The CLI previously exposed only human progress and `--fail-on-error`, which +finishes all semantic work before declining publication. Repeated provider-wide +failures could therefore spend one retry sequence per file. Bridge 1 is +intentionally model-free and read-only, so it cannot own this lifecycle. + +OpenAI-compatible endpoint URLs were also encoded into analyzer versions. A +temporary loopback port could invalidate every model-backed record even when +the provider, model, prompts, and response contracts were unchanged. + +## Decision + +The application index workflow accepts cooperative cancellation and independent +failure limits. `fail_fast` means one failed semantic unit; `max_failures` +defines another positive threshold. They are mutually exclusive. Reaching a +threshold stops issuing work, cancels in-flight provider waits, aborts the +publication transaction, and preserves the prior active generation. +`fail_on_error` without a limit retains its original finish-then-refuse behavior. + +A job-scoped circuit breaker sits in the shared provider runtime. Typed +authentication, authorization, missing credential, quota, model, and +configuration failures open it immediately. Three consecutive exhausted +transient failures with the same safe error code and provider/model identity +also open it; success resets the sequence. The key never includes response +text, endpoints, or secrets. + +CLI index commands expose `--progress jsonl` as a pure flushed stream of full +`ProgressEvent` schema 3 objects. Human summary output is suppressed and +diagnostics remain on stderr. + +Bridge protocol 2.0 adds a closed `index` method for `build` and `update`. It +requires `expected_snapshot_digest`, owns the complete application workflow, +and sends correlated `$/progress` notifications. Cancellation, caller timeout, +EOF, and shutdown feed the same cooperative cancellation event. Bridge 1.0 and +1.1 remain read-only and model-free. Bridge capabilities explicitly publish +current and readable index, manifest, record, progress, and context-package +schema versions. + +Analyzer identity includes analyzer, prompt, response-schema, provider, and +model identity, but excludes transport endpoint. Legacy analyzer versions with +a terminal `+base.` suffix compare as the neutral identity. The next +update republishes semantic and repository-map records with the neutral +identity without new model calls. + +Known Bridge index failures use safe typed JSON-RPC categories and bounded +structured data. Raw provider bodies, credentialed URLs, secrets, absolute +paths, tracebacks, and exception representations are excluded. `-32603` is +reserved for unexpected defects. + +## Consequences + +- Hosts can track and cancel one atomic index job without managing internal + storage state. +- A failed provider cannot trigger unbounded repository-wide repeated calls. +- CLI subprocess integrations receive stable machine progress without parsing + Rich output. +- Endpoint changes no longer create false staleness, while provider/model or + analysis-contract changes still invalidate records. +- Bridge 2 has narrowly scoped index and provider authority; it still cannot + write source, mutate Git, or execute arbitrary commands. +- Persisted index, manifest, and record schemas remain version 2; progress + remains version 3 and the package version remains 0.5.1 until release work. diff --git a/docs/guides/bridge.md b/docs/guides/bridge.md index e3f26b9..1826fd6 100644 --- a/docs/guides/bridge.md +++ b/docs/guides/bridge.md @@ -1,15 +1,19 @@ -# Generic bridge v1 +# Generic bridge protocols -ContextForge bridge v1 is a persistent, workspace-bound, model-free service for -trusted local integrations. Start it as a child process: +ContextForge Bridge is a persistent, workspace-bound service for trusted local +integrations. Protocols 1.0 and 1.1 are model-free and read-only. Protocol 2.0 +adds opt-in, tracked index mutation through the same application workflow used +by the CLI. Start it as a child process: ```bash contextforge bridge --stdio --workspace /path/to/repository ``` -It is not a remote API, sandbox, session manager, model gateway, or agent -orchestrator. The consumer owns model selection, prompts, retries, working-set -state, and candidate choice. ContextForge retains repository truth: scanning, +It is not a remote API, sandbox, session manager, or agent orchestrator. For +discovery the consumer owns model selection, prompts, working-set state, and +candidate choice. A Bridge 2 index request may select a configured provider and +bounded execution policy, but cannot supply prompts. ContextForge retains +repository truth: scanning, ignore/protection policy, current index provenance, path authorization, source identity, verified reads, budgets, and canonical package construction. @@ -29,7 +33,7 @@ context-package schema. The first application request must be: ``` The response reports `protocol_version`, `supported_protocol_versions`, exact -method/operation capabilities, limits, and read-only policy. A client must +method/operation capabilities, limits, schemas, and policy. A client must compare the selected version with the version it implements before continuing. Missing negotiation returns `PROTOCOL_NEGOTIATION_REQUIRED`; an unsupported version returns `INCOMPATIBLE_PROTOCOL_VERSION` and the supported list. @@ -38,8 +42,16 @@ V1 requests and parameter objects are closed. Unknown methods, fields, and operations fail rather than being ignored. Compatible optional evolution requires explicit minor-version negotiation. Required fields, changed source or budget semantics, weaker verification, or changed success/failure meaning -require a new major version. The normative frame schema is -[`contextforge-bridge-v1.schema.json`](../schemas/contextforge-bridge-v1.schema.json). +require a new major version. The normative frame schemas are +[`contextforge-bridge-v1.schema.json`](../schemas/contextforge-bridge-v1.schema.json) +and +[`contextforge-bridge-v2.schema.json`](../schemas/contextforge-bridge-v2.schema.json). + +`hello.capabilities.schemas` reports independent persisted/wire formats rather +than inferring them from the bridge version: index, manifest, and record are +current 2/readable 1–2; progress is current 3/readable 1–3; context package is +current/readable 1. Bridge 1 response compatibility does not depend on these +versions. ## Repository flow @@ -82,6 +94,7 @@ index cannot be mistaken for a fully enriched one. | `hello` | `protocol_version` | Negotiated version, package version, capabilities, workspace identity, and policy | | `status` | none | Current readiness, source drift, and read-only index status | | `snapshot` | none | New authoritative digest and bounded inventory summary | +| `index` (v2) | action and expected snapshot digest | Atomic tracked build/update job using the application workflow | | `discover` | `expected_snapshot_digest`, `task` | Deterministic candidates and preparation identity; never a model call | | `expand` | digest, preparation ID, operation | One bounded read-only evidence result and cumulative usage | | `read` | digest, preparation ID, non-empty items | All-or-nothing verified source excerpts and selection identity | @@ -96,6 +109,37 @@ portable relative paths. Read/package items must be unique and sorted by candidate ID; line ranges are one-based, inclusive, sorted, and disjoint. See the normative schema for every budget and response field. +## Bridge 2 tracked index jobs + +Negotiate `2.0`, call `snapshot`, then pass the exact digest to `index`: + +```json +{"jsonrpc":"2.0","id":"build-7","method":"index","params":{"action":"update","expected_snapshot_digest":"<64 hex characters>","provider":"openai-compatible","model":"exact/model-id","base_url":"http://127.0.0.1:1234/v1","concurrency":2,"max_failures":3}} +``` + +`action` is `build` or `update`. Optional fields mirror the bounded CLI provider, +model, endpoint, concurrency, timeout, context-window, JSON repair, output-token, +failure, force, file-limit, and stale-lock recovery policies. `fail_fast` and +`max_failures` are mutually exclusive. `fail_on_error` alone keeps its existing +meaning: finish all eligible work but do not publish if any semantic file fails. + +The bridge owns scanning, lock acquisition, staging, generation validation, and +atomic publication. It verifies the expected snapshot before starting and +rescans immediately before publication. Cancellation, timeout, clean EOF, and +shutdown signal the application cancellation token; partial generations never +become active. + +While the request runs, Bridge 2 emits notifications before its final response: + +```json +{"jsonrpc":"2.0","method":"$/progress","params":{"request_id":"build-7","event":{"schema_version":3,"operation_id":"bridge-index-...","sequence":4,"status":"running"}}} +``` + +The real `event` is the full closed `ProgressEvent` schema 3 object. Correlate +notifications with `params.request_id`; sequence is monotonic within the +operation. A successful result contains `generation_id`, `snapshot_digest`, +`index_schema`, statistics, and `partial`. + JSON-RPC standard errors retain their numeric meaning. ContextForge also puts a stable uppercase typed code in `error.data.code`. Integration-relevant v1 codes include `PROTOCOL_NEGOTIATION_REQUIRED`, `INCOMPATIBLE_PROTOCOL_VERSION`, @@ -106,6 +150,22 @@ include `PROTOCOL_NEGOTIATION_REQUIRED`, `INCOMPATIBLE_PROTOCOL_VERSION`, `APPLICATION_REQUEST_REJECTED`, and `INTERNAL_ERROR`. Errors never share a `result` payload, and internal exceptions or local paths are not returned. +Bridge 2 index failures additionally use dedicated numeric/typed categories: + +| JSON-RPC | Typed code | Meaning | +| --- | --- | --- | +| `-32001` | `SOURCE_IDENTITY_CHANGED` | Snapshot drift before or during the job | +| `-32009` | `PROVIDER_FAILURE` / `PROVIDER_CONFIGURATION_ERROR` | Safe provider or configuration failure | +| `-32010` | `FAILURE_LIMIT_REACHED` | `fail_fast`/`max_failures` threshold reached | +| `-32011` | `PROVIDER_CIRCUIT_OPEN` | Provider-wide circuit opened | +| `-32012` | `INDEX_STORAGE_ERROR` | Safe index storage failure | +| `-32013` | `INDEX_LOCKED` | Active or unrecoverable writer lock | + +Their `error.data` includes `code`, `error_code`, `phase`, safe `reason`, +`retryable`, and `operation_id`. Provider bodies, credentialed URLs, secrets, +absolute paths, tracebacks, and exception representations are never returned. +`-32603 INTERNAL_ERROR` is reserved for unexpected defects. + ## Verified source and identity changes Index records, semantic summaries, rankings, and consumer/model output are @@ -135,25 +195,26 @@ Cancellation is cooperative and uses the target request ID: The target completes normally if it won the race, or fails with typed `REQUEST_CANCELLED`. Cancellation does not return partial read/package output -and grants no mutation capability. A cancellation notification has no response; +or publish a partial index generation. A cancellation notification has no response; include its own JSON-RPC `id` only when a `{"cancelled": true|false}` response is needed. Send `shutdown` after outstanding work is resolved, wait for its response, then close stdin and wait for the child process. Clean stdin EOF also stops the -bridge. Abrupt process termination is safe with respect to repository and index -state because bridge operations are read-only. +bridge. For Bridge 1 this remains read-only. Bridge 2 cancellation does not +activate a partial generation; abrupt termination may leave recoverable staging +or lock metadata, while the prior active generation remains authoritative. Shutdown and clean EOF use the same bounded drain. The bridge first stops accepting work, signals every active request's cooperative cancellation event, and waits at most 5 seconds. Any request task still pending then receives direct asyncio cancellation and gets at most another 0.1 seconds for cleanup. After that 5.1-second maximum drain budget, the bridge detaches any remaining task and -does not wait for it again. These internal v1 limits are fixed rather than CLI +does not wait for it again. These internal bridge limits are fixed rather than CLI configurable. A timed-out request cannot return a partial success, and the shutdown response remains a normal serialized JSON-RPC frame. -## Security and read-only boundary +## Security and mutation boundary Bridge 1.1 coverage includes `semantic_partial_files`, `semantic_chunks_planned`, and `semantic_chunks_completed`. Partial files do @@ -169,8 +230,11 @@ stdio through a network or untrusted broker without an external security layer. Bridge v1 cannot write repository source, mutate Git, invoke a shell or arbitrary subprocess, call a model/provider, access external data, mutate the index, or -publish artifacts to disk. `package` returns the canonical package in memory. -The workspace is fixed at process start and requests cannot replace it. +publish artifacts to disk. Bridge 2 relaxes only model/provider access under the +configured policy and verified atomic writes beneath `.contextforge/index`. +Neither version writes source or Git state. `package` returns the canonical +package in memory. The workspace is fixed at process start and requests cannot +replace it. See the runnable [generic bridge client](../../examples/generic_bridge_client.py) and [troubleshooting guide](troubleshooting.md). diff --git a/src/contextforge/cli/main.py b/src/contextforge/cli/main.py index d8d62d0..be38b18 100644 --- a/src/contextforge/cli/main.py +++ b/src/contextforge/cli/main.py @@ -277,7 +277,7 @@ def bridge( typer.Option( "--stdio", help=( - "Required in v1. Read UTF-8 NDJSON requests from stdin and write " + "Required. Read UTF-8 NDJSON requests from stdin and write " "only JSON-RPC 2.0 responses to stdout." ), ), @@ -288,7 +288,7 @@ def bridge( "--workspace", help=( "Repository root to bind for the lifetime of this verified " - "read-only local integration session." + "local integration session." ), exists=True, file_okay=False, @@ -297,15 +297,16 @@ def bridge( ), ] = Path("."), ) -> None: - """Run trusted-local, model-free ContextForge bridge protocol v1. + """Run the trusted-local ContextForge bridge protocols. - The client must negotiate protocol 1.0 or 1.1 with hello before repository calls. - Stdout is protocol-only; bounded diagnostics use stderr. The bridge never - writes source or index state and never selects or invokes a model. + The client must negotiate protocol 1.0, 1.1, or 2.0 with hello. Versions + 1.0/1.1 are model-free and read-only. Version 2.0 additionally exposes + atomic tracked index jobs under the configured provider policy. Stdout is + protocol-only and bounded diagnostics use stderr. """ if not stdio: - _exit_with_error("bridge v1 requires --stdio", code=2) + _exit_with_error("bridge requires --stdio", code=2) input_stream = cast(BinaryIO, getattr(sys.stdin, "buffer", sys.stdin)) output_stream = cast(BinaryIO, getattr(sys.stdout, "buffer", sys.stdout)) try: From bc0e35c60d32f11d67f567231192da58d8e1aa7c Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:37:12 +0300 Subject: [PATCH 06/11] fix(bridge): close tracked index job races --- src/contextforge/application.py | 22 ++- src/contextforge/bridge/server.py | 169 +++++++++++++----- src/contextforge/intelligence/maps.py | 8 +- src/contextforge/intelligence/semantics.py | 26 ++- src/contextforge/intelligence/store.py | 8 +- tests/test_bridge.py | 197 ++++++++++++++++++++- tests/test_cli_intelligence.py | 80 ++++++++- tests/test_semantic_analysis.py | 19 ++ 8 files changed, 473 insertions(+), 56 deletions(-) diff --git a/src/contextforge/application.py b/src/contextforge/application.py index 6a3bc87..36bb5fa 100644 --- a/src/contextforge/application.py +++ b/src/contextforge/application.py @@ -212,6 +212,7 @@ async def build_repository_index( operation_id: str | None = None, parent_operation_id: str | None = None, cancellation: asyncio.Event | None = None, + expected_snapshot_digest: str | None = None, ) -> IndexBuildReport: """Build/update all index phases while retaining a prior pointer on failure.""" @@ -266,6 +267,7 @@ async def build_repository_index( confirm_unknown_lock=confirm_unknown_lock, progress=reporter, cancellation=cancellation, + expected_snapshot_digest=expected_snapshot_digest, ) except BaseException as exc: _report_terminal_exception(reporter, exc) @@ -314,6 +316,7 @@ async def _build_repository_index( confirm_unknown_lock: bool, progress: ProgressReporter, cancellation: asyncio.Event | None, + expected_snapshot_digest: str | None, ) -> IndexBuildReport: """Implement index construction under the public progress boundary.""" @@ -344,6 +347,14 @@ async def _build_repository_index( ) snapshot = await asyncio.to_thread(scan_repository, root) _raise_if_index_cancelled(cancellation) + snapshot_digest = calculate_source_snapshot_digest(snapshot) + if ( + expected_snapshot_digest is not None + and snapshot_digest != expected_snapshot_digest + ): + raise IndexSourceChangedError( + "repository source identity differs from expected snapshot" + ) progress.report( "scan", "Repository scan completed.", @@ -378,7 +389,10 @@ async def _build_repository_index( recover_stale=recover_stale_lock, confirm_unknown=confirm_unknown_lock, ) as lock, - index_publication_transaction(lock), + index_publication_transaction( + lock, + before_publish=lambda: _raise_if_index_cancelled(cancellation), + ), ): try: progress.report( @@ -607,9 +621,8 @@ def observe_semantic(event: ProgressEvent) -> None: ) _raise_if_index_cancelled(cancellation) current_snapshot = await asyncio.to_thread(scan_repository, root) - if calculate_source_snapshot_digest( - current_snapshot - ) != calculate_source_snapshot_digest(snapshot): + _raise_if_index_cancelled(cancellation) + if calculate_source_snapshot_digest(current_snapshot) != snapshot_digest: raise IndexSourceChangedError( "repository source identity changed before index publication" ) @@ -643,6 +656,7 @@ def observe_semantic(event: ProgressEvent) -> None: phase_weight=3 if model_enabled else 10, metadata={"generation_id": active.generation_id}, ) + _raise_if_index_cancelled(cancellation) except BaseException: if previous is not None: with suppress(Exception): diff --git a/src/contextforge/bridge/server.py b/src/contextforge/bridge/server.py index c6554f3..5957971 100644 --- a/src/contextforge/bridge/server.py +++ b/src/contextforge/bridge/server.py @@ -105,6 +105,7 @@ MAX_PREPARATIONS = 128 DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 5.0 FORCED_CANCELLATION_TIMEOUT_SECONDS = 0.1 +MAX_PENDING_PROGRESS_EVENTS = 256 PARSE_ERROR = -32700 INVALID_REQUEST = -32600 @@ -196,6 +197,94 @@ def _write(self, payload: bytes) -> None: self._stream.flush() +class _ProgressPublisher: + """Serialize one bounded progress stream without spawning per-event tasks.""" + + def __init__( + self, + writer: _SerializedWriter, + request_id: str | int, + cancellation: asyncio.Event, + operation_id: str, + *, + capacity: int = MAX_PENDING_PROGRESS_EVENTS, + ) -> None: + self.last_event: ProgressEvent | None = None + self._writer = writer + self._request_id = request_id + self._cancellation = cancellation + self._operation_id = operation_id + self._queue: asyncio.Queue[ProgressEvent | None] = asyncio.Queue( + maxsize=capacity + ) + self._overflowed = False + self._closed = False + self._task = asyncio.create_task(self._run()) + + def observe(self, event: ProgressEvent) -> None: + self.last_event = event + if self._closed or self._overflowed: + return + try: + self._queue.put_nowait(event) + except asyncio.QueueFull: + self._overflowed = True + self._cancellation.set() + + async def close(self, *, check_overflow: bool = True) -> None: + if not self._closed: + self._closed = True + if not self._task.done(): + stopper = asyncio.create_task(self._queue.put(None)) + try: + done, _ = await asyncio.wait( + {self._task, stopper}, return_when=asyncio.FIRST_COMPLETED + ) + if self._task in done: + stopper.cancel() + finally: + if not stopper.done(): + stopper.cancel() + await asyncio.gather(stopper, return_exceptions=True) + await self._task + if check_overflow and self._overflowed: + raise BridgeFault( + INDEX_BUILD_FAILED, + "INDEX_BUILD_FAILED", + "The client did not consume index progress quickly enough.", + data={ + "error_code": "progress_backpressure", + "phase": ( + "initialize" + if self.last_event is None + else self.last_event.phase_id + ), + "reason": "The Bridge progress delivery queue reached its limit.", + "retryable": True, + "operation_id": self._operation_id, + }, + ) + + async def _run(self) -> None: + while True: + event = await self._queue.get() + try: + if event is None: + return + await self._writer.write( + { + "jsonrpc": JSONRPC_VERSION, + "method": "$/progress", + "params": { + "request_id": self._request_id, + "event": event.model_dump(mode="json"), + }, + } + ) + finally: + self._queue.task_done() + + class _BoundedDiagnostics: def __init__(self, stream: TextIO | None) -> None: self._stream = stream @@ -469,12 +558,20 @@ async def _process_request( if timeout_ms is None: result = await operation else: - try: - result = await asyncio.wait_for( - operation, timeout=timeout_ms / 1000 - ) - except TimeoutError: + operation_task = asyncio.create_task(operation) + done, _ = await asyncio.wait( + {operation_task}, timeout=timeout_ms / 1000 + ) + if operation_task in done: + result = operation_task.result() + else: cancellation.set() + if method == "index": + with suppress(asyncio.CancelledError, Exception): + await operation_task + else: + operation_task.cancel() + await asyncio.gather(operation_task, return_exceptions=True) raise BridgeFault( REQUEST_TIMEOUT, "REQUEST_TIMEOUT", @@ -737,33 +834,9 @@ async def _index( ).hexdigest()[:24] ) provider: ModelProvider | None = None - last_event: ProgressEvent | None = None - progress_tail: asyncio.Task[None] | None = None - - def observe(event: ProgressEvent) -> None: - nonlocal last_event, progress_tail - last_event = event - previous = progress_tail - - async def publish() -> None: - if previous is not None: - await previous - await self._require_writer().write( - { - "jsonrpc": JSONRPC_VERSION, - "method": "$/progress", - "params": { - "request_id": request_id, - "event": event.model_dump(mode="json"), - }, - } - ) - - progress_tail = asyncio.create_task(publish()) - - async def flush_progress() -> None: - if progress_tail is not None: - await progress_tail + publisher = _ProgressPublisher( + self._require_writer(), request_id, cancellation, operation_id + ) try: snapshot = await asyncio.to_thread(scan_repository, self.workspace) @@ -773,7 +846,7 @@ async def flush_progress() -> None: IndexSourceChangedError( "repository source identity differs from expected snapshot" ), - last_event, + publisher.last_event, operation_id, ) try: @@ -797,7 +870,7 @@ async def flush_progress() -> None: ProviderConfigurationError( "provider configuration could not be resolved" ), - last_event, + publisher.last_event, operation_id, ) from None concurrency = ( @@ -827,11 +900,12 @@ async def flush_progress() -> None: ), recover_stale_lock=params.recover_stale_lock, confirm_unknown_lock=params.confirm_unknown_lock, - progress=observe, + progress=publisher.observe, operation_id=operation_id, cancellation=cancellation, + expected_snapshot_digest=params.expected_snapshot_digest, ) - await flush_progress() + await publisher.close() self._snapshot_digest = report.manifest.build.source_snapshot_digest self._preparations.clear() return { @@ -843,10 +917,10 @@ async def flush_progress() -> None: "statistics": report.manifest.statistics.model_dump(mode="json"), } except asyncio.CancelledError: - await flush_progress() + await publisher.close() raise except BridgeFault: - await flush_progress() + await publisher.close(check_overflow=False) raise except ( ApplicationError, @@ -857,9 +931,11 @@ async def flush_progress() -> None: SemanticAnalysisError, ValueError, ) as exc: - await flush_progress() - raise _index_bridge_fault(exc, last_event, operation_id) from None + await publisher.close(check_overflow=False) + raise _index_bridge_fault(exc, publisher.last_event, operation_id) from None finally: + with suppress(Exception, asyncio.CancelledError): + await publisher.close(check_overflow=False) if provider is not None: with suppress(ModelProviderError): await provider.close() @@ -1468,11 +1544,13 @@ def _index_bridge_fault( error_code = "index_build_failed" reason = "ContextForge could not complete the index operation." retryable = False + provider_failure: ModelProviderError | None = None current: BaseException | None = error seen: set[int] = set() while current is not None and id(current) not in seen: seen.add(id(current)) if isinstance(current, ModelProviderError): + provider_failure = current error_code, reason = provider_error_details(current) retryable = ( classify_retry(current) is RetryClassification.RETRYABLE @@ -1510,9 +1588,14 @@ def _index_bridge_fault( error_code = "provider_circuit_open" reason = "The provider circuit breaker opened during indexing." retryable = False - elif isinstance(error, ModelProviderError): - rpc_code = PROVIDER_FAILURE - typed_rpc_code = "PROVIDER_FAILURE" + elif provider_failure is not None: + if provider_failure.circuit_opened: + rpc_code = PROVIDER_CIRCUIT_OPEN + typed_rpc_code = "PROVIDER_CIRCUIT_OPEN" + retryable = False + else: + rpc_code = PROVIDER_FAILURE + typed_rpc_code = "PROVIDER_FAILURE" elif isinstance(error, IndexLockError): rpc_code = INDEX_LOCKED typed_rpc_code = "INDEX_LOCKED" diff --git a/src/contextforge/intelligence/maps.py b/src/contextforge/intelligence/maps.py index dd8fd74..dfe3c8f 100644 --- a/src/contextforge/intelligence/maps.py +++ b/src/contextforge/intelligence/maps.py @@ -477,6 +477,7 @@ async def build_repository_maps( architecture: ArchitectureMap | None = None features: FeatureMap | None = None outcomes: list[GlobalMapOutcome] = [] + failure_causes: list[BaseException] = [] final_requests = 0 final_requests += 1 @@ -508,6 +509,7 @@ async def build_repository_maps( ) from exc diagnostic = _failure_diagnostic("architecture-map-failed", exc) outcomes.append(GlobalMapOutcome("architecture", "failed", 1, diagnostic)) + failure_causes.append(exc) _raise_if_cancelled(cancellation) final_requests += 1 @@ -539,13 +541,17 @@ async def build_repository_maps( ) from exc diagnostic = _failure_diagnostic("feature-map-failed", exc) outcomes.append(GlobalMapOutcome("features", "failed", 1, diagnostic)) + failure_causes.append(exc) failures = tuple(item for item in outcomes if item.status == "failed") if failures and active_options.fail_on_error: - raise GlobalMapAnalysisError( + error = GlobalMapAnalysisError( f"repository map analysis failed for {len(failures)} map(s); " "index not published" ) + if failure_causes: + raise error from failure_causes[0] + raise error if failures and active_options.recover_previous and previous_records is not None: old_overview, old_architecture, old_features = previous_records if ( diff --git a/src/contextforge/intelligence/semantics.py b/src/contextforge/intelligence/semantics.py index fd83d51..a949671 100644 --- a/src/contextforge/intelligence/semantics.py +++ b/src/contextforge/intelligence/semantics.py @@ -884,6 +884,7 @@ async def build_semantic_index( write_index_record(lock, _interpretation_location(path), _serialize(analysis)) semaphore = asyncio.Semaphore(active_options.max_concurrency) + failure_causes: dict[str, BaseException] = {} async def analyze_one( project_file: ProjectFile, @@ -974,6 +975,7 @@ async def analyze_one( path=project_file.path, ) tracker.fail(project_file.path, diagnostic) + failure_causes[project_file.path] = exc emit( "semantic", "semantic.analysis.failed", @@ -1040,8 +1042,13 @@ def schedule_available() -> None: pending, return_when=asyncio.FIRST_COMPLETED ) limit_diagnostic: AnalysisDiagnostic | None = None - for task in done: - result = task.result() + completed = await asyncio.gather(*done, return_exceptions=True) + first_exception: BaseException | None = None + for result in completed: + if isinstance(result, BaseException): + if first_exception is None: + first_exception = result + continue task_results.append(result) _, work, diagnostic, _ = result if work is not None: @@ -1054,6 +1061,8 @@ def schedule_available() -> None: and limit_diagnostic is None ): limit_diagnostic = diagnostic + if first_exception is not None: + raise first_exception if limit_diagnostic is not None: cancelled_units = len(pending) for unfinished in pending: @@ -1105,9 +1114,20 @@ def schedule_available() -> None: ) if failures and active_options.fail_on_error: tracker.abort() - raise SemanticAnalysisError( + error = SemanticAnalysisError( f"semantic analysis failed for {len(failures)} file(s); index not published" ) + cause = next( + ( + failure_causes[diagnostic.path] + for diagnostic in failures + if diagnostic.path in failure_causes + ), + None, + ) + if cause is not None: + raise error from cause + raise error _raise_if_cancelled(cancellation) states: list[IndexedFileState] = [] diff --git a/src/contextforge/intelligence/store.py b/src/contextforge/intelligence/store.py index c28fd10..0218b3d 100644 --- a/src/contextforge/intelligence/store.py +++ b/src/contextforge/intelligence/store.py @@ -69,7 +69,11 @@ class _PublicationTransaction: @contextmanager -def index_publication_transaction(lock: IndexWriteLock) -> Iterator[None]: +def index_publication_transaction( + lock: IndexWriteLock, + *, + before_publish: Callable[[], None] | None = None, +) -> Iterator[None]: """Keep intermediate generations private to this build's async context.""" if _publication_transaction.get() is not None: raise IndexPublicationError("nested publication transactions are not supported") @@ -81,6 +85,8 @@ def index_publication_transaction(lock: IndexWriteLock) -> Iterator[None]: raise else: if transaction.pending is not None: + if before_publish is not None: + before_publish() _activate_manifest(lock, transaction.pending) finally: _publication_transaction.reset(token) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index 9d5ae9e..ca6e1d4 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -13,6 +13,7 @@ import pytest from pydantic import ValidationError +import contextforge.application as application_module import contextforge.bridge.server as bridge_module from contextforge.bridge import MAX_JSONRPC_MESSAGE_BYTES, BridgeServer from contextforge.bridge.models import ( @@ -22,9 +23,16 @@ IndexParams, ReadParams, ) -from contextforge.intelligence import acquire_index_lock, build_structural_index -from contextforge.models import ProviderAuthenticationError -from contextforge.progress import ProgressEvent, ProgressStatus +from contextforge.intelligence import ( + GlobalMapAnalysisError, + IndexManifestNotFoundError, + acquire_index_lock, + build_structural_index, + calculate_source_snapshot_digest, + load_manifest, +) +from contextforge.models import ProviderAuthenticationError, ProviderTimeoutError +from contextforge.progress import ProgressEvent, ProgressReporter, ProgressStatus from contextforge.repositories import scan_repository @@ -192,6 +200,68 @@ async def close(self) -> None: await self.task +def test_bridge_progress_publisher_is_bounded_and_uses_one_writer_task() -> None: + class SlowWriter: + def __init__(self) -> None: + self.frames: list[dict[str, Any]] = [] + + async def write(self, frame: dict[str, Any]) -> None: + self.frames.append(frame) + + async def exercise() -> None: + writer = SlowWriter() + cancellation = asyncio.Event() + publisher = bridge_module._ProgressPublisher( + cast(Any, writer), + "index-1", + cancellation, + "operation-1", + capacity=1, + ) + reporter = ProgressReporter( + "operation-1", "repository.index.build", observer=publisher.observe + ) + reporter.report("scan", "first", percentage=1) + reporter.report("scan", "second", percentage=2) + + assert cancellation.is_set() + with pytest.raises(bridge_module.BridgeFault) as raised: + await publisher.close() + + assert raised.value.typed_code == "INDEX_BUILD_FAILED" + assert raised.value.data["error_code"] == "progress_backpressure" + assert len(writer.frames) == 1 + assert writer.frames[0]["params"]["event"]["sequence"] == 0 + + asyncio.run(exercise()) + + +def test_bridge_progress_publisher_close_does_not_hang_after_writer_failure() -> None: + class BrokenWriter: + async def write(self, frame: dict[str, Any]) -> None: + del frame + raise OSError("closed pipe") + + async def exercise() -> None: + publisher = bridge_module._ProgressPublisher( + cast(Any, BrokenWriter()), + "index-1", + asyncio.Event(), + "operation-1", + capacity=1, + ) + reporter = ProgressReporter( + "operation-1", "repository.index.build", observer=publisher.observe + ) + reporter.report("scan", "first", percentage=1) + reporter.report("scan", "second", percentage=2) + + with pytest.raises(OSError, match="closed pipe"): + await asyncio.wait_for(publisher.close(), timeout=1) + + asyncio.run(exercise()) + + def _request( request_id: str | int, method: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: @@ -426,6 +496,107 @@ async def exercise() -> None: asyncio.run(exercise()) +def test_bridge_v2_rechecks_expected_snapshot_inside_index_workflow( + tmp_path: Path, monkeypatch: Any +) -> None: + source = tmp_path / "app.py" + source.write_text("VALUE = 1\n", encoding="utf-8") + expected = calculate_source_snapshot_digest(scan_repository(tmp_path)) + original_load = cast(Any, bridge_module).load_project_configuration + mutated = False + + def mutate_after_bridge_precheck(path: Path) -> Any: + nonlocal mutated + if not mutated: + source.write_text("VALUE = 2\n", encoding="utf-8") + mutated = True + return original_load(path) + + monkeypatch.setattr( + bridge_module, "load_project_configuration", mutate_after_bridge_precheck + ) + + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) + await harness.response(1) + harness.input.send( + _request( + "snapshot-race", + "index", + { + "action": "build", + "expected_snapshot_digest": expected, + "provider": "none", + }, + ) + ) + response = await asyncio.to_thread(harness.output.wait_for_id, "snapshot-race") + assert response["error"]["data"]["code"] == "SOURCE_IDENTITY_CHANGED" + with pytest.raises(IndexManifestNotFoundError): + load_manifest(tmp_path) + await harness.close() + + asyncio.run(exercise()) + + +def test_bridge_v2_index_timeout_signals_worker_before_lock_release( + tmp_path: Path, monkeypatch: Any +) -> None: + (tmp_path / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + cancellation_seen = threading.Event() + lock_was_active = False + + def wait_for_cancellation( + snapshot: object, + lock: Any, + **kwargs: object, + ) -> None: + del snapshot + nonlocal lock_was_active + cancellation = cast(asyncio.Event, kwargs["cancellation"]) + deadline = time.monotonic() + 5 + while not cancellation.is_set(): + if time.monotonic() >= deadline: + raise AssertionError("index timeout did not signal cancellation") + time.sleep(0.001) + lock_was_active = lock.active and lock.layout.lock.is_file() + cancellation_seen.set() + raise asyncio.CancelledError + + monkeypatch.setattr( + application_module, "build_structural_index", wait_for_cancellation + ) + + async def exercise() -> None: + harness = _Harness(tmp_path) + await harness.start(negotiated=False) + harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) + await harness.response(1) + digest = calculate_source_snapshot_digest(scan_repository(tmp_path)) + harness.input.send( + _request( + "index-timeout", + "index", + { + "action": "build", + "expected_snapshot_digest": digest, + "provider": "none", + "timeout_ms": 500, + }, + ) + ) + response = await asyncio.to_thread(harness.output.wait_for_id, "index-timeout") + assert response["error"]["data"]["code"] == "REQUEST_TIMEOUT" + assert cancellation_seen.is_set() + assert lock_was_active is True + assert not (tmp_path / ".contextforge" / "index" / "lock.json").exists() + await harness.close() + + asyncio.run(exercise()) + + def test_bridge_v2_index_errors_are_typed_and_safe( tmp_path: Path, monkeypatch: Any ) -> None: @@ -509,6 +680,26 @@ async def exercise() -> None: asyncio.run(exercise()) +def test_bridge_v2_classifies_a_provider_failure_wrapped_by_index_phases() -> None: + try: + try: + raise ProviderTimeoutError("unsafe timeout detail") + except ProviderTimeoutError as cause: + raise GlobalMapAnalysisError("aggregate map failure") from cause + except GlobalMapAnalysisError as error: + fault = bridge_module._index_bridge_fault(error, None, "operation-1") + + assert fault.rpc_code == bridge_module.PROVIDER_FAILURE + assert fault.typed_code == "PROVIDER_FAILURE" + assert fault.data == { + "error_code": "provider_timeout", + "phase": "initialize", + "reason": "provider request timed out", + "retryable": True, + "operation_id": "operation-1", + } + + def test_bridge_rejects_malformed_oversized_unknown_and_invalid_requests( tmp_path: Path, ) -> None: diff --git a/tests/test_cli_intelligence.py b/tests/test_cli_intelligence.py index 427fd70..584da17 100644 --- a/tests/test_cli_intelligence.py +++ b/tests/test_cli_intelligence.py @@ -26,9 +26,14 @@ SelectionReason, ) from contextforge.discovery.renderers import DiscoveryResultFormat -from contextforge.intelligence import IndexManifestNotFoundError, load_manifest +from contextforge.intelligence import ( + IndexManifestNotFoundError, + calculate_source_snapshot_digest, + load_manifest, +) from contextforge.models import FakeModelProvider, ProviderConfiguration from contextforge.progress import ProgressEvent, ProgressStatus +from contextforge.repositories import scan_repository runner = CliRunner() TERMINAL_WIDTH = 140 @@ -275,6 +280,79 @@ def mutate_after_structural(*args: Any, **kwargs: Any) -> Any: assert load_manifest(tmp_path) == previous +def test_index_rejects_a_snapshot_other_than_the_callers_precondition( + tmp_path: Path, +) -> None: + source = tmp_path / "app.py" + _write(tmp_path, "app.py", "VALUE = 1\n") + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + ) + ) + previous = load_manifest(tmp_path) + expected = calculate_source_snapshot_digest(scan_repository(tmp_path)) + source.write_text("VALUE = 2\n", encoding="utf-8") + + with pytest.raises(IndexSourceChangedError, match="expected snapshot"): + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + update_only=True, + expected_snapshot_digest=expected, + ) + ) + + assert load_manifest(tmp_path) == previous + + +def test_index_cancellation_during_final_scan_cannot_publish( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = tmp_path / "app.py" + _write(tmp_path, "app.py", "VALUE = 1\n") + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + ) + ) + previous = load_manifest(tmp_path) + source.write_text("VALUE = 2\n", encoding="utf-8") + cancellation = asyncio.Event() + original_scan = cast(Any, application_module).scan_repository + scan_count = 0 + + def cancel_during_final_scan(*args: Any, **kwargs: Any) -> Any: + nonlocal scan_count + snapshot = original_scan(*args, **kwargs) + scan_count += 1 + if scan_count == 2: + cancellation.set() + return snapshot + + monkeypatch.setattr(application_module, "scan_repository", cancel_during_final_scan) + + with pytest.raises(asyncio.CancelledError): + asyncio.run( + build_repository_index( + tmp_path, + provider=None, + provider_configuration=None, + update_only=True, + cancellation=cancellation, + ) + ) + + assert scan_count == 2 + assert load_manifest(tmp_path) == previous + + def test_index_cancellation_maps_to_130( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_semantic_analysis.py b/tests/test_semantic_analysis.py index 9921c18..94f2979 100644 --- a/tests/test_semantic_analysis.py +++ b/tests/test_semantic_analysis.py @@ -1163,6 +1163,25 @@ def test_fail_on_error_keeps_prior_valid_generation_active(tmp_path: Path) -> No assert load_manifest(tmp_path) == structural +def test_fail_on_error_preserves_the_typed_provider_cause(tmp_path: Path) -> None: + snapshot = _snapshot_with_facts(tmp_path, {"app.py": "pass\n"}) + + with ( + acquire_index_lock(tmp_path, "semantic-provider-cause") as lock, + pytest.raises(SemanticAnalysisError) as raised, + ): + asyncio.run( + build_semantic_index( + snapshot, + lock, + _provider(scripts=[ProviderTimeoutError("unsafe provider detail")]), + options=SemanticAnalysisOptions(fail_on_error=True), + ) + ) + + assert isinstance(raised.value.__cause__, ProviderTimeoutError) + + @pytest.mark.parametrize( ("concurrency", "failure_limit", "expected_calls"), [(1, 1, 1), (1, 2, 2), (2, 1, 2)], From e9f361573030b37d0f717472347e307fa142c624 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Mon, 7 Sep 2026 21:37:39 +0300 Subject: [PATCH 07/11] docs(bridge): clarify tracked job safety guarantees --- CHANGELOG.md | 13 ++++++++++--- docs/decisions/003-host-managed-index-jobs.md | 7 +++++++ docs/guides/bridge.md | 16 ++++++++++++---- 3 files changed, 29 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d73462..4939848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,9 +29,16 @@ and Python distribution versions follow PEP 440. ### Fixed -- Revalidate the repository snapshot immediately before atomic index - publication and preserve the prior active generation on cancellation, - failure limits, provider circuit opening, or source drift. +- Enforce the caller's expected snapshot inside the index workflow, revalidate + it immediately before atomic publication, and check cancellation at the + manifest activation boundary. +- Signal cooperative index cancellation before unwinding a timed-out Bridge + request so background structural workers cannot outlive their writer lock. +- Bound Bridge progress delivery with one writer task, drain every completed + semantic task, and preserve typed provider causes through semantic and map + aggregation. +- Preserve the prior active generation on cancellation, failure limits, + provider circuit opening, source drift, or progress backpressure. - Return safe typed Bridge index errors without provider bodies, credentialed URLs, absolute paths, tracebacks, or exception representations. diff --git a/docs/decisions/003-host-managed-index-jobs.md b/docs/decisions/003-host-managed-index-jobs.md index 5b0a10c..2ac25c6 100644 --- a/docs/decisions/003-host-managed-index-jobs.md +++ b/docs/decisions/003-host-managed-index-jobs.md @@ -45,6 +45,13 @@ EOF, and shutdown feed the same cooperative cancellation event. Bridge 1.0 and current and readable index, manifest, record, progress, and context-package schema versions. +The application validates the expected digest against its own build snapshot +and checks cancellation inside the publication transaction immediately before +manifest activation. Bridge timeout sets cooperative cancellation before it +unwinds index work, so a background structural worker retains its writer lock +until it has stopped. Progress notifications use one writer task and a bounded +queue; backpressure cancels rather than accumulating unbounded tasks. + Analyzer identity includes analyzer, prompt, response-schema, provider, and model identity, but excludes transport endpoint. Legacy analyzer versions with a terminal `+base.` suffix compare as the neutral identity. The next diff --git a/docs/guides/bridge.md b/docs/guides/bridge.md index 1826fd6..2183f3a 100644 --- a/docs/guides/bridge.md +++ b/docs/guides/bridge.md @@ -124,10 +124,12 @@ failure, force, file-limit, and stale-lock recovery policies. `fail_fast` and meaning: finish all eligible work but do not publish if any semantic file fails. The bridge owns scanning, lock acquisition, staging, generation validation, and -atomic publication. It verifies the expected snapshot before starting and -rescans immediately before publication. Cancellation, timeout, clean EOF, and -shutdown signal the application cancellation token; partial generations never -become active. +atomic publication. The application workflow verifies the expected snapshot +against the exact scan used for the build and rescans immediately before +publication. Cancellation is checked again at manifest activation. Timeout, +clean EOF, and shutdown signal the application cancellation token; partial +generations never become active. A timed-out index request finishes cooperative +worker cleanup before its writer lock is released. While the request runs, Bridge 2 emits notifications before its final response: @@ -140,6 +142,12 @@ notifications with `params.request_id`; sequence is monotonic within the operation. A successful result contains `generation_id`, `snapshot_digest`, `index_schema`, statistics, and `partial`. +Clients must continuously consume Bridge stdout while an index request is +active. Progress delivery uses one writer task and a bounded 256-event queue. +If that queue fills, the index job is cancelled without publication and returns +`INDEX_BUILD_FAILED` with `error.data.error_code` set to +`progress_backpressure` and `retryable` set to `true`. + JSON-RPC standard errors retain their numeric meaning. ContextForge also puts a stable uppercase typed code in `error.data.code`. Integration-relevant v1 codes include `PROTOCOL_NEGOTIATION_REQUIRED`, `INCOMPATIBLE_PROTOCOL_VERSION`, From 14db6159f30d3a78439fe0983cbf8027b6b3291f Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:12:09 +0300 Subject: [PATCH 08/11] fix(bridge): bound progress and timeout cleanup --- src/contextforge/bridge/server.py | 114 +++++++++++++++++++++++++----- tests/test_bridge.py | 81 ++++++++++++++++++--- 2 files changed, 169 insertions(+), 26 deletions(-) diff --git a/src/contextforge/bridge/server.py b/src/contextforge/bridge/server.py index 5957971..7916dfe 100644 --- a/src/contextforge/bridge/server.py +++ b/src/contextforge/bridge/server.py @@ -106,6 +106,7 @@ DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 5.0 FORCED_CANCELLATION_TIMEOUT_SECONDS = 0.1 MAX_PENDING_PROGRESS_EVENTS = 256 +PROGRESS_BACKPRESSURE_TIMEOUT_SECONDS = 5.0 PARSE_ERROR = -32700 INVALID_REQUEST = -32600 @@ -198,7 +199,7 @@ def _write(self, payload: bytes) -> None: class _ProgressPublisher: - """Serialize one bounded progress stream without spawning per-event tasks.""" + """Serialize one bounded progress stream with coalesced producer bursts.""" def __init__( self, @@ -208,7 +209,15 @@ def __init__( operation_id: str, *, capacity: int = MAX_PENDING_PROGRESS_EVENTS, + backpressure_timeout_seconds: float = PROGRESS_BACKPRESSURE_TIMEOUT_SECONDS, ) -> None: + if capacity <= 0: + raise ValueError("capacity must be positive") + if ( + not math.isfinite(backpressure_timeout_seconds) + or backpressure_timeout_seconds <= 0 + ): + raise ValueError("backpressure_timeout_seconds must be finite and positive") self.last_event: ProgressEvent | None = None self._writer = writer self._request_id = request_id @@ -217,23 +226,32 @@ def __init__( self._queue: asyncio.Queue[ProgressEvent | None] = asyncio.Queue( maxsize=capacity ) + self._backpressure_timeout_seconds = backpressure_timeout_seconds + self._pending_event: ProgressEvent | None = None + self._pending_task: asyncio.Task[None] | None = None self._overflowed = False self._closed = False self._task = asyncio.create_task(self._run()) def observe(self, event: ProgressEvent) -> None: self.last_event = event - if self._closed or self._overflowed: + if self._closed or self._overflowed or self._cancellation.is_set(): return try: self._queue.put_nowait(event) except asyncio.QueueFull: - self._overflowed = True - self._cancellation.set() + # Progress events are cumulative snapshots. Keep only the newest event + # while one bounded enqueue waits for the writer. This distinguishes a + # synchronous producer burst from a client that is actually not reading. + self._pending_event = event + if self._pending_task is None or self._pending_task.done(): + self._pending_task = asyncio.create_task(self._enqueue_pending()) async def close(self, *, check_overflow: bool = True) -> None: if not self._closed: self._closed = True + if self._pending_task is not None: + await self._pending_task if not self._task.done(): stopper = asyncio.create_task(self._queue.put(None)) try: @@ -271,19 +289,57 @@ async def _run(self) -> None: try: if event is None: return - await self._writer.write( - { - "jsonrpc": JSONRPC_VERSION, - "method": "$/progress", - "params": { - "request_id": self._request_id, - "event": event.model_dump(mode="json"), - }, - } - ) + if not self._cancellation.is_set(): + await self._writer.write( + { + "jsonrpc": JSONRPC_VERSION, + "method": "$/progress", + "params": { + "request_id": self._request_id, + "event": event.model_dump(mode="json"), + }, + } + ) finally: self._queue.task_done() + async def _enqueue_pending(self) -> None: + try: + while self._pending_event is not None and not self._overflowed: + event = self._pending_event + self._pending_event = None + put_task = asyncio.create_task(self._queue.put(event)) + cancellation_task = asyncio.create_task(self._cancellation.wait()) + done: set[asyncio.Task[Any]] = set() + try: + done, _ = await asyncio.wait( + {put_task, cancellation_task}, + timeout=self._backpressure_timeout_seconds, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for task in (put_task, cancellation_task): + if not task.done(): + task.cancel() + await asyncio.gather( + put_task, cancellation_task, return_exceptions=True + ) + if cancellation_task in done: + return + if put_task not in done: + self._overflowed = True + self._cancellation.set() + return + put_task.result() + finally: + self._pending_task = None + if ( + self._pending_event is not None + and not self._overflowed + and not self._cancellation.is_set() + ): + self._pending_task = asyncio.create_task(self._enqueue_pending()) + class _BoundedDiagnostics: def __init__(self, stream: TextIO | None) -> None: @@ -327,6 +383,7 @@ def __init__( OrderedDict() ) self._active: dict[tuple[str, str | int], _ActiveRequest] = {} + self._background_tasks: set[asyncio.Task[Any]] = set() self._writer: _SerializedWriter | None = None self._diagnostics = _BoundedDiagnostics(None) self._shutting_down = False @@ -389,6 +446,11 @@ async def _drain_active_requests(self) -> None: for request in tuple(self._active.values()) if request.task is not current and not request.task.done() } + tasks.update( + task + for task in tuple(self._background_tasks) + if task is not current and not task.done() + ) if not tasks: return done, pending = await asyncio.wait( @@ -416,7 +478,7 @@ async def _drain_active_requests(self) -> None: task.add_done_callback(self._consume_task_result) self._forget_active_tasks(tasks) - def _consume_task_result(self, task: asyncio.Task[None]) -> None: + def _consume_task_result(self, task: asyncio.Task[Any]) -> None: """Consume a shutdown outcome and retain a safe unexpected-failure signal.""" if task.cancelled(): @@ -426,12 +488,29 @@ def _consume_task_result(self, task: asyncio.Task[None]) -> None: "bridge active request ended unexpectedly during shutdown" ) - def _forget_active_tasks(self, tasks: set[asyncio.Task[None]]) -> None: + def _forget_active_tasks(self, tasks: set[asyncio.Task[Any]]) -> None: """Detach drained or abandoned requests from the bridge lifecycle.""" for key, request in tuple(self._active.items()): if request.task in tasks: self._active.pop(key, None) + self._background_tasks.difference_update(tasks) + + def _track_background_task(self, task: asyncio.Task[Any]) -> None: + """Retain timed-out index cleanup until its writer lock is released.""" + + self._background_tasks.add(task) + task.add_done_callback(self._finish_background_task) + + def _finish_background_task(self, task: asyncio.Task[Any]) -> None: + self._background_tasks.discard(task) + if task.cancelled(): + return + error = task.exception() + if error is not None and not isinstance(error, BridgeFault): + self._diagnostics.write( + "bridge timed-out index cleanup ended with an internal error" + ) def _decode_frame(self, line: bytes) -> dict[str, Any] | BridgeFault: try: @@ -567,8 +646,7 @@ async def _process_request( else: cancellation.set() if method == "index": - with suppress(asyncio.CancelledError, Exception): - await operation_task + self._track_background_task(operation_task) else: operation_task.cancel() await asyncio.gather(operation_task, return_exceptions=True) diff --git a/tests/test_bridge.py b/tests/test_bridge.py index ca6e1d4..9e8ada1 100644 --- a/tests/test_bridge.py +++ b/tests/test_bridge.py @@ -200,8 +200,8 @@ async def close(self) -> None: await self.task -def test_bridge_progress_publisher_is_bounded_and_uses_one_writer_task() -> None: - class SlowWriter: +def test_bridge_progress_publisher_coalesces_synchronous_bursts() -> None: + class ImmediateWriter: def __init__(self) -> None: self.frames: list[dict[str, Any]] = [] @@ -209,7 +209,7 @@ async def write(self, frame: dict[str, Any]) -> None: self.frames.append(frame) async def exercise() -> None: - writer = SlowWriter() + writer = ImmediateWriter() cancellation = asyncio.Event() publisher = bridge_module._ProgressPublisher( cast(Any, writer), @@ -217,21 +217,61 @@ async def exercise() -> None: cancellation, "operation-1", capacity=1, + backpressure_timeout_seconds=0.1, + ) + reporter = ProgressReporter( + "operation-1", "repository.index.build", observer=publisher.observe + ) + for sequence in range(300): + reporter.report("scan", f"event {sequence}", percentage=sequence / 3) + + await publisher.close() + + assert not cancellation.is_set() + assert len(writer.frames) == 2 + assert writer.frames[0]["params"]["event"]["sequence"] == 0 + assert writer.frames[-1]["params"]["event"]["sequence"] == 299 + + asyncio.run(exercise()) + + +def test_bridge_progress_publisher_detects_sustained_backpressure() -> None: + class BlockedWriter: + def __init__(self) -> None: + self.started = asyncio.Event() + self.release = asyncio.Event() + + async def write(self, frame: dict[str, Any]) -> None: + del frame + self.started.set() + await self.release.wait() + + async def exercise() -> None: + writer = BlockedWriter() + cancellation = asyncio.Event() + publisher = bridge_module._ProgressPublisher( + cast(Any, writer), + "index-1", + cancellation, + "operation-1", + capacity=1, + backpressure_timeout_seconds=0.01, ) reporter = ProgressReporter( "operation-1", "repository.index.build", observer=publisher.observe ) reporter.report("scan", "first", percentage=1) + await asyncio.wait_for(writer.started.wait(), timeout=1) reporter.report("scan", "second", percentage=2) + reporter.report("scan", "third", percentage=3) - assert cancellation.is_set() + await asyncio.wait_for(cancellation.wait(), timeout=1) + writer.release.set() with pytest.raises(bridge_module.BridgeFault) as raised: await publisher.close() assert raised.value.typed_code == "INDEX_BUILD_FAILED" assert raised.value.data["error_code"] == "progress_backpressure" - assert len(writer.frames) == 1 - assert writer.frames[0]["params"]["event"]["sequence"] == 0 asyncio.run(exercise()) @@ -546,6 +586,7 @@ def test_bridge_v2_index_timeout_signals_worker_before_lock_release( ) -> None: (tmp_path / "app.py").write_text("VALUE = 1\n", encoding="utf-8") cancellation_seen = threading.Event() + release_worker = threading.Event() lock_was_active = False def wait_for_cancellation( @@ -563,6 +604,8 @@ def wait_for_cancellation( time.sleep(0.001) lock_was_active = lock.active and lock.layout.lock.is_file() cancellation_seen.set() + if not release_worker.wait(timeout=5): + raise AssertionError("test did not release the timed-out index worker") raise asyncio.CancelledError monkeypatch.setattr( @@ -575,6 +618,7 @@ async def exercise() -> None: harness.input.send(_request("hello", "hello", {"protocol_version": "2.0"})) await harness.response(1) digest = calculate_source_snapshot_digest(scan_repository(tmp_path)) + started_at = time.monotonic() harness.input.send( _request( "index-timeout", @@ -583,15 +627,36 @@ async def exercise() -> None: "action": "build", "expected_snapshot_digest": digest, "provider": "none", - "timeout_ms": 500, + "timeout_ms": 50, }, ) ) response = await asyncio.to_thread(harness.output.wait_for_id, "index-timeout") + elapsed = time.monotonic() - started_at assert response["error"]["data"]["code"] == "REQUEST_TIMEOUT" - assert cancellation_seen.is_set() + assert await asyncio.to_thread(cancellation_seen.wait, 1) assert lock_was_active is True + assert elapsed < 0.5 + assert (tmp_path / ".contextforge" / "index" / "lock.json").is_file() + assert len(harness.server._background_tasks) == 1 + release_worker.set() + deadline = time.monotonic() + 5 + while harness.server._background_tasks: + if time.monotonic() >= deadline: + raise AssertionError("timed-out index cleanup did not finish") + await asyncio.sleep(0.01) assert not (tmp_path / ".contextforge" / "index" / "lock.json").exists() + frames = [json.loads(chunk) for chunk in harness.output.chunks] + timeout_position = next( + index + for index, frame in enumerate(frames) + if frame.get("id") == "index-timeout" + ) + assert all( + frame.get("method") != "$/progress" + for frame in frames[timeout_position + 1 :] + ) + assert harness.stderr.getvalue() == "" await harness.close() asyncio.run(exercise()) From 803fbeb1f749e525ea59e14f73a8efcf5c2f07fe Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:12:23 +0300 Subject: [PATCH 09/11] docs(bridge): clarify bounded job delivery --- CHANGELOG.md | 11 +++++----- docs/decisions/003-host-managed-index-jobs.md | 8 ++++--- docs/guides/bridge.md | 21 ++++++++++++------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4939848..301c45c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,11 +32,12 @@ and Python distribution versions follow PEP 440. - Enforce the caller's expected snapshot inside the index workflow, revalidate it immediately before atomic publication, and check cancellation at the manifest activation boundary. -- Signal cooperative index cancellation before unwinding a timed-out Bridge - request so background structural workers cannot outlive their writer lock. -- Bound Bridge progress delivery with one writer task, drain every completed - semantic task, and preserve typed provider causes through semantic and map - aggregation. +- Return Bridge index timeouts at the caller's deadline while retaining + cooperative cleanup and its writer lock as tracked background work. +- Bound Bridge progress delivery with one writer task, coalesce synchronous + cumulative progress bursts, detect sustained client backpressure, drain every + completed semantic task, and preserve typed provider causes through semantic + and map aggregation. - Preserve the prior active generation on cancellation, failure limits, provider circuit opening, source drift, or progress backpressure. - Return safe typed Bridge index errors without provider bodies, credentialed diff --git a/docs/decisions/003-host-managed-index-jobs.md b/docs/decisions/003-host-managed-index-jobs.md index 2ac25c6..14429fc 100644 --- a/docs/decisions/003-host-managed-index-jobs.md +++ b/docs/decisions/003-host-managed-index-jobs.md @@ -48,9 +48,11 @@ schema versions. The application validates the expected digest against its own build snapshot and checks cancellation inside the publication transaction immediately before manifest activation. Bridge timeout sets cooperative cancellation before it -unwinds index work, so a background structural worker retains its writer lock -until it has stopped. Progress notifications use one writer task and a bounded -queue; backpressure cancels rather than accumulating unbounded tasks. +returns the deadline error, and the bridge tracks background cleanup so a +structural worker retains its writer lock until it has stopped. Progress +notifications use one writer task and a bounded queue. Cumulative snapshots from +synchronous producer bursts are coalesced; sustained backpressure cancels rather +than accumulating unbounded tasks. Analyzer identity includes analyzer, prompt, response-schema, provider, and model identity, but excludes transport endpoint. Legacy analyzer versions with diff --git a/docs/guides/bridge.md b/docs/guides/bridge.md index 2183f3a..3482ddf 100644 --- a/docs/guides/bridge.md +++ b/docs/guides/bridge.md @@ -128,8 +128,10 @@ atomic publication. The application workflow verifies the expected snapshot against the exact scan used for the build and rescans immediately before publication. Cancellation is checked again at manifest activation. Timeout, clean EOF, and shutdown signal the application cancellation token; partial -generations never become active. A timed-out index request finishes cooperative -worker cleanup before its writer lock is released. +generations never become active. A timed-out index request returns +`REQUEST_TIMEOUT` at the caller's deadline while cooperative worker cleanup +continues as tracked bridge work. Its writer lock remains held until that cleanup +finishes, preventing a second writer from observing half-finished staging. While the request runs, Bridge 2 emits notifications before its final response: @@ -139,12 +141,15 @@ While the request runs, Bridge 2 emits notifications before its final response: The real `event` is the full closed `ProgressEvent` schema 3 object. Correlate notifications with `params.request_id`; sequence is monotonic within the -operation. A successful result contains `generation_id`, `snapshot_digest`, -`index_schema`, statistics, and `partial`. +operation but may contain gaps when cumulative snapshots from a synchronous +producer burst are coalesced. A successful result contains `generation_id`, +`snapshot_digest`, `index_schema`, statistics, and `partial`. Clients must continuously consume Bridge stdout while an index request is active. Progress delivery uses one writer task and a bounded 256-event queue. -If that queue fills, the index job is cancelled without publication and returns +An instantaneous local producer burst retains the newest cumulative snapshot +instead of being mistaken for a slow client. If no queue slot becomes available +for 5 seconds, the index job is cancelled without publication and returns `INDEX_BUILD_FAILED` with `error.data.error_code` set to `progress_backpressure` and `retryable` set to `true`. @@ -219,8 +224,10 @@ and waits at most 5 seconds. Any request task still pending then receives direct asyncio cancellation and gets at most another 0.1 seconds for cleanup. After that 5.1-second maximum drain budget, the bridge detaches any remaining task and does not wait for it again. These internal bridge limits are fixed rather than CLI -configurable. A timed-out request cannot return a partial success, and the -shutdown response remains a normal serialized JSON-RPC frame. +configurable. Caller timeout returns its JSON-RPC error immediately; the timed-out +index cleanup remains part of this shutdown drain and cannot emit later progress +or return a partial success. The shutdown response remains a normal serialized +JSON-RPC frame. ## Security and mutation boundary From b041df597cef9226000ec58706307c29ac897a59 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:18:57 +0300 Subject: [PATCH 10/11] docs(roadmap): record host integration hardening --- ROADMAP.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index cf7f63b..8c0dd79 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -81,6 +81,22 @@ This roadmap describes broad milestones only. It does not promise dates. - [x] Adapt structured model requests to truncation and reported runtime context limits without weakening strict validation. +### Unreleased host-integration hardening (complete) + +- [x] Add independent fail-fast and bounded-failure index policies while + preserving the existing `--fail-on-error` contract. +- [x] Classify provider failures, stop retrying terminal authentication, quota, + model, and configuration errors, and add a job-scoped circuit breaker. +- [x] Expose clean JSONL index progress and bounded Bridge progress + notifications that coalesce synchronous bursts while detecting sustained + client backpressure. +- [x] Add opt-in Bridge 2 tracked build/update jobs with cooperative + cancellation, caller-deadline responses, background lock-safe cleanup, + snapshot preconditions, and atomic publication guards. +- [x] Publish readable/current index-family schema capabilities, stabilize + analyzer identity across endpoint changes, and return safe typed Bridge + integration errors. + ## Later - Full multi-root workspaces. From 66f9ebf400e2ec4fc0f1ac98d89b543acb51e945 Mon Sep 17 00:00:00 2001 From: Kirill <106469980+waterflane@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:26:14 +0300 Subject: [PATCH 11/11] fix(release): update bridge distribution smoke --- scripts/smoke_distribution.py | 4 ++-- src/contextforge/bridge/__init__.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/smoke_distribution.py b/scripts/smoke_distribution.py index cc3952d..bee257f 100644 --- a/scripts/smoke_distribution.py +++ b/scripts/smoke_distribution.py @@ -18,8 +18,8 @@ assert installed.metadata["Version"] == __version__ assert installed.metadata["License-Expression"] == "Apache-2.0" assert contextforge.__version__ == __version__ -assert BRIDGE_PROTOCOL_VERSION == "1.1" -assert SUPPORTED_BRIDGE_PROTOCOL_VERSIONS == ("1.0", "1.1") +assert BRIDGE_PROTOCOL_VERSION == "2.0" +assert SUPPORTED_BRIDGE_PROTOCOL_VERSIONS == ("1.0", "1.1", "2.0") assert BridgeServer.__module__ == "contextforge.bridge.server" assert find_spec("contextforge.protocol") is None diff --git a/src/contextforge/bridge/__init__.py b/src/contextforge/bridge/__init__.py index ed25065..f258da4 100644 --- a/src/contextforge/bridge/__init__.py +++ b/src/contextforge/bridge/__init__.py @@ -1,4 +1,4 @@ -"""Persistent generic ContextForge bridge protocol v1.""" +"""Persistent generic ContextForge bridge protocols.""" from .protocol import BRIDGE_PROTOCOL_VERSION, SUPPORTED_BRIDGE_PROTOCOL_VERSIONS from .server import (