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>:
- Open the cursor session in HAPI. The auto-migrator fires inside
resumeSession → maybeAutoMigrateLegacyCursorSession.
findLegacyChatStore('<sid>', $HOME) returns whichever drawer readdir handed back first - which is filesystem-order, not the canonical cwd-derived drawer.
- 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.
- 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)
-
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.
-
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.
-
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).
-
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.
Bug
The legacy stream-json → ACP migrator added in #844 picks the wrong source store when the same
cursorSessionIdexists under more than one workspace-hash directory.findLegacyChatStore()inhub/src/cursor/cursorLegacyMigrator.tsiteratesreaddirSync(~/.cursor/chats)and returns the first<wsh>/<cursorSessionId>/store.dbit 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.dband~/.cursor/chats/<wsh-B>/<sid>/store.dbboth exist for the same<sid>:resumeSession→maybeAutoMigrateLegacyCursorSession.findLegacyChatStore('<sid>', $HOME)returns whichever drawer readdir handed back first - which is filesystem-order, not the canonical cwd-derived drawer.cps the alienstore.dbinto~/.cursor/acp-sessions/<sid>/store.db, writes the meta sidecar, runs the verify probe (which only checks thatsession/loadsucceeds against the transplanted store - "loads cleanly" is not the same as "loaded the right content"), thenrms the source.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
findLegacyChatStoreinhub/src/cursor/cursorLegacyMigrator.ts: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 couldmd5()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)
Path-priority discovery in
findLegacyChatStore- take an optional 3rd arg = canonical workspace path. Computemd5(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.Ambiguity surface in caller -
maybeAutoMigrateLegacyCursorSession()catches the ambiguous outcome, sets a newcursorMigrationState='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.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-ablegetHapiMessageCountmigrator dep) againstSELECT COUNT(*) FROM blobson the candidate. If HAPI count > 100 AND candidate blobs < count/4, refuse withsize_mismatchand surface the same ambiguous banner. Skip entirely when HAPI count is 0 (brand-new / never-synced session - tiny store is legitimate there).Diagnostic logging at info level on every successful transplant -
[migrator] transplantedlog capturingcursorSessionId, pickedworkspaceHash, total candidate count discovered (1 / N),sourceBytes,sourceBlobCount,targetAcpPath,sourceRemoved,canonicalHash. Future regressions of this shape diagnosable fromjournalctl -u hapi-hubalone, without needing forensic blob-overlap comparison on the destination store.Tests
Unit tests in
hub/src/cursor/cursorLegacyMigrator.test.ts:AmbiguousLegacyStoreErrorlisting all threeAmbiguousLegacyStoreErrorlisting all threesize_mismatchPlus
hub/src/sync/syncEngineAutoMigrate.test.tstest that the banner gets promoted from'in_progress'to'ambiguous'on both refusal reasons.Notes
55d1bbb7 feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP).I have a fork-staged PR ready and will open it against
tiann/hapi:mainafter the fork-side bot pass. Filing this issue first so the PR has aCloses #Nto reference.