Files
luzia/lib/luzia_status_publisher_impl.py
admin ec33ac1936 Refactor cockpit to use DockerTmuxController pattern
Based on claude-code-tools TmuxCLIController, this refactor:

- Added DockerTmuxController class for robust tmux session management
- Implements send_keys() with configurable delay_enter
- Implements capture_pane() for output retrieval
- Implements wait_for_prompt() for pattern-based completion detection
- Implements wait_for_idle() for content-hash-based idle detection
- Implements wait_for_shell_prompt() for shell prompt detection

Also includes workflow improvements:
- Pre-task git snapshot before agent execution
- Post-task commit protocol in agent guidelines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:42:16 -03:00

541 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Luzia Status Event Publisher
Real-time status updates from Luzia orchestrator to Claude interface
Usage:
publisher = LuziaStatusPublisher()
await publisher.publish_task_started(...)
await publisher.publish_progress(...)
await publisher.publish_task_completed(...)
"""
import json
import asyncio
from datetime import datetime
from enum import Enum
from dataclasses import dataclass, asdict, field
from typing import Optional, List, Dict, Any, Callable
from pathlib import Path
import logging
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class StatusMessageType(Enum):
"""Enumeration of all status message types"""
TASK_STARTED = "TASK_STARTED"
PROGRESS_UPDATE = "PROGRESS_UPDATE"
TASK_COMPLETED = "TASK_COMPLETED"
TASK_QUEUED = "TASK_QUEUED"
TASK_WARNING = "TASK_WARNING"
TASK_FAILED = "TASK_FAILED"
SYSTEM_ALERT = "SYSTEM_ALERT"
class Severity(Enum):
"""Severity levels for status messages"""
INFO = "info"
SUCCESS = "success"
WARNING = "warning"
ERROR = "error"
CRITICAL = "critical"
@dataclass
class StatusMessage:
"""Compact status message for real-time updates"""
type: StatusMessageType
task_id: str
project: str
timestamp: int
# Progress fields (optional)
progress_percent: Optional[int] = None
current_step: Optional[int] = None
total_steps: Optional[int] = None
current_step_name: Optional[str] = None
# Time fields
elapsed_seconds: Optional[int] = None
estimated_remaining_seconds: Optional[int] = None
# Status fields
description: Optional[str] = None
status: Optional[str] = None
# Warning/Error fields
alert_type: Optional[str] = None
error: Optional[str] = None
warning_type: Optional[str] = None
message: Optional[str] = None
recommendation: Optional[str] = None
# Context
severity: Severity = Severity.INFO
queue_position: Optional[int] = None
queue_ahead: Optional[List[str]] = field(default_factory=list)
findings_count: Optional[int] = None
retriable: Optional[bool] = None
retry_count: Optional[int] = None
def to_dict(self) -> Dict[str, Any]:
"""Convert to dict, removing None values"""
result = asdict(self)
result['type'] = self.type.value
result['severity'] = self.severity.value
return {k: v for k, v in result.items() if v is not None}
def to_json(self) -> str:
"""Convert to JSON"""
return json.dumps(self.to_dict())
def to_compact_display(self) -> str:
"""Format for CLI display (one line + details)"""
emoji_map = {
StatusMessageType.TASK_STARTED: "🟡",
StatusMessageType.PROGRESS_UPDATE: "🟢",
StatusMessageType.TASK_COMPLETED: "",
StatusMessageType.TASK_QUEUED: "🔵",
StatusMessageType.TASK_WARNING: "⚠️",
StatusMessageType.TASK_FAILED: "",
StatusMessageType.SYSTEM_ALERT: "",
}
emoji = emoji_map.get(self.type, "")
task_short = self.task_id.split('-')[-1] if '-' in self.task_id else self.task_id[:8]
# Build display based on message type
if self.type == StatusMessageType.TASK_STARTED:
time_str = (
f"est. {self.estimated_remaining_seconds//60}m"
if self.estimated_remaining_seconds
else "..."
)
return (
f"{emoji} [{self.project}-{task_short}] Starting... ⏱ {time_str}\n"
f" └─ {self.description}"
)
elif self.type == StatusMessageType.PROGRESS_UPDATE:
elapsed_m = self.elapsed_seconds // 60
elapsed_s = self.elapsed_seconds % 60
remaining_m = (
self.estimated_remaining_seconds // 60
if self.estimated_remaining_seconds
else 0
)
remaining_s = (
self.estimated_remaining_seconds % 60
if self.estimated_remaining_seconds
else 0
)
lines = [
f"{emoji} [{self.project}-{task_short}] In Progress - {self.progress_percent}% "
f"({self.current_step}/{self.total_steps}) ⏱ {elapsed_m}m {elapsed_s}s",
f" └─ {self.current_step_name}",
f" └─ Est. remaining: {remaining_m}m {remaining_s}s"
]
return "\n".join(lines)
elif self.type == StatusMessageType.TASK_COMPLETED:
elapsed_m = self.elapsed_seconds // 60
elapsed_s = self.elapsed_seconds % 60
details = []
if self.findings_count:
word = "finding" if self.findings_count == 1 else "findings"
details.append(f"{self.findings_count} {word}")
if self.recommendation:
details.append("recommendation")
detail_str = ", ".join(details) if details else "Done"
return (
f"{emoji} [{self.project}-{task_short}] Completed ✓ ({elapsed_m}m {elapsed_s}s)\n"
f" └─ {detail_str}"
)
elif self.type == StatusMessageType.TASK_QUEUED:
queue_str = f"{len(self.queue_ahead or [])} ahead"
wait_m = (
self.estimated_remaining_seconds // 60
if self.estimated_remaining_seconds
else 0
)
lines = [
f"{emoji} [{self.project}-{task_short}] Queued ({queue_str}) ⏱ waiting",
f" └─ Reason: {self.message}",
]
if wait_m > 0:
lines.append(f" └─ Est. wait: {wait_m}m")
return "\n".join(lines)
elif self.type == StatusMessageType.TASK_WARNING:
elapsed_m = self.elapsed_seconds // 60
elapsed_s = self.elapsed_seconds % 60
lines = [
f"{emoji} [{self.project}-{task_short}] In Progress - {self.progress_percent}% "
f"({self.current_step}/{self.total_steps}) ⏱ {elapsed_m}m {elapsed_s}s",
f" └─ {self.current_step_name}",
f" └─ Alert: {self.message}",
]
if self.recommendation:
lines.append(f" └─ {self.recommendation}")
return "\n".join(lines)
elif self.type == StatusMessageType.TASK_FAILED:
elapsed_m = self.elapsed_seconds // 60
elapsed_s = self.elapsed_seconds % 60
lines = [
f"{emoji} [{self.project}-{task_short}] Failed ({elapsed_m}m {elapsed_s}s)",
f" └─ Error: {self.error}",
]
if self.retriable and self.retry_count:
lines.append(f" └─ Auto-retry: Queued (attempt {self.retry_count}/5)")
return "\n".join(lines)
elif self.type == StatusMessageType.SYSTEM_ALERT:
return (
f"{emoji} SYSTEM ALERT - {self.message}\n"
f" └─ Action: {self.recommendation}"
)
return f"{emoji} {self.description or 'Status update'}"
class LuziaStatusPublisher:
"""Publishes real-time status updates to Claude interface"""
def __init__(self, event_handler: Optional[Callable] = None, max_queue_size: int = 100):
"""
Initialize status publisher
Args:
event_handler: Optional async function to call on each event
max_queue_size: Maximum events in queue before dropping
"""
self.event_handler = event_handler
self.event_queue: asyncio.Queue = asyncio.Queue(maxsize=max_queue_size)
self.active_tasks: Dict[str, Dict[str, Any]] = {}
self.verbosity_level = "normal" # quiet, normal, verbose
self.message_history: List[StatusMessage] = []
self.max_history = 100
def set_verbosity(self, level: str):
"""Set how chatty Luzia is: quiet, normal, verbose"""
if level not in ("quiet", "normal", "verbose"):
logger.warning(f"Invalid verbosity level: {level}. Using 'normal'")
level = "normal"
self.verbosity_level = level
async def publish_task_started(
self,
task_id: str,
project: str,
description: str,
estimated_duration_seconds: int = 300
):
"""Task has started"""
msg = StatusMessage(
type=StatusMessageType.TASK_STARTED,
task_id=task_id,
project=project,
description=description,
timestamp=int(datetime.now().timestamp()),
estimated_remaining_seconds=estimated_duration_seconds,
severity=Severity.INFO
)
self.active_tasks[task_id] = {
"start_time": datetime.now(),
"project": project,
"description": description
}
await self._publish(msg)
async def publish_progress(
self,
task_id: str,
progress_percent: int,
current_step: int,
total_steps: int,
current_step_name: str,
elapsed_seconds: int,
estimated_remaining_seconds: int
):
"""Update task progress"""
if task_id not in self.active_tasks:
logger.warning(f"Progress update for unknown task: {task_id}")
return
msg = StatusMessage(
type=StatusMessageType.PROGRESS_UPDATE,
task_id=task_id,
project=self.active_tasks[task_id]["project"],
progress_percent=progress_percent,
current_step=current_step,
total_steps=total_steps,
current_step_name=current_step_name,
elapsed_seconds=elapsed_seconds,
estimated_remaining_seconds=estimated_remaining_seconds,
timestamp=int(datetime.now().timestamp()),
severity=Severity.INFO
)
# Only publish progress updates based on verbosity level
should_publish = (
self.verbosity_level == "verbose"
or progress_percent % 25 == 0
or progress_percent == 100
)
if should_publish:
await self._publish(msg)
async def publish_task_completed(
self,
task_id: str,
elapsed_seconds: int,
findings_count: int = 0,
recommendations_count: int = 0,
status: str = "APPROVED"
):
"""Task completed successfully"""
if task_id not in self.active_tasks:
logger.warning(f"Completion for unknown task: {task_id}")
return
task_info = self.active_tasks[task_id]
msg = StatusMessage(
type=StatusMessageType.TASK_COMPLETED,
task_id=task_id,
project=task_info["project"],
description=task_info["description"],
elapsed_seconds=elapsed_seconds,
findings_count=findings_count if findings_count > 0 else None,
timestamp=int(datetime.now().timestamp()),
status=status,
severity=Severity.SUCCESS
)
await self._publish(msg)
del self.active_tasks[task_id]
async def publish_task_queued(
self,
task_id: str,
project: str,
description: str,
reason: str,
queue_position: int,
queue_ahead: List[str],
estimated_wait_seconds: int
):
"""Task queued waiting for resources"""
msg = StatusMessage(
type=StatusMessageType.TASK_QUEUED,
task_id=task_id,
project=project,
description=description,
message=reason,
queue_position=queue_position,
queue_ahead=queue_ahead,
estimated_remaining_seconds=estimated_wait_seconds,
timestamp=int(datetime.now().timestamp()),
severity=Severity.INFO
)
await self._publish(msg)
async def publish_warning(
self,
task_id: str,
warning_type: str,
message: str,
current_step: int,
total_steps: int,
current_step_name: str,
elapsed_seconds: int,
progress_percent: int,
recommendation: str = None
):
"""Task warning (duration exceeded, resource warning, etc)"""
if task_id not in self.active_tasks:
logger.warning(f"Warning for unknown task: {task_id}")
return
task_info = self.active_tasks[task_id]
msg = StatusMessage(
type=StatusMessageType.TASK_WARNING,
task_id=task_id,
project=task_info["project"],
warning_type=warning_type,
message=message,
current_step=current_step,
total_steps=total_steps,
current_step_name=current_step_name,
elapsed_seconds=elapsed_seconds,
progress_percent=progress_percent,
recommendation=recommendation,
timestamp=int(datetime.now().timestamp()),
severity=Severity.WARNING
)
await self._publish(msg)
async def publish_task_failed(
self,
task_id: str,
error: str,
elapsed_seconds: int,
retry_count: int = 0,
retriable: bool = False
):
"""Task failed"""
if task_id not in self.active_tasks:
logger.warning(f"Failure for unknown task: {task_id}")
return
task_info = self.active_tasks[task_id]
msg = StatusMessage(
type=StatusMessageType.TASK_FAILED,
task_id=task_id,
project=task_info["project"],
error=error,
elapsed_seconds=elapsed_seconds,
retry_count=retry_count if retriable else None,
retriable=retriable,
timestamp=int(datetime.now().timestamp()),
severity=Severity.ERROR
)
await self._publish(msg)
if not retriable:
del self.active_tasks[task_id]
async def publish_system_alert(
self,
alert_type: str,
message: str,
recommendation: str,
severity: Severity = Severity.WARNING
):
"""System-level alert (resource, health, etc)"""
msg = StatusMessage(
type=StatusMessageType.SYSTEM_ALERT,
task_id="system",
project="luzia",
alert_type=alert_type,
message=message,
recommendation=recommendation,
timestamp=int(datetime.now().timestamp()),
severity=severity
)
await self._publish(msg)
async def _publish(self, msg: StatusMessage):
"""Publish message to event queue"""
try:
await self.event_queue.put_nowait(msg)
# Keep history
self.message_history.append(msg)
if len(self.message_history) > self.max_history:
self.message_history = self.message_history[-self.max_history:]
# Call event handler if provided
if self.event_handler:
try:
result = self.event_handler(msg)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.error(f"Error in event handler: {e}")
except asyncio.QueueFull:
logger.warning("Status queue full, dropping oldest message")
try:
self.event_queue.get_nowait()
await self.event_queue.put_nowait(msg)
except asyncio.QueueEmpty:
pass
async def get_events_stream(self):
"""Async generator for consuming events"""
while True:
try:
msg = await self.event_queue.get()
yield msg
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Error in event stream: {e}")
await asyncio.sleep(1)
def get_active_tasks_summary(self) -> Dict[str, Any]:
"""Get summary of all active tasks"""
return {
"active_count": len(self.active_tasks),
"tasks": self.active_tasks,
"timestamp": int(datetime.now().timestamp())
}
def get_message_history(self, limit: int = 10) -> List[StatusMessage]:
"""Get last N messages from history"""
return self.message_history[-limit:] if self.message_history else []
async def example_usage():
"""Example usage of the status publisher"""
def on_event(msg: StatusMessage):
"""Handle event - print to console"""
print(msg.to_compact_display())
print()
# Create publisher
publisher = LuziaStatusPublisher(event_handler=on_event)
publisher.set_verbosity("normal")
# Simulate a task
task_id = "musica-fix-001"
await publisher.publish_task_started(
task_id=task_id,
project="musica",
description="Fix audio synthesis engine",
estimated_duration_seconds=600
)
for progress in [25, 50, 75, 100]:
await asyncio.sleep(1)
await publisher.publish_progress(
task_id=task_id,
progress_percent=progress,
current_step=progress // 25,
total_steps=4,
current_step_name=f"Step {progress // 25}: Testing phase",
elapsed_seconds=int(600 * progress / 100),
estimated_remaining_seconds=int(600 * (100 - progress) / 100)
)
await publisher.publish_task_completed(
task_id=task_id,
elapsed_seconds=615,
findings_count=2,
status="APPROVED"
)
if __name__ == "__main__":
# Run example
asyncio.run(example_usage())