Spaces:
Sleeping
Sleeping
| """Run queued messaging nodes through a managed CLI session.""" | |
| import asyncio | |
| from collections.abc import Callable | |
| from loguru import logger | |
| from free_claude_code.core.diagnostics import ( | |
| format_user_error_preview, | |
| safe_exception_message, | |
| ) | |
| from free_claude_code.core.trace import trace_event | |
| from .event_parser import parse_cli_event | |
| from .managed_protocols import ManagedClaudeSessionManagerProtocol | |
| from .node_event_pipeline import handle_session_info_event, process_parsed_cli_event | |
| from .platforms.ports import OutboundMessenger | |
| from .safe_diagnostics import format_exception_for_log | |
| from .session import SessionStore | |
| from .transcript import RenderCtx, TranscriptBuffer | |
| from .trees import CancellationReason, NodeClaim, TreeQueueManager, TreeSnapshot | |
| from .ui_updates import ThrottledTranscriptEditor | |
| class MessagingNodeRunner: | |
| """Owns the lifecycle of one queued messaging node.""" | |
| def __init__( | |
| self, | |
| *, | |
| platform_name: str, | |
| outbound: OutboundMessenger, | |
| cli_manager: ManagedClaudeSessionManagerProtocol, | |
| session_store: SessionStore, | |
| get_tree_queue: Callable[[], TreeQueueManager], | |
| format_status: Callable[[str, str, str | None], str], | |
| get_parse_mode: Callable[[], str | None], | |
| get_render_ctx: Callable[[], RenderCtx], | |
| get_limit_chars: Callable[[], int], | |
| debug_platform_edits: bool = False, | |
| debug_subagent_stack: bool = False, | |
| log_raw_cli_diagnostics: bool = False, | |
| log_messaging_error_details: bool = False, | |
| ) -> None: | |
| self.platform_name = platform_name | |
| self.outbound = outbound | |
| self.cli_manager = cli_manager | |
| self.session_store = session_store | |
| self._get_tree_queue = get_tree_queue | |
| self._format_status = format_status | |
| self._get_parse_mode = get_parse_mode | |
| self._get_render_ctx = get_render_ctx | |
| self._get_limit_chars = get_limit_chars | |
| self._debug_platform_edits = debug_platform_edits | |
| self._debug_subagent_stack = debug_subagent_stack | |
| self._log_raw_cli_diagnostics = log_raw_cli_diagnostics | |
| self._log_messaging_error_details = log_messaging_error_details | |
| def _create_transcript_and_render_ctx( | |
| self, | |
| ) -> tuple[TranscriptBuffer, RenderCtx]: | |
| """Create transcript buffer and render context for node processing.""" | |
| transcript = TranscriptBuffer( | |
| show_tool_results=False, | |
| debug_subagent_stack=self._debug_subagent_stack, | |
| ) | |
| return transcript, self._get_render_ctx() | |
| def _save_snapshot(self, snapshot: TreeSnapshot | None) -> None: | |
| """Persist a snapshot returned by the active aggregate manager.""" | |
| if snapshot is None: | |
| return | |
| self.session_store.save_tree_snapshot(snapshot) | |
| async def _record_session(self, claim: NodeClaim, session_id: str) -> None: | |
| snapshot = await self._get_tree_queue().record_session(claim, session_id) | |
| self._save_snapshot(snapshot) | |
| async def _complete_claim( | |
| self, | |
| claim: NodeClaim, | |
| session_id: str | None, | |
| ) -> None: | |
| snapshot = await self._get_tree_queue().complete_claim(claim, session_id) | |
| self._save_snapshot(snapshot) | |
| async def _fail_claim( | |
| self, | |
| claim: NodeClaim, | |
| *, | |
| propagate: bool, | |
| child_status_text: str | None = None, | |
| ) -> None: | |
| result = await self._get_tree_queue().fail_claim( | |
| claim, | |
| propagate=propagate, | |
| ) | |
| self._save_snapshot(result.snapshot) | |
| if child_status_text is None: | |
| return | |
| for child in result.affected: | |
| if child.node_id == claim.node.node_id: | |
| continue | |
| self.outbound.fire_and_forget( | |
| self.outbound.queue_edit_message( | |
| child.scope.chat_id, | |
| child.status_message_id, | |
| self._format_status("❌", "Cancelled:", child_status_text), | |
| parse_mode=self._get_parse_mode(), | |
| ) | |
| ) | |
| async def process_node( | |
| self, | |
| claim: NodeClaim, | |
| ) -> None: | |
| """Core task processor for a single CLI interaction.""" | |
| node_id = claim.node.node_id | |
| status_msg_id = claim.node.status_message_id | |
| chat_id = claim.node.scope.chat_id | |
| with logger.contextualize(node_id=node_id, chat_id=chat_id): | |
| await self._process_node_impl(claim, chat_id, status_msg_id) | |
| async def _process_node_impl( | |
| self, | |
| claim: NodeClaim, | |
| chat_id: str, | |
| status_msg_id: str, | |
| ) -> None: | |
| """Internal implementation of process_node with context bound.""" | |
| node_id = claim.node.node_id | |
| transcript, render_ctx = self._create_transcript_and_render_ctx() | |
| had_transcript_events = False | |
| non_exit_error: str | None = None | |
| terminal_seen = False | |
| captured_session_id = None | |
| temp_session_id = None | |
| last_status: str | None = None | |
| parent_session_id = claim.parent_session_id | |
| platform_nm = self.platform_name | |
| if parent_session_id: | |
| trace_event( | |
| stage="claude_cli", | |
| event="claude_cli.fork.from_parent_session", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| parent_session_id=parent_session_id, | |
| ) | |
| editor = ThrottledTranscriptEditor( | |
| outbound=self.outbound, | |
| parse_mode=self._get_parse_mode(), | |
| get_limit_chars=self._get_limit_chars, | |
| transcript=transcript, | |
| render_ctx=render_ctx, | |
| node_id=node_id, | |
| chat_id=chat_id, | |
| status_msg_id=status_msg_id, | |
| debug_platform_edits=self._debug_platform_edits, | |
| log_messaging_error_details=self._log_messaging_error_details, | |
| ) | |
| async def update_ui(status: str | None = None, force: bool = False) -> None: | |
| await editor.update(status, force=force) | |
| try: | |
| try: | |
| ( | |
| cli_session, | |
| session_or_temp_id, | |
| is_new, | |
| ) = await self.cli_manager.get_or_create_session( | |
| session_id=parent_session_id | |
| ) | |
| if is_new: | |
| temp_session_id = session_or_temp_id | |
| else: | |
| captured_session_id = session_or_temp_id | |
| sess_evt = ( | |
| "claude_cli.session.pending_created" | |
| if is_new | |
| else "claude_cli.session.reused" | |
| ) | |
| trace_event( | |
| stage="claude_cli", | |
| event=sess_evt, | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| status_message_id=status_msg_id, | |
| session_handle=str(session_or_temp_id), | |
| parent_resume_session_id=parent_session_id, | |
| fork_requested=bool(parent_session_id), | |
| ) | |
| trace_event( | |
| stage="claude_cli", | |
| event="claude_cli.request.sent", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| prompt=claim.prompt, | |
| fork_session_arg=bool(parent_session_id), | |
| resume_session_arg=parent_session_id, | |
| ) | |
| except RuntimeError as e: | |
| error_message = safe_exception_message(e) | |
| transcript.apply({"type": "error", "message": error_message}) | |
| await update_ui( | |
| self._format_status("⏳", "Session limit reached", None), | |
| force=True, | |
| ) | |
| await self._fail_claim( | |
| claim, | |
| propagate=False, | |
| ) | |
| trace_event( | |
| stage="claude_cli", | |
| event="claude_cli.session.limit_reached", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| ) | |
| return | |
| async for event_data in cli_session.start_task( | |
| claim.prompt, | |
| session_id=parent_session_id, | |
| fork_session=bool(parent_session_id), | |
| ): | |
| if not isinstance(event_data, dict): | |
| logger.warning( | |
| f"HANDLER: Non-dict event received: {type(event_data)}" | |
| ) | |
| continue | |
| ( | |
| captured_session_id, | |
| temp_session_id, | |
| ) = await handle_session_info_event( | |
| event_data, | |
| claim, | |
| captured_session_id, | |
| temp_session_id, | |
| cli_manager=self.cli_manager, | |
| record_session=lambda session_id: self._record_session( | |
| claim, session_id | |
| ), | |
| ) | |
| if event_data.get("type") == "session_info": | |
| continue | |
| parsed_list = parse_cli_event( | |
| event_data, log_raw_cli=self._log_raw_cli_diagnostics | |
| ) | |
| for parsed in parsed_list: | |
| ptype = parsed.get("type") | |
| if ( | |
| ptype == "error" | |
| and parsed.get("source") == "exit" | |
| and non_exit_error is not None | |
| ): | |
| await self._fail_claim( | |
| claim, | |
| propagate=True, | |
| child_status_text="Parent task failed", | |
| ) | |
| terminal_seen = True | |
| continue | |
| propagate_failure = parsed.get("source") == "exit" | |
| async def fail_parsed_event( | |
| error_message: str, | |
| child_status: str, | |
| propagate: bool = propagate_failure, | |
| ) -> None: | |
| await self._fail_claim( | |
| claim, | |
| propagate=propagate, | |
| child_status_text=child_status, | |
| ) | |
| ( | |
| last_status, | |
| had_transcript_events, | |
| ) = await process_parsed_cli_event( | |
| parsed, | |
| transcript, | |
| update_ui, | |
| last_status, | |
| had_transcript_events, | |
| claim, | |
| captured_session_id, | |
| format_status=self._format_status, | |
| complete_claim=lambda session_id: self._complete_claim( | |
| claim, session_id | |
| ), | |
| fail_claim=fail_parsed_event, | |
| log_messaging_error_details=self._log_messaging_error_details, | |
| ) | |
| if ptype == "error" and parsed.get("source") != "exit": | |
| error_message = parsed.get("message", "Unknown error") | |
| non_exit_error = ( | |
| error_message | |
| if isinstance(error_message, str) | |
| else str(error_message) | |
| ) | |
| if (ptype == "error" and parsed.get("source") == "exit") or ( | |
| ptype == "complete" and parsed.get("status") == "success" | |
| ): | |
| terminal_seen = True | |
| if non_exit_error is not None and not terminal_seen: | |
| await self._fail_claim( | |
| claim, | |
| propagate=True, | |
| child_status_text="Parent task failed", | |
| ) | |
| elif not terminal_seen: | |
| error_message = "Claude CLI ended without a terminal event" | |
| transcript.apply({"type": "error", "message": error_message}) | |
| await update_ui( | |
| self._format_status("💥", "Task Failed", None), | |
| force=True, | |
| ) | |
| await self._fail_claim( | |
| claim, | |
| propagate=True, | |
| child_status_text="Parent task failed", | |
| ) | |
| except asyncio.CancelledError as exc: | |
| trace_event( | |
| stage="claude_cli", | |
| event="turn.processor.cancelled", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| ) | |
| logger.warning(f"HANDLER: Task cancelled for node {node_id}") | |
| reason = exc.args[0] if exc.args else None | |
| if reason is CancellationReason.STOP: | |
| await update_ui(self._format_status("⏹", "Stopped.", None), force=True) | |
| elif reason is not CancellationReason.CLEAR: | |
| transcript.apply({"type": "error", "message": "Task was cancelled"}) | |
| await update_ui( | |
| self._format_status("❌", "Cancelled", None), force=True | |
| ) | |
| await self._fail_claim( | |
| claim, | |
| propagate=False, | |
| ) | |
| except Exception as e: | |
| trace_event( | |
| stage="claude_cli", | |
| event="turn.processor.exception", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| exc_type=type(e).__name__, | |
| ) | |
| logger.error( | |
| "HANDLER: Task failed with exception: {}", | |
| format_exception_for_log( | |
| e, log_full_message=self._log_messaging_error_details | |
| ), | |
| ) | |
| error_msg = format_user_error_preview(e) | |
| transcript.apply({"type": "error", "message": error_msg}) | |
| await update_ui(self._format_status("💥", "Task Failed", None), force=True) | |
| await self._fail_claim( | |
| claim, | |
| propagate=True, | |
| child_status_text="Parent task failed", | |
| ) | |
| finally: | |
| trace_event( | |
| stage="routing", | |
| event="turn.processor.finished", | |
| source=platform_nm, | |
| chat_id=chat_id, | |
| node_id=node_id, | |
| claude_session_id=captured_session_id or temp_session_id, | |
| ) | |
| try: | |
| if captured_session_id: | |
| await self.cli_manager.remove_session(captured_session_id) | |
| elif temp_session_id: | |
| await self.cli_manager.remove_session(temp_session_id) | |
| except Exception as e: | |
| logger.debug( | |
| "Failed to remove session for node {}: {}", | |
| node_id, | |
| format_exception_for_log( | |
| e, log_full_message=self._log_messaging_error_details | |
| ), | |
| ) | |
| __all__ = ["MessagingNodeRunner"] | |