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 -->
488 行
18 KiB
Python
488 行
18 KiB
Python
"""Atomic runtime aggregate for one messaging conversation tree."""
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass
|
|
from uuid import uuid4
|
|
|
|
from loguru import logger
|
|
|
|
from ..models import MessageScope
|
|
from .graph import MessageTreeGraph
|
|
from .identity import TreeIdentity
|
|
from .node import MessageNode, MessageReferenceKind, MessageState
|
|
from .queue import MessageNodeQueue
|
|
from .snapshot import TreeSnapshot
|
|
from .transitions import (
|
|
AdmissionRejection,
|
|
CompletionResult,
|
|
FailureResult,
|
|
MessageSubtreeRemoval,
|
|
NodeClaim,
|
|
NodeUiTarget,
|
|
NodeView,
|
|
QueueDecision,
|
|
QueueEntry,
|
|
ReplyTarget,
|
|
TreeCancellation,
|
|
)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _ActiveClaim:
|
|
"""Runtime execution identity kept separate from the node's UI state."""
|
|
|
|
claim: NodeClaim
|
|
cancellation_requested: bool = False
|
|
|
|
|
|
class MessageTree:
|
|
"""Own graph, queue, claim identity, and every concurrency invariant."""
|
|
|
|
def __init__(
|
|
self,
|
|
root_node: MessageNode,
|
|
*,
|
|
graph: MessageTreeGraph | None = None,
|
|
) -> None:
|
|
self._graph = graph or MessageTreeGraph(root_node)
|
|
self._queue = MessageNodeQueue()
|
|
self._lock = asyncio.Lock()
|
|
self._active: _ActiveClaim | None = None
|
|
self._restored_snapshot: TreeSnapshot | None = None
|
|
self._restored_stale_targets: tuple[NodeUiTarget, ...] = ()
|
|
logger.debug("Created MessageTree with root {}", self.root_id)
|
|
|
|
@property
|
|
def root_id(self) -> str:
|
|
return self._graph.root_id
|
|
|
|
@property
|
|
def identity(self) -> TreeIdentity:
|
|
return self._graph.identity
|
|
|
|
@property
|
|
def restored_snapshot(self) -> TreeSnapshot | None:
|
|
"""Normalized startup snapshot captured before the tree is published."""
|
|
return self._restored_snapshot
|
|
|
|
@property
|
|
def restored_stale_targets(self) -> tuple[NodeUiTarget, ...]:
|
|
"""UI targets normalized from runnable to interrupted on restore."""
|
|
return self._restored_stale_targets
|
|
|
|
def _ui_target(self, node: MessageNode) -> NodeUiTarget:
|
|
if node.status_message_id is None:
|
|
raise ValueError("Runnable node has no status message")
|
|
return NodeUiTarget(
|
|
scope=node.scope,
|
|
node_id=node.node_id,
|
|
status_message_id=node.status_message_id,
|
|
)
|
|
|
|
def _queue_entries(self) -> tuple[QueueEntry, ...]:
|
|
entries: list[QueueEntry] = []
|
|
for node_id in self._queue.items():
|
|
node = self._graph.get_node(node_id)
|
|
if node is None or node.state is not MessageState.PENDING:
|
|
continue
|
|
entries.append(
|
|
QueueEntry(node=self._ui_target(node), position=len(entries) + 1)
|
|
)
|
|
return tuple(entries)
|
|
|
|
def _claim(self, node: MessageNode) -> NodeClaim:
|
|
node.update_state(MessageState.IN_PROGRESS)
|
|
claim = NodeClaim(
|
|
identity=self.identity,
|
|
claim_id=uuid4().hex,
|
|
node=self._ui_target(node),
|
|
prompt=node.prompt,
|
|
parent_session_id=self._graph.get_parent_session_id(node.node_id),
|
|
)
|
|
self._active = _ActiveClaim(claim=claim)
|
|
return claim
|
|
|
|
def _enqueue_or_claim(self, node_id: str) -> QueueDecision:
|
|
node = self._graph.get_node(node_id)
|
|
if node is None or node.state is not MessageState.PENDING:
|
|
return QueueDecision(
|
|
claim=None,
|
|
position=None,
|
|
snapshot=None,
|
|
rejection=AdmissionRejection.DUPLICATE,
|
|
)
|
|
|
|
if self._active is None:
|
|
claim = self._claim(node)
|
|
return QueueDecision(
|
|
claim=claim,
|
|
position=None,
|
|
snapshot=self._graph.snapshot(),
|
|
)
|
|
|
|
if not self._queue.put(node_id):
|
|
return QueueDecision(
|
|
claim=None,
|
|
position=None,
|
|
snapshot=None,
|
|
rejection=AdmissionRejection.DUPLICATE,
|
|
)
|
|
|
|
position = self._queue.qsize()
|
|
logger.info("Queued node {}, position {}", node_id, position)
|
|
return QueueDecision(
|
|
claim=None,
|
|
position=position,
|
|
snapshot=self._graph.snapshot(),
|
|
)
|
|
|
|
async def enqueue_or_claim(self, node_id: str) -> QueueDecision:
|
|
"""Atomically reject, queue, or exclusively claim an existing node."""
|
|
async with self._lock:
|
|
return self._enqueue_or_claim(node_id)
|
|
|
|
async def add_and_enqueue(
|
|
self,
|
|
node_id: str,
|
|
scope: MessageScope,
|
|
prompt: str,
|
|
status_message_id: str,
|
|
parent_id: str,
|
|
parent_reference_id: str,
|
|
) -> QueueDecision:
|
|
"""Atomically add a reply and admit it to this tree."""
|
|
async with self._lock:
|
|
self._graph.add_node(
|
|
node_id=node_id,
|
|
scope=scope,
|
|
prompt=prompt,
|
|
status_message_id=status_message_id,
|
|
parent_id=parent_id,
|
|
parent_reference_id=parent_reference_id,
|
|
)
|
|
return self._enqueue_or_claim(node_id)
|
|
|
|
async def finish_and_claim_next(self, claim_id: str) -> CompletionResult:
|
|
"""Release only the matching claim and atomically select its successor."""
|
|
async with self._lock:
|
|
if self._active is None or self._active.claim.claim_id != claim_id:
|
|
return CompletionResult(
|
|
next_claim=None,
|
|
queue=self._queue_entries(),
|
|
)
|
|
|
|
self._active = None
|
|
next_claim: NodeClaim | None = None
|
|
while node_id := self._queue.pop():
|
|
node = self._graph.get_node(node_id)
|
|
if node is not None and node.state is MessageState.PENDING:
|
|
next_claim = self._claim(node)
|
|
break
|
|
|
|
return CompletionResult(
|
|
next_claim=next_claim,
|
|
queue=self._queue_entries(),
|
|
)
|
|
|
|
async def cancel_node(
|
|
self,
|
|
node_id: str,
|
|
) -> TreeCancellation:
|
|
"""Atomically cancel one active, queued, or stale runnable node."""
|
|
async with self._lock:
|
|
node = self._graph.get_node(node_id)
|
|
active_claim = (
|
|
self._active.claim
|
|
if self._active is not None
|
|
and self._active.claim.node.node_id == node_id
|
|
else None
|
|
)
|
|
if active_claim is not None:
|
|
active = self._active
|
|
if active is not None:
|
|
active.cancellation_requested = True
|
|
if node is None:
|
|
return TreeCancellation(
|
|
nodes=(),
|
|
active_claim=active_claim,
|
|
queue_update=None,
|
|
)
|
|
|
|
queue_changed = self._queue.remove(node_id)
|
|
cancelled_nodes: tuple[NodeUiTarget, ...] = ()
|
|
if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS):
|
|
node.mark_error()
|
|
cancelled_nodes = (self._ui_target(node),)
|
|
elif node.state is MessageState.ERROR and active_claim is not None:
|
|
cancelled_nodes = (self._ui_target(node),)
|
|
return TreeCancellation(
|
|
nodes=cancelled_nodes,
|
|
active_claim=active_claim,
|
|
queue_update=self._queue_entries() if queue_changed else None,
|
|
)
|
|
|
|
async def cancel_all(
|
|
self,
|
|
) -> TreeCancellation:
|
|
"""Atomically cancel every runnable node present at the transition."""
|
|
async with self._lock:
|
|
cancelled_nodes: list[NodeUiTarget] = []
|
|
seen: set[str] = set()
|
|
active_claim: NodeClaim | None = None
|
|
|
|
if self._active is not None:
|
|
active_claim = self._active.claim
|
|
self._active.cancellation_requested = True
|
|
active_node = self._graph.get_node(active_claim.node.node_id)
|
|
if active_node is not None and active_node.state in (
|
|
MessageState.PENDING,
|
|
MessageState.IN_PROGRESS,
|
|
):
|
|
active_node.mark_error()
|
|
seen.add(active_node.node_id)
|
|
cancelled_nodes.append(self._ui_target(active_node))
|
|
elif active_node is not None:
|
|
seen.add(active_node.node_id)
|
|
if active_node.state is MessageState.ERROR:
|
|
cancelled_nodes.append(self._ui_target(active_node))
|
|
|
|
queued_ids = self._queue.drain()
|
|
for node_id in queued_ids:
|
|
node = self._graph.get_node(node_id)
|
|
if node is None or node.state not in (
|
|
MessageState.PENDING,
|
|
MessageState.IN_PROGRESS,
|
|
):
|
|
continue
|
|
node.mark_error()
|
|
seen.add(node_id)
|
|
cancelled_nodes.append(self._ui_target(node))
|
|
|
|
for node in self._graph.all_nodes():
|
|
if node.node_id in seen or node.state not in (
|
|
MessageState.PENDING,
|
|
MessageState.IN_PROGRESS,
|
|
):
|
|
continue
|
|
node.mark_error()
|
|
cancelled_nodes.append(self._ui_target(node))
|
|
|
|
return TreeCancellation(
|
|
nodes=tuple(cancelled_nodes),
|
|
active_claim=active_claim,
|
|
queue_update=() if queued_ids else None,
|
|
)
|
|
|
|
async def remove_message_subtree(
|
|
self,
|
|
reference_id: str,
|
|
) -> MessageSubtreeRemoval:
|
|
"""Atomically cancel and detach one literal platform reply subtree."""
|
|
async with self._lock:
|
|
resolved = self._graph.resolve_reference(reference_id)
|
|
reference_ids = tuple(self._graph.get_reference_descendants(reference_id))
|
|
if resolved is None or not reference_ids:
|
|
empty = TreeCancellation(
|
|
nodes=(),
|
|
active_claim=None,
|
|
queue_update=None,
|
|
)
|
|
return MessageSubtreeRemoval(
|
|
cancellation=empty,
|
|
removed_message_ids=frozenset(),
|
|
removed_entire_tree=False,
|
|
)
|
|
|
|
owner, reference_kind = resolved
|
|
removed_node_ids = {
|
|
candidate
|
|
for candidate in reference_ids
|
|
if self._graph.get_node(candidate) is not None
|
|
}
|
|
affected_node_ids = set(removed_node_ids)
|
|
if reference_kind is MessageReferenceKind.STATUS:
|
|
affected_node_ids.add(owner.node_id)
|
|
active_claim = (
|
|
self._active.claim
|
|
if self._active is not None
|
|
and self._active.claim.node.node_id in affected_node_ids
|
|
else None
|
|
)
|
|
if active_claim is not None:
|
|
active = self._active
|
|
if active is not None:
|
|
active.cancellation_requested = True
|
|
cancelled_nodes: list[NodeUiTarget] = []
|
|
queue_changed = False
|
|
|
|
for node_id in affected_node_ids:
|
|
node = self._graph.get_node(node_id)
|
|
if node is None:
|
|
continue
|
|
queue_changed = self._queue.remove(node_id) or queue_changed
|
|
if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS):
|
|
target = self._ui_target(node)
|
|
node.mark_error()
|
|
cancelled_nodes.append(target)
|
|
elif (
|
|
node.state is MessageState.ERROR
|
|
and active_claim is not None
|
|
and active_claim.node.node_id == node_id
|
|
):
|
|
cancelled_nodes.append(self._ui_target(node))
|
|
|
|
if reference_kind is MessageReferenceKind.STATUS:
|
|
self._graph.clear_status(owner.node_id)
|
|
removed_entire_tree = self.root_id in removed_node_ids
|
|
self._graph.remove_nodes(removed_node_ids)
|
|
cancellation = TreeCancellation(
|
|
nodes=tuple(cancelled_nodes),
|
|
active_claim=active_claim,
|
|
queue_update=self._queue_entries() if queue_changed else None,
|
|
)
|
|
return MessageSubtreeRemoval(
|
|
cancellation=cancellation,
|
|
removed_message_ids=frozenset(reference_ids),
|
|
removed_entire_tree=removed_entire_tree,
|
|
)
|
|
|
|
async def record_session(
|
|
self, claim_id: str, session_id: str
|
|
) -> TreeSnapshot | None:
|
|
"""Record a real CLI session only for the currently active claim."""
|
|
async with self._lock:
|
|
if (
|
|
self._active is None
|
|
or self._active.claim.claim_id != claim_id
|
|
or self._active.cancellation_requested
|
|
):
|
|
return None
|
|
node = self._graph.get_node(self._active.claim.node.node_id)
|
|
if node is None or node.state is not MessageState.IN_PROGRESS:
|
|
return None
|
|
node.update_state(MessageState.IN_PROGRESS, session_id=session_id)
|
|
return self._graph.snapshot()
|
|
|
|
async def complete_claim(
|
|
self, claim_id: str, session_id: str | None
|
|
) -> TreeSnapshot | None:
|
|
"""Mark the currently active claim complete."""
|
|
async with self._lock:
|
|
if (
|
|
self._active is None
|
|
or self._active.claim.claim_id != claim_id
|
|
or self._active.cancellation_requested
|
|
):
|
|
return None
|
|
node = self._graph.get_node(self._active.claim.node.node_id)
|
|
if node is None or node.state not in (
|
|
MessageState.IN_PROGRESS,
|
|
MessageState.ERROR,
|
|
):
|
|
return None
|
|
node.update_state(MessageState.COMPLETED, session_id=session_id)
|
|
return self._graph.snapshot()
|
|
|
|
async def fail_claim(
|
|
self,
|
|
claim_id: str,
|
|
*,
|
|
propagate: bool,
|
|
) -> FailureResult:
|
|
"""Atomically fail the active claim and its pending descendants."""
|
|
async with self._lock:
|
|
if (
|
|
self._active is None
|
|
or self._active.claim.claim_id != claim_id
|
|
or self._active.cancellation_requested
|
|
):
|
|
return FailureResult(affected=(), queue_update=None, snapshot=None)
|
|
node = self._graph.get_node(self._active.claim.node.node_id)
|
|
if node is None:
|
|
return FailureResult(affected=(), queue_update=None, snapshot=None)
|
|
|
|
affected: list[NodeUiTarget] = []
|
|
queue_changed = False
|
|
if node.state is not MessageState.COMPLETED:
|
|
if node.state is not MessageState.ERROR:
|
|
node.mark_error()
|
|
affected.append(self._ui_target(node))
|
|
|
|
if propagate:
|
|
for descendant_id in self._graph.get_descendants(node.node_id)[1:]:
|
|
child = self._graph.get_node(descendant_id)
|
|
if child is None or child.state is not MessageState.PENDING:
|
|
continue
|
|
child.mark_error()
|
|
queue_changed = (
|
|
self._queue.remove(child.node_id) or queue_changed
|
|
)
|
|
affected.append(self._ui_target(child))
|
|
|
|
return FailureResult(
|
|
affected=tuple(affected),
|
|
queue_update=self._queue_entries() if queue_changed else None,
|
|
snapshot=self._graph.snapshot(),
|
|
)
|
|
|
|
async def resolve_reply(self, reference_id: str) -> ReplyTarget | None:
|
|
"""Resolve a node/status reference without exposing the mutable graph."""
|
|
async with self._lock:
|
|
resolved = self._graph.resolve_reference(reference_id)
|
|
if resolved is None:
|
|
return None
|
|
node, reference_kind = resolved
|
|
return ReplyTarget(
|
|
node_id=node.node_id,
|
|
reference_id=reference_id,
|
|
reference_kind=reference_kind,
|
|
queue_position=(self._queue.qsize() + 1)
|
|
if self._active is not None
|
|
else None,
|
|
)
|
|
|
|
async def node_view(self, node_id: str) -> NodeView | None:
|
|
"""Return a copied node read model."""
|
|
async with self._lock:
|
|
node = self._graph.get_node(node_id)
|
|
if node is None:
|
|
return None
|
|
return NodeView(
|
|
identity=self.identity,
|
|
node_id=node.node_id,
|
|
state=node.state,
|
|
parent_id=node.parent_id,
|
|
session_id=node.session_id,
|
|
)
|
|
|
|
async def snapshot(self) -> TreeSnapshot:
|
|
"""Capture a detached persistence snapshot under the aggregate lock."""
|
|
async with self._lock:
|
|
return self._graph.snapshot()
|
|
|
|
async def message_ids_for_chat(self, platform: str, chat_id: str) -> set[str]:
|
|
"""Copy every prompt and FCC status belonging to one platform chat."""
|
|
async with self._lock:
|
|
if self.identity.scope.platform != str(platform) or (
|
|
self.identity.scope.chat_id != str(chat_id)
|
|
):
|
|
return set()
|
|
return self._graph.all_reference_ids()
|
|
|
|
@classmethod
|
|
def from_snapshot(cls, snapshot: TreeSnapshot) -> MessageTree:
|
|
"""Restore and reconcile interrupted nodes before publishing the tree."""
|
|
graph = MessageTreeGraph.from_snapshot(snapshot)
|
|
tree = cls(graph.get_root(), graph=graph)
|
|
stale_targets: list[NodeUiTarget] = []
|
|
for node in graph.all_nodes():
|
|
if node.state in (MessageState.PENDING, MessageState.IN_PROGRESS):
|
|
stale_targets.append(tree._ui_target(node))
|
|
node.mark_error()
|
|
tree._restored_stale_targets = tuple(stale_targets)
|
|
tree._restored_snapshot = graph.snapshot()
|
|
return tree
|
|
|
|
|
|
__all__ = ["MessageTree"]
|