fix(client): preserve pending waits across reconnect

This commit is contained in:
Turtle
2026-08-04 18:52:11 +08:00
parent 00d4349eb4
commit 3a54694d28
17 changed files with 219 additions and 64 deletions

View File

@@ -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)
}
},
})

View File

@@ -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)

View File

@@ -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).