alishahryar1--free-claude-code
4951983b5e
## 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 -->
144 行
4.5 KiB
Python
144 行
4.5 KiB
Python
"""Shared queued delivery helper for messaging platforms."""
|
|
|
|
import asyncio
|
|
import hashlib
|
|
from collections.abc import Awaitable, Callable
|
|
from typing import Any, cast
|
|
|
|
from loguru import logger
|
|
|
|
from ..limiter import MessagingRateLimiter
|
|
|
|
SendOperation = Callable[
|
|
[str, str, str | None, str | None, str | None],
|
|
Awaitable[str],
|
|
]
|
|
EditOperation = Callable[[str, str, str, str | None], Awaitable[None]]
|
|
DeleteManyOperation = Callable[[str, list[str]], Awaitable[None]]
|
|
|
|
|
|
class PlatformOutbox:
|
|
"""Own queueing, deduplication, and fire-and-forget delivery policy."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
limiter: MessagingRateLimiter,
|
|
send: SendOperation,
|
|
edit: EditOperation,
|
|
delete_many: DeleteManyOperation,
|
|
) -> None:
|
|
self._limiter = limiter
|
|
self._send = send
|
|
self._edit = edit
|
|
self._delete_many = delete_many
|
|
self._background_tasks: set[asyncio.Future[Any]] = set()
|
|
self._closed = False
|
|
|
|
async def queue_send_message(
|
|
self,
|
|
chat_id: str,
|
|
text: str,
|
|
reply_to: str | None = None,
|
|
parse_mode: str | None = None,
|
|
fire_and_forget: bool = True,
|
|
message_thread_id: str | None = None,
|
|
) -> str | None:
|
|
"""Queue or immediately send a platform message."""
|
|
self._require_open()
|
|
|
|
async def _send() -> str:
|
|
return await self._send(
|
|
chat_id,
|
|
text,
|
|
reply_to,
|
|
parse_mode,
|
|
message_thread_id,
|
|
)
|
|
|
|
if fire_and_forget:
|
|
self._limiter.fire_and_forget(_send)
|
|
return None
|
|
return cast(str | None, await self._limiter.enqueue(_send))
|
|
|
|
async def queue_edit_message(
|
|
self,
|
|
chat_id: str,
|
|
message_id: str,
|
|
text: str,
|
|
parse_mode: str | None = None,
|
|
fire_and_forget: bool = True,
|
|
) -> None:
|
|
"""Queue or immediately edit a platform message."""
|
|
self._require_open()
|
|
|
|
async def _edit() -> None:
|
|
await self._edit(chat_id, message_id, text, parse_mode)
|
|
|
|
dedup_key = f"edit:{chat_id}:{message_id}"
|
|
if fire_and_forget:
|
|
self._limiter.fire_and_forget(_edit, dedup_key=dedup_key)
|
|
else:
|
|
await self._limiter.enqueue(_edit, dedup_key=dedup_key)
|
|
|
|
async def queue_delete_messages(
|
|
self,
|
|
chat_id: str,
|
|
message_ids: list[str],
|
|
fire_and_forget: bool = True,
|
|
) -> None:
|
|
"""Queue or immediately bulk-delete platform messages."""
|
|
self._require_open()
|
|
ids_snapshot = tuple(str(message_id) for message_id in message_ids)
|
|
if not ids_snapshot:
|
|
return
|
|
|
|
async def _delete_many() -> None:
|
|
await self._delete_many(chat_id, list(ids_snapshot))
|
|
|
|
digest = hashlib.sha256("\x1f".join(ids_snapshot).encode()).hexdigest()[:16]
|
|
dedup_key = f"del_bulk:{chat_id}:{digest}"
|
|
if fire_and_forget:
|
|
self._limiter.fire_and_forget(_delete_many, dedup_key=dedup_key)
|
|
else:
|
|
await self._limiter.enqueue(_delete_many, dedup_key=dedup_key)
|
|
|
|
def fire_and_forget(self, task: Awaitable[Any]) -> None:
|
|
"""Run and retain arbitrary outbound work until completion or shutdown."""
|
|
future = asyncio.ensure_future(task)
|
|
if self._closed:
|
|
future.cancel()
|
|
raise RuntimeError("Platform outbox is closed.")
|
|
self._background_tasks.add(future)
|
|
future.add_done_callback(self._complete_background_task)
|
|
|
|
async def close(self) -> None:
|
|
"""Cancel and await arbitrary outbound work owned by this outbox."""
|
|
if not self._closed:
|
|
self._closed = True
|
|
tasks = tuple(self._background_tasks)
|
|
for task in tasks:
|
|
task.cancel()
|
|
try:
|
|
if tasks:
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
finally:
|
|
self._background_tasks.difference_update(
|
|
task for task in tasks if task.done()
|
|
)
|
|
|
|
def _complete_background_task(self, task: asyncio.Future[Any]) -> None:
|
|
self._background_tasks.discard(task)
|
|
if task.cancelled():
|
|
return
|
|
error = task.exception()
|
|
if error is not None:
|
|
logger.error(
|
|
"Outbound background task failed: exc_type={}",
|
|
type(error).__name__,
|
|
)
|
|
|
|
def _require_open(self) -> None:
|
|
if self._closed:
|
|
raise RuntimeError("Platform outbox is closed.")
|