* tiann/hapi#824 — sync-on-open auto-migration. Returns the (possibly * refreshed-from-cache) session. If the session is a legacy stream-json * Cursor session AND the env flag is on, attempts a transplant migration * synchronously before the caller spawns the runner. * * The
(session: Session, namespace: string)
| 823 | * caller proceeds with the legacy launcher. |
| 824 | */ |
| 825 | private async maybeAutoMigrateLegacyCursorSession(session: Session, namespace: string): Promise<Session> { |
| 826 | const md = session.metadata |
| 827 | const flagRaw = process.env.HAPI_CURSOR_LEGACY_AUTO_MIGRATE?.trim().toLowerCase() ?? '' |
| 828 | console.info('[auto-migrate] considering', { |
| 829 | sessionId: session.id, |
| 830 | flavor: md?.flavor ?? null, |
| 831 | proto: md?.cursorSessionProtocol ?? null, |
| 832 | hasCursorId: typeof md?.cursorSessionId === 'string' && md.cursorSessionId.length > 0, |
| 833 | envFlag: flagRaw === '' ? '(unset; default on)' : flagRaw |
| 834 | }) |
| 835 | |
| 836 | if (flagRaw === '0' || flagRaw === 'false' || flagRaw === 'no' || flagRaw === 'off') { |
| 837 | console.info('[auto-migrate] skipped: env flag disabled', { sessionId: session.id }) |
| 838 | return session |
| 839 | } |
| 840 | if (!md || md.flavor !== 'cursor') { |
| 841 | console.info('[auto-migrate] skipped: not a cursor session', { sessionId: session.id, flavor: md?.flavor ?? null }) |
| 842 | return session |
| 843 | } |
| 844 | if (md.cursorSessionProtocol === 'acp') { |
| 845 | console.info('[auto-migrate] skipped: already ACP', { sessionId: session.id }) |
| 846 | return session |
| 847 | } |
| 848 | if (typeof md.cursorSessionId !== 'string' || md.cursorSessionId.length === 0) { |
| 849 | console.info('[auto-migrate] skipped: no cursorSessionId', { sessionId: session.id }) |
| 850 | return session |
| 851 | } |
| 852 | |
| 853 | console.info('[auto-migrate] starting transplant', { sessionId: session.id, cursorSessionId: md.cursorSessionId }) |
| 854 | // UX A++: surface the migration to the user via a banner in the |
| 855 | // web UI. We set `cursorMigrationState='in_progress'` on the row |
| 856 | // BEFORE the long-running transplant; the sessionCache.refresh() |
| 857 | // call emits a `session-updated` SSE event the web client uses to |
| 858 | // render the banner. The flag is cleared on success by the same |
| 859 | // metadata write that flips cursorSessionProtocol to 'acp' (see |
| 860 | // flipCursorSessionProtocolToAcp) so the banner disappears in the |
| 861 | // same render tick the chat re-renders as ACP — no flicker. On |
| 862 | // failure we clear the flag explicitly in the catch path below. |
| 863 | const flagSet = this.setCursorMigrationStateInProgress(session.id, namespace) |
| 864 | let bannerCleanupNeeded = flagSet |
| 865 | try { |
| 866 | const migrator = this.buildMigratorForRequest({}) |
| 867 | // Codex #34 P2 (round 13): for inactive rows whose metadata |
| 868 | // still reads `lifecycleState === 'running'` (e.g. orphaned by |
| 869 | // a hub crash where the lifecycle transition didn't land), the |
| 870 | // migrator's preflight refuses with `running_refused` unless |
| 871 | // `forceArchiveRunning` is true. resumeSession's caller-side |
| 872 | // guard already ensured `session.active === false` (see the |
| 873 | // early-return above), so we know there's no runner to yank; |
| 874 | // the `running` lifecycle is stale metadata, not a live agent. |
| 875 | // This is exactly the stale-row case the sync-on-open path is |
| 876 | // meant to clean up — refusing here would defeat the whole |
| 877 | // point and silently fall back to the legacy launcher forever. |
| 878 | const outcome = await migrator.migrateOne(session, { forceArchiveRunning: true }) |
| 879 | if (outcome.ok) { |
| 880 | console.info('[auto-migrate] success', { |
| 881 | sessionId: session.id, |
| 882 | cursorSessionId: md.cursorSessionId, |