Skip to content

bug(cursor): legacy-to-ACP migrator picks wrong source store when cursorSessionId exists in multiple workspace hashes (#844 regression) #873

Description

@heavygee

Bug

The legacy stream-json → ACP migrator added in #844 picks the wrong source store when the same cursorSessionId exists under more than one workspace-hash directory.

findLegacyChatStore() in hub/src/cursor/cursorLegacyMigrator.ts iterates readdirSync(~/.cursor/chats) and returns the first <wsh>/<cursorSessionId>/store.db it finds. Whenever an operator has the same cursor session id sitting in multiple workspace-hash drawers - which happens when the session was opened from a worktree, a sibling clone, or any non-canonical cwd at some point in its life - the readdir order picks an arbitrary candidate.

Repro

On a host where ~/.cursor/chats/<wsh-A>/<sid>/store.db and ~/.cursor/chats/<wsh-B>/<sid>/store.db both exist for the same <sid>:

  1. Open the cursor session in HAPI. The auto-migrator fires inside resumeSessionmaybeAutoMigrateLegacyCursorSession.
  2. findLegacyChatStore('<sid>', $HOME) returns whichever drawer readdir handed back first - which is filesystem-order, not the canonical cwd-derived drawer.
  3. Migrator cps the alien store.db into ~/.cursor/acp-sessions/<sid>/store.db, writes the meta sidecar, runs the verify probe (which only checks that session/load succeeds against the transplanted store - "loads cleanly" is not the same as "loaded the right content"), then rms the source.
  4. Migration is reported successful. Session opens. History is whatever was in the wrong drawer.

Symptom

Session resurrects with no recall of prior history. The web "Upgrading Cursor session" banner shows briefly, then disappears, and the chat re-renders empty (or with whatever stale content the alien store carried).

Real-world hit (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21 103-blob history, two with stale 19 / 568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content over the ACP target, deleted the source drawer, and marked migration successful. The verify probe gave a false positive because the alien store loads cleanly.

Root cause

findLegacyChatStore in hub/src/cursor/cursorLegacyMigrator.ts:

for (const wsh of entries) {
    const candidate = join(chatsRoot, wsh, cursorSessionId, 'store.db')
    try {
        const st = statSync(candidate)
        if (st.isFile()) {
            return { workspaceHash: wsh, storeDbPath: candidate }
        }
    } catch { /* keep scanning */ }
}

First-match-wins is fine in the happy single-drawer case but unsound when 2+ drawers carry the same <sid>. The function has no awareness of (a) the canonical workspace path it could md5() to jump straight to the right drawer, (b) the size/blob-count of HAPI's known history that would let it sanity-check the candidate.

Proposed fix (4 parts)

  1. Path-priority discovery in findLegacyChatStore - take an optional 3rd arg = canonical workspace path. Compute md5(canonicalPath) and check that drawer FIRST. Only fall back to the readdir scan if the canonical drawer is empty. When falling back: if 2+ drawers contain the session id, return a structured ambiguity error listing every candidate (workspaceHash, sizeBytes, mtimeMs) rather than silently picking one. Keep the 1-candidate happy path.

  2. Ambiguity surface in caller - maybeAutoMigrateLegacyCursorSession() catches the ambiguous outcome, sets a new cursorMigrationState='ambiguous' flag (instead of clearing 'in_progress' silently), and the web banner switches from "Upgrading..." to "Manual resolution needed" with the candidate list. Operator can then verify which drawer is real and delete the others before retrying.

  3. Size sanity check before transplant - even when discovery picks one candidate unambiguously, compare HAPI's known message count for the session (new MessageStore.countMessages(sessionId) + DI-able getHapiMessageCount migrator dep) against SELECT COUNT(*) FROM blobs on the candidate. If HAPI count > 100 AND candidate blobs < count/4, refuse with size_mismatch and surface the same ambiguous banner. Skip entirely when HAPI count is 0 (brand-new / never-synced session - tiny store is legitimate there).

  4. Diagnostic logging at info level on every successful transplant - [migrator] transplanted log capturing cursorSessionId, picked workspaceHash, total candidate count discovered (1 / N), sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonicalHash. Future regressions of this shape diagnosable from journalctl -u hapi-hub alone, without needing forensic blob-overlap comparison on the destination store.

Tests

Unit tests in hub/src/cursor/cursorLegacyMigrator.test.ts:

  • single-drawer → returns it (regression guard for the happy case)
  • 3 drawers + canonical path matching one → returns the canonical-hash drawer, NOT the first readdir match (verified by controlling creation order in tmpfs)
  • 3 drawers + no canonical path → throws AmbiguousLegacyStoreError listing all three
  • 3 drawers + canonical path matching none → throws AmbiguousLegacyStoreError listing all three
  • 19-blob candidate vs 6000-message HAPI session → refuses with size_mismatch
  • 21k-blob candidate vs same → proceeds
  • 0-message HAPI session + 19-blob candidate → size check skipped, proceeds

Plus hub/src/sync/syncEngineAutoMigrate.test.ts test that the banner gets promoted from 'in_progress' to 'ambiguous' on both refusal reasons.

Notes

I have a fork-staged PR ready and will open it against tiann/hapi:main after the fork-side bot pass. Filing this issue first so the PR has a Closes #N to reference.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions