fix(client): preserve pending waits across reconnect
This commit is contained in:
@@ -219,17 +219,14 @@ export function apply(ctx: Context): void {
|
||||
console.error('[web-runtime] history reconnect failed:', error)
|
||||
}
|
||||
},
|
||||
onStateChange: (state) => {
|
||||
// Generation death fires before any next-generation frame can arrive
|
||||
// (reconnect replays flow from stream open, ahead of onConnected):
|
||||
// the only safe moment to drop generation-scoped interaction state.
|
||||
if (state === 'reconnecting') {
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
onDisconnected: () => {
|
||||
// Reconnect replays flow from stream open, ahead of onConnected, so each
|
||||
// generation death is the only safe moment to drop generation-owned state.
|
||||
sessions.handleDisconnected()
|
||||
try {
|
||||
sessionHistory.handleDisconnected()
|
||||
} catch (error) {
|
||||
console.error('[web-runtime] history disconnect failed:', error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -78,10 +78,24 @@ function bufferedRequestKey(envelope: RpcRequest<MuxFrame>): string | undefined
|
||||
case 'approval/requested': return `a:${frame.approvalId}`
|
||||
case 'question/requested': return `q:${envelope.rpcId}`
|
||||
case 'session/queue': return 'queue'
|
||||
/* v8 ignore next -- pendingBuffers contains only the three frame types above. */
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Match ui-question's binary plan-review routing at the wire boundary. */
|
||||
function questionInteractionStatus(
|
||||
questions: Extract<MuxFrame, { type: 'question/requested' }>['questions'],
|
||||
): PendingInteractionStatus {
|
||||
if (questions.length !== 1) return 'question'
|
||||
const question = questions[0] as typeof questions[number]
|
||||
const intent = question.intent
|
||||
if (intent?.kind !== 'plan-review' || question.detail === undefined) return 'question'
|
||||
if (question.multiSelect === true) return 'question'
|
||||
const options = question.options ?? []
|
||||
if (options.length > 2) return 'question'
|
||||
return options.some(option => option.label === intent.approve) ? 'plan-review' : 'question'
|
||||
}
|
||||
|
||||
/** Instance cluster + frame entry + the session list (see the web client architecture RFC). */
|
||||
export class SessionManager {
|
||||
@@ -620,11 +634,10 @@ export class SessionManager {
|
||||
// them so last-wins cannot pin a phantom value over recomputed truth.
|
||||
this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq)
|
||||
this.notifier.markDirty()
|
||||
// New mux-generation baseline: buffered session/queue frames belong to
|
||||
// the previous generation and the host is about to resend the live
|
||||
// snapshot — drop them, or every reconnect appends a duplicate batch
|
||||
// (and enough reconnects push real approval/question frames past the
|
||||
// cap). Same re-baseline signal Session uses for its own mirror.
|
||||
// New mux-generation baseline: discard the previous queue snapshot.
|
||||
// The host omits session/queue when the live queue is empty, so retaining
|
||||
// it could replay stale work when the Session is instantiated later.
|
||||
// This is the same re-baseline signal Session uses for its own mirror.
|
||||
const buffered = this.pendingBuffers.get(frame.sessionId)
|
||||
if (buffered !== undefined) {
|
||||
const kept = buffered.filter(item => item.payload.type !== 'session/queue')
|
||||
@@ -644,9 +657,7 @@ export class SessionManager {
|
||||
this.trackPending(
|
||||
frame.sessionId,
|
||||
`q:${envelope.rpcId}`,
|
||||
frame.questions.length === 1 && frame.questions[0]?.intent?.kind === 'plan-review'
|
||||
? 'plan-review'
|
||||
: 'question',
|
||||
questionInteractionStatus(frame.questions),
|
||||
)
|
||||
} else if (frame.type === 'question/resolved') {
|
||||
this.resolvePending(frame.sessionId, `q:${frame.questionRpcId}`)
|
||||
@@ -782,14 +793,14 @@ export class SessionManager {
|
||||
* request with its live rpcId.
|
||||
*/
|
||||
handleDisconnected(): void {
|
||||
for (const session of this.sessions.values()) session.handleDisconnected()
|
||||
if (this.pendingInteractions.size > 0) {
|
||||
this.pendingInteractions.clear()
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
for (const [sessionId, buffer] of [...this.pendingBuffers]) {
|
||||
const kept = buffer.filter(item =>
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'approval/resolved'
|
||||
&& item.payload.type !== 'question/requested' && item.payload.type !== 'question/resolved')
|
||||
item.payload.type !== 'approval/requested' && item.payload.type !== 'question/requested')
|
||||
if (kept.length === buffer.length) continue
|
||||
if (kept.length === 0) this.pendingBuffers.delete(sessionId)
|
||||
else this.pendingBuffers.set(sessionId, kept)
|
||||
|
||||
@@ -393,8 +393,9 @@ export class Session implements SessionFace {
|
||||
}
|
||||
|
||||
/** Reconnect rebuild (manager calls this on onConnected for instances that were opened):
|
||||
* reset the window and rerun open; pending waits for the baseline replay. Invalidates any
|
||||
* in-flight open first — its history request rode the dead connection and must not settle
|
||||
* reset the window and rerun open. Pending waits reset at generation death, before the next
|
||||
* baseline replay can arrive, so this method preserves freshly replayed waits. Invalidates
|
||||
* any in-flight open first — its history request rode the dead connection and must not settle
|
||||
* the fresh generation into 'error' (audit S4). */
|
||||
async resync(): Promise<void> {
|
||||
// The queue mirror is NOT cleared here: onConnected (which drives resync)
|
||||
@@ -410,10 +411,6 @@ export class Session implements SessionFace {
|
||||
this.events = []
|
||||
this.views = []
|
||||
this.baseSeq = 0
|
||||
// Superseded, not settled: the baseline replay re-sends still-pending requested frames verbatim
|
||||
// (same rpcId), re-minting fresh waits; a stale reference's respond() still reaches the host.
|
||||
this.pending.clear()
|
||||
this.pendingRev++
|
||||
this.subscribedLastSeq = null
|
||||
this.liveBuffer = []
|
||||
this.notifier.markDirty()
|
||||
@@ -442,6 +439,16 @@ export class Session implements SessionFace {
|
||||
|
||||
// ---- Manager-only entry points (@internal; never called by the UI) ----
|
||||
|
||||
/** Discard generation-scoped waits before a next-generation replay can arrive. */
|
||||
handleDisconnected(): void {
|
||||
if (this.pending.size === 0) return
|
||||
// Superseded, not settled: a stale reference may already be responding,
|
||||
// and the replay re-mints each still-pending request with the same rpcId.
|
||||
this.pending.clear()
|
||||
this.pendingRev++
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mux frame arrival (the dispatch switch).
|
||||
* @param rpcId - the frame envelope id (the respond backfill key for requested frames).
|
||||
|
||||
Reference in New Issue
Block a user