项目文件夹

文件
Ali Khokhar 1a476562fc Make provider model discovery the sole catalog owner (#1221)
## 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 -->
2026-07-20 23:53:55 -07:00

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