fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds (closes #939) (#948)

* fix(hub+cli): defer mergeSessions on cursor ACP reopen until session/load succeeds

Emit session-ready from the CLI after ACP load/newSession completes; hub
resumeSession and cursor dedup wait for that signal before merging rows so a
failed session/load no longer deletes the archived session the operator can retry.

Refs #917. Closes #939.

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

* fix(hub): gate session-ready wait on cursor ACP protocol only

Legacy stream-json Cursor resumes use cursorLegacyRemoteLauncher, which does
not emit session-ready; limiting the defer-merge and dedup gates to ACP avoids
60s resume_failed timeouts on those sessions.

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

* fix(hub): block ACP dedup until session-ready, including on session-end

Inactive ACP spawns that never emitted session-ready could still trigger
deduplicateByAgentSessionId on session-end and delete the original row.
Require session-ready for all ACP dedup paths and skip end-of-session dedup
when load never succeeded.

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

* fix(hub): restore session-end dedup for non-ACP cursor duplicates

Only skip the session-end dedup retry for Cursor ACP rows that never emitted
session-ready. Codex/Claude/legacy Cursor duplicates still merge when the live
row ends.

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

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-18 10:10:10 +08:00
committed by GitHub
co-authored by Cursor
parent 0bf17b818e
commit 26d3c2eb34
11 changed files with 455 additions and 22 deletions
+51 -17
View File
@@ -138,15 +138,7 @@ import { ApiSessionClient } from '@/api/apiSession';
function makeSession(sessionId: string | null): CursorSession {
const queue = new MessageQueue2<EnhancedMode>(() => 'mode');
const client = {
rpcHandlerManager: {
registerHandler: vi.fn()
},
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
} as unknown as ApiSessionClient;
const client = makeClient();
const session = new CursorSession({
api: {} as never,
@@ -168,6 +160,19 @@ function makeSession(sessionId: string | null): CursorSession {
return session;
}
function makeClient() {
return {
rpcHandlerManager: {
registerHandler: vi.fn()
},
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
}
describe('cursorAcpRemoteLauncher', () => {
beforeEach(() => {
harness.initializeError = null;
@@ -265,6 +270,26 @@ describe('cursorAcpRemoteLauncher', () => {
expect(harness.newSessionCalled).toBe(true);
expect(harness.loadSessionCalled).toBe(false);
expect(session.onSessionFoundWithProtocol).toHaveBeenCalledWith('new-acp-session', 'acp');
expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1);
});
it('emits session-ready after session/load succeeds', async () => {
const session = makeSession('resume-thread-ready');
await cursorAcpRemoteLauncher(session);
expect(harness.loadSessionCalled).toBe(true);
expect(session.client.emitSessionReady).toHaveBeenCalledTimes(1);
});
it('does not emit session-ready when session/load fails', async () => {
harness.loadSessionError = new Error('session not found');
const session = makeSession('old-stream-json-id');
await expect(cursorAcpRemoteLauncher(session)).rejects.toThrow(
/Legacy stream-json sessions cannot be loaded via ACP/
);
expect(session.client.emitSessionReady).not.toHaveBeenCalled();
});
it('applies debug mode immediately when setPermissionMode is called', async () => {
@@ -274,7 +299,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -318,7 +344,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive
keepAlive,
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -362,7 +389,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive
keepAlive,
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -409,7 +437,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -453,7 +482,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -509,7 +539,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive
keepAlive,
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -550,7 +581,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -595,7 +627,8 @@ describe('cursorAcpRemoteLauncher', () => {
updateMetadata: vi.fn(),
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn()
keepAlive: vi.fn(),
emitSessionReady: vi.fn()
} as unknown as ApiSessionClient;
const session = new CursorSession({
@@ -636,6 +669,7 @@ describe('cursorAcpRemoteLauncher', () => {
sendSessionEvent: vi.fn(),
sendAgentMessage: vi.fn(),
keepAlive: vi.fn(),
emitSessionReady: vi.fn(),
emitMessagesConsumed: vi.fn()
} as unknown as ApiSessionClient;
+31 -1
View File
@@ -105,7 +105,7 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
mcpServers: mcpServerList
});
} catch (error) {
logger.warn('[cursor-acp] session/load failed', error);
logger.warn('[cursor-acp] session/load failed', formatAcpLoadError(error));
throw new Error(
'Failed to resume Cursor ACP session. Legacy stream-json sessions cannot be loaded via ACP.'
);
@@ -125,6 +125,8 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
session.onSessionFoundWithProtocol(acpSessionId, 'acp');
}
session.client.emitSessionReady();
syncCursorModelsFromAcp(backend, acpSessionId);
const initialMetadata = backend.getSessionModelsMetadata(acpSessionId);
@@ -436,6 +438,34 @@ class CursorAcpRemoteLauncher extends RemoteLauncherBase {
}
}
function formatAcpLoadError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const record: Record<string, unknown> = {
name: error.name,
message: error.message
};
const code = (error as Error & { code?: unknown }).code;
if (code !== undefined) {
record.code = code;
}
const data = (error as Error & { data?: unknown }).data;
if (data !== undefined) {
record.data = data;
}
const cause = error.cause;
if (cause !== undefined) {
record.cause = cause instanceof Error
? { name: cause.name, message: cause.message }
: cause;
}
return record;
}
if (typeof error === 'object' && error !== null) {
return { ...(error as Record<string, unknown>) };
}
return { message: String(error) };
}
function isSpawnDefaultModel(modelId: string): boolean {
const normalized = modelId.trim().toLowerCase();
return normalized === 'auto' || normalized === 'default' || normalized === 'default[]';