diff --git a/gateway/run.py b/gateway/run.py index 29dfa884ec55..382ebbcdc8c7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2660,11 +2660,11 @@ async def start(self) -> bool: pass else: try: - suspended = self.session_store.suspend_recently_active() - if suspended: - logger.info("Suspended %d in-flight session(s) from previous run", suspended) + resumed = self.session_store.suspend_recently_active() + if resumed: + logger.info("Marked %d in-flight session(s) as resume-pending from previous run", resumed) except Exception as e: - logger.warning("Session suspension on startup failed: %s", e) + logger.warning("Session resume-pending marking failed: %s", e) # Stuck-loop detection (#7536): if a session has been active across # 3+ consecutive restarts, it's probably stuck in a loop (the same @@ -6367,7 +6367,7 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str, run_g # Auto voice reply: send TTS audio before the text response _already_sent = bool(agent_result.get("already_sent")) - if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent): + if self._should_send_voice_reply(event, response, agent_messages, already_sent=_already_sent, history_offset=agent_result.get("history_offset", len(agent_messages))): await self._send_voice_reply(event, response) # If streaming already delivered the response, extract and @@ -8096,6 +8096,7 @@ def _should_send_voice_reply( response: str, agent_messages: list, already_sent: bool = False, + history_offset: int = 0, ) -> bool: """Decide whether the runner should send a TTS voice reply. @@ -8122,14 +8123,15 @@ def _should_send_voice_reply( if not should: return False - # Dedup: agent already called TTS tool + # Dedup: agent already called TTS tool (current turn only, not full history) + current_turn_messages = agent_messages[history_offset:] if history_offset else agent_messages has_agent_tts = any( msg.get("role") == "assistant" and any( tc.get("function", {}).get("name") == "text_to_speech" for tc in (msg.get("tool_calls") or []) ) - for msg in agent_messages + for msg in current_turn_messages ) if has_agent_tts: return False diff --git a/gateway/session.py b/gateway/session.py index fcff336afa76..6b2921de9bb9 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1086,19 +1086,20 @@ def prune_old_entries(self, max_age_days: int) -> int: return len(removed_keys) def suspend_recently_active(self, max_age_seconds: int = 120) -> int: - """Mark recently-active sessions as suspended. - - Called on gateway startup to prevent sessions that were likely - in-flight when the gateway last exited from being blindly resumed - (#7536). Only suspends sessions updated within *max_age_seconds* - to avoid resetting long-idle sessions that are harmless to resume. - Returns the number of sessions that were suspended. - - Entries flagged ``resume_pending=True`` are skipped — those were - marked intentionally by the drain-timeout path as recoverable. - Terminal escalation for genuinely stuck ``resume_pending`` sessions - is handled by the existing ``.restart_failure_counts`` stuck-loop - counter, which runs after this method on startup. + """Mark recently-active sessions as resume-pending after dirty shutdown. + + Called on gateway startup when the previous exit was not clean + (no .clean_shutdown marker). Instead of hard-suspending sessions + (which wipes conversation history), marks them as resume_pending + so the user can seamlessly continue where they left off. + + Stuck-loop escalation (3+ consecutive dirty restarts) is handled + separately by ``_suspend_stuck_loop_sessions()`` which runs after + this method — genuinely broken sessions still get suspended. + + Entries flagged ``resume_pending=True`` or ``suspended=True`` are + skipped to avoid overriding explicit user actions (/stop). + Returns the number of sessions that were marked. """ from datetime import timedelta @@ -1107,10 +1108,11 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int: with self._lock: self._ensure_loaded_locked() for entry in self._entries.values(): - if entry.resume_pending: + if entry.resume_pending or entry.suspended: continue - if not entry.suspended and entry.updated_at >= cutoff: - entry.suspended = True + if entry.updated_at >= cutoff: + entry.resume_pending = True + entry.resume_reason = "dirty_shutdown" count += 1 if count: self._save()