alishahryar1--free-claude-code
3fba1c6fc3
## 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 -->
309 行
9.8 KiB
Python
309 行
9.8 KiB
Python
"""Deterministic manager concurrency contracts."""
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from free_claude_code.messaging.models import IncomingMessage, MessageScope
|
|
from free_claude_code.messaging.trees import (
|
|
CancellationReason,
|
|
CancellationUiOwner,
|
|
NodeClaim,
|
|
QueueEntry,
|
|
TreeQueueManager,
|
|
)
|
|
|
|
_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:
|
|
loop = asyncio.get_running_loop()
|
|
for _ in range(30):
|
|
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_one_tree_processes_fifo_with_transition_owned_queue_updates() -> None:
|
|
node_ids = ("root", "a", "b", "c")
|
|
releases = {node_id: asyncio.Event() for node_id in node_ids}
|
|
completions = {node_id: asyncio.Event() for node_id in node_ids}
|
|
started: asyncio.Queue[str] = asyncio.Queue()
|
|
started_callbacks: list[str] = []
|
|
queue_updates: list[tuple[tuple[str, int], ...]] = []
|
|
manager: TreeQueueManager
|
|
|
|
async def process(claim: NodeClaim) -> None:
|
|
node_id = claim.node.node_id
|
|
started.put_nowait(node_id)
|
|
await releases[node_id].wait()
|
|
await manager.complete_claim(claim, f"session-{node_id}")
|
|
completions[node_id].set()
|
|
|
|
async def capture_started(claim: NodeClaim) -> None:
|
|
started_callbacks.append(claim.node.node_id)
|
|
|
|
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,
|
|
node_started_callback=capture_started,
|
|
)
|
|
root = await manager.admit(_incoming("root"), "status-root")
|
|
assert root.claim is not None
|
|
identity = root.claim.identity
|
|
assert await started.get() == "root"
|
|
|
|
decisions = [
|
|
await manager.admit(
|
|
_incoming(node_id, reply_to="root"),
|
|
f"status-{node_id}",
|
|
parent_reference_id="root",
|
|
)
|
|
for node_id in node_ids[1:]
|
|
]
|
|
assert [decision.position for decision in decisions] == [1, 2, 3]
|
|
|
|
observed = ["root"]
|
|
for node_id in node_ids:
|
|
releases[node_id].set()
|
|
await completions[node_id].wait()
|
|
if node_id != node_ids[-1]:
|
|
observed.append(await started.get())
|
|
|
|
await _wait_for_no_tasks(manager)
|
|
|
|
assert observed == list(node_ids)
|
|
assert started_callbacks == ["a", "b", "c"]
|
|
assert queue_updates == [
|
|
(("b", 1), ("c", 2)),
|
|
(("c", 1),),
|
|
(),
|
|
]
|
|
snapshot = await manager.snapshot()
|
|
assert {
|
|
node_id: snapshot.trees[identity].nodes[node_id]["state"]
|
|
for node_id in node_ids
|
|
} == dict.fromkeys(node_ids, "completed")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_separate_trees_process_in_parallel() -> None:
|
|
started = {node_id: asyncio.Event() for node_id in ("one", "two")}
|
|
releases = {node_id: asyncio.Event() for node_id in ("one", "two")}
|
|
completed = {node_id: asyncio.Event() for node_id in ("one", "two")}
|
|
all_started = asyncio.Event()
|
|
active = 0
|
|
maximum_active = 0
|
|
manager: TreeQueueManager
|
|
|
|
async def process(claim: NodeClaim) -> None:
|
|
nonlocal active, maximum_active
|
|
node_id = claim.node.node_id
|
|
active += 1
|
|
maximum_active = max(maximum_active, active)
|
|
started[node_id].set()
|
|
if all(event.is_set() for event in started.values()):
|
|
all_started.set()
|
|
try:
|
|
await releases[node_id].wait()
|
|
await manager.complete_claim(claim, f"session-{node_id}")
|
|
finally:
|
|
active -= 1
|
|
completed[node_id].set()
|
|
|
|
manager = TreeQueueManager(process)
|
|
await asyncio.gather(
|
|
manager.admit(_incoming("one"), "status-one"),
|
|
manager.admit(_incoming("two"), "status-two"),
|
|
)
|
|
await all_started.wait()
|
|
|
|
assert maximum_active == 2
|
|
assert active == 2
|
|
assert manager.get_tree_count() == 2
|
|
|
|
releases["one"].set()
|
|
releases["two"].set()
|
|
await asyncio.gather(*(event.wait() for event in completed.values()))
|
|
await _wait_for_no_tasks(manager)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_all_cancels_active_and_queued_work_across_trees() -> None:
|
|
active_started = {node_id: asyncio.Event() for node_id in ("one", "two")}
|
|
processed: list[str] = []
|
|
|
|
async def process(claim: NodeClaim) -> None:
|
|
node_id = claim.node.node_id
|
|
processed.append(node_id)
|
|
if node_id in active_started:
|
|
active_started[node_id].set()
|
|
await asyncio.Event().wait()
|
|
|
|
manager = TreeQueueManager(process)
|
|
await manager.admit(_incoming("one"), "status-one")
|
|
await manager.admit(_incoming("two"), "status-two")
|
|
await asyncio.gather(*(event.wait() for event in active_started.values()))
|
|
await manager.admit(
|
|
_incoming("one-child", reply_to="one"),
|
|
"status-one-child",
|
|
parent_reference_id="one",
|
|
)
|
|
await manager.admit(
|
|
_incoming("two-child", reply_to="two"),
|
|
"status-two-child",
|
|
parent_reference_id="two",
|
|
)
|
|
|
|
result = await manager.cancel_all(reason=CancellationReason.STOP)
|
|
|
|
owners = {effect.node.node_id: effect.ui_owner for effect in result.effects}
|
|
assert owners == {
|
|
"one": CancellationUiOwner.RUNNER,
|
|
"one-child": CancellationUiOwner.WORKFLOW,
|
|
"two": CancellationUiOwner.RUNNER,
|
|
"two-child": CancellationUiOwner.WORKFLOW,
|
|
}
|
|
assert len(result.snapshots) == 2
|
|
assert {
|
|
node["state"]
|
|
for snapshot in result.snapshots
|
|
for node in snapshot.nodes.values()
|
|
} == {"error"}
|
|
assert set(processed) == {"one", "two"}
|
|
assert manager.task_count() == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branch_removal_atomically_unindexes_subtree_and_preserves_sibling() -> (
|
|
None
|
|
):
|
|
root_started = asyncio.Event()
|
|
release_root = asyncio.Event()
|
|
sibling_started = asyncio.Event()
|
|
release_sibling = asyncio.Event()
|
|
unexpected: list[str] = []
|
|
|
|
async def process(claim: NodeClaim) -> None:
|
|
node_id = claim.node.node_id
|
|
if node_id == "root":
|
|
root_started.set()
|
|
await release_root.wait()
|
|
elif node_id == "sibling":
|
|
sibling_started.set()
|
|
await release_sibling.wait()
|
|
else:
|
|
unexpected.append(node_id)
|
|
|
|
manager = TreeQueueManager(process)
|
|
await manager.admit(_incoming("root"), "status-root")
|
|
await root_started.wait()
|
|
await manager.admit(
|
|
_incoming("branch", reply_to="root"),
|
|
"status-branch",
|
|
parent_reference_id="root",
|
|
)
|
|
await manager.admit(
|
|
_incoming("leaf", reply_to="branch"),
|
|
"status-leaf",
|
|
parent_reference_id="branch",
|
|
)
|
|
await manager.admit(
|
|
_incoming("sibling", reply_to="root"),
|
|
"status-sibling",
|
|
parent_reference_id="root",
|
|
)
|
|
|
|
result = await manager.remove_message_subtree(
|
|
_SCOPE,
|
|
"branch",
|
|
reason=CancellationReason.STOP,
|
|
)
|
|
|
|
assert result.removed_tree_identity is None
|
|
assert result.delete_message_ids == frozenset(
|
|
{"branch", "status-branch", "leaf", "status-leaf"}
|
|
)
|
|
assert {
|
|
effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects
|
|
} == {
|
|
"branch": CancellationUiOwner.WORKFLOW,
|
|
"leaf": CancellationUiOwner.WORKFLOW,
|
|
}
|
|
assert len(result.cancellation.snapshots) == 1
|
|
assert set(result.cancellation.snapshots[0].nodes) == {"root", "sibling"}
|
|
assert await manager.resolve_node_id(_SCOPE, "branch") is None
|
|
assert await manager.resolve_node_id(_SCOPE, "status-leaf") is None
|
|
assert await manager.resolve_node_id(_SCOPE, "sibling") == "sibling"
|
|
|
|
release_root.set()
|
|
await asyncio.wait_for(sibling_started.wait(), timeout=1)
|
|
assert unexpected == []
|
|
release_sibling.set()
|
|
await _wait_for_no_tasks(manager)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_root_removal_atomically_cancels_and_unindexes_entire_tree() -> None:
|
|
root_started = asyncio.Event()
|
|
processed: list[str] = []
|
|
|
|
async def process(claim: NodeClaim) -> None:
|
|
processed.append(claim.node.node_id)
|
|
root_started.set()
|
|
await asyncio.Event().wait()
|
|
|
|
manager = TreeQueueManager(process)
|
|
await manager.admit(_incoming("root"), "status-root")
|
|
await root_started.wait()
|
|
await manager.admit(
|
|
_incoming("child", reply_to="root"),
|
|
"status-child",
|
|
parent_reference_id="root",
|
|
)
|
|
|
|
result = await manager.remove_message_subtree(
|
|
_SCOPE,
|
|
"root",
|
|
reason=CancellationReason.STOP,
|
|
)
|
|
|
|
assert result.removed_tree_identity is not None
|
|
assert result.removed_tree_identity.scope == _SCOPE
|
|
assert result.removed_tree_identity.root_id == "root"
|
|
assert result.delete_message_ids == frozenset(
|
|
{"root", "status-root", "child", "status-child"}
|
|
)
|
|
assert {
|
|
effect.node.node_id: effect.ui_owner for effect in result.cancellation.effects
|
|
} == {
|
|
"root": CancellationUiOwner.RUNNER,
|
|
"child": CancellationUiOwner.WORKFLOW,
|
|
}
|
|
assert result.cancellation.snapshots == ()
|
|
assert manager.get_tree_count() == 0
|
|
assert manager.task_count() == 0
|
|
assert await manager.resolve_node_id(_SCOPE, "root") is None
|
|
assert await manager.resolve_node_id(_SCOPE, "status-child") is None
|
|
assert processed == ["root"]
|