From c133a07d1241ffa5004d1ca725af4d238f1fe622 Mon Sep 17 00:00:00 2001 From: fulvian Date: Sat, 11 Oct 2025 23:31:53 +0200 Subject: [PATCH] fix(sessions): Re-enable session tracking with active_files support and multi-session safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1: Add active_files parameter to WorkSessionManager - Add active_files parameter to update_session_progress() method - Update parameter documentation with clear usage examples - Enable JSON-based file list storage in work_sessions table Phase 2: Re-enable PostToolUse session tracking - Remove DISABLED comments from WorkSessionManager calls - Re-activate active_files tracking for Write/Edit/MultiEdit tools - Re-activate active_tasks tracking for TodoWrite operations - Restore proper session progress updates via WorkSessionManager abstraction layer Phase 3: Add session ID-based idempotency to SessionStart - Implement idempotency check at start of initialize_session() - Check if session exists AND status='active' before cleanup - Early return with session resume if already initialized - Prevents duplicate session creation in multi-session scenarios - Multi-session safe: Uses session_id instead of PID for detection Phase 4: Fix session duration formatting - Add _format_duration() method to SessionSummaryGenerator - Handle short sessions (<1 minute) by showing seconds - Add duration_formatted field to SessionSummary dataclass - Update markdown template to use human-readable duration - Fixes "0 minutes" bug for sessions shorter than 60 seconds Impact: - ✅ Fixes empty session summaries (active_files now tracked) - ✅ Fixes wrong durations (now shows "45 seconds" instead of "0 minutes") - ✅ Prevents duplicate session creation (idempotent SessionStart) - ✅ Multi-session safe (concurrent Sonnet + GLM sessions supported) Context: Lost changes from previous session due to Edit tool failure All 4 files re-implemented from plan with proper testing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../hooks/devstream/memory/post_tool_use.py | 24 ++++----- .../hooks/devstream/sessions/session_start.py | 30 +++++++++++ .../sessions/session_summary_generator.py | 53 ++++++++++++++++++- .../sessions/work_session_manager.py | 8 ++- 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/.claude/hooks/devstream/memory/post_tool_use.py b/.claude/hooks/devstream/memory/post_tool_use.py index 0f32aa3..820f069 100755 --- a/.claude/hooks/devstream/memory/post_tool_use.py +++ b/.claude/hooks/devstream/memory/post_tool_use.py @@ -1012,12 +1012,11 @@ async def update_session_tracking( if file_path not in current_files: current_files.append(file_path) - # DISABLED: WorkSessionManager.update_session_progress() doesn't accept active_files - # Event Sourcing captures this via capture_session_event() instead - # await session_manager.update_session_progress( - # session_id=session_id, - # active_files=current_files - # ) + # Update session with active_files via WorkSessionManager + await session_manager.update_session_progress( + session_id=session_id, + active_files=current_files + ) self.base.debug_log( f"Updated active_files via WorkSessionManager: {file_path} " @@ -1042,13 +1041,12 @@ async def update_session_tracking( current_tasks.append(task_content) tasks_updated = True - # DISABLED: WorkSessionManager.update_session_progress() doesn't accept active_tasks - # Event Sourcing captures this via capture_session_event() instead - # if tasks_updated: - # await session_manager.update_session_progress( - # session_id=session_id, - # active_tasks=current_tasks - # ) + # Update session with active_tasks via WorkSessionManager + if tasks_updated: + await session_manager.update_session_progress( + session_id=session_id, + active_tasks=current_tasks + ) self.base.debug_log( f"Updated active_tasks via WorkSessionManager: " diff --git a/.claude/hooks/devstream/sessions/session_start.py b/.claude/hooks/devstream/sessions/session_start.py index 8a101f5..4910ecb 100755 --- a/.claude/hooks/devstream/sessions/session_start.py +++ b/.claude/hooks/devstream/sessions/session_start.py @@ -96,6 +96,36 @@ async def initialize_session(self, session_id: str) -> Dict[str, Any]: } try: + # Session ID-based idempotency check (v2 - multi-session safe) + # Check if session already exists and is active before cleanup + existing_session = await self.session_manager.get_session(session_id) + + if existing_session and existing_session.status == "active": + self.logger.info( + f"Session {session_id[:12]}... already initialized - idempotent return" + ) + + # Update last_activity_at and return existing session + await self.session_manager.resume_session(session_id) + + # Bind context for automatic log propagation + self.session_manager.bind_session_context( + session_id=existing_session.id, + session_name=existing_session.session_name + ) + + results["success"] = True + results["session_resumed"] = True + results["session_data"] = { + "id": existing_session.id, + "status": existing_session.status, + "started_at": existing_session.started_at.isoformat(), + "tokens_used": existing_session.tokens_used + } + + self.logger.debug(f"Idempotent return for active session: {session_id[:12]}...") + return results + # Proactive cleanup of zombie sessions before checking limits self.logger.info("Performing proactive session cleanup...") cleanup_stats = self.cleanup_manager.aggressive_cleanup() diff --git a/.claude/hooks/devstream/sessions/session_summary_generator.py b/.claude/hooks/devstream/sessions/session_summary_generator.py index 5ef2532..3958af8 100644 --- a/.claude/hooks/devstream/sessions/session_summary_generator.py +++ b/.claude/hooks/devstream/sessions/session_summary_generator.py @@ -43,6 +43,7 @@ class SessionSummary: started_at: datetime ended_at: datetime duration_minutes: int + duration_formatted: str # Human-readable duration (e.g., "2 hours 15 minutes", "45 seconds") # Work accomplished tasks_completed: int @@ -78,7 +79,7 @@ def to_markdown(self) -> str: **Session**: {self.session_name or self.session_id} **Started**: {started} **Ended**: {ended} -**Duration**: {self.duration_minutes} minutes +**Duration**: {self.duration_formatted} **Status**: {self.status} --- @@ -165,6 +166,51 @@ def __init__(self): self.logger.info("SessionSummaryGenerator initialized") + def _format_duration(self, started_at: datetime, ended_at: datetime) -> str: + """ + Format session duration in human-readable format. + + Handles short sessions (<1 minute) by showing seconds instead of "0 minutes". + + Args: + started_at: Session start timestamp + ended_at: Session end timestamp + + Returns: + Human-readable duration string (e.g., "2 hours 15 minutes", "45 seconds", "1 second") + + Examples: + >>> _format_duration(datetime(2025, 1, 1, 10, 0, 0), datetime(2025, 1, 1, 10, 0, 30)) + "30 seconds" + >>> _format_duration(datetime(2025, 1, 1, 10, 0, 0), datetime(2025, 1, 1, 10, 5, 0)) + "5 minutes" + >>> _format_duration(datetime(2025, 1, 1, 10, 0, 0), datetime(2025, 1, 1, 12, 15, 0)) + "2 hours 15 minutes" + """ + if not ended_at or not started_at: + return "0 minutes" + + duration_seconds = int((ended_at - started_at).total_seconds()) + + # Less than 1 minute → show seconds + if duration_seconds < 60: + return f"{duration_seconds} second{'s' if duration_seconds != 1 else ''}" + + # Less than 1 hour → show minutes + elif duration_seconds < 3600: + minutes = duration_seconds // 60 + return f"{minutes} minute{'s' if minutes != 1 else ''}" + + # 1 hour or more → show hours + minutes + else: + hours = duration_seconds // 3600 + minutes = (duration_seconds % 3600) // 60 + parts = [] + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes > 0: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + return " ".join(parts) + def aggregate_session_data( self, session_data: SessionData, @@ -201,10 +247,14 @@ def aggregate_session_data( duration = ended_at - session_data.started_at duration_minutes = int(duration.total_seconds() / 60) + # Format duration for human readability (handles short sessions) + duration_formatted = self._format_duration(session_data.started_at, ended_at) + self.logger.debug( "Aggregating session data", session_id=session_data.session_id, duration_minutes=duration_minutes, + duration_formatted=duration_formatted, tasks_completed=task_stats.completed if task_stats else 0, files_modified=memory_stats.files_modified if memory_stats else 0 ) @@ -216,6 +266,7 @@ def aggregate_session_data( started_at=session_data.started_at, ended_at=ended_at, duration_minutes=duration_minutes, + duration_formatted=duration_formatted, # Work accomplished tasks_completed=task_stats.completed if task_stats else 0, diff --git a/.claude/hooks/devstream/sessions/work_session_manager.py b/.claude/hooks/devstream/sessions/work_session_manager.py index 3cbadcf..f0a6a2e 100644 --- a/.claude/hooks/devstream/sessions/work_session_manager.py +++ b/.claude/hooks/devstream/sessions/work_session_manager.py @@ -301,7 +301,8 @@ async def update_session_progress( session_id: str, tokens_delta: int = 0, active_tasks: Optional[List[str]] = None, - completed_tasks: Optional[List[str]] = None + completed_tasks: Optional[List[str]] = None, + active_files: Optional[List[str]] = None ) -> bool: """ Update session progress metrics. @@ -311,6 +312,7 @@ async def update_session_progress( tokens_delta: Token count increment (added to existing tokens_used) active_tasks: Current active tasks list (replaces existing) completed_tasks: Current completed tasks list (replaces existing) + active_files: Current active files list (replaces existing) Returns: bool: True if update successful @@ -338,6 +340,10 @@ async def update_session_progress( updates.append("completed_tasks = ?") params.append(json.dumps(completed_tasks)) + if active_files is not None: + updates.append("active_files = ?") + params.append(json.dumps(active_files)) + # Add session_id for WHERE clause params.append(session_id)