Files
alishahryar1--free-claude-code/src/free_claude_code/messaging/node_runner.py
T
wehub-resource-sync 5296d0e97c
CI / Ban suppressions and legacy annotations (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff-check (push) Has been cancelled
CI / ruff-format (push) Has been cancelled
CI / ty (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:44 +08:00

409 lines
15 KiB
Python

"""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"]