Skip to content

fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression)#877

Merged
tiann merged 5 commits into
tiann:mainfrom
heavygee:fix/cursor-migrator-path-priority
Jun 11, 2026
Merged

fix(cursor): migrator path-priority + ambiguity surface (closes #844 regression)#877
tiann merged 5 commits into
tiann:mainfrom
heavygee:fix/cursor-migrator-path-priority

Conversation

@heavygee

Copy link
Copy Markdown
Collaborator

Summary

  • Part 1 - Path-priority discovery: findLegacyChatStore() now accepts an optional canonical workspace path. It computes md5(canonicalPath) and checks that drawer first; only falls back to readdirSync scanning 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.
  • Part 2 - Ambiguity surface: When multiple drawers contain the same cursorSessionId and none matches the canonical path hash, the migrator throws AmbiguousLegacyStoreError (listing all candidates with workspaceHash, bytes, mtimeMs) instead of silently transplanting. The caller in syncEngine promotes the session's cursorMigrationState to 'ambiguous' and the web CursorMigrationBanner renders a "Manual review needed" state so the operator can resolve on disk.
  • Part 3 - Size sanity check before transplant: Before copying, the migrator compares HAPI's known message count for the session against the candidate store's SELECT COUNT(*) FROM blobs. If HAPI message count > 100 AND candidate blob count < messageCount / 4, the transplant is refused with size_mismatch and the ambiguous banner is surfaced. Skipped when HAPI message count == 0 (brand-new session).
  • Part 4 - Diagnostic logging on successful transplant: Every successful transplant now logs migrator:transplanted at info with cursorSessionId, workspaceHash, total candidate count at discovery time, sourceBytes, sourceBlobCount, targetAcpPath, sourceRemoved, and canonicalPathMd5. Future session losses are diagnosable from journalctl alone.

Test plan

Extended hub/src/cursor/cursorLegacyMigrator.test.ts:

  • Single drawer -> returns it (regression guard)
  • Three drawers + canonical path matching one -> returns canonical-hash drawer, not first readdir match
  • Three drawers + no canonical path -> throws AmbiguousLegacyStoreError listing all three
  • Three drawers + canonical path matching none -> throws AmbiguousLegacyStoreError
  • Size sanity: 19-blob candidate vs 6000-message HAPI session -> refuses size_mismatch; 200-blob padded candidate vs same -> proceeds
  • 0-message HAPI session + 19-blob candidate -> size check skipped, proceeds
  • messageCount = 101 boundary test (first value above skip threshold)
  • workspaceHashFromPath contract pinned against independently-computed md5sum values (prevents algorithm drift)
  • countLegacyStoreBlobs and listLegacyChatStoreCandidates unit tests

Added hub/src/sync/syncEngineAutoMigrate.test.ts tests:

  • cursorMigrationState promoted to 'ambiguous' on both ambiguous_legacy_store and size_mismatch refusals

All existing hub/src/cursor/*.test.ts and hub/src/sync/*.test.ts pass. bun typecheck && bun run test green (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.

heavygee and others added 3 commits June 10, 2026 18:38
…#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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 transplant and [migrator] size sanity check refused transplant instead. 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

Comment thread web/src/lib/locales/en.ts Outdated
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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 — findLegacyChatStore now protects against traversal ids, but the newly exported listLegacyChatStoreCandidates joins 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.db shape. 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: bun is not installed in this runner)

HAPI Bot

Comment thread hub/src/cursor/cursorLegacyMigrator.ts Outdated
Comment thread hub/src/cursor/cursorLegacyMigrator.ts
…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>

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: bun is not installed in this runner)

HAPI Bot

@tiann tiann merged commit 3e2e482 into tiann:main Jun 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants