Bug Report: The REASON why Cursor keeps losing our chats and agents - and how to fix it!

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

When a workspace folder’s creation timestamp changes (e.g., after a git clone, folder move, or even certain backup/restore operations), Cursor computes a new workspace ID and creates a fresh workspace entry (because, wrongly, it’s hashing the folder timestamp, node birthtimeMs, with the folder path to create the ID).

The old chats remain in the database but are now orphaned - they exist but are invisible because they’re associated with an ID that no longer matches any workspace.

And - Viola - Users lose all their work, support forums overflow with anger, and so on!

Steps to Reproduce

Do some stuff in a project.
back it up.
erase the folder.
restore the backup.
all history lost.
(or any other operation that breaks birthtimeMs timestamp)

Expected Behavior

Stable history, that never gets lost.

Operating System

Windows 10/11
MacOS
Linux

Version Information

All versions of cursor, from the past upto and including 2.4

Additional Information

The best fix is to hardcode 0 instead of the birthtimeMs number every time any new project is opened. It won’t solve the problem for past chats, but will stop it occurring ever again for new ones.

I’m marking this as “unusable” because of the gargantuan destruction of value that regularly takes place when this occurs. I’ve audited all my cursor backups for the last 6 months, and find that there’s over 12 times where all my history got orphaned because of this - that’s literally hundreds of painfully trained agents I built, that your bug destroyed.

Does this stop you from using Cursor

Yes - Cursor is unusable

Opus-4.5 wrote this fix. It is untested - if you use it:
a) BACKUP FIRST
b) open Cursor in every folder you use (so it upgrades your DB and creates a correct current ID)
c) use at own risk!
d) CHECK afterwards (restore backup if problems)

#!/usr/bin/env python3
"""
find_orphaned_chats.py - Find and rescue orphaned chats in Cursor databases

This tool detects chats that are "orphaned" due to workspace ID changes.
When a folder's creation timestamp changes, Cursor computes a new workspace ID,
leaving old chats invisible but still in the database.

This tool:
1. Finds all workspace IDs referenced in the global database
2. Compares against current workspaceStorage folders
3. Identifies orphaned workspace IDs
4. Maps orphaned IDs to current IDs by project path
5. Optionally migrates orphaned chats to current workspace IDs

Usage:
    python find_orphaned_chats.py --scan                    # Just scan and report
    python find_orphaned_chats.py --fix                     # Fix orphaned chats
    python find_orphaned_chats.py --fix --dry-run           # Preview fixes

Author: Generated by Cursor AI Assistant
Date: 2026-01-24
"""

import argparse
import json
import os
import re
import shutil
import sqlite3
import sys
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Set, Optional, Tuple
from urllib.parse import unquote


def normalize_path(path: str) -> str:
    """Normalize path for comparison."""
    if not path:
        return None
    path = path.lower().replace('\\', '/').rstrip('/')
    path = re.sub(r'^[a-z]:', '', path)
    path = re.sub(r'^/mnt/[a-z]', '', path)
    return path


def extract_project_name(path: str) -> str:
    """Extract project name from path."""
    if not path:
        return None
    normalized = normalize_path(path)
    return normalized.split('/')[-1] if normalized else None


def get_workspace_path_from_json(workspace_dir: Path) -> Optional[str]:
    """Get workspace path from workspace.json."""
    workspace_json = workspace_dir / 'workspace.json'
    if workspace_json.exists():
        try:
            with open(workspace_json, encoding='utf-8') as f:
                data = json.load(f)
                if 'folder' in data:
                    folder = data['folder']
                    if folder.startswith('file:///'):
                        return unquote(folder[8:])
                    return folder
        except:
            pass
    return None


def get_cursor_paths() -> Dict[str, Path]:
    """Get Cursor user directory paths."""
    cursor_user = Path(os.path.expandvars('%APPDATA%')) / 'Cursor' / 'User'
    return {
        'user': cursor_user,
        'global_storage': cursor_user / 'globalStorage',
        'global_db': cursor_user / 'globalStorage' / 'state.vscdb',
        'workspace_storage': cursor_user / 'workspaceStorage',
    }


