mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP (#844)
* feat(cursor): invisible sync-on-open migrator from legacy stream-json to ACP Closes #824 When the operator reopens a legacy stream-json Cursor session in HAPI, the hub now transparently transplants its `~/.cursor/chats/<wsh>/<uuid>/store.db` into `~/.cursor/acp-sessions/<uuid>/`, verifies it loads via `agent acp`, flips `metadata.cursorSessionProtocol = 'acp'`, and removes the legacy source - all before `resumeSession` returns. Subsequent opens are pure ACP. The primary justification is safety, not feature parity. #784 (`cursor-agent` fabricates `Questions skipped by the user` responses in legacy stream-json mode) still fires regularly in dogfood despite #801's mitigation: the agent ships destructive side effects against fabricated consent. Migration to ACP closes the protocol-level door because the `AskQuestion` tool does not exist on the ACP side, so there is nothing to fabricate. working. That tradeoff was reasonable at the time. The accumulated #784 evidence makes legacy sessions actively unsafe; this PR makes the upgrade path invisible enough that users stop avoiding it. A pre-PR spike established that legacy and ACP `store.db` files use the identical SQLite schema; only the directory layout differs. The migrator therefore: 1. Sanity-checks the source store and pre-flips state (`session.active`, `lifecycleState`, on-disk presence, target collision) 2. Optionally archives a stale-running row (`forceArchiveRunning: true` is the default for the auto-migrate path because the caller already verified `session.active === false`) 3. Atomically creates `~/.cursor/acp-sessions/<uuid>/` with mode `0o700` 4. Copies `store.db` and chmods to `0o600` (multi-user-host hardening) 5. Writes a minimal `meta.json` sidecar (`schemaVersion`, `cwd`, optional `title`) with mode `0o600` 6. Spawns `agent acp` under HAPI_HOME isolation and verifies the session loads via `session/load`. On long histories the verify also drives a trivial single-turn prompt; on short ones load-only is enough 7. Flips `cursorSessionProtocol = 'acp'` AND clears the `cursorMigrationState` banner flag in a SINGLE metadata write 8. Removes the legacy source store (only after verify succeeded and the protocol flip committed). The legacy `~/.cursor/chats` parent dir is left as-is Every failure leaves the legacy state intact. No `rm` fires without a verify success AND a committed protocol flip. The transplant takes 15-20s on long histories (copy a multi-hundred-MB store, spawn `agent acp`, replay thousands of notifications, tear down the probe). Without a progress indicator the wait reads as "broken" to a fresh reviewer. A minimal banner ships alongside the migrator: - Hub sets `metadata.cursorMigrationState = 'in_progress'` BEFORE the long-running transplant. The session-cache refresh emits the existing `session-updated` SSE event (no new event type), so the web client picks it up in milliseconds. No client-side polling needed. - Hub clears the flag in the SAME metadata write that flips `cursorSessionProtocol` to `'acp'` on success, so the banner disappears in the same render tick the chat re-renders as ACP - no flicker window. - Hub clears the flag explicitly in the auto-migrate helper's `finally` on failure/exception, so the banner never gets stuck if migration falls back to the legacy launcher. - Web renders an accessible (role=status, aria-live=polite) banner with an indeterminate spinner. Deliberately no fake percentage - we do not have phase data and a fake progress bar would lie. This PR is intentionally sequenced AFTER swear01's three ACP mop-up PRs (merged today asad038bbf,8094b500,fa363c2f), all of which are prerequisite for safe concurrent ACP launches. The verify probe spawns `agent acp` directly via `AcpVerifyProbe` under HAPI_HOME isolation (the migrator overrides `HOME` to a temp dir for the verify pass), so it never touches `<real-HAPI_HOME>/locks/agent-acp-active/` at all. Per swear01's #835 design note, the post-flip ACP launcher claims the lock through the standard `registerActiveAcpTransport` entry and behaves like any other concurrent ACP start. The migrator itself never writes `pid` or `count` files directly. The auto-migrate path is gated by `HAPI_CURSOR_LEGACY_AUTO_MIGRATE`. Set to `0`, `false`, `no`, or `off` to suppress it entirely (legacy sessions keep running through the existing stream-json launcher). Default is on. A REST endpoint at `POST /api/sessions/:id/migrate-to-acp` allows explicit migration of a single session outside the sync-on-open path (e.g. for a specific cold archived session a user wants to re-engage). Bulk migration surfaces (CLI subcommand, web button, bulk REST endpoint, candidate-listing API) were deliberately stripped per reviewer feedback; per-session sync-on-open + this escape hatch are the only two paths. - 343 hub unit tests (4 new for the migration banner flag transitions, 53 for the migrator core, 32 for the verify probe, 15 for the auto- migrate helper guard matrix, plus existing suites) - 3 integration tests against a real `agent acp` (skipped by default unless `HAPI_CURSOR_LEGACY_MIGRATOR_INTEGRATION=1`) - 10 web unit tests for the banner component (visibility paths + a11y) - Typecheck clean across cli, web, hub Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): address upstream Codex review findings on #844 (2 majors) Finding 1 (Major) — verify probe `agentLookupHome` ignored `metadata.homeDir` in service-account hub deployments. `migrateOne` resolved the legacy store under `metadata.homeDir` (the recorded session-owner home) but the default createProbe factory still set `agentLookupHome` from `this.deps.homeDir()` (the hub user's home). On a service-account hub, the store lookup succeeded but `agent acp` discovery fell back to the hub user's `~/.local/bin`, so verify silently failed and sync-on-open quietly fell back to legacy. Fix: - Widen `CursorLegacyMigratorDeps.createProbe` signature from `(env) => AcpVerifyProbe` to `(env, agentLookupHome) => AcpVerifyProbe` - Default factory uses the passed `agentLookupHome` - `verifyInTempHome` threads `opts.sourceHome` (already the resolved session-owner home) through as the 2nd arg 2 new regression tests pin the contract: - service-account case (metadata.homeDir != deps.homeDir()): captured agentLookupHome MUST equal metadata.homeDir - legacy session record (no metadata.homeDir): falls back to deps.homeDir() correctly Finding 2 (Major) — `bun.lock` win32-x64 pinned to 0.20.0 while `cli/package.json` required 0.20.1 (rebase artifact from the v0.20.0 → v0.20.1 release commit landing in upstream/main between the original spike and the rebase). Frozen-install Windows users would either get the wrong native binary or have the lock rejected. Fix: regenerated bun.lock so the entry resolves `@twsxtd/hapi-win32-x64@0.20.1`. `bun install --frozen-lockfile` now passes clean. Test budget: - 391 hub unit tests pass (2 new for createProbe agentLookupHome contract, +0 regressions) - Typecheck clean across cli + web + hub - `bun install --frozen-lockfile` clean Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -147,6 +147,40 @@ export const RenameSessionRequestSchema = z.object({
|
||||
|
||||
export type RenameSessionRequest = z.infer<typeof RenameSessionRequestSchema>
|
||||
|
||||
/** Per-session legacy stream-json → ACP migrator request. See tiann/hapi#824. */
|
||||
export const CursorMigrateToAcpRequestSchema = z.object({
|
||||
/** Skip removing the legacy ~/.cursor/chats source store.db even after verify passes. */
|
||||
keepSource: z.boolean().optional(),
|
||||
/** Allow migrating a session whose lifecycleState === 'running' by archiving it first. */
|
||||
forceArchiveRunning: z.boolean().optional(),
|
||||
/** Skip the verify-by-prompt step (session/load alone is run). */
|
||||
skipVerify: z.boolean().optional()
|
||||
})
|
||||
|
||||
export type CursorMigrateToAcpRequest = z.infer<typeof CursorMigrateToAcpRequestSchema>
|
||||
|
||||
export type CursorMigrateOutcome =
|
||||
| { ok: true; sessionId: string; acpSessionId: string; replayNotifications: number; durationMs: number; lastUsedModelPreserved: string | null; sourceRemoved: boolean }
|
||||
| { ok: false; sessionId: string; reason: CursorMigrateRefusalReason; message: string; durationMs: number }
|
||||
|
||||
export type CursorMigrateRefusalReason =
|
||||
| 'not_cursor_session'
|
||||
| 'already_acp'
|
||||
| 'running_refused'
|
||||
| 'no_cursor_session_id'
|
||||
| 'no_legacy_store_on_disk'
|
||||
| 'target_already_exists'
|
||||
| 'verify_load_failed'
|
||||
| 'verify_prompt_failed'
|
||||
| 'metadata_write_failed'
|
||||
| 'archive_failed'
|
||||
| 'lock_release_timeout'
|
||||
| 'acp_transport_active'
|
||||
| 'session_resumed_during_migrate'
|
||||
| 'legacy_store_modified_during_migrate'
|
||||
| 'cross_host_session'
|
||||
| 'internal_error'
|
||||
|
||||
export const UploadFileRequestSchema = z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
content: z.string().min(1),
|
||||
|
||||
@@ -39,6 +39,7 @@ export const MetadataSchema = z.object({
|
||||
opencodeSessionId: z.string().optional(),
|
||||
cursorSessionId: z.string().optional(),
|
||||
cursorSessionProtocol: z.enum(['acp', 'stream-json']).optional(),
|
||||
cursorMigrationState: z.enum(['in_progress']).optional(),
|
||||
kimiSessionId: z.string().optional(),
|
||||
tools: z.array(z.string()).optional(),
|
||||
slashCommands: z.array(z.string()).optional(),
|
||||
|
||||
Reference in New Issue
Block a user