fix(cursor): persist resume id early and return 409 for resume_unavailable (#745)

Remote cursor launcher now mirrors local launcher by writing cursorSessionId
to hub metadata as soon as --resume is known, before the agent init event.
POST /sessions/:id/resume maps resume_unavailable to 409 with clearer guidance.

Fixes tiann/hapi#744

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-05-31 09:40:05 +08:00
committed by GitHub
co-authored by Cursor
parent 6200ae1370
commit c58e8cea9e
5 changed files with 70 additions and 4 deletions
+3
View File
@@ -82,6 +82,9 @@ class CursorRemoteLauncher extends RemoteLauncherBase {
};
let cursorSessionId: string | null = session.sessionId;
if (cursorSessionId) {
session.onSessionFound(cursorSessionId);
}
while (!this.shouldExit) {
const waitSignal = this.abortController.signal;
+34 -2
View File
@@ -818,7 +818,7 @@ describe('session model', () => {
expect(result).toEqual({
type: 'error',
message: 'Resume session ID unavailable',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
@@ -1011,7 +1011,39 @@ describe('session model', () => {
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
type: 'error',
message: 'Resume session ID unavailable',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
engine.stop()
}
})
it('returns resume_unavailable when a cursor session lacks cursorSessionId', () => {
const store = new Store(':memory:')
const engine = new SyncEngine(
store,
{} as never,
new RpcRegistry(),
{ broadcast() {} } as never
)
try {
const session = engine.getOrCreateSession(
'local-resume-cursor-no-id',
{
path: '/tmp/project',
host: 'localhost',
machineId: 'machine-1',
flavor: 'cursor'
},
null,
'default'
)
expect(engine.resolveLocalResumeTarget(session.id, 'default')).toEqual({
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
} finally {
+5 -1
View File
@@ -540,7 +540,11 @@ export class SyncEngine {
const agentSessionId = this.resolveAgentResumeId(session, namespace)
if (!agentSessionId) {
return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' }
return {
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
}
}
return {
+26
View File
@@ -569,6 +569,32 @@ describe('sessions routes', () => {
expect(capturedResumeOpts).toEqual({ permissionMode: 'bypassPermissions' })
})
it('returns 409 when resume token is unavailable', async () => {
const session = createSession({
active: false,
metadata: { path: '/tmp/project', host: 'localhost', flavor: 'cursor' }
})
const { app } = createApp(session, {
resumeSession: async () => ({
type: 'error',
message: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
})
const response = await app.request('/api/sessions/session-1/resume', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({})
})
expect(response.status).toBe(409)
expect(await response.json()).toEqual({
error: 'Resume session ID unavailable. Start a new session in this directory, or retry after the agent has initialized.',
code: 'resume_unavailable'
})
})
it('falls back to metadata slash commands when RPC listing fails', async () => {
const session = createSession({
metadata: {
+2 -1
View File
@@ -140,7 +140,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho
const status = result.code === 'no_machine_online' ? 503
: result.code === 'access_denied' ? 403
: result.code === 'session_not_found' ? 404
: 500
: result.code === 'resume_unavailable' ? 409
: 500
return c.json({ error: result.message, code: result.code }, status)
}