diff --git a/hub.py b/hub.py index 54e66af..5b12ab4 100644 --- a/hub.py +++ b/hub.py @@ -269,6 +269,19 @@ class WSHub: self.project_connections[conn.project] = set() self.project_connections[conn.project].add(conn.conn_id) + # FIX: Reassign orphaned pending_owners (from dead conn_ids) to this new connection. + # When Extension reconnects, old conn_id entries become stale. + reassigned = 0 + for rid, cid in list(self.pending_owners.items()): + if cid not in self.connections: + self.pending_owners[rid] = conn.conn_id + reassigned += 1 + if reassigned: + logger.info( + f"[HUB] Reassigned {reassigned} orphaned pending_owners " + f"to new conn {conn.conn_id} (project={conn.project})" + ) + # Broadcast instance update to all project connections asyncio.create_task(self._broadcast_instance_update(conn.project)) @@ -292,10 +305,26 @@ class WSHub: if not self.project_connections[project]: del self.project_connections[project] - # Clean up pending ownership + # Reassign pending ownership to another connection in same project + # (instead of deleting — prevents approval responses from being lost) stale = [rid for rid, cid in self.pending_owners.items() if cid == conn_id] - for rid in stale: - del self.pending_owners[rid] + if stale: + remaining = self.project_connections.get(project, set()) - {conn_id} + new_owner = None + for cid in remaining: + c = self.connections.get(cid) + if c and c.authenticated: + new_owner = cid + break + for rid in stale: + if new_owner: + self.pending_owners[rid] = new_owner + logger.info( + f"[HUB] Reassigned pending {rid[:12]} → {new_owner} " + f"(disconnected {conn_id})" + ) + else: + del self.pending_owners[rid] # Close WebSocket if still open if not conn.ws.closed: @@ -374,13 +403,39 @@ class WSHub: return False async def send_response_to_pending_owner(self, request_id: str, message: dict): - """Route a response to the Extension that created the pending request.""" + """Route a response to the Extension that created the pending request. + + Falls back to any active connection in the same project if the + original owner disconnected (e.g. Extension WS reconnected with + a new conn_id). + """ conn_id = self.pending_owners.get(request_id) if conn_id: - await self.send_to_connection(conn_id, message) - # Clean up after response delivered + conn = self.connections.get(conn_id) + if conn and conn.authenticated and not conn.ws.closed: + await self.send_to_connection(conn_id, message) + self.pending_owners.pop(request_id, None) + return True + # Original owner dead — try to find any active connection in same project + project = conn.project if conn else None + if project: + for cid in self.project_connections.get(project, set()): + c = self.connections.get(cid) + if c and c.authenticated and not c.ws.closed: + await self.send_to_connection(cid, message) + self.pending_owners.pop(request_id, None) + logger.info( + f"[HUB] Rerouted response {request_id[:12]} → {cid} " + f"(original {conn_id} dead)" + ) + return True + # No active connection found — clean up self.pending_owners.pop(request_id, None) - return True + logger.warning( + f"[HUB] Response {request_id[:12]} lost: owner {conn_id} dead, " + f"no active connections in project" + ) + return False logger.warning(f"[HUB] No owner for pending {request_id[:12]}") return False