mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-02 23:51:34 +00:00
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:
@@ -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;
|
||||
|
||||
|
||||
@@ -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[]';
|
||||
|
||||
Reference in New Issue
Block a user