class OrphanedChatFinder:
    """Find and fix orphaned chats."""
    
    def __init__(self, paths: Dict[str, Path]):
        self.paths = paths
        self.current_workspaces = {}      # workspace_id -> path
        self.referenced_workspaces = {}   # workspace_id -> set of composer_ids
        self.orphaned_workspaces = {}     # workspace_id -> info
        self.remap_table = {}             # old_id -> new_id
        
    def load_current_workspaces(self):
        """Load workspace IDs from current workspaceStorage."""
        workspace_storage = self.paths['workspace_storage']
        
        if not workspace_storage.exists():
            print(f"Warning: workspaceStorage not found: {workspace_storage}")
            return
        
        for workspace_dir in workspace_storage.iterdir():
            if not workspace_dir.is_dir():
                continue
            
            workspace_id = workspace_dir.name
            if len(workspace_id) != 32:
                continue
            
            path = get_workspace_path_from_json(workspace_dir)
            if path:
                self.current_workspaces[workspace_id] = {
                    'path': path,
                    'project': extract_project_name(path),
                    'normalized': normalize_path(path),
                }
        
        print(f"Loaded {len(self.current_workspaces)} current workspaces")
    
    def scan_global_database(self):
        """Scan global database for workspace ID references."""
        global_db = self.paths['global_db']
        
        if not global_db.exists():
            print(f"Error: Global database not found: {global_db}")
            return
        
        try:
            conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
            cursor = conn.cursor()
            
            # Find all keys that contain workspace-like IDs (32 hex chars)
            cursor.execute("SELECT key FROM cursorDiskKV")
            
            workspace_pattern = re.compile(r'[a-f0-9]{32}')
            
            for (key,) in cursor.fetchall():
                matches = workspace_pattern.findall(key)
                for match in matches:
                    if match not in self.referenced_workspaces:
                        self.referenced_workspaces[match] = set()
                    
                    # If this is a composerData key, track the composer
                    if key.startswith('composerData:'):
                        composer_id = key.split(':')[1]
                        # The workspace ID might be in the key or we need to check the value
                        self.referenced_workspaces[match].add(composer_id)
            
            # Also scan ItemTable for workspace references
            cursor.execute("SELECT key, value FROM ItemTable")
            for key, value in cursor.fetchall():
                if value:
                    try:
                        value_str = value.decode('utf-8') if isinstance(value, bytes) else value
                        matches = workspace_pattern.findall(value_str)
                        for match in matches:
                            if match not in self.referenced_workspaces:
                                self.referenced_workspaces[match] = set()
                    except:
                        pass
            
            conn.close()
            
        except sqlite3.DatabaseError as e:
            print(f"Database error: {e}")
    
    def find_orphaned_workspaces(self):
        """Identify workspace IDs that are referenced but don't exist."""
        current_ids = set(self.current_workspaces.keys())
        referenced_ids = set(self.referenced_workspaces.keys())
        
        orphaned_ids = referenced_ids - current_ids
        
        print(f"\nWorkspace ID Analysis:")
        print(f"  Current workspaces: {len(current_ids)}")
        print(f"  Referenced in DB: {len(referenced_ids)}")
        print(f"  Orphaned: {len(orphaned_ids)}")
        
        # Try to identify what project each orphaned ID belonged to
        for orphan_id in orphaned_ids:
            composers = self.referenced_workspaces.get(orphan_id, set())
            self.orphaned_workspaces[orphan_id] = {
                'composers': composers,
                'composer_count': len(composers),
            }
        
        return orphaned_ids
    
    def analyze_orphaned_chats(self):
        """Analyze orphaned chats to find their original projects."""
        global_db = self.paths['global_db']
        
        if not global_db.exists():
            return
        
        try:
            conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
            cursor = conn.cursor()
            
            # For each orphaned workspace, try to find associated chats
            for orphan_id in self.orphaned_workspaces:
                # Look for inlineDiffs entries which often contain workspace IDs
                cursor.execute(
                    "SELECT key FROM cursorDiskKV WHERE key LIKE ?",
                    (f'inlineDiffs-{orphan_id}%',)
                )
                
                inline_diffs = cursor.fetchall()
                if inline_diffs:
                    self.orphaned_workspaces[orphan_id]['has_inline_diffs'] = True
                
                # Count bubbles and other data
                cursor.execute(
                    "SELECT COUNT(*) FROM cursorDiskKV WHERE key LIKE ?",
                    (f'%{orphan_id}%',)
                )
                count = cursor.fetchone()[0]
                self.orphaned_workspaces[orphan_id]['total_keys'] = count
            
            conn.close()
            
        except sqlite3.DatabaseError as e:
            print(f"Database error: {e}")
    
    def build_remap_table_from_workspace_dbs(self):
        """Try to find project paths from old workspace databases."""
        workspace_storage = self.paths['workspace_storage']
        
        # First, build a map of project paths to current workspace IDs
        path_to_current_id = {}
        for wid, info in self.current_workspaces.items():
            normalized = info.get('normalized')
            project = info.get('project')
            if normalized:
                path_to_current_id[normalized] = wid
            if project:
                path_to_current_id[project] = wid
        
        # Check if any orphaned IDs have workspace folders (they might still exist)
        for orphan_id in self.orphaned_workspaces:
            orphan_dir = workspace_storage / orphan_id
            if orphan_dir.exists():
                path = get_workspace_path_from_json(orphan_dir)
                if path:
                    normalized = normalize_path(path)
                    project = extract_project_name(path)
                    
                    self.orphaned_workspaces[orphan_id]['path'] = path
                    self.orphaned_workspaces[orphan_id]['project'] = project
                    
                    # Find matching current workspace
                    target_id = path_to_current_id.get(normalized) or path_to_current_id.get(project)
                    if target_id and target_id != orphan_id:
                        self.remap_table[orphan_id] = target_id
                        self.orphaned_workspaces[orphan_id]['can_remap_to'] = target_id
    
    def scan_for_orphaned_composers(self) -> List[Dict]:
        """Find composers that reference orphaned workspace IDs."""
        global_db = self.paths['global_db']
        orphaned_composers = []
        
        if not global_db.exists():
            return orphaned_composers
        
        try:
            conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
            cursor = conn.cursor()
            
            # Get all composers
            cursor.execute("""
                SELECT key, value FROM cursorDiskKV 
                WHERE key LIKE 'composerData:%' AND value IS NOT NULL
            """)
            
            for key, value in cursor.fetchall():
                composer_id = key.split(':')[1]
                
                try:
                    if isinstance(value, bytes):
                        value_str = value.decode('utf-8')
                    else:
                        value_str = value
                    
                    # Check if this composer references any orphaned workspace
                    for orphan_id in self.orphaned_workspaces:
                        if orphan_id in value_str:
                            data = json.loads(value_str)
                            orphaned_composers.append({
                                'composer_id': composer_id,
                                'name': data.get('name', '(unnamed)'),
                                'orphan_workspace_id': orphan_id,
                                'created_at': data.get('createdAt', 0),
                                'bubble_count': len(data.get('fullConversationHeadersOnly', data.get('conversation', []))),
                            })
                            break
                except:
                    pass
            
            conn.close()
            
        except sqlite3.DatabaseError as e:
            print(f"Database error: {e}")
        
        return orphaned_composers
    
    def fix_orphaned_chats(self, dry_run: bool = True) -> Dict:
        """Remap orphaned chats to current workspace IDs."""
        global_db = self.paths['global_db']
        
        stats = {
            'keys_updated': 0,
            'composers_fixed': 0,
            'errors': [],
        }
        
        if not self.remap_table:
            print("No remappings available - cannot fix orphaned chats")
            return stats
        
        if not global_db.exists():
            stats['errors'].append("Global database not found")
            return stats
        
        try:
            if dry_run:
                conn = sqlite3.connect(f'file:{global_db}?mode=ro', uri=True)
            else:
                conn = sqlite3.connect(global_db)
            
            cursor = conn.cursor()
            
            # For each remap, update all keys and values
            for old_id, new_id in self.remap_table.items():
                print(f"\n  Remapping {old_id[:16]}... -> {new_id[:16]}...")
                
                # Find all keys containing the old ID
                cursor.execute(
                    "SELECT key, value FROM cursorDiskKV WHERE key LIKE ? OR value LIKE ?",
                    (f'%{old_id}%', f'%{old_id}%')
                )
                
                rows = cursor.fetchall()
                print(f"    Found {len(rows)} keys to update")
                
                for key, value in rows:
                    new_key = key.replace(old_id, new_id) if old_id in key else key
                    
                    new_value = value
                    if value:
                        if isinstance(value, bytes):
                            try:
                                value_str = value.decode('utf-8')
                                if old_id in value_str:
                                    new_value = value_str.replace(old_id, new_id).encode('utf-8')
                            except:
                                pass
                        elif isinstance(value, str) and old_id in value:
                            new_value = value.replace(old_id, new_id)
                    
                    if not dry_run:
                        if new_key != key:
                            # Delete old key, insert new
                            cursor.execute("DELETE FROM cursorDiskKV WHERE key = ?", (key,))
                            cursor.execute(
                                "INSERT OR REPLACE INTO cursorDiskKV (key, value) VALUES (?, ?)",
                                (new_key, new_value)
                            )
                        else:
                            # Just update value
                            cursor.execute(
                                "UPDATE cursorDiskKV SET value = ? WHERE key = ?",
                                (new_value, key)
                            )
                    
                    stats['keys_updated'] += 1
                    
                    if key.startswith('composerData:'):
                        stats['composers_fixed'] += 1
            
            if not dry_run:
                conn.commit()
            
            conn.close()
            
        except sqlite3.DatabaseError as e:
            stats['errors'].append(f"Database error: {e}")
        except Exception as e:
            stats['errors'].append(f"Error: {e}")
        
        return stats


def main():
    parser = argparse.ArgumentParser(
        description='Find and rescue orphaned chats in Cursor databases',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
This tool detects chats that became "orphaned" when workspace IDs changed.

Common causes of workspace ID changes:
  - git clone (new folder creation time)
  - Moving/copying project folders
  - Restoring from backup
  - Changing folder names

Examples:
    # Scan and report orphaned chats
    python find_orphaned_chats.py --scan
    
    # Preview fixes without making changes
    python find_orphaned_chats.py --fix --dry-run
    
    # Actually fix orphaned chats
    python find_orphaned_chats.py --fix
"""
    )
    
    parser.add_argument(
        '--scan',
        action='store_true',
        help='Scan for orphaned chats and report'
    )
    parser.add_argument(
        '--fix',
        action='store_true',
        help='Fix orphaned chats by remapping workspace IDs'
    )
    parser.add_argument(
        '--dry-run',
        action='store_true',
        help='Preview changes without modifying database'
    )
    parser.add_argument(
        '--no-backup',
        action='store_true',
        help='Skip creating backup before fixing'
    )
    parser.add_argument(
        '-v', '--verbose',
        action='store_true',
        help='Verbose output'
    )
    
    args = parser.parse_args()
    
    if not args.scan and not args.fix:
        args.scan = True  # Default to scan
    
    paths = get_cursor_paths()
    
    print("=" * 70)
    print("Orphaned Chat Finder")
    print("=" * 70)
    print(f"\nCursor User Dir: {paths['user']}")
    print(f"Global Database: {paths['global_db']}")
    print(f"Workspace Storage: {paths['workspace_storage']}")
    
    finder = OrphanedChatFinder(paths)
    
    # Load current workspaces
    print("\n" + "-" * 70)
    print("Loading current workspaces...")
    print("-" * 70)
    finder.load_current_workspaces()
    
    # Scan global database
    print("\n" + "-" * 70)
    print("Scanning global database for workspace references...")
    print("-" * 70)
    finder.scan_global_database()
    
    # Find orphaned workspaces
    print("\n" + "-" * 70)
    print("Identifying orphaned workspaces...")
    print("-" * 70)
    orphaned_ids = finder.find_orphaned_workspaces()
    
    if not orphaned_ids:
        print("\nNo orphaned workspaces found!")
        return 0
    
    # Analyze orphaned chats
    print("\n" + "-" * 70)
    print("Analyzing orphaned workspace data...")
    print("-" * 70)
    finder.analyze_orphaned_chats()
    finder.build_remap_table_from_workspace_dbs()
    
    # Report findings
    print("\n" + "=" * 70)
    print("ORPHANED WORKSPACES")
    print("=" * 70)
    
    remappable = []
    not_remappable = []
    
    for orphan_id, info in finder.orphaned_workspaces.items():
        if 'can_remap_to' in info:
            remappable.append((orphan_id, info))
        else:
            not_remappable.append((orphan_id, info))
    
    if remappable:
        print(f"\n### Can Be Remapped ({len(remappable)}) ###")
        for orphan_id, info in remappable:
            project = info.get('project', 'unknown')
            target = info.get('can_remap_to', 'unknown')
            keys = info.get('total_keys', 0)
            print(f"\n  Project: {project}")
            print(f"    Old ID: {orphan_id}")
            print(f"    New ID: {target}")
            print(f"    Keys to migrate: {keys}")
    
    if not_remappable:
        print(f"\n### Cannot Auto-Remap ({len(not_remappable)}) ###")
        for orphan_id, info in not_remappable[:10]:
            keys = info.get('total_keys', 0)
            project = info.get('project', 'unknown')
            print(f"  {orphan_id[:16]}... - {keys} keys (project: {project})")
        if len(not_remappable) > 10:
            print(f"  ... and {len(not_remappable) - 10} more")
    
    # Find orphaned composers
    print("\n" + "-" * 70)
    print("Scanning for orphaned composers...")
    print("-" * 70)
    orphaned_composers = finder.scan_for_orphaned_composers()
    
    if orphaned_composers:
        print(f"\nFound {len(orphaned_composers)} orphaned composers:")
        for comp in sorted(orphaned_composers, key=lambda x: x['created_at'], reverse=True)[:20]:
            from datetime import datetime
            date_str = datetime.fromtimestamp(comp['created_at']/1000).strftime('%Y-%m-%d') if comp['created_at'] else 'unknown'
            print(f"  [{date_str}] {comp['name'][:50]} ({comp['bubble_count']} bubbles)")
        if len(orphaned_composers) > 20:
            print(f"  ... and {len(orphaned_composers) - 20} more")
    
    # Fix if requested
    if args.fix:
        if not finder.remap_table:
            print("\nNo remappings available - nothing to fix automatically.")
            print("The orphaned workspaces may need manual investigation.")
            return 0
        
        print("\n" + "=" * 70)
        print("FIXING ORPHANED CHATS")
        print("=" * 70)
        
        if args.dry_run:
            print("\n[DRY RUN MODE]")
        else:
            print("\n[LIVE MODE]")
            
            if not args.no_backup:
                timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
                backup_path = paths['global_db'].parent / f"state.vscdb.backup-orphan-fix-{timestamp}"
                shutil.copy2(paths['global_db'], backup_path)
                print(f"Backup created: {backup_path}")
        
        stats = finder.fix_orphaned_chats(dry_run=args.dry_run)
        
        print(f"\nResults:")
        print(f"  Keys updated: {stats['keys_updated']}")
        print(f"  Composers fixed: {stats['composers_fixed']}")
        
        if stats['errors']:
            print(f"  Errors: {stats['errors']}")
        
        if args.dry_run:
            print("\n[DRY RUN] No changes were made.")
        else:
            print("\nNext steps:")
            print("1. Close Cursor IDE completely")
            print("2. Delete WAL files:")
            print(f"   Remove-Item '{paths['global_db'].parent / 'state.vscdb-wal'}' -Force -ErrorAction SilentlyContinue")
            print(f"   Remove-Item '{paths['global_db'].parent / 'state.vscdb-shm'}' -Force -ErrorAction SilentlyContinue")
            print("3. Start Cursor IDE")
            print("4. Check if previously missing chats are now visible")
    
    return 0


if __name__ == '__main__':
    sys.exit(main())


[And those lost 33 agents are only from one of my three PCs I work from!]

Hey, thanks for the report.

Losing chat history is a known issue that shows up in different ways. I’ll share your analysis with the team since identifying a specific cause (the workspace ID changing because of the folder timestamp) is really helpful for fixing it.

For now, just a heads up that community scripts that modify the DB are use at your own risk, like you said. That said, the approach of remapping workspace IDs does seem reasonable.

Let me know if you have any updates or extra observations.

Hey, @deanrie - serious question - is Cursor hiring? Would they consider a team-member based in Australia? I’ve ported cursor-agent to windows and raspberry-pi, fixed a range of IDE bugs that annoy me - all locally for my own sanity: and I generally know how to fix at least a few dozen more which just persist. I’m a professional coder (since 1982!), with security-clearance even: I’d love to get more officially involved and just get rid of all these problems that drive folks mad!

I had this problem and this fix worked perfectly for me. I accidentally moved the workspace file (it was located in a place I didn’t expect). Cursor generated a new id when it couldn’t find the workspace where it expected which explained the blank everything. Was able to reconnect my existing workspace via the instructions in the article.