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

* fix(cursor): migrator path-priority + ambiguity surface (closes #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.

* fix(cursor): address cold review on migrator path-priority fix

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.

* fix(cursor): cold-review polish on migrator path-priority (tiann/hapi#873)

- 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/hapi#873.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): correct log-search strings in ambiguous banner copy

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/hapi#877 bot finding (Minor).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cursor): address #877 bot Minor findings (trim + boundary guard)

- 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/hapi#877 bot review Minor findings.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-11 09:11:58 +08:00
committed by GitHub
co-authored by Cursor
parent 434cd9021d
commit 3e2e48222a
12 changed files with 932 additions and 43 deletions
@@ -1,7 +1,7 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, cleanup } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
import { CursorMigrationBanner, isCursorMigrationInProgress } from './CursorMigrationBanner'
import { CursorMigrationBanner, isCursorMigrationAmbiguous, isCursorMigrationInProgress } from './CursorMigrationBanner'
import type { Metadata } from '@/types/api'
function renderWithProviders(ui: React.ReactElement) {
@@ -83,4 +83,31 @@ describe('CursorMigrationBanner', () => {
const status = screen.getByRole('status')
expect(status).toHaveAttribute('aria-live', 'polite')
})
/**
* tiann/hapi#873: when the migrator refuses to transplant a legacy
* store (ambiguous source or size mismatch), the hub promotes
* cursorMigrationState to 'ambiguous'. The banner must switch to a
* "manual review needed" surface rather than disappear.
*/
it('renders the ambiguous banner when cursorMigrationState is ambiguous', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
expect(screen.getByTestId('cursor-migration-banner-ambiguous')).toBeInTheDocument()
expect(screen.getByText('Cursor session upgrade needs manual review')).toBeInTheDocument()
expect(screen.queryByTestId('cursor-migration-banner')).not.toBeInTheDocument()
})
it('uses role=alert on the ambiguous banner so it surfaces over the in-progress styling', () => {
renderWithProviders(<CursorMigrationBanner metadata={metadata({ cursorMigrationState: 'ambiguous' })} />)
const alert = screen.getByRole('alert')
expect(alert).toHaveAttribute('aria-live', 'polite')
})
it('isCursorMigrationAmbiguous returns true only for the ambiguous state', () => {
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'ambiguous' }))).toBe(true)
expect(isCursorMigrationAmbiguous(metadata({ cursorMigrationState: 'in_progress' }))).toBe(false)
expect(isCursorMigrationAmbiguous(metadata())).toBe(false)
expect(isCursorMigrationAmbiguous(null)).toBe(false)
expect(isCursorMigrationAmbiguous(undefined)).toBe(false)
})
})
+56 -20
View File
@@ -30,29 +30,65 @@ export function isCursorMigrationInProgress(metadata: Metadata | undefined | nul
return metadata.cursorMigrationState === 'in_progress'
}
/**
* tiann/hapi#873: the migrator refused to transplant a legacy store -
* either because the same cursorSessionId exists in multiple workspace-hash
* drawers (`ambiguous_legacy_store`) or because the candidate's blob count
* is dramatically lower than HAPI's known history (`size_mismatch`). The
* hub promotes `cursorMigrationState` from 'in_progress' to 'ambiguous' so
* this banner can switch from "Upgrading..." to a "manual review needed"
* surface instead of disappearing silently.
*/
export function isCursorMigrationAmbiguous(metadata: Metadata | undefined | null): boolean {
if (!metadata) return false
return metadata.cursorMigrationState === 'ambiguous'
}
export function CursorMigrationBanner({ metadata }: { metadata: Metadata | undefined | null }) {
const { t } = useTranslation()
if (!isCursorMigrationInProgress(metadata)) {
return null
}
return (
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
<div
role="status"
aria-live="polite"
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
>
<span
aria-hidden="true"
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<div className="min-w-0 flex-1">
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
<div className="text-xs text-[var(--app-hint)]">
{t('session.cursorMigration.banner.body')}
if (isCursorMigrationInProgress(metadata)) {
return (
<div className="px-3 pt-3" data-testid="cursor-migration-banner">
<div
role="status"
aria-live="polite"
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
>
<span
aria-hidden="true"
className="mt-0.5 inline-block h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-t-transparent"
/>
<div className="min-w-0 flex-1">
<div className="font-medium">{t('session.cursorMigration.banner.title')}</div>
<div className="text-xs text-[var(--app-hint)]">
{t('session.cursorMigration.banner.body')}
</div>
</div>
</div>
</div>
</div>
)
)
}
if (isCursorMigrationAmbiguous(metadata)) {
return (
<div className="px-3 pt-3" data-testid="cursor-migration-banner-ambiguous">
<div
role="alert"
aria-live="polite"
className="mx-auto flex w-full max-w-content items-start gap-3 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] p-3 text-sm text-[var(--app-text)]"
>
<span
aria-hidden="true"
className="mt-0.5 inline-block h-4 w-4 shrink-0 rounded-full border-2 border-current"
>!</span>
<div className="min-w-0 flex-1">
<div className="font-medium">{t('session.cursorMigration.bannerAmbiguous.title')}</div>
<div className="text-xs text-[var(--app-hint)]">
{t('session.cursorMigration.bannerAmbiguous.body')}
</div>
</div>
</div>
</div>
)
}
return null
}
+2
View File
@@ -120,6 +120,8 @@ export default {
'session.time.importedFromCodex.daysAgo': 'imported from Codex {n}d ago',
'session.cursorMigration.banner.title': 'Upgrading Cursor session',
'session.cursorMigration.banner.body': 'Switching this legacy chat to the safer ACP protocol. This takes 15-20 seconds for sessions with long history; your conversation will resume automatically and any draft text will survive.',
'session.cursorMigration.bannerAmbiguous.title': 'Cursor session upgrade needs manual review',
'session.cursorMigration.bannerAmbiguous.body': 'This chat exists on disk in multiple workspace folders or the on-disk size does not match the synced history, so HAPI refused to transplant it automatically. Check hub logs for the candidate list (search for "[migrator] ambiguous legacy store" or "[migrator] size sanity check refused"), delete the stale drawers under ~/.cursor/chats/, and reopen the session.',
// Session inactive
'session.inactive.autoResume': 'This session is inactive. Send a message to resume.',
+2
View File
@@ -120,6 +120,8 @@ export default {
'session.time.importedFromCodex.daysAgo': '{n} 天前从codex客户端导入',
'session.cursorMigration.banner.title': '正在升级 Cursor 会话',
'session.cursorMigration.banner.body': '正在将此旧版会话切换到更安全的 ACP 协议。历史较长的会话需要 15-20 秒;对话会自动恢复,已输入但未发送的草稿不会丢失。',
'session.cursorMigration.bannerAmbiguous.title': 'Cursor 会话升级需要人工处理',
'session.cursorMigration.bannerAmbiguous.body': '此会话在磁盘上的多个工作区下存在,或本地存储大小与已同步的历史记录不匹配,因此 HAPI 拒绝自动迁移。请在 hub 日志中搜索 "[migrator] ambiguous legacy store" 或 "[migrator] size sanity check refused" 获取候选列表,删除 ~/.cursor/chats/ 下的陈旧目录后,再重新打开此会话。',
// Session inactive
'session.inactive.autoResume': '此会话已停止。发送消息即可恢复。',