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;