alishahryar1--free-claude-code
1a476562fc
## Problem Startup model-list I/O was split between a non-enforcing configured-model validator and the real discovery path. Both queried providers and populated the same cache even though synchronous cache warm-up was the validator's only required effect. ## Changes | Before | After | | --- | --- | | Validation and discovery independently resolved providers, queried model lists, and cached results. | `ProviderModelDiscovery` solely owns model-list queries, failure reporting, and cache population. | | Startup ran configured-model validation and then launched discovery. | Startup synchronously warms referenced providers through discovery, then launches the existing missing-provider background pass. | | Absence from a provider catalog produced a non-enforcing missing-model warning. | Provider catalogs remain discovery metadata and provider execution remains authoritative. | | Configured model references retained environment-source metadata for validator diagnostics. | Configured model references retain only routing data. | | Successful startup providers could be represented by two separate subsystems. | Focused tests enforce concurrent warm-up, single successful queries, failed-query eligibility, and warm-before-background ordering. | | The package version was `4.11.5`. | The package version is `4.11.6` with an updated lockfile. | <!-- greptile_comment --> <details open><summary><h3>Greptile Summary</h3></summary> This PR makes provider discovery the sole owner of model catalogs. The main changes are: - Warms routed provider catalogs before background discovery starts. - Reuses successful warm results while retrying failed providers. - Removes configured-model catalog validation and source metadata. - Moves query-failure reporting into the discovery module. - Updates focused tests, architecture docs, and package version. </details> <h3>Confidence Score: 5/5</h3> This looks safe to merge. No blocking issues found in the changed code. Provider failures remain isolated and eligible for background retry. Successful warm results are not queried again by the missing-provider pass. Lease release and startup cleanup remain protected by existing control flow. None. <details><summary><h3><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="20" align="absmiddle"></a> T-Rex Logs</h3></summary> **What T-Rex did** - \`test\_runtime\_warm\_queries\_referenced\_providers\_concurrently\`, \`test\_startup\_discovery\_queries\_each\_successful\_provider\_once\`, \`test\_failed\_startup\_warm\_remains\_eligible\_for\_background\_refresh\`, \`test\_runtime\_warm\_caches\_all\_referenced\_provider\_models\`, and \`test\_runtime\_startup\_warms\_catalog\_before\_background\_refresh\` all passed. - The complete verbose HEAD run, including command, working directory, exit code, test nodes, and summary, is preserved in \`trex-artifacts/provider-discovery-startup-validation.log\` and the paired after artifact. <a href="https://app.greptile.com/trex/runs/15203372/artifacts"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifactsDark.svg?v=4"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"><img alt="View all artifacts" src="https://greptile-static-assets.s3.amazonaws.com/badges/ViewAllArtifacts.svg?v=4"></picture></a> <sub><a href="https://www.greptile.com/trex"><img alt="T-Rex" src="https://greptile-static-assets.s3.amazonaws.com/trex/trex_green.svg" height="14" align="absmiddle"></a> Ran code and verified through T-Rex</sub> </details> <details open><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | src/free_claude_code/providers/runtime/discovery.py | Centralizes catalog queries, failure reporting, referenced-provider warming, and cache population. | | src/free_claude_code/runtime/provider_manager.py | Replaces validation with discovery-based warming under a generation lease. | | src/free_claude_code/runtime/application.py | Warms referenced catalogs before launching the missing-provider background pass. | | src/free_claude_code/config/model_refs.py | Removes validation-only source metadata while preserving deterministic deduplication. | | tests/providers/test_model_discovery.py | Covers concurrent warming, partial failures, retry eligibility, and single successful queries. | </details> <details open><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant App as ApplicationRuntime participant Manager as ProviderRuntimeManager participant Discovery as ProviderModelDiscovery participant Provider participant Cache as ProviderModelCache App->>Manager: warm_referenced_model_cache() Manager->>Discovery: warm referenced providers par Provider queries Discovery->>Provider: list_model_infos() end Provider-->>Discovery: metadata or failure Discovery->>Cache: cache successful results Discovery-->>Manager: refresh result Manager-->>App: warm complete App->>Manager: start_model_list_refresh() Manager->>Discovery: refresh only missing providers Discovery->>Cache: cache remaining catalogs ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant App as ApplicationRuntime participant Manager as ProviderRuntimeManager participant Discovery as ProviderModelDiscovery participant Provider participant Cache as ProviderModelCache App->>Manager: warm_referenced_model_cache() Manager->>Discovery: warm referenced providers par Provider queries Discovery->>Provider: list_model_infos() end Provider-->>Discovery: metadata or failure Discovery->>Cache: cache successful results Discovery-->>Manager: refresh result Manager-->>App: warm complete App->>Manager: start_model_list_refresh() Manager->>Discovery: refresh only missing providers Discovery->>Cache: cache remaining catalogs ``` </a> </details> <sub>Reviews (1): Last reviewed commit: ["Unify provider model discovery ownership"](https://github.com/alishahryar1/free-claude-code/commit/33f68e4338fd2326a7d8fd3e7e28246b47fa4210) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=45862060)</sub> <!-- /greptile_comment -->
938 行
30 KiB
Python
938 行
30 KiB
Python
import asyncio
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import free_claude_code.messaging.session.persistence as persistence_module
|
|
from free_claude_code.config.admin.persistence import PreparedAdminUpdate
|
|
from free_claude_code.config.settings import Settings
|
|
from free_claude_code.messaging.command_context import StopOutcome
|
|
from free_claude_code.messaging.platforms.ports import (
|
|
InboundMessageHandler,
|
|
MessagingPlatformComponents,
|
|
MessagingStartupNotice,
|
|
)
|
|
from free_claude_code.messaging.session import SessionStore
|
|
from free_claude_code.messaging.workflow import MessagingWorkflow
|
|
from free_claude_code.providers.runtime import ProviderRuntime
|
|
from free_claude_code.runtime.application import ApplicationRuntime
|
|
from free_claude_code.runtime.provider_manager import ProviderRuntimeManager
|
|
|
|
|
|
class TrackingRuntime(ProviderRuntime):
|
|
def __init__(self, settings: Settings) -> None:
|
|
super().__init__(settings)
|
|
self.cleanup_calls = 0
|
|
|
|
async def cleanup(self) -> None:
|
|
self.cleanup_calls += 1
|
|
await super().cleanup()
|
|
|
|
|
|
class TrackingFactory:
|
|
def __init__(self) -> None:
|
|
self.runtimes: list[TrackingRuntime] = []
|
|
self.fail = False
|
|
self.events: list[str] = []
|
|
|
|
def __call__(self, settings: Settings) -> ProviderRuntime:
|
|
self.events.append(f"construct:{settings.model}")
|
|
if self.fail:
|
|
raise RuntimeError("candidate failed")
|
|
runtime = TrackingRuntime(settings)
|
|
self.runtimes.append(runtime)
|
|
return runtime
|
|
|
|
|
|
class TrackingTranscriber:
|
|
def __init__(self, events: list[str]) -> None:
|
|
self.events = events
|
|
self.close_calls = 0
|
|
|
|
async def transcribe(self, file_path: Path) -> str:
|
|
assert isinstance(file_path, Path)
|
|
return "transcribed"
|
|
|
|
async def close(self) -> None:
|
|
self.close_calls += 1
|
|
self.events.append("transcriber.close")
|
|
|
|
|
|
class FailingTranscriber(TrackingTranscriber):
|
|
async def close(self) -> None:
|
|
await super().close()
|
|
raise RuntimeError("transcriber close failed")
|
|
|
|
|
|
class CancelledTranscriber(TrackingTranscriber):
|
|
async def close(self) -> None:
|
|
await super().close()
|
|
raise asyncio.CancelledError
|
|
|
|
|
|
class CancellingOnceTranscriber(TrackingTranscriber):
|
|
async def close(self) -> None:
|
|
await super().close()
|
|
if self.close_calls == 1:
|
|
raise asyncio.CancelledError
|
|
|
|
|
|
class TrackingMessagingRuntime:
|
|
name = "tracking"
|
|
|
|
def __init__(
|
|
self,
|
|
events: list[str],
|
|
*,
|
|
fail_quiesce_once: bool = False,
|
|
fail_close_once: bool = False,
|
|
) -> None:
|
|
self.events = events
|
|
self.fail_quiesce_once = fail_quiesce_once
|
|
self.fail_close_once = fail_close_once
|
|
|
|
async def start(self) -> None:
|
|
self.events.append("messaging.start")
|
|
|
|
async def quiesce(self) -> None:
|
|
self.events.append("messaging.quiesce")
|
|
if self.fail_quiesce_once:
|
|
self.fail_quiesce_once = False
|
|
raise RuntimeError("quiesce failed")
|
|
|
|
async def close(self) -> None:
|
|
self.events.append("messaging.close")
|
|
if self.fail_close_once:
|
|
self.fail_close_once = False
|
|
raise RuntimeError("close failed")
|
|
|
|
def on_message(self, handler: InboundMessageHandler) -> None:
|
|
assert callable(handler)
|
|
|
|
@property
|
|
def is_connected(self) -> bool:
|
|
return True
|
|
|
|
|
|
class PersistentlyFailingMessagingRuntime(TrackingMessagingRuntime):
|
|
def __init__(self, events: list[str]) -> None:
|
|
super().__init__(events)
|
|
self.fail_quiesce = True
|
|
|
|
async def quiesce(self) -> None:
|
|
self.events.append("messaging.quiesce")
|
|
if self.fail_quiesce:
|
|
raise RuntimeError("quiesce failed")
|
|
|
|
|
|
def _settings(model: str, *, port: int = 8082) -> Settings:
|
|
return Settings().model_copy(update={"model": model, "port": port})
|
|
|
|
|
|
def _prepared(
|
|
settings: Settings,
|
|
tmp_path,
|
|
*,
|
|
pending_fields: tuple[str, ...] = (),
|
|
) -> PreparedAdminUpdate:
|
|
return PreparedAdminUpdate(
|
|
target_values={"MODEL": settings.model},
|
|
settings=settings,
|
|
errors=(),
|
|
pending_fields=pending_fields,
|
|
path=tmp_path / ".env",
|
|
)
|
|
|
|
|
|
def _applied_response(pending_fields: tuple[str, ...] = ()) -> dict[str, object]:
|
|
return {
|
|
"applied": True,
|
|
"valid": True,
|
|
"errors": [],
|
|
"env_preview": "MODEL=updated\n",
|
|
"path": ".env",
|
|
"pending_fields": list(pending_fields),
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_all_maps_messaging_outcome_to_application_count() -> None:
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
workflow = MagicMock()
|
|
workflow.stop_all_tasks = AsyncMock(
|
|
return_value=StopOutcome(
|
|
cancelled_count=3,
|
|
status_feedback_scopes=frozenset(),
|
|
fallback_required=False,
|
|
)
|
|
)
|
|
runtime._messaging_workflow = workflow
|
|
|
|
result = await runtime.stop_all()
|
|
|
|
assert result is not None
|
|
assert result.cancelled_count == 3
|
|
workflow.stop_all_tasks.assert_awaited_once()
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_provider_apply_constructs_before_commit_then_publishes(tmp_path) -> None:
|
|
factory = TrackingFactory()
|
|
manager = ProviderRuntimeManager(
|
|
_settings("nvidia_nim/old"),
|
|
runtime_factory=factory,
|
|
)
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
prepared = _prepared(_settings("nvidia_nim/new"), tmp_path)
|
|
factory.events.clear()
|
|
|
|
def commit(_prepared_update: PreparedAdminUpdate) -> dict[str, object]:
|
|
factory.events.append("commit")
|
|
assert manager.current_generation_id == 1
|
|
return _applied_response()
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.prepare_admin_update",
|
|
return_value=prepared,
|
|
),
|
|
patch(
|
|
"free_claude_code.runtime.application.commit_prepared_admin_update",
|
|
side_effect=commit,
|
|
),
|
|
):
|
|
result = await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"})
|
|
|
|
assert factory.events == ["construct:nvidia_nim/new", "commit"]
|
|
assert manager.current_generation_id == 2
|
|
assert manager.current_settings().model == "nvidia_nim/new"
|
|
assert result["restart"] == {
|
|
"required": False,
|
|
"automatic": False,
|
|
"admin_url": None,
|
|
"fields": [],
|
|
}
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_candidate_failure_never_commits_and_preserves_current(tmp_path) -> None:
|
|
factory = TrackingFactory()
|
|
manager = ProviderRuntimeManager(
|
|
_settings("nvidia_nim/old"),
|
|
runtime_factory=factory,
|
|
)
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
prepared = _prepared(_settings("nvidia_nim/new"), tmp_path)
|
|
factory.fail = True
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.prepare_admin_update",
|
|
return_value=prepared,
|
|
),
|
|
patch(
|
|
"free_claude_code.runtime.application.commit_prepared_admin_update"
|
|
) as commit,
|
|
pytest.raises(RuntimeError, match="candidate failed"),
|
|
):
|
|
await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"})
|
|
|
|
commit.assert_not_called()
|
|
assert manager.current_generation_id == 1
|
|
assert manager.current_settings().model == "nvidia_nim/old"
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persistence_failure_closes_candidate_and_preserves_current(
|
|
tmp_path,
|
|
) -> None:
|
|
factory = TrackingFactory()
|
|
manager = ProviderRuntimeManager(
|
|
_settings("nvidia_nim/old"),
|
|
runtime_factory=factory,
|
|
)
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
prepared = _prepared(_settings("nvidia_nim/new"), tmp_path)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.prepare_admin_update",
|
|
return_value=prepared,
|
|
),
|
|
patch(
|
|
"free_claude_code.runtime.application.commit_prepared_admin_update",
|
|
side_effect=OSError("disk full"),
|
|
),
|
|
pytest.raises(OSError, match="disk full"),
|
|
):
|
|
await runtime.apply_admin_config({"MODEL": "nvidia_nim/new"})
|
|
|
|
assert manager.current_generation_id == 1
|
|
assert factory.runtimes[0].cleanup_calls == 0
|
|
assert factory.runtimes[1].cleanup_calls == 1
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_restart_required_apply_commits_without_hot_publication(tmp_path) -> None:
|
|
factory = TrackingFactory()
|
|
manager = ProviderRuntimeManager(
|
|
_settings("nvidia_nim/old"),
|
|
runtime_factory=factory,
|
|
)
|
|
restart = AsyncMock()
|
|
runtime = ApplicationRuntime(
|
|
manager,
|
|
transcriber=None,
|
|
restart_callback=restart,
|
|
)
|
|
prepared = _prepared(
|
|
_settings("nvidia_nim/old", port=9090),
|
|
tmp_path,
|
|
pending_fields=("PORT",),
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.prepare_admin_update",
|
|
return_value=prepared,
|
|
),
|
|
patch(
|
|
"free_claude_code.runtime.application.commit_prepared_admin_update",
|
|
return_value=_applied_response(("PORT",)),
|
|
) as commit,
|
|
):
|
|
result = await runtime.apply_admin_config({"PORT": "9090"})
|
|
|
|
commit.assert_called_once_with(prepared)
|
|
assert manager.current_generation_id == 1
|
|
assert len(factory.runtimes) == 1
|
|
assert result["restart"] == {
|
|
"required": True,
|
|
"automatic": True,
|
|
"admin_url": "http://127.0.0.1:9090/admin",
|
|
"fields": ["PORT"],
|
|
}
|
|
restart.assert_not_awaited()
|
|
await runtime.request_restart()
|
|
restart.assert_awaited_once()
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_drains_messaging_before_transcriber_and_is_idempotent() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = TrackingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
runtime._messaging_runtime = TrackingMessagingRuntime(events)
|
|
workflow = MagicMock()
|
|
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
|
|
runtime._messaging_workflow = workflow
|
|
runtime._cli_manager = MagicMock()
|
|
|
|
assert runtime.is_closed is False
|
|
assert await runtime.close() is True
|
|
assert await runtime.close() is True
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
"transcriber.close",
|
|
]
|
|
assert transcriber.close_calls == 1
|
|
assert runtime._transcriber is None
|
|
assert runtime._messaging_runtime is None
|
|
assert runtime._messaging_workflow is None
|
|
assert runtime.is_closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retains_transcriber_ownership_when_close_fails() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = FailingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
runtime._messaging_runtime = TrackingMessagingRuntime(events)
|
|
|
|
assert await runtime.close() is False
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.close",
|
|
"transcriber.close",
|
|
]
|
|
assert transcriber.close_calls == 1
|
|
assert runtime._transcriber is transcriber
|
|
assert runtime._closed is False
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retries_runtime_before_closing_later_resources() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = TrackingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
messaging = TrackingMessagingRuntime(events, fail_close_once=True)
|
|
runtime._messaging_runtime = messaging
|
|
|
|
assert await runtime.close() is False
|
|
|
|
assert events == ["messaging.quiesce", "messaging.close"]
|
|
assert runtime._messaging_runtime is messaging
|
|
assert runtime._transcriber is transcriber
|
|
assert runtime._closed is False
|
|
|
|
assert await runtime.close() is True
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.close",
|
|
"messaging.quiesce",
|
|
"messaging.close",
|
|
"transcriber.close",
|
|
]
|
|
assert runtime._messaging_runtime is None
|
|
assert runtime._transcriber is None
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retries_workflow_close_before_closing_delivery() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
workflow = MagicMock()
|
|
close_calls = 0
|
|
|
|
async def close_workflow() -> None:
|
|
nonlocal close_calls
|
|
close_calls += 1
|
|
if close_calls == 1:
|
|
raise RuntimeError("drain failed")
|
|
events.append("workflow.close")
|
|
|
|
workflow.close = AsyncMock(side_effect=close_workflow)
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
|
|
assert await runtime.close() is False
|
|
|
|
assert events == ["messaging.quiesce"]
|
|
assert runtime._messaging_runtime is messaging
|
|
assert runtime._messaging_workflow is workflow
|
|
assert runtime._closed is False
|
|
|
|
assert await runtime.close() is True
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
]
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_does_not_drain_workflow_until_ingress_is_quiescent() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True)
|
|
workflow = MagicMock()
|
|
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
|
|
assert await runtime.close() is False
|
|
|
|
workflow.close.assert_not_awaited()
|
|
assert runtime._messaging_runtime is messaging
|
|
assert runtime._messaging_workflow is workflow
|
|
|
|
assert await runtime.close() is True
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
]
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retries_failed_persistence_before_closing_delivery() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
workflow = MagicMock()
|
|
close_calls = 0
|
|
|
|
async def close_workflow() -> None:
|
|
nonlocal close_calls
|
|
close_calls += 1
|
|
if close_calls == 1:
|
|
raise RuntimeError("flush failed")
|
|
events.append("workflow.close")
|
|
|
|
workflow.close = AsyncMock(side_effect=close_workflow)
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
|
|
await runtime.close()
|
|
|
|
assert runtime._messaging_workflow is workflow
|
|
assert runtime._messaging_runtime is messaging
|
|
assert "messaging.close" not in events
|
|
|
|
await runtime.close()
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
]
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_close_retries_real_workflow_persistence_without_losing_latest_state(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
cli_manager = MagicMock()
|
|
cli_manager.stop_all = AsyncMock()
|
|
outbound = MagicMock()
|
|
store_path = tmp_path / "sessions.json"
|
|
real_replace = persistence_module.os.replace
|
|
replace_calls = 0
|
|
|
|
def fail_first_replace(source: str, target: str) -> None:
|
|
nonlocal replace_calls
|
|
replace_calls += 1
|
|
if replace_calls == 1:
|
|
raise OSError("replace failed once")
|
|
real_replace(source, target)
|
|
|
|
with patch.object(persistence_module.threading, "Timer"):
|
|
store = SessionStore(storage_path=str(store_path))
|
|
store.record_message_id(
|
|
"telegram",
|
|
"chat_1",
|
|
"old",
|
|
"out",
|
|
"status",
|
|
)
|
|
store.flush_pending_save()
|
|
store.record_message_id(
|
|
"telegram",
|
|
"chat_1",
|
|
"latest",
|
|
"out",
|
|
"status",
|
|
)
|
|
workflow = MessagingWorkflow(outbound, cli_manager, store)
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
runtime._cli_manager = cli_manager
|
|
|
|
with patch.object(
|
|
persistence_module.os,
|
|
"replace",
|
|
side_effect=fail_first_replace,
|
|
):
|
|
assert await runtime.close() is False
|
|
|
|
assert runtime._messaging_runtime is messaging
|
|
assert runtime._messaging_workflow is workflow
|
|
assert runtime._cli_manager is cli_manager
|
|
assert runtime.is_closed is False
|
|
assert store.dirty is True
|
|
assert store.get_tracked_message_ids_for_chat("telegram", "chat_1") == [
|
|
"old",
|
|
"latest",
|
|
]
|
|
assert SessionStore(
|
|
storage_path=str(store_path)
|
|
).get_tracked_message_ids_for_chat("telegram", "chat_1") == ["old"]
|
|
|
|
assert await runtime.close() is True
|
|
|
|
assert runtime._messaging_runtime is None
|
|
assert runtime._messaging_workflow is None
|
|
assert runtime._cli_manager is None
|
|
assert runtime.is_closed is True
|
|
assert store.dirty is False
|
|
assert SessionStore(
|
|
storage_path=str(store_path)
|
|
).get_tracked_message_ids_for_chat("telegram", "chat_1") == [
|
|
"old",
|
|
"latest",
|
|
]
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"messaging.close",
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_transcriber_close_retains_ownership() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = CancelledTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await runtime._cleanup_transcriber()
|
|
|
|
assert transcriber.close_calls == 1
|
|
assert runtime._transcriber is transcriber
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_application_close_remains_retryable() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = CancellingOnceTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await runtime.close()
|
|
|
|
assert runtime._closed is False
|
|
assert runtime._transcriber is transcriber
|
|
|
|
await runtime.close()
|
|
|
|
assert transcriber.close_calls == 2
|
|
assert runtime._transcriber is None
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_failure_closes_owned_transcriber() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = TrackingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
|
|
with (
|
|
patch.object(
|
|
manager,
|
|
"warm_referenced_model_cache",
|
|
AsyncMock(side_effect=RuntimeError("startup failed")),
|
|
),
|
|
pytest.raises(RuntimeError, match="startup failed"),
|
|
):
|
|
await runtime.start()
|
|
|
|
assert transcriber.close_calls == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_startup_cancellation_cleans_partial_messaging_and_reraises() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = TrackingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
entered = asyncio.Event()
|
|
|
|
async def start_messaging() -> None:
|
|
runtime._messaging_runtime = messaging
|
|
entered.set()
|
|
await asyncio.Event().wait()
|
|
|
|
with patch.object(
|
|
runtime,
|
|
"_start_messaging_if_configured",
|
|
side_effect=start_messaging,
|
|
):
|
|
start_task = asyncio.create_task(runtime.start())
|
|
await entered.wait()
|
|
start_task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await start_task
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.close",
|
|
"transcriber.close",
|
|
]
|
|
assert runtime._closed is True
|
|
assert runtime._messaging_runtime is None
|
|
assert runtime._transcriber is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_start_retries_transient_partial_messaging_cleanup() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events, fail_quiesce_once=True)
|
|
workflow = MagicMock()
|
|
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
|
|
cli_manager = MagicMock()
|
|
components = MessagingPlatformComponents(
|
|
name="tracking",
|
|
runtime=messaging,
|
|
outbound=MagicMock(),
|
|
)
|
|
startup_failure = RuntimeError("partial messaging startup failed")
|
|
|
|
async def fail_after_publication(
|
|
published: MessagingPlatformComponents,
|
|
) -> None:
|
|
assert published is components
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
runtime._cli_manager = cli_manager
|
|
raise startup_failure
|
|
|
|
with (
|
|
patch.object(
|
|
manager,
|
|
"warm_referenced_model_cache",
|
|
AsyncMock(),
|
|
),
|
|
patch.object(manager, "start_model_list_refresh"),
|
|
patch(
|
|
"free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
|
|
return_value=components,
|
|
),
|
|
patch.object(
|
|
runtime,
|
|
"_start_messaging_workflow",
|
|
side_effect=fail_after_publication,
|
|
),
|
|
pytest.raises(RuntimeError, match="cleanup incomplete") as raised,
|
|
):
|
|
await runtime.start()
|
|
|
|
assert raised.value.__cause__ is startup_failure
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
]
|
|
workflow.close.assert_awaited_once()
|
|
assert runtime._messaging_runtime is None
|
|
assert runtime._messaging_workflow is None
|
|
assert runtime._cli_manager is None
|
|
assert runtime.is_closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_public_start_retains_persistently_unclean_partial_messaging_graph() -> (
|
|
None
|
|
):
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
transcriber = TrackingTranscriber(events)
|
|
runtime = ApplicationRuntime(manager, transcriber=transcriber)
|
|
messaging = PersistentlyFailingMessagingRuntime(events)
|
|
workflow = MagicMock()
|
|
workflow.close = AsyncMock(side_effect=lambda: events.append("workflow.close"))
|
|
cli_manager = MagicMock()
|
|
components = MessagingPlatformComponents(
|
|
name="tracking",
|
|
runtime=messaging,
|
|
outbound=MagicMock(),
|
|
)
|
|
startup_failure = RuntimeError("partial messaging startup failed")
|
|
|
|
async def fail_after_publication(
|
|
published: MessagingPlatformComponents,
|
|
) -> None:
|
|
assert published is components
|
|
runtime._messaging_runtime = messaging
|
|
runtime._messaging_workflow = workflow
|
|
runtime._cli_manager = cli_manager
|
|
raise startup_failure
|
|
|
|
with (
|
|
patch.object(
|
|
manager,
|
|
"warm_referenced_model_cache",
|
|
AsyncMock(),
|
|
),
|
|
patch.object(manager, "start_model_list_refresh"),
|
|
patch(
|
|
"free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
|
|
return_value=components,
|
|
),
|
|
patch.object(
|
|
runtime,
|
|
"_start_messaging_workflow",
|
|
side_effect=fail_after_publication,
|
|
),
|
|
pytest.raises(RuntimeError, match="cleanup incomplete") as raised,
|
|
):
|
|
await runtime.start()
|
|
|
|
assert raised.value.__cause__ is startup_failure
|
|
assert events == ["messaging.quiesce", "messaging.quiesce"]
|
|
workflow.close.assert_not_awaited()
|
|
assert transcriber.close_calls == 0
|
|
assert runtime._messaging_runtime is messaging
|
|
assert runtime._messaging_workflow is workflow
|
|
assert runtime._cli_manager is cli_manager
|
|
assert runtime._transcriber is transcriber
|
|
assert runtime._provider_manager_closed is False
|
|
assert runtime.is_closed is False
|
|
|
|
messaging.fail_quiesce = False
|
|
assert await runtime.close() is True
|
|
|
|
assert events == [
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"messaging.quiesce",
|
|
"workflow.close",
|
|
"messaging.close",
|
|
"transcriber.close",
|
|
]
|
|
assert runtime.is_closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messaging_start_failure_is_nonfatal_after_complete_cleanup() -> None:
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
|
|
side_effect=RuntimeError("messaging unavailable"),
|
|
),
|
|
patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=True)),
|
|
):
|
|
await runtime._start_messaging_if_configured()
|
|
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_messaging_start_failure_fails_closed_when_cleanup_is_incomplete() -> (
|
|
None
|
|
):
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components",
|
|
side_effect=RuntimeError("messaging unavailable"),
|
|
),
|
|
patch.object(runtime, "_cleanup_messaging", AsyncMock(return_value=False)),
|
|
pytest.raises(RuntimeError, match="cleanup incomplete") as exc_info,
|
|
):
|
|
await runtime._start_messaging_if_configured()
|
|
|
|
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
|
await manager.close()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_composition_records_runtime_before_workspace_setup() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
components = MessagingPlatformComponents(
|
|
name="tracking",
|
|
runtime=messaging,
|
|
outbound=MagicMock(),
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.os.makedirs",
|
|
side_effect=OSError("workspace failed"),
|
|
),
|
|
pytest.raises(OSError, match="workspace failed"),
|
|
):
|
|
await runtime._start_messaging_workflow(components)
|
|
|
|
assert runtime._messaging_runtime is messaging
|
|
|
|
await runtime.close()
|
|
|
|
assert events == ["messaging.quiesce", "messaging.close"]
|
|
assert runtime._closed is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_composition_publishes_startup_notice_after_runtime_and_repair() -> None:
|
|
events: list[str] = []
|
|
manager = ProviderRuntimeManager(_settings("nvidia_nim/model"))
|
|
runtime = ApplicationRuntime(manager, transcriber=None)
|
|
messaging = TrackingMessagingRuntime(events)
|
|
notice = MessagingStartupNotice(
|
|
chat_id="chat",
|
|
transport_label="test transport",
|
|
)
|
|
components = MessagingPlatformComponents(
|
|
name="tracking",
|
|
runtime=messaging,
|
|
outbound=MagicMock(),
|
|
startup_notice=notice,
|
|
)
|
|
workflow = MagicMock()
|
|
workflow.handle_message = AsyncMock()
|
|
workflow.restore.side_effect = lambda: events.append("workflow.restore")
|
|
workflow.repair_restored_statuses = AsyncMock(
|
|
side_effect=lambda: events.append("workflow.repair")
|
|
)
|
|
workflow.publish_startup_notice = AsyncMock(
|
|
side_effect=lambda published: events.append("workflow.notice")
|
|
)
|
|
workflow.close = AsyncMock()
|
|
cli_manager = MagicMock()
|
|
|
|
with (
|
|
patch(
|
|
"free_claude_code.runtime.application.cli_managed.ManagedClaudeSessionManager",
|
|
return_value=cli_manager,
|
|
) as manager_constructor,
|
|
patch("free_claude_code.runtime.application.messaging_session.SessionStore"),
|
|
patch(
|
|
"free_claude_code.runtime.application.messaging_workflow_module.MessagingWorkflow",
|
|
return_value=workflow,
|
|
),
|
|
):
|
|
await runtime._start_messaging_workflow(components)
|
|
|
|
assert events == [
|
|
"workflow.restore",
|
|
"messaging.start",
|
|
"workflow.repair",
|
|
"workflow.notice",
|
|
]
|
|
workflow.publish_startup_notice.assert_awaited_once_with(notice)
|
|
assert manager_constructor.call_args.kwargs["proxy_root_url"] == (
|
|
"http://127.0.0.1:8082"
|
|
)
|
|
assert "api_url" not in manager_constructor.call_args.kwargs
|
|
assert "plans_directory" not in manager_constructor.call_args.kwargs
|
|
|
|
assert await runtime.close() is True
|