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
+62 -2
View File
@@ -133,6 +133,8 @@ export class SyncEngine {
private readonly messageService: MessageService
private readonly rpcGateway: RpcGateway
private inactivityTimer: NodeJS.Timeout | null = null
/** Sessions that emitted `session-ready` (Cursor ACP load/newSession complete). */
private readonly sessionReadyIds = new Set<string>()
constructor(
private readonly store: Store,
@@ -269,6 +271,9 @@ export class SyncEngine {
this.sessionCache.refreshSession(event.sessionId)
const after = this.sessionCache.getSession(event.sessionId)
if (after?.metadata && !this.hasSameAgentSessionIds(before?.metadata ?? null, after.metadata)) {
if (!this.canRunCursorDedup(after)) {
return
}
void this.sessionCache.deduplicateByAgentSessionId(event.sessionId).catch(() => {
// best-effort: dedup failure is harmless, web-side safety net hides remaining duplicates
})
@@ -306,11 +311,21 @@ export class SyncEngine {
this.triggerDedupIfNeeded(payload.sid)
}
handleSessionReady(payload: { sid: string; time: number }): void {
this.sessionReadyIds.add(payload.sid)
this.triggerDedupIfNeeded(payload.sid)
}
clearQueuedThinkingGrace(sessionId: string): void {
this.sessionCache.clearQueuedThinkingGrace(sessionId)
}
handleSessionEnd(payload: { sid: string; time: number; reason?: 'completed' | 'terminated' | 'error' }): void {
const before = this.sessionCache.getSession(payload.sid)
const isCursorAcp = before?.metadata?.flavor === 'cursor'
&& before.metadata.cursorSessionProtocol === 'acp'
const shouldRetryDedup = !isCursorAcp || this.sessionReadyIds.has(payload.sid)
this.sessionCache.handleSessionEnd(payload)
this.eventPublisher.emit({
type: 'session-ended',
@@ -318,8 +333,12 @@ export class SyncEngine {
reason: payload.reason
})
// Retry dedup now that this session is inactive — a prior dedup may have
// skipped it because it was still active at the time.
this.triggerDedupIfNeeded(payload.sid)
// skipped it because it was still active at the time. Cursor ACP rows that
// never reached session-ready must not dedup-merge the original on failure.
if (shouldRetryDedup) {
this.triggerDedupIfNeeded(payload.sid)
}
this.sessionReadyIds.delete(payload.sid)
}
handleBackgroundTaskDelta(sessionId: string, delta: { started: number; completed: number }): void {
@@ -1163,6 +1182,19 @@ export class SyncEngine {
// permissionMode is passed to spawnSession above; do not call set-session-config here.
// session-alive can arrive before the CLI registers that RPC handler, which caused resume_failed.
const needsReadyBeforeMerge = spawnResult.sessionId !== access.sessionId
&& flavor === 'cursor'
&& metadata.cursorSessionProtocol === 'acp'
if (needsReadyBeforeMerge) {
const readyResult = await this.waitForSessionReady(spawnResult.sessionId)
if (readyResult !== 'ready') {
const message = readyResult === 'ended'
? 'Session ended before Cursor ACP load completed'
: 'Session failed to become ready'
return { type: 'error', message, code: 'resume_failed' }
}
}
if (spawnResult.sessionId !== access.sessionId) {
// The old session may have already been merged by the automatic dedup path
// (triggered when the spawned CLI sets its agent session ID in metadata).
@@ -1410,9 +1442,22 @@ export class SyncEngine {
&& (prev?.kimiSessionId ?? null) === (next.kimiSessionId ?? null)
}
private canRunCursorDedup(session: Session): boolean {
if (session.metadata?.flavor !== 'cursor') {
return true
}
if (session.metadata?.cursorSessionProtocol !== 'acp') {
return true
}
return this.sessionReadyIds.has(session.id)
}
private triggerDedupIfNeeded(sessionId: string): void {
const session = this.sessionCache.getSession(sessionId)
if (session?.metadata) {
if (!this.canRunCursorDedup(session)) {
return
}
void this.sessionCache.deduplicateByAgentSessionId(sessionId).catch(() => {
// best-effort: web-side safety net hides remaining duplicates
})
@@ -1431,6 +1476,21 @@ export class SyncEngine {
return false
}
async waitForSessionReady(sessionId: string, timeoutMs: number = 60_000): Promise<'ready' | 'ended' | 'timeout'> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
if (this.sessionReadyIds.has(sessionId)) {
return 'ready'
}
const session = this.getSession(sessionId)
if (!session?.active) {
return 'ended'
}
await new Promise((resolve) => setTimeout(resolve, 250))
}
return 'timeout'
}
async waitForSessionInactive(sessionId: string, timeoutMs: number = 15_000): Promise<boolean> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {