项目文件夹

文件
Ali Khokhar 3fba1c6fc3 Give messaging clear exact subtree semantics (#1072)
## Problem

Messaging `/clear` did not follow the selected platform message
boundary. Reply clears preserved the selected user prompt, while
standalone clears preserved user-authored messages and could reset FCC
state outside the invoking chat.

## Changes

| Before | After |
| --- | --- |
| Reply `/clear` removed a logical conversation branch but retained the
selected message. | Reply `/clear` deletes the selected message and its
literal reply subtree, including the clear command. |
| Standalone `/clear` retained user prompts and voice notes while
resetting global messaging state. | Standalone `/clear` deletes every
tracked message and resets FCC state only in the invoking platform and
chat. |
| Trees recorded only logical execution parentage. | Trees separately
persist logical execution ancestry and exact prompt/status reply
ownership. |
| Clear coordination used one global admission boundary. | Per-chat
clear generations coordinate admission, voice cancellation, persistence,
and best-effort platform deletion. |
| Persistence tracked only FCC-authored clearable output. | Persistence
tracks managed inbound and outbound messages and migrates legacy
entries. |

<!-- greptile_comment -->

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

This PR gives messaging `/clear` exact per-chat and reply-subtree
behavior. The main changes are:

- Per-chat clear generations for admission and startup-notice cleanup.
- Managed inbound and outbound message tracking for deletion.
- Exact prompt/status reply ownership in message trees.
- Scoped voice cancellation and clear persistence updates.
- Updated docs, smoke coverage, and messaging tests.
</details>

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

The clear flow is mostly well-contained, with one upgrade-path issue in
legacy tree restoration.

Newly created prompt/status subtrees use the new exact reference fields
consistently, and legacy snapshots can map old status replies to prompt
references. However, reply `/clear` on an upgraded status can miss
descendants and leave stale state/messages.

src/free_claude_code/messaging/trees/snapshot.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 attempted to prepare and run a focused legacy snapshot
reproduction harness for legacy status replies detach, but tool access
was blocked before execution.
- A messaging clear smoke test harness was executed and reported a
passing result: 20 items collected and 20 passed in 1.69 seconds, with
traces for test\_reply\_clear\_uses\_literal and related paths shown in
the log.

<a
href="https://app.greptile.com/trex/runs/14131337/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/workflow.py | Adds per-chat clear
generations, managed inbound recording, scoped clears, and
startup-notice invalidation. |
| src/free_claude_code/messaging/trees/graph.py | Adds exact
prompt/status reference resolution and literal reply-subtree traversal.
|
| src/free_claude_code/messaging/trees/runtime.py | Adds exact
message-subtree removal and status-only clearing behavior. |
| src/free_claude_code/messaging/trees/snapshot.py | Adds
parent_reference_id persistence and legacy fallback; the fallback can
miss legacy status-reply descendants. |
| src/free_claude_code/messaging/session/managed_message_log.py |
Replaces the clearable output log with managed inbound and outbound
message tracking. |
| src/free_claude_code/messaging/commands.py | Routes reply and
standalone `/clear` through the new exact deletion ID flows. |

</details>

<details open><summary><h3>Flowchart</h3></summary>

<a href="#gh-light-mode-only">

```mermaid
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Incoming message] --> B{Standalone /clear?}
B -- yes --> C[Clear invoking chat]
C --> D[Cancel scoped voice work]
C --> E[Collect managed and tree message IDs]
C --> F[Advance chat clear generation]
F --> G[Detach scoped trees]
G --> H[Clear scoped session store]
H --> I[Best-effort platform deletes]
B -- no --> J[Record managed inbound message]
J --> K[Admit with stop and clear token]
K --> L{Reply /clear?}
L -- yes --> M[Resolve exact prompt or status reference]
M --> N[Remove literal reference subtree]
N --> I
L -- no --> O[Queue or run tree node]
```

</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"}}}%%
flowchart TD
A[Incoming message] --> B{Standalone /clear?}
B -- yes --> C[Clear invoking chat]
C --> D[Cancel scoped voice work]
C --> E[Collect managed and tree message IDs]
C --> F[Advance chat clear generation]
F --> G[Detach scoped trees]
G --> H[Clear scoped session store]
H --> I[Best-effort platform deletes]
B -- no --> J[Record managed inbound message]
J --> K[Admit with stop and clear token]
K --> L{Reply /clear?}
L -- yes --> M[Resolve exact prompt or status reference]
M --> N[Remove literal reference subtree]
N --> I
L -- no --> O[Queue or run tree node]
```

</a>
</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%2Fclear-message-subtree%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%2Fclear-message-subtree%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%2Fmessaging%2Ftrees%2Fsnapshot.py%3A192-193%0A**Legacy%20Status%20Replies%20Detach**%0A%0AWhen%20an%20upgraded%20legacy%20snapshot%20contains%20a%20child%20that%20originally%20replied%20to%20its%20parent%20status%2C%20this%20fallback%20rewrites%20the%20missing%20exact%20reference%20to%20the%20parent%20prompt.%20A%20later%20reply%20%60%2Fclear%60%20on%20that%20status%20traverses%20from%20the%20status%20ID%2C%20finds%20no%20migrated%20child%20edge%2C%20and%20leaves%20the%20old%20status-reply%20descendants%20and%20their%20managed%20messages%20behind.%0A%0A&repo=alishahryar1%2Ffree-claude-code&pr=1072&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: ["Give messaging clear exact
subtree
seman..."](https://github.com/alishahryar1/free-claude-code/commit/0a3baecf9470da4bb866c864c1d1f01abd517085)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=43593126)</sub>

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

<!-- /greptile_comment -->
2026-07-11 22:26:16 -07:00

654 行
22 KiB
Python

import asyncio
import pytest
from free_claude_code.messaging.models import MessageScope
from free_claude_code.messaging.voice import (
PendingVoiceClaim,
PendingVoiceRegistry,
VoiceCancellationResult,
VoiceHandoffOutcome,
)
TELEGRAM_CHAT = MessageScope(platform="telegram", chat_id="chat")
DISCORD_CHAT = MessageScope(platform="discord", chat_id="chat")
class AsyncNoop:
async def __call__(self) -> None:
return None
class FatalVoiceFailure(BaseException):
pass
async def _reserve_bound(
registry: PendingVoiceRegistry,
*,
scope: MessageScope = TELEGRAM_CHAT,
voice_id: str = "voice-1",
status_id: str = "status-1",
) -> PendingVoiceClaim:
claim = await registry.reserve(scope, voice_id)
assert claim is not None
assert await registry.bind_status(claim, status_id) is True
return claim
@pytest.mark.asyncio
async def test_cancel_before_status_binding_rejects_late_flow() -> None:
registry = PendingVoiceRegistry()
claim = await registry.reserve(TELEGRAM_CHAT, "voice-1")
callback_called = False
async def callback() -> None:
nonlocal callback_called
callback_called = True
assert claim is not None
cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1")
assert cancellation is not None
assert cancellation == VoiceCancellationResult(
scope=TELEGRAM_CHAT,
voice_message_id="voice-1",
status_message_id=None,
delete_message_ids=frozenset({"voice-1"}),
)
assert cancellation.delete_message_ids == frozenset({"voice-1"})
assert await registry.bind_status(claim, "status-1") is False
assert await registry.handoff(claim, callback) is VoiceHandoffOutcome.REJECTED
assert callback_called is False
@pytest.mark.asyncio
async def test_handoff_remains_addressable_until_callback_completes() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
await release.wait()
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None
release.set()
assert await asyncio.wait_for(handoff_task, timeout=1) is (
VoiceHandoffOutcome.COMPLETED
)
assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None
@pytest.mark.asyncio
async def test_cancel_removes_then_drains_handoff_without_holding_lock() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
cancellation_received = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_received.set()
await release.wait()
raise
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1"))
await asyncio.wait_for(cancellation_received.wait(), timeout=1)
assert not cancel_task.done()
replacement = await asyncio.wait_for(
registry.reserve(TELEGRAM_CHAT, "voice-1"), timeout=1
)
assert replacement is not None
assert await registry.bind_status(replacement, "status-new") is True
release.set()
cancellation = await asyncio.wait_for(cancel_task, timeout=1)
assert cancellation is not None
assert cancellation == VoiceCancellationResult(
scope=TELEGRAM_CHAT,
voice_message_id="voice-1",
status_message_id="status-1",
delete_message_ids=frozenset({"status-1"}),
)
assert cancellation.delete_message_ids == frozenset({"status-1"})
assert await asyncio.wait_for(handoff_task, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None
@pytest.mark.asyncio
async def test_voice_reference_authorizes_voice_and_status_deletion() -> None:
registry = PendingVoiceRegistry()
await _reserve_bound(registry)
cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1")
assert cancellation is not None
assert cancellation.delete_message_ids == frozenset({"voice-1", "status-1"})
@pytest.mark.asyncio
async def test_cancel_scope_preserves_other_platform_chats() -> None:
registry = PendingVoiceRegistry()
await _reserve_bound(registry)
await _reserve_bound(
registry,
scope=DISCORD_CHAT,
voice_id="voice-2",
status_id="status-2",
)
cancellations = await registry.cancel_scope(TELEGRAM_CHAT)
assert len(cancellations) == 1
assert cancellations[0].delete_message_ids == frozenset({"voice-1", "status-1"})
assert await registry.cancel(DISCORD_CHAT, "status-2") is not None
@pytest.mark.asyncio
async def test_handoff_propagates_callback_error_when_completion_wins() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
async def callback() -> None:
raise RuntimeError("handler failed")
with pytest.raises(RuntimeError, match="handler failed"):
await registry.handoff(claim, callback)
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_handoff_suppresses_late_callback_error_when_cancellation_wins() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise RuntimeError("late handler failure") from None
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None
assert await asyncio.wait_for(handoff_task, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
@pytest.mark.asyncio
async def test_handoff_caller_cancellation_drains_child_and_propagates() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
cancellation_received = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_received.set()
await release.wait()
raise
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
handoff_task.cancel()
await asyncio.wait_for(cancellation_received.wait(), timeout=1)
assert handoff_task.cancelling() == 1
assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None
assert await registry.reserve(TELEGRAM_CHAT, "status-1") is None
handoff_task.cancel()
await asyncio.sleep(0)
assert handoff_task.cancelling() == 2
handoff_task.cancel()
await asyncio.sleep(0)
assert handoff_task.cancelling() == 3
assert not handoff_task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await handoff_task
assert handoff_task.cancelling() == 3
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_handoff_drains_child_when_cancellation_is_already_pending() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
finished = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await release.wait()
finally:
finished.set()
async def cancelled_caller() -> VoiceHandoffOutcome:
current = asyncio.current_task()
assert current is not None
current.cancel()
return await registry.handoff(claim, callback)
handoff_task = asyncio.create_task(cancelled_caller())
try:
with pytest.raises(asyncio.CancelledError):
await handoff_task
assert started.is_set()
assert finished.is_set()
assert handoff_task.cancelling() == 1
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
finally:
release.set()
await asyncio.sleep(0)
@pytest.mark.asyncio
async def test_external_cancel_owns_aliases_during_caller_cancelled_drain() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
caller_cancellation_received = asyncio.Event()
external_cancellation_received = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
caller_cancellation_received.set()
try:
await release.wait()
except asyncio.CancelledError:
external_cancellation_received.set()
await release.wait()
raise
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
handoff_task.cancel()
await asyncio.wait_for(caller_cancellation_received.wait(), timeout=1)
cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "status-1"))
await asyncio.wait_for(external_cancellation_received.wait(), timeout=1)
replacement = await registry.reserve(TELEGRAM_CHAT, "voice-1")
assert replacement is not None
assert await registry.bind_status(replacement, "status-new") is True
release.set()
cancellation = await asyncio.wait_for(cancel_task, timeout=1)
assert cancellation == VoiceCancellationResult(
scope=TELEGRAM_CHAT,
voice_message_id="voice-1",
status_message_id="status-1",
delete_message_ids=frozenset({"status-1"}),
)
with pytest.raises(asyncio.CancelledError):
await handoff_task
assert await registry.cancel(TELEGRAM_CHAT, "status-new") is not None
@pytest.mark.asyncio
async def test_handoff_finalization_survives_repeated_caller_cancellation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
finalization_started = asyncio.Event()
release_finalization = asyncio.Event()
complete_if_owned = registry._complete_if_owned
async def gated_completion(entry) -> bool:
finalization_started.set()
await release_finalization.wait()
return await complete_if_owned(entry)
monkeypatch.setattr(registry, "_complete_if_owned", gated_completion)
handoff_task = asyncio.create_task(registry.handoff(claim, AsyncNoop()))
await asyncio.wait_for(finalization_started.wait(), timeout=1)
handoff_task.cancel()
await asyncio.sleep(0)
handoff_task.cancel()
await asyncio.sleep(0)
assert not handoff_task.done()
release_finalization.set()
with pytest.raises(asyncio.CancelledError):
await handoff_task
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_cancel_drain_survives_repeated_caller_cancellation() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
child_cancelled = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
child_cancelled.set()
await release.wait()
raise
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
cancel_task = asyncio.create_task(registry.cancel(TELEGRAM_CHAT, "voice-1"))
await asyncio.wait_for(child_cancelled.wait(), timeout=1)
cancel_task.cancel()
await asyncio.sleep(0)
cancel_task.cancel()
await asyncio.sleep(0)
assert not cancel_task.done()
release.set()
with pytest.raises(asyncio.CancelledError):
await cancel_task
assert await asyncio.wait_for(handoff_task, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_timeout_and_independent_cancellation_remain_distinct() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
child_cancelled = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
child_cancelled.set()
await release.wait()
raise
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
async def timed_cancel() -> VoiceCancellationResult | None:
async with asyncio.timeout(0.01):
return await registry.cancel(TELEGRAM_CHAT, "voice-1")
cancel_task = asyncio.create_task(timed_cancel())
await asyncio.wait_for(child_cancelled.wait(), timeout=1)
await asyncio.sleep(0.05)
assert cancel_task.cancelling() == 1
cancel_task.cancel()
await asyncio.sleep(0)
assert cancel_task.cancelling() == 2
release.set()
with pytest.raises(asyncio.CancelledError):
await cancel_task
assert await asyncio.wait_for(handoff_task, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
@pytest.mark.asyncio
async def test_reentrant_callback_cancellation_does_not_join_a_cycle() -> None:
registry = PendingVoiceRegistry()
first = await _reserve_bound(
registry,
voice_id="voice-1",
status_id="status-1",
)
second = await _reserve_bound(
registry,
voice_id="voice-2",
status_id="status-2",
)
second_started = asyncio.Event()
first_cancellation: VoiceCancellationResult | None = None
second_cancellation: VoiceCancellationResult | None = None
async def first_callback() -> None:
nonlocal second_cancellation
await second_started.wait()
second_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-2")
async def second_callback() -> None:
nonlocal first_cancellation
second_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
first_cancellation = await registry.cancel(TELEGRAM_CHAT, "voice-1")
raise
first_handoff = asyncio.create_task(registry.handoff(first, first_callback))
second_handoff = asyncio.create_task(registry.handoff(second, second_callback))
assert await asyncio.wait_for(first_handoff, timeout=1) is (
VoiceHandoffOutcome.COMPLETED
)
assert await asyncio.wait_for(second_handoff, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
assert first_cancellation is None
assert second_cancellation is not None
assert second_cancellation.voice_message_id == "voice-2"
@pytest.mark.asyncio
async def test_nested_callback_task_cannot_cancel_its_own_claim() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
cancellation: VoiceCancellationResult | None = None
async def nested_cancel() -> None:
nonlocal cancellation
cancellation = await registry.cancel(TELEGRAM_CHAT, "status-1")
async def callback() -> None:
await asyncio.create_task(nested_cancel())
assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is (
VoiceHandoffOutcome.COMPLETED
)
assert cancellation is None
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_nested_callback_task_is_excluded_from_bulk_cancellation() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
cancellations: tuple[VoiceCancellationResult, ...] | None = None
async def nested_cancel_all() -> None:
nonlocal cancellations
cancellations = await registry.cancel_all()
async def callback() -> None:
await asyncio.create_task(nested_cancel_all())
assert await asyncio.wait_for(registry.handoff(claim, callback), timeout=1) is (
VoiceHandoffOutcome.COMPLETED
)
assert cancellations == ()
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
@pytest.mark.asyncio
async def test_fatal_callback_failure_releases_aliases_and_propagates() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
async def callback() -> None:
raise FatalVoiceFailure
with pytest.raises(FatalVoiceFailure):
await registry.handoff(claim, callback)
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None
@pytest.mark.asyncio
async def test_fatal_failure_after_caller_cancellation_releases_aliases() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
cancellation_received = asyncio.Event()
release = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_received.set()
await release.wait()
raise FatalVoiceFailure from None
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
handoff_task.cancel()
await asyncio.wait_for(cancellation_received.wait(), timeout=1)
assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None
release.set()
with pytest.raises(FatalVoiceFailure):
await handoff_task
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is None
assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None
@pytest.mark.asyncio
async def test_fatal_loser_failure_is_retrieved_and_propagated() -> None:
registry = PendingVoiceRegistry()
claim = await _reserve_bound(registry)
started = asyncio.Event()
async def callback() -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise FatalVoiceFailure from None
handoff_task = asyncio.create_task(registry.handoff(claim, callback))
await asyncio.wait_for(started.wait(), timeout=1)
with pytest.raises(FatalVoiceFailure):
await registry.cancel(TELEGRAM_CHAT, "voice-1")
with pytest.raises(FatalVoiceFailure):
await handoff_task
assert await registry.cancel(TELEGRAM_CHAT, "status-1") is None
@pytest.mark.asyncio
async def test_cancel_all_deduplicates_aliases_and_excludes_current_child() -> None:
registry = PendingVoiceRegistry()
first = await _reserve_bound(registry, voice_id="voice-1", status_id="status-1")
second = await _reserve_bound(
registry,
scope=DISCORD_CHAT,
voice_id="voice-2",
status_id="status-2",
)
first_started = asyncio.Event()
first_cancelled = asyncio.Event()
cancellations: tuple[VoiceCancellationResult, ...] | None = None
async def first_callback() -> None:
first_started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
first_cancelled.set()
raise
async def second_callback() -> None:
nonlocal cancellations
await first_started.wait()
cancellations = await registry.cancel_all()
first_handoff = asyncio.create_task(registry.handoff(first, first_callback))
second_handoff = asyncio.create_task(registry.handoff(second, second_callback))
assert await asyncio.wait_for(first_handoff, timeout=1) is (
VoiceHandoffOutcome.CANCELLED
)
assert await asyncio.wait_for(second_handoff, timeout=1) is (
VoiceHandoffOutcome.COMPLETED
)
assert first_cancelled.is_set()
assert cancellations is not None
assert len(cancellations) == 1
assert {result.scope for result in cancellations} == {TELEGRAM_CHAT}
assert {result.delete_message_ids for result in cancellations} == {
frozenset({"voice-1", "status-1"}),
}
@pytest.mark.asyncio
async def test_stale_claim_cannot_mutate_reused_voice_id() -> None:
registry = PendingVoiceRegistry()
stale_claim = await registry.reserve(TELEGRAM_CHAT, "voice-1")
assert stale_claim is not None
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None
current_claim = await _reserve_bound(
registry, voice_id="voice-1", status_id="status-current"
)
assert current_claim != stale_claim
assert await registry.bind_status(stale_claim, "status-stale") is False
assert (
await registry.handoff(stale_claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED
)
assert await registry.discard(stale_claim) is False
assert await registry.cancel(TELEGRAM_CHAT, "status-current") is not None
@pytest.mark.asyncio
async def test_pending_voice_registry_rejects_duplicate_and_unbound_handoff() -> None:
registry = PendingVoiceRegistry()
claim = await registry.reserve(TELEGRAM_CHAT, "voice-1")
assert claim is not None
assert await registry.reserve(TELEGRAM_CHAT, "voice-1") is None
assert await registry.handoff(claim, AsyncNoop()) is VoiceHandoffOutcome.REJECTED
assert await registry.cancel(TELEGRAM_CHAT, "voice-1") is not None