fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression)#877
Conversation
…#844 regression) The legacy-to-ACP migrator's `findLegacyChatStore()` walks `~/.cursor/chats/<workspace-hash>/<cursorSessionId>/store.db` via `readdirSync()` and returns the FIRST match. When the same cursor session id exists in more than one workspace-hash drawer (operator opened the session from a worktree, an old workspace clone, etc.) the readdir order picks an arbitrary candidate. The migrator then transplants alien content into the ACP target, deletes the source drawer, and reports success - because the verify probe only checks "loads cleanly", not "loaded the right content". Operator session resurrects with no recall of its real history. Four-part fix (all four must land together): 1. Path-priority discovery in `findLegacyChatStore(id, home, cwd?)`: - Optional 3rd arg = canonical workspace path (caller passes `session.metadata.path`). - Compute md5(cwd) and check that drawer FIRST. - Fall back to readdir scan only if the canonical drawer is empty. - If 2+ candidates remain after fallback, throw `AmbiguousLegacyStoreError` listing all of them (workspaceHash, sizeBytes, mtimeMs). 2. Ambiguity surface in `maybeAutoMigrateLegacyCursorSession`: - Catch `ambiguous_legacy_store` / `size_mismatch` refusals and promote `cursorMigrationState` from 'in_progress' to a new 'ambiguous' state instead of silently clearing the banner. Operator sees an actionable web-banner. 3. Size sanity check before transplant: - Compare HAPI's known message count (new `MessageStore.countMessages` + `CursorLegacyMigratorDeps.getHapiMessageCount` dep) against the candidate `store.db`'s blob count. If message count > 100 AND blob count < messageCount/4, refuse with `size_mismatch`. - Skipped when message count is 0 (brand-new session) or the dep is unwired (unit tests, CLI direct callers). 4. Diagnostic logging on every successful transplant: - `[migrator] transplanted` info log capturing cursorSessionId, picked workspaceHash, candidate count discovered, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, canonical-path md5. Future regressions of this bug shape are diagnosable from `journalctl -u hapi-hub` without blob-overlap forensics. Tests added in `hub/src/cursor/cursorLegacyMigrator.test.ts`: - regression guard for single-drawer discovery - canonical-path wins over readdir order - ambiguity throws with all candidates listed (3-drawer + 2-drawer no-canonical-arg variants) - canonical-path resolves ambiguity cleanly - listLegacyChatStoreCandidates enumeration - workspaceHashFromPath shape - migrateOne happy path with canonical workspace + 3 sibling decoys - migrateOne refuses with ambiguous_legacy_store (3 drawers, no canonical match) and leaves all sources untouched - migrateOne proceeds when canonical path resolves - size_mismatch refuses tiny candidate when messageCount=6000 - size_mismatch passes when candidate blob count meets the floor - size sanity skipped on messageCount=0, missing dep, throwing dep, boundary (messageCount=100) - countLegacyStoreBlobs returns counts / null on bad path And in `hub/src/sync/syncEngineAutoMigrate.test.ts`: - cursorMigrationState promoted to 'ambiguous' on ambiguous_legacy_store / size_mismatch refusals. Schema: - `shared/src/schemas.ts`: cursorMigrationState enum gains 'ambiguous'. - `shared/src/apiTypes.ts`: CursorMigrateRefusalReason gains 'ambiguous_legacy_store' + 'size_mismatch'. Real-world repro (operator's tooling session, 2026-06-09): three legacy drawers contained one cursor session id - one with the real 21k-blob history, two with stale 19/568-blob diagnostic snapshots. Migrator silently transplanted the 568-blob alien content; resurrected session had no memory of prior history. Manual rescue completed; this fix prevents recurrence and surfaces the ambiguity to the operator instead.
Self-review against the cold-PR rubric surfaces four polish items on
the previous commit; all four addressed in-loop before push.
- Major: `migrator:transplanted` candidate count was captured AFTER
the source rm, so for the dominant single-candidate happy path the
log reported `candidateCount=0, sourceRemoved=true`. Useless for
diagnosing a future regression of the bug shape this PR is fixing.
Snapshot candidates + source-side size + source-side blob count
BEFORE any destructive step and use those for the log.
- Minor: `sourceBytes` and `sourceBlobCount` were read from the
destination path (acpSessionDir/store.db). The cp guarantees they
match, but the field names imply source-side measurement. Now they
measure the source directly.
- Minor: `setCursorMigrationStateAmbiguous` silently returned false on
cache miss / repeated version mismatch / write failure, letting the
finally{} block clear the banner without any log. Now emits a
warn-level log so the gap is diagnosable from journalctl.
- Minor: `findLegacyChatStore` is exported public API and used as a
free function in unit tests. An out-of-band caller bypassing
preflightSession could pass `..` or `/etc/passwd` and have the inner
`join(chatsRoot, wsh, id, 'store.db')` resolve to an arbitrary on-
disk path. The probe is read-only `statSync` so blast radius is
small, but enforce the same CURSOR_SESSION_ID_RE at the function
boundary as a defence-in-depth. New unit test locks the behaviour.
Hub test suite: 414 pass, 0 fail. Typecheck clean across cli/web/hub.
- Web `CursorMigrationBanner` now renders a "Manual review needed" state for `cursorMigrationState === 'ambiguous'` (Major #1: caller was promoting the metadata flag but no UI surfaced it). - Pin the md5-fixture contract for `workspaceHashFromPath`: raw, no-normalization, trailing-slash-distinct hashes computed via `printf '%s' <path> | md5sum` (Major #2: prevents algorithm drift that would silently revert path-priority discovery to fallback). - Snapshot full candidate set BEFORE the canonical fast-path resolves a single drawer so the `migrator:transplanted` log reports the decision-time count, not a post-rm undercount (Minor #1). - Warn log when canonical-path drawer is missing but readdir hands back exactly one candidate - regression-equivalent behaviour, but the size mismatch warrants a journalctl trail (path-normalization corner case the maintainer can grep for). - Boundary test: `messageCount = 101` (first value above the skip threshold) engages the size sanity check, pinning the cutoff contract (Nit). - Schema docstring on `cursorMigrationState` enum spelling out the banner contract per value (Nit). - syncEngine `getHapiMessageCount` warn-logs `countMessages` throws instead of silently downgrading to 0 (would chronically disable the floor). Drafted with claude-4.6-sonnet-thinking via Cursor; reviewed and tested by the operator. tiann#873. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
- [Minor] Banner points operators at log tags that are never emitted — the new ambiguous-state copy tells users to search for
migrator:ambiguous_legacy_store/migrator:size_mismatch, but the server logs[migrator] ambiguous legacy store; refusing transplantand[migrator] size sanity check refused transplantinstead. That makes the manual-recovery path harder right when the user needs the candidate list. Evidence:web/src/lib/locales/en.ts:124,web/src/lib/locales/zh-CN.ts:124,hub/src/cursor/cursorLegacyMigrator.ts:621,hub/src/cursor/cursorLegacyMigrator.ts:681.
Suggested fix:log.warn('migrator:ambiguous_legacy_store', { sessionId: session.id, cursorSessionId, canonicalWorkspacePath: canonicalWorkspacePath.length > 0 ? canonicalWorkspacePath : null, canonicalHash: canonicalHashStr, candidates: err.candidates }) log.warn('migrator:size_mismatch', { sessionId: session.id, cursorSessionId, ...sizeMismatch.context })
Summary
- Review mode: initial
- One recovery-path issue found. I did not find other high-confidence correctness/security regressions in the changed migrator, sync-state promotion, schemas, or banner behavior. Residual risk: the size threshold is heuristic and should be watched in real deployments.
Testing
- Not run (automation)
HAPI Bot
The en/zh-CN locale strings told users to grep for 'migrator:ambiguous_legacy_store' and 'migrator:size_mismatch' but the hub emits '[migrator] ambiguous legacy store; refusing transplant' and '[migrator] size sanity check refused transplant'. Fix both locale files to quote the actual log prefix so the journalctl grep the operator is directed to actually hits. Addresses tiann#877 bot finding (Minor). Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
-
[Minor] Canonical path hashing trims valid path bytes — Cursor's drawer name is pinned as md5 of the raw workspace path, but the lookup trims before hashing, so a valid workspace path with leading/trailing spaces hashes to the wrong drawer and can either fall back to a stale single candidate or surface a false ambiguity. Evidence:
hub/src/cursor/cursorLegacyMigrator.ts:292.
Suggested fix:const canonicalPath = typeof sessionWorkspacePath === 'string' ? sessionWorkspacePath : '' if (canonicalPath.length > 0) { const canonicalHash = workspaceHashFromPath(canonicalPath) // ... }
-
[Minor] Exported candidate scanner bypasses the cursorSessionId basename guard —
findLegacyChatStorenow protects against traversal ids, but the newly exportedlistLegacyChatStoreCandidatesjoins the id directly into each drawer path. Any future direct caller can reintroduce the same boundary bug and receive stat metadata for paths outside the intended<wsh>/<cursorSessionId>/store.dbshape. Evidence:hub/src/cursor/cursorLegacyMigrator.ts:322.
Suggested fix:export function listLegacyChatStoreCandidates(cursorSessionId: string, home: string): LegacyStoreCandidate[] { if (!CURSOR_SESSION_ID_RE.test(cursorSessionId) || cursorSessionId === '.' || cursorSessionId === '..') { return [] } const chatsRoot = join(home, '.cursor', 'chats') // ... }
Summary
- Review mode: follow-up after new commits
- Two minor lookup-boundary issues remain in the new migrator helpers. The prior bot finding about mismatched log-search strings was addressed in this follow-up.
Testing
- Not run (automation:
bunis not installed in this runner)
HAPI Bot
…ard) - Remove .trim() from canonical path before hashing: Cursor hashes raw workspace-path bytes; trimming a POSIX path with leading/ trailing spaces would hash to the wrong drawer, causing a false canonical miss and potential ambiguity refusal. - Add CURSOR_SESSION_ID_RE guard to listLegacyChatStoreCandidates: the function was exported without the same traversal-ID boundary check present in findLegacyChatStore. A future direct caller bypassing findLegacyChatStore could stat paths outside the intended <wsh>/<cursorSessionId>/store.db shape. - Move CURSOR_SESSION_ID_RE declaration above both functions that reference it so there is no temporal-dead-zone hazard. Addresses tiann#877 bot review Minor findings. Co-authored-by: Cursor <cursoragent@cursor.com>
There was a problem hiding this comment.
Findings
None.
Summary
- Review mode: follow-up after new commits
- No issues found in the current diff. The prior bot findings about raw-path hashing and scanner boundary validation are addressed in this head. Residual risk is in the heuristic nature of the message-count/blob-count size sanity floor; the PR adds targeted migrator and auto-migration banner coverage for that path.
Testing
- Not run (automation:
bunis not installed in this runner)
HAPI Bot
Summary
findLegacyChatStore()now accepts an optional canonical workspace path. It computesmd5(canonicalPath)and checks that drawer first; only falls back toreaddirSyncscanning when the canonical drawer has no match. This eliminates the readdir-ordering-dependent first-match-wins behaviour that caused the feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP #844 regression.cursorSessionIdand none matches the canonical path hash, the migrator throwsAmbiguousLegacyStoreError(listing all candidates withworkspaceHash,bytes,mtimeMs) instead of silently transplanting. The caller insyncEnginepromotes the session'scursorMigrationStateto'ambiguous'and the webCursorMigrationBannerrenders a "Manual review needed" state so the operator can resolve on disk.SELECT COUNT(*) FROM blobs. If HAPI message count > 100 AND candidate blob count <messageCount / 4, the transplant is refused withsize_mismatchand the ambiguous banner is surfaced. Skipped when HAPI message count == 0 (brand-new session).migrator:transplantedat info withcursorSessionId,workspaceHash, total candidate count at discovery time,sourceBytes,sourceBlobCount,targetAcpPath,sourceRemoved, andcanonicalPathMd5. Future session losses are diagnosable fromjournalctlalone.Test plan
Extended
hub/src/cursor/cursorLegacyMigrator.test.ts:AmbiguousLegacyStoreErrorlisting all threeAmbiguousLegacyStoreErrorsize_mismatch; 200-blob padded candidate vs same -> proceedsmessageCount = 101boundary test (first value above skip threshold)workspaceHashFromPathcontract pinned against independently-computedmd5sumvalues (prevents algorithm drift)countLegacyStoreBlobsandlistLegacyChatStoreCandidatesunit testsAdded
hub/src/sync/syncEngineAutoMigrate.test.tstests:cursorMigrationStatepromoted to'ambiguous'on bothambiguous_legacy_storeandsize_mismatchrefusalsAll existing
hub/src/cursor/*.test.tsandhub/src/sync/*.test.tspass.bun typecheck && bun run testgreen (82 hub migrator tests, 964 web tests, 972 total non-integration tests).Closes #873
Disclosure
Drafted with claude-sonnet-4-5 (claude-4.6-sonnet-medium-thinking) via Cursor; reviewed and tested by the human contributor.