项目文件夹

文件
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

439 行
13 KiB
Python

"""Manager-level task and cancellation ownership tests."""
import asyncio
import logging
import pytest
from free_claude_code.messaging.models import IncomingMessage, MessageScope
from free_claude_code.messaging.trees import (
CancellationReason,
CancellationUiOwner,
FailureResult,
MessageState,
NodeClaim,
QueueEntry,
TreeQueueManager,
)
from free_claude_code.messaging.trees import manager as manager_module
from free_claude_code.messaging.trees import processor as processor_module
from free_claude_code.messaging.trees.node import MessageNode
from free_claude_code.messaging.trees.processor import TreeQueueProcessor
from free_claude_code.messaging.trees.runtime import MessageTree
_SCOPE = MessageScope(platform="telegram", chat_id="chat")
def _incoming(node_id: str, *, reply_to: str | None = None) -> IncomingMessage:
return IncomingMessage(
text=f"prompt {node_id}",
chat_id=_SCOPE.chat_id,
user_id="user",
message_id=node_id,
platform=_SCOPE.platform,
reply_to_message_id=reply_to,
)
async def _wait_for_no_tasks(manager: TreeQueueManager) -> None:
"""Yield deterministic ready-queue checkpoints until task cleanup completes."""
loop = asyncio.get_running_loop()
for _ in range(20):
if manager.task_count() == 0:
return
checkpoint = asyncio.Event()
loop.call_soon(checkpoint.set)
await checkpoint.wait()
assert manager.task_count() == 0
@pytest.mark.asyncio
async def test_active_cancel_returns_runner_owned_effect_and_terminal_snapshot() -> (
None
):
started = asyncio.Event()
async def process(_claim: NodeClaim) -> None:
started.set()
await asyncio.Event().wait()
manager = TreeQueueManager(process)
await manager.admit(_incoming("root"), "status-root")
await started.wait()
result = await manager.cancel_node(
_SCOPE,
"root",
reason=CancellationReason.STOP,
)
assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [
("root", CancellationUiOwner.RUNNER)
]
assert len(result.snapshots) == 1
assert result.snapshots[0].nodes["root"]["state"] == "error"
view = await manager.get_node(_SCOPE, "root")
assert view is not None and view.state is MessageState.ERROR
assert manager.task_count() == 0
@pytest.mark.asyncio
async def test_queued_cancel_returns_workflow_effect_and_exact_queue_update() -> None:
release_root = asyncio.Event()
root_started = asyncio.Event()
child_started = asyncio.Event()
queue_updates: list[tuple[tuple[str, int], ...]] = []
async def process(claim: NodeClaim) -> None:
if claim.node.node_id == "root":
root_started.set()
await release_root.wait()
else:
child_started.set()
async def capture_queue(queue: tuple[QueueEntry, ...]) -> None:
queue_updates.append(
tuple((entry.node.node_id, entry.position) for entry in queue)
)
manager = TreeQueueManager(process, queue_update_callback=capture_queue)
await manager.admit(_incoming("root"), "status-root")
await root_started.wait()
decision = await manager.admit(
_incoming("child", reply_to="root"),
"status-child",
parent_reference_id="root",
)
assert decision.position == 1
result = await manager.cancel_node(
_SCOPE,
"child",
reason=CancellationReason.STOP,
)
assert [(effect.node.node_id, effect.ui_owner) for effect in result.effects] == [
("child", CancellationUiOwner.WORKFLOW)
]
assert queue_updates == [()]
assert result.snapshots[0].nodes["child"]["state"] == "error"
assert child_started.is_set() is False
release_root.set()
await _wait_for_no_tasks(manager)
assert child_started.is_set() is False
@pytest.mark.asyncio
async def test_cancel_cleanup_timeout_is_bounded_and_task_remains_owned(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(manager_module, "CANCEL_TASK_DRAIN_TIMEOUT_S", 0.01)
started = asyncio.Event()
cancellation_seen = asyncio.Event()
release_cleanup = asyncio.Event()
async def process(_claim: NodeClaim) -> None:
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
cancellation_seen.set()
await release_cleanup.wait()
raise
manager = TreeQueueManager(process)
await manager.admit(_incoming("root"), "status-root")
await started.wait()
try:
result = await asyncio.wait_for(
manager.cancel_node(
_SCOPE,
"root",
reason=CancellationReason.STOP,
),
timeout=0.5,
)
await cancellation_seen.wait()
assert result.effects[0].node.node_id == "root"
assert manager.task_count() == 1
finally:
release_cleanup.set()
await _wait_for_no_tasks(manager)
@pytest.mark.asyncio
async def test_escaped_processor_failure_persists_effects_through_manager_owner() -> (
None
):
started = asyncio.Event()
release = asyncio.Event()
failures: list[FailureResult] = []
queue_updates: list[tuple[QueueEntry, ...]] = []
async def process(claim: NodeClaim) -> None:
if claim.node.node_id == "root":
started.set()
await release.wait()
raise RuntimeError("processor boundary failed")
async def capture_queue(queue: tuple[QueueEntry, ...]) -> None:
queue_updates.append(queue)
manager = TreeQueueManager(
process,
queue_update_callback=capture_queue,
unexpected_failure_callback=failures.append,
)
await manager.admit(_incoming("root"), "status-root")
await started.wait()
await manager.admit(
_incoming("child", reply_to="root"),
"status-child",
parent_reference_id="root",
)
release.set()
await _wait_for_no_tasks(manager)
assert len(failures) == 1
failure = failures[0]
assert failure.snapshot is not None
assert {target.node_id for target in failure.affected} == {"root", "child"}
assert failure.snapshot.nodes["root"]["state"] == "error"
assert failure.snapshot.nodes["child"]["state"] == "error"
assert queue_updates == [()]
@pytest.mark.asyncio
async def test_wait_idle_spans_successor_publication_and_completion() -> None:
root_started = asyncio.Event()
release_root = asyncio.Event()
child_started = asyncio.Event()
release_child = asyncio.Event()
async def process(claim: NodeClaim) -> None:
if claim.node.node_id == "root":
root_started.set()
await release_root.wait()
return
child_started.set()
await release_child.wait()
manager = TreeQueueManager(process)
await asyncio.wait_for(manager.wait_idle(), timeout=0.1)
await manager.admit(_incoming("root"), "status-root")
await asyncio.wait_for(root_started.wait(), timeout=1)
await manager.admit(
_incoming("child", reply_to="root"),
"status-child",
parent_reference_id="root",
)
idle_task = asyncio.create_task(manager.wait_idle())
try:
await asyncio.sleep(0)
assert not idle_task.done()
release_root.set()
await asyncio.wait_for(child_started.wait(), timeout=1)
assert not idle_task.done()
release_child.set()
await asyncio.wait_for(idle_task, timeout=1)
assert manager.task_count() == 0
finally:
release_root.set()
release_child.set()
if not idle_task.done():
idle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await idle_task
@pytest.mark.asyncio
async def test_wait_idle_spans_pre_run_cancellation_recovery() -> None:
finish_started = asyncio.Event()
release_finish = asyncio.Event()
async def process(_claim: NodeClaim) -> None:
raise AssertionError("pre-run cancellation must not enter the processor")
async def fail_claim(_claim: NodeClaim) -> None:
raise AssertionError("cancellation must not fail the claim")
async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
finish_started.set()
await release_finish.wait()
tree = MessageTree(
MessageNode(
node_id="root",
scope=_SCOPE,
prompt="prompt root",
status_message_id="status-root",
)
)
decision = await tree.enqueue_or_claim("root")
assert decision.claim is not None
processor = TreeQueueProcessor(
process,
claim_failure_callback=fail_claim,
claim_finished_callback=finish_claim,
)
processor.launch(tree, decision.claim)
cancelled = processor.cancel(decision.claim, CancellationReason.STOP)
assert cancelled is not None
assert cancelled.runner_started is False
idle_task = asyncio.create_task(processor.wait_idle())
try:
await asyncio.wait_for(finish_started.wait(), timeout=1)
assert not idle_task.done()
release_finish.set()
await asyncio.wait_for(cancelled.task, timeout=1)
await asyncio.wait_for(idle_task, timeout=1)
assert processor.task_count() == 0
finally:
release_finish.set()
if not idle_task.done():
idle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await idle_task
@pytest.mark.asyncio
@pytest.mark.parametrize(
("log_messaging_error_details", "secret_is_logged"),
[(False, False), (True, True)],
)
async def test_wait_idle_surfaces_finish_failure_without_leaking_task_owner(
caplog: pytest.LogCaptureFixture,
log_messaging_error_details: bool,
secret_is_logged: bool,
) -> None:
secret = "unique-finish-callback-secret"
finish_error = RuntimeError(secret)
finish_calls = 0
async def process(_claim: NodeClaim) -> None:
return
async def fail_claim(_claim: NodeClaim) -> None:
raise AssertionError("successful processing must not fail the claim")
async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
nonlocal finish_calls
finish_calls += 1
raise finish_error
tree = MessageTree(
MessageNode(
node_id="root",
scope=_SCOPE,
prompt="prompt root",
status_message_id="status-root",
)
)
decision = await tree.enqueue_or_claim("root")
assert decision.claim is not None
processor = TreeQueueProcessor(
process,
claim_failure_callback=fail_claim,
claim_finished_callback=finish_claim,
log_messaging_error_details=log_messaging_error_details,
)
with caplog.at_level(logging.ERROR):
processor.launch(tree, decision.claim)
with pytest.raises(RuntimeError) as raised:
await asyncio.wait_for(processor.wait_idle(), timeout=1)
assert raised.value is finish_error
assert finish_calls == 1
assert processor.task_count() == 0
await asyncio.wait_for(processor.wait_idle(), timeout=0.1)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "Claim completion callback failed for node root" in messages
assert "RuntimeError" in messages
assert (secret in messages) is secret_is_logged
@pytest.mark.asyncio
async def test_failed_launch_rolls_idle_state_back(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def process(_claim: NodeClaim) -> None:
return
async def fail_claim(_claim: NodeClaim) -> None:
return
async def finish_claim(_tree: MessageTree, _claim: NodeClaim) -> None:
return
tree = MessageTree(
MessageNode(
node_id="root",
scope=_SCOPE,
prompt="prompt root",
status_message_id="status-root",
)
)
decision = await tree.enqueue_or_claim("root")
assert decision.claim is not None
processor = TreeQueueProcessor(
process,
claim_failure_callback=fail_claim,
claim_finished_callback=finish_claim,
)
def fail_create_task(*_args: object, **_kwargs: object) -> None:
raise RuntimeError("launch failed")
monkeypatch.setattr(
processor_module.asyncio,
"create_task",
fail_create_task,
)
with pytest.raises(RuntimeError, match="launch failed"):
processor.launch(tree, decision.claim)
assert processor.task_count() == 0
await asyncio.wait_for(processor.wait_idle(), timeout=0.1)
@pytest.mark.asyncio
@pytest.mark.parametrize(
("log_messaging_error_details", "secret_is_logged"),
[(False, False), (True, True)],
)
async def test_processor_failure_logging_respects_diagnostic_policy(
caplog: pytest.LogCaptureFixture,
log_messaging_error_details: bool,
secret_is_logged: bool,
) -> None:
secret = "unique-processor-exception-secret"
async def process(_claim: NodeClaim) -> None:
raise RuntimeError(secret)
manager = TreeQueueManager(
process,
log_messaging_error_details=log_messaging_error_details,
)
with caplog.at_level(logging.ERROR):
await manager.admit(_incoming("root"), "status-root")
await asyncio.wait_for(manager.wait_idle(), timeout=1)
messages = "\n".join(record.getMessage() for record in caplog.records)
assert "Error processing node root" in messages
assert "RuntimeError" in messages
assert (secret in messages) is secret_is_logged