fix(web,hub): queued bar SSE + never-started inactive resume (#761)

* fix(web): apply messages-consumed on global SSE connection

The global all-sessions SSE subscription returned early on message-stream
events without updating the message-window store. When session-scoped SSE
was reconnecting or the user had another session selected, messages-consumed
never cleared the queued bar even though the hub had stamped invoked_at.

Also harden mergeMessages so a stale invokedAt:null snapshot cannot clobber
an existing ack timestamp.

Fixes tiann/hapi#758

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web,hub): resume never-started inactive sessions on first send

Hub fresh-spawns when inactive session has path but no agent thread id and
zero messages. Web guards resume, updates inactive banner copy, and surfaces
resume_unavailable before POST /resume when resume is impossible.

Fixes tiann/hapi#759

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): scope sessionResume guard to current flavor only

Hub `resolveAgentResumeId` only honors the metadata.flavor's id; the web
guard was falling back across all flavors so a cursor session with a stale
codexSessionId still tried to resume and 409'd. Mirror the hub switch and
default to claude when flavor is unknown.

Addresses HAPI Bot review on tiann/hapi#761.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): allow claude session resume via hub message-id recovery

Hub `resolveAgentResumeId` falls back to `recoverClaudeSessionIdFromMessages`
on the claude branch when `metadata.claudeSessionId` is absent, so the web
guard must not block inactive claude sessions that have stored messages but
no metadata id. Other flavors have no such recovery path and stay rejected.

Addresses second HAPI Bot review thread on tiann/hapi#761
(`web/src/lib/sessionResume.ts:41`).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-06-01 12:07:13 +08:00
committed by GitHub
co-authored by Cursor
parent df35a8c523
commit cc4025abdb
11 changed files with 306 additions and 7 deletions
+30 -5
View File
@@ -617,6 +617,18 @@ export class SyncEngine {
return undefined
}
/** Inactive session with directory path but no agent thread and no prior user turn. */
private canFreshSpawnNeverStartedSession(session: Session, sessionId: string, namespace: string): boolean {
const metadata = session.metadata
if (!metadata || typeof metadata.path !== 'string' || metadata.path.length === 0) {
return false
}
if (this.resolveAgentResumeId(session, namespace)) {
return false
}
return this.store.messages.getFirstMessages(sessionId, 1).length === 0
}
async resumeSession(sessionId: string, namespace: string, opts?: { permissionMode?: PermissionMode }): Promise<ResumeSessionResult> {
const access = this.sessionCache.resolveSessionAccess(sessionId, namespace)
if (!access.ok) {
@@ -633,14 +645,27 @@ export class SyncEngine {
}
const targetResult = this.resolveLocalResumeTarget(access.sessionId, namespace)
if (targetResult.type === 'error') {
let flavor: AgentFlavor
let resumeToken: string | undefined
let directory: string
if (targetResult.type === 'success') {
flavor = targetResult.target.flavor
resumeToken = targetResult.target.agentSessionId
directory = targetResult.target.directory
} else if (
targetResult.code === 'resume_unavailable'
&& this.canFreshSpawnNeverStartedSession(session, access.sessionId, namespace)
) {
const metadata = session.metadata!
flavor = this.resolveFlavor(session)
resumeToken = undefined
directory = metadata.path
} else {
return targetResult
}
const target = targetResult.target
const metadata = session.metadata!
const flavor = target.flavor
const resumeToken = target.agentSessionId
const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace)
if (onlineMachines.length === 0) {
@@ -668,7 +693,7 @@ export class SyncEngine {
?? session.metadata?.preferredPermissionMode
const spawnResult = await this.rpcGateway.spawnSession(
targetMachine.id,
target.directory,
directory,
flavor,
session.model ?? undefined,
session.modelReasoningEffort ?? undefined,