项目文件夹

文件
Ali Khokhar 4951983b5e Replace global runtime resources with explicit ownership (#1042)
## Problem

Provider, messaging, and transcription resources relied on
process-global state, leaving replacement, cancellation, and shutdown
ownership ambiguous. Separate server lifetimes could share
event-loop-bound resources or retain failed cleanup work.

## Changes

| Before | After |
| --- | --- |
| Provider clients found limiters through global singleton and scoped
registries. | Each provider instance receives and owns one explicitly
constructed limiter. |
| Messaging queues and voice pipelines relied on singleton or
module-global state. | Each platform owns its limiter and outbox, while
the application owns one injected transcriber. |
| Messaging shutdown mixed ingress, active work, delivery, and SDK
cleanup. | Application shutdown quiesces ingress, drains work, closes
delivery, then releases transcription and providers. |
| Cancelled or failed provider cleanup could be forgotten or treated as
complete. | The provider manager retains shielded generation and
unpublished-runtime cleanup until it succeeds. |
| Discord and Telegram startup tasks could outlive or poison runtime
readiness. | Platform runtimes observe long-lived tasks and retry only
independently repeatable lifecycle steps. |
| Constructor-captured security and diagnostic settings appeared
hot-applicable. | Admin marks those settings restart-required so applied
policy matches the running resource graph. |
| Lifecycle races lacked direct ownership coverage. | Deterministic
cancellation, retry, isolation, teardown, and live smoke contracts
protect the final ownership model. |

<!-- greptile_comment -->

<details open><summary><h3>Greptile Summary</h3></summary>

This PR moves runtime resources from global state into explicitly owned
application objects. The main changes are:

- Provider generations own their rate limiters and cleanup tasks.
- Messaging platforms own their limiter, outbox, ingress, and delivery
lifecycle.
- Application shutdown now runs through ordered cleanup gates.
- Voice transcription is injected as an owned runtime resource.
- Admin config marks constructor-captured settings as restart-required.
</details>

<h3>Confidence Score: 4/5</h3>

The shutdown path needs a bounded cleanup result before merging.

Cleanup steps that hang never reach the retryable incomplete-shutdown
path. ASGI shutdown can remain stuck while waiting for an external SDK,
transcriber, workflow, or provider cleanup. The retry ownership model
works only after cleanup returns or raises.

src/free_claude_code/runtime/application.py

<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**
- T-Rex ran the requested verification, but its local artifact
references were not uploaded.
- The validation run completed successfully with EXIT\_CODE: 0 and 62
tests passed in 3.91 seconds, using the command uv run pytest -vv
tests/runtime/test\_application\_runtime.py
tests/runtime/test\_provider\_manager.py
tests/providers/test\_provider\_runtime.py.

<a
href="https://app.greptile.com/trex/runs/14064214/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/runtime/application.py | Refactors shutdown into
ordered retryable cleanup gates, but cleanup awaitables can still block
shutdown forever. |
| src/free_claude_code/runtime/asgi.py | Reports incomplete runtime
shutdown when `close()` returns false. |
| src/free_claude_code/runtime/provider_manager.py | Adds owned provider
cleanup retry state and shielded generation cleanup. |

</details>

<a
href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22alishahryar1%2Ffree-claude-code%22%20on%20the%20existing%20branch%20%22refactor%2Fruntime-owned-resources%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22refactor%2Fruntime-owned-resources%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Asrc%2Ffree_claude_code%2Fruntime%2Fapplication.py%3A59%0A**Cleanup%20Await%20Blocks%20Shutdown**%0A%0AWhen%20a%20platform%20SDK%20stop%2C%20workflow%20drain%2C%20transcriber%20close%2C%20or%20provider%20cleanup%20hangs%2C%20this%20helper%20waits%20forever%20and%20never%20returns%20%60False%60.%20ASGI%20shutdown%20stays%20stuck%20in%20%60runtime.close%28%29%60%20instead%20of%20reporting%20an%20incomplete%20shutdown%2C%20so%20the%20retained%20resource%20graph%20cannot%20be%20retried%20cleanly.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1042&platform=github"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=6"><source
media="(prefers-color-scheme: light)"
srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"><img
alt="Fix All in Codex"
src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=6"></picture></a>

<sub>Reviews (2): Last reviewed commit: ["Report incomplete runtime
shutdown to
AS..."](https://github.com/alishahryar1/free-claude-code/commit/338b2bd179c3875b15bbd52818dd04c780e5d46d)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43454593)</sub>

> Greptile also left **1 inline comment** on this PR.

**Context used:**

- Context used - CLAUDE.md
([source](https://app.greptile.com/alishahryar1/github/Alishahryar1/free-claude-code/-/custom-context?memory=d2fd24d8-0dec-4faf-8ee4-e085e215a2f8))

<!-- /greptile_comment -->
2026-07-10 18:46:50 -07:00

187 行
4.9 KiB
Python

import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from free_claude_code.messaging.platforms.outbox import PlatformOutbox
def _noop_outbox(*, limiter=None, delete_many=None) -> PlatformOutbox:
async def send(
chat_id: str,
text: str,
reply_to: str | None,
parse_mode: str | None,
message_thread_id: str | None,
) -> str:
return f"{chat_id}:{text}:{reply_to}:{parse_mode}:{message_thread_id}"
async def edit(
chat_id: str,
message_id: str,
text: str,
parse_mode: str | None,
) -> None:
return None
async def default_delete_many(chat_id: str, message_ids: list[str]) -> None:
return None
return PlatformOutbox(
limiter=limiter or MagicMock(),
send=send,
edit=edit,
delete_many=delete_many or default_delete_many,
)
@pytest.mark.asyncio
async def test_queue_send_awaits_required_limiter() -> None:
limiter = MagicMock()
async def enqueue(operation, dedup_key=None):
return await operation()
limiter.enqueue = AsyncMock(side_effect=enqueue)
outbox = _noop_outbox(limiter=limiter)
result = await outbox.queue_send_message(
"chat",
"hello",
reply_to="reply",
parse_mode="MarkdownV2",
fire_and_forget=False,
message_thread_id="thread",
)
assert result == "chat:hello:reply:MarkdownV2:thread"
limiter.enqueue.assert_awaited_once()
@pytest.mark.asyncio
async def test_queue_edit_awaits_limiter_with_dedup_key() -> None:
limiter = MagicMock()
limiter.enqueue = AsyncMock()
outbox = _noop_outbox(limiter=limiter)
await outbox.queue_edit_message(
"chat",
"message",
"updated",
parse_mode="MarkdownV2",
fire_and_forget=False,
)
limiter.enqueue.assert_awaited_once()
operation = limiter.enqueue.call_args.args[0]
assert limiter.enqueue.call_args.kwargs["dedup_key"] == "edit:chat:message"
await operation()
@pytest.mark.asyncio
async def test_queue_delete_many_skips_empty_batches() -> None:
limiter = MagicMock()
outbox = _noop_outbox(limiter=limiter)
await outbox.queue_delete_messages("chat", [], fire_and_forget=True)
limiter.fire_and_forget.assert_not_called()
@pytest.mark.asyncio
async def test_queue_delete_many_dedups_by_batch() -> None:
limiter = MagicMock()
outbox = _noop_outbox(limiter=limiter)
await outbox.queue_delete_messages("chat", ["1", "2"], fire_and_forget=True)
limiter.fire_and_forget.assert_called_once()
assert (
limiter.fire_and_forget.call_args.kwargs["dedup_key"]
== "del_bulk:chat:11f0530a8259fffb"
)
@pytest.mark.asyncio
async def test_queue_delete_many_snapshots_ids_before_queueing() -> None:
limiter = MagicMock()
deleted: list[list[str]] = []
async def delete_many(_chat_id: str, message_ids: list[str]) -> None:
deleted.append(message_ids)
outbox = _noop_outbox(limiter=limiter, delete_many=delete_many)
message_ids = ["1", "2"]
await outbox.queue_delete_messages("chat", message_ids, fire_and_forget=True)
message_ids.append("3")
operation = limiter.fire_and_forget.call_args.args[0]
await operation()
assert deleted == [["1", "2"]]
@pytest.mark.asyncio
async def test_close_cancels_and_settles_owned_background_work() -> None:
outbox = _noop_outbox()
started = asyncio.Event()
cancelled = asyncio.Event()
async def pending() -> None:
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
outbox.fire_and_forget(pending())
await started.wait()
await outbox.close()
assert cancelled.is_set()
assert outbox._background_tasks == set()
@pytest.mark.asyncio
async def test_completed_background_failure_is_observed_and_released() -> None:
outbox = _noop_outbox()
async def fail() -> None:
raise RuntimeError("background failed")
with patch("free_claude_code.messaging.platforms.outbox.logger.error") as error_log:
outbox.fire_and_forget(fail())
await asyncio.sleep(0)
await asyncio.sleep(0)
assert outbox._background_tasks == set()
error_log.assert_called_once_with(
"Outbound background task failed: exc_type={}",
"RuntimeError",
)
@pytest.mark.asyncio
async def test_close_rejects_all_later_work() -> None:
limiter = MagicMock()
outbox = _noop_outbox(limiter=limiter)
await outbox.close()
with pytest.raises(RuntimeError, match="outbox is closed"):
await outbox.queue_send_message("chat", "message")
ran = False
async def late_task() -> None:
nonlocal ran
ran = True
with pytest.raises(RuntimeError, match="outbox is closed"):
outbox.fire_and_forget(late_task())
await asyncio.sleep(0)
assert ran is False
assert outbox._background_tasks == set()
limiter.fire_and_forget.assert_not_called()