Fix messaging task launch on uvloop (#1117)
Browse files## Problem
uvloop 0.22.1 rejects the Python 3.14-only `eager_start` task keyword,
so every Telegram and Discord message fails on Unix. Removing the
keyword alone would let eager task factories execute claims before FCC
publishes task ownership. Fixes #1108 and fixes #1115.
## Changes
| Before | After |
| --- | --- |
| Claim tasks depend on `eager_start=False`, which uvloop does not
accept. | Claim tasks use the portable event-loop contract behind an
explicit ownership gate. |
| Task execution ordering depends on event-loop keyword support. | Task
execution begins only after FCC attaches the task and completion
callback. |
| Regression coverage exercises native asyncio only. | Regression
coverage exercises a restricted task contract, eager task factories, and
real uvloop. |
| The package version is 4.5.0. | The package version is 4.5.1 with a
refreshed lockfile. |
<!-- greptile_comment -->
<details open><summary><h3>Greptile Summary</h3></summary>
This PR fixes messaging claim launch on event loops that reject
Python-specific task keywords. The main changes are:
- Removes `eager_start=False` from claim task creation.
- Adds an explicit ownership gate before claim processing starts.
- Adds tests for portable task creation, eager task factories, and
uvloop.
- Bumps the package version and lockfile entry to `4.5.1`.
</details>
<h3>Confidence Score: 5/5</h3>
This looks safe to merge after a small test hardening cleanup.
The runtime task ownership change preserves the intended launch
ordering, and no blocking issues were found in the changed runtime code.
tests/messaging/test_tree_ownership_concurrency.py needs a small
hardening update so the uvloop case skips cleanly when the optional
package is absent.
<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 executed the messaging ownership regression pytest suite to
capture runtime proof for the messaging concurrency changes.
- During the run, the first shell wrapper used PIPESTATUS\[0\] under
/bin/sh, producing a harmless Bad substitution during version evidence
collection.
- The pytest command was rerun after the substitution issue, and the run
completed with EXIT\_CODE: 0.
- The regression suite provides focused runtime evidence for the
messaging concurrency behavior, avoiding unrelated suites or external
services.
<a
href="https://app.greptile.com/trex/runs/14465005/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/messaging/trees/processor.py | Replaces the
uvloop-incompatible task keyword with an ownership event that opens
after task registration. |
| tests/messaging/test_tree_ownership_concurrency.py | Adds task-launch
tests, with one uvloop test that can fail instead of skip when uvloop is
absent. |
| pyproject.toml | Bumps the package version to `4.5.1`. |
| uv.lock | Updates the editable package version to match
`pyproject.toml`. |
</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%22ali%2Ffix-messaging-event-loop-compatibility%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22ali%2Ffix-messaging-event-loop-compatibility%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%0Atests%2Fmessaging%2Ftest_tree_ownership_concurrency.py%3A485%0A**Optional%20Uvloop%20Becomes%20Required**%0A%0AWhen%20tests%20run%20on%20Linux%20or%20macOS%20without%20the%20optional%20%60uvloop%60%20package%20installed%2C%20this%20import%20raises%20%60ModuleNotFoundError%60%20instead%20of%20skipping%20the%20uvloop-only%20case.%20A%20contributor%20or%20CI%20job%20that%20installs%20only%20the%20test%20dependencies%20can%20fail%20this%20test%20even%20though%20the%20platform%20skip%20condition%20passes.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1117&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 (1): Last reviewed commit: ["Fix messaging task launch on
uvloop"](https://github.com/alishahryar1/free-claude-code/commit/180a7255df0a43a20706cdde2f1cd2d6830921d7)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=44296382)</sub>
> Greptile also left **1 inline comment** on this PR.
<!-- /greptile_comment -->
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
-
version = "4.5.
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
|
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "free-claude-code"
|
| 7 |
+
version = "4.5.1"
|
| 8 |
description = "Local proxy connecting coding agents to OpenAI-compatible AI providers"
|
| 9 |
readme = "README.md"
|
| 10 |
requires-python = ">=3.14.0"
|
|
@@ -81,8 +81,10 @@ class TreeQueueProcessor:
|
|
| 81 |
slot = _TaskSlot(tree=tree, claim=claim)
|
| 82 |
self._tasks[key] = slot
|
| 83 |
self._idle.clear()
|
|
|
|
| 84 |
claim_runner = self._run_claim(
|
| 85 |
slot,
|
|
|
|
| 86 |
announce_started=announce_started,
|
| 87 |
queue=queue,
|
| 88 |
)
|
|
@@ -90,7 +92,6 @@ class TreeQueueProcessor:
|
|
| 90 |
task = asyncio.create_task(
|
| 91 |
claim_runner,
|
| 92 |
name=(f"messaging-claim-{claim.identity.root_id}-{claim.claim_id[:8]}"),
|
| 93 |
-
eager_start=False,
|
| 94 |
)
|
| 95 |
except BaseException:
|
| 96 |
claim_runner.close()
|
|
@@ -101,6 +102,7 @@ class TreeQueueProcessor:
|
|
| 101 |
raise
|
| 102 |
slot.task = task
|
| 103 |
task.add_done_callback(lambda _task, claim_key=key: self._task_done(claim_key))
|
|
|
|
| 104 |
|
| 105 |
def _task_done(self, key: str) -> None:
|
| 106 |
"""Recover a claim if its task was cancelled before entering its body."""
|
|
@@ -156,9 +158,11 @@ class TreeQueueProcessor:
|
|
| 156 |
self,
|
| 157 |
slot: _TaskSlot,
|
| 158 |
*,
|
|
|
|
| 159 |
announce_started: bool,
|
| 160 |
queue: tuple[QueueEntry, ...],
|
| 161 |
) -> None:
|
|
|
|
| 162 |
claim = slot.claim
|
| 163 |
try:
|
| 164 |
if announce_started:
|
|
|
|
| 81 |
slot = _TaskSlot(tree=tree, claim=claim)
|
| 82 |
self._tasks[key] = slot
|
| 83 |
self._idle.clear()
|
| 84 |
+
ownership_ready = asyncio.Event()
|
| 85 |
claim_runner = self._run_claim(
|
| 86 |
slot,
|
| 87 |
+
ownership_ready=ownership_ready,
|
| 88 |
announce_started=announce_started,
|
| 89 |
queue=queue,
|
| 90 |
)
|
|
|
|
| 92 |
task = asyncio.create_task(
|
| 93 |
claim_runner,
|
| 94 |
name=(f"messaging-claim-{claim.identity.root_id}-{claim.claim_id[:8]}"),
|
|
|
|
| 95 |
)
|
| 96 |
except BaseException:
|
| 97 |
claim_runner.close()
|
|
|
|
| 102 |
raise
|
| 103 |
slot.task = task
|
| 104 |
task.add_done_callback(lambda _task, claim_key=key: self._task_done(claim_key))
|
| 105 |
+
ownership_ready.set()
|
| 106 |
|
| 107 |
def _task_done(self, key: str) -> None:
|
| 108 |
"""Recover a claim if its task was cancelled before entering its body."""
|
|
|
|
| 158 |
self,
|
| 159 |
slot: _TaskSlot,
|
| 160 |
*,
|
| 161 |
+
ownership_ready: asyncio.Event,
|
| 162 |
announce_started: bool,
|
| 163 |
queue: tuple[QueueEntry, ...],
|
| 164 |
) -> None:
|
| 165 |
+
await ownership_ready.wait()
|
| 166 |
claim = slot.claim
|
| 167 |
try:
|
| 168 |
if announce_started:
|
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import asyncio
|
| 2 |
import contextlib
|
|
|
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
|
@@ -450,6 +451,54 @@ async def test_eager_task_factory_cannot_run_claim_before_admission_returns() ->
|
|
| 450 |
await _wait_for_no_tasks(manager)
|
| 451 |
|
| 452 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
@pytest.mark.asyncio
|
| 454 |
async def test_absorbed_pre_run_cancellation_cannot_start_node_processor() -> None:
|
| 455 |
root_started = asyncio.Event()
|
|
|
|
| 1 |
import asyncio
|
| 2 |
import contextlib
|
| 3 |
+
import sys
|
| 4 |
|
| 5 |
import pytest
|
| 6 |
|
|
|
|
| 451 |
await _wait_for_no_tasks(manager)
|
| 452 |
|
| 453 |
|
| 454 |
+
@pytest.mark.asyncio
|
| 455 |
+
async def test_claim_launch_uses_portable_event_loop_task_contract(
|
| 456 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 457 |
+
) -> None:
|
| 458 |
+
processed = asyncio.Event()
|
| 459 |
+
|
| 460 |
+
async def process(_claim: NodeClaim) -> None:
|
| 461 |
+
processed.set()
|
| 462 |
+
|
| 463 |
+
loop = asyncio.get_running_loop()
|
| 464 |
+
original_create_task = loop.create_task
|
| 465 |
+
|
| 466 |
+
def portable_create_task(coro, *, name=None, context=None):
|
| 467 |
+
return original_create_task(coro, name=name, context=context)
|
| 468 |
+
|
| 469 |
+
monkeypatch.setattr(loop, "create_task", portable_create_task)
|
| 470 |
+
manager = TreeQueueManager(process)
|
| 471 |
+
|
| 472 |
+
decision = await manager.admit(_incoming("root"), "status-root")
|
| 473 |
+
await asyncio.wait_for(manager.wait_idle(), timeout=1)
|
| 474 |
+
|
| 475 |
+
assert decision.accepted is True
|
| 476 |
+
assert processed.is_set()
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
@pytest.mark.skipif(
|
| 480 |
+
sys.platform in {"cygwin", "win32"} or sys.implementation.name == "pypy",
|
| 481 |
+
reason="uvloop is not installed on this Python platform",
|
| 482 |
+
)
|
| 483 |
+
def test_claim_launch_runs_on_uvloop() -> None:
|
| 484 |
+
uvloop = pytest.importorskip("uvloop")
|
| 485 |
+
|
| 486 |
+
async def scenario() -> None:
|
| 487 |
+
processed = asyncio.Event()
|
| 488 |
+
|
| 489 |
+
async def process(_claim: NodeClaim) -> None:
|
| 490 |
+
processed.set()
|
| 491 |
+
|
| 492 |
+
manager = TreeQueueManager(process)
|
| 493 |
+
decision = await manager.admit(_incoming("root"), "status-root")
|
| 494 |
+
await asyncio.wait_for(manager.wait_idle(), timeout=1)
|
| 495 |
+
|
| 496 |
+
assert decision.accepted is True
|
| 497 |
+
assert processed.is_set()
|
| 498 |
+
|
| 499 |
+
uvloop.run(scenario())
|
| 500 |
+
|
| 501 |
+
|
| 502 |
@pytest.mark.asyncio
|
| 503 |
async def test_absorbed_pre_run_cancellation_cannot_start_node_processor() -> None:
|
| 504 |
root_started = asyncio.Event()
|
|
@@ -561,7 +561,7 @@ wheels = [
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
-
version = "4.5.
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|
|
|
|
| 561 |
|
| 562 |
[[package]]
|
| 563 |
name = "free-claude-code"
|
| 564 |
+
version = "4.5.1"
|
| 565 |
source = { editable = "." }
|
| 566 |
dependencies = [
|
| 567 |
{ name = "aiohttp" },
|