mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user