* Apply the post-migration metadata flip in hapi.db: * - metadata.cursorSessionProtocol = 'acp' * - session.model = lastUsedModel (if provided) * * Returns 'success' on a clean write, 'version-mismatch' if the metadata * version moved underneath us (caller retries) or 'n
(
sessionId: string,
namespace: string,
lastUsedModel: string | null
)
| 446 | * writes funnel through the existing cache-refresh path. |
| 447 | */ |
| 448 | flipCursorSessionProtocolToAcp( |
| 449 | sessionId: string, |
| 450 | namespace: string, |
| 451 | lastUsedModel: string | null |
| 452 | ): { result: 'success' | 'version-mismatch' | 'not-found' | 'session-active' } { |
| 453 | for (let attempt = 0; attempt < 2; attempt += 1) { |
| 454 | const latest = this.sessionCache.getSessionByNamespace(sessionId, namespace) |
| 455 | ?? this.sessionCache.refreshSession(sessionId) |
| 456 | if (!latest?.metadata) { |
| 457 | return { result: 'not-found' } |
| 458 | } |
| 459 | // Combined SSE-event payload contract (UX A++): clear the |
| 460 | // `cursorMigrationState='in_progress'` flag in the SAME metadata |
| 461 | // write that flips `cursorSessionProtocol` to 'acp'. The web |
| 462 | // banner keys off `cursorMigrationState`, so a single SSE |
| 463 | // session-updated event swaps both atomically — banner gone, |
| 464 | // protocol flipped — preventing a flicker window where the |
| 465 | // banner has already disappeared but the chat hasn't re-rendered |
| 466 | // to the ACP transport yet. |
| 467 | const carriedMigrationState = latest.metadata.cursorMigrationState |
| 468 | // Atomic active-check inside the same synchronous flip op so |
| 469 | // that a resume cannot land between the migrator's recheck |
| 470 | // and the actual DB update. Bun is single-threaded — once |
| 471 | // we've read `latest` and the row is inactive, no other JS |
| 472 | // can mutate active=true until this method returns. Codex |
| 473 | // review #34 P1 v2: the migrator's recheck is best-effort; |
| 474 | // this is the authoritative gate. |
| 475 | // |
| 476 | // Codex review #34 P2 v5: only block on `active === true`, |
| 477 | // NOT on lifecycleState === 'running'. After a force-archive |
| 478 | // flow archiveSession() synchronously sets active=false but |
| 479 | // the cleanup metadata write that flips lifecycleState |
| 480 | // 'running' → 'archived' may still be in-flight, and that |
| 481 | // is OUR archive completing, not a resume race. The active |
| 482 | // flag is the authoritative live-runner signal. |
| 483 | if (latest.active === true) { |
| 484 | return { result: 'session-active' } |
| 485 | } |
| 486 | // Codex review #34 P2 v7: ALSO clear a stale lifecycleState |
| 487 | // value if it still says 'running'. The migrator now skips |
| 488 | // archiveSession() for stale-running rows (active=false but |
| 489 | // lifecycle=running with --force-archive-running) because |
| 490 | // there's no live runner to archive. Without this fixup, |
| 491 | // successfully migrated stale rows would retain lifecycle= |
| 492 | // running forever and any downstream code that filters by |
| 493 | // lifecycleState (not the cache active flag) would keep |
| 494 | // treating archived ACP sessions as live. |
| 495 | const oldLifecycle = typeof latest.metadata.lifecycleState === 'string' ? latest.metadata.lifecycleState : undefined |
| 496 | const nextMetadata: typeof latest.metadata = { |
| 497 | ...latest.metadata, |
| 498 | cursorSessionProtocol: 'acp' as const, |
| 499 | ...(oldLifecycle === 'running' ? { lifecycleState: 'archived' as const } : {}) |
| 500 | } |
| 501 | // Drop the migration-in-progress flag in the same write (see |
| 502 | // header comment). Safe whether or not it was set. |
| 503 | if (carriedMigrationState !== undefined) { |
| 504 | delete nextMetadata.cursorMigrationState |
| 505 | } |
no test coverage detected