fix(desktop): interrupt-first after Stop so edit/resend avoids session-busy

Stop clears frontend busy immediately while the gateway may still wind
down. Edit/restore then passed interruptFirst=false and raced 4009
session busy. Keep a short per-session cooldown after cancel so rewind
still interrupt-first, and expire the submit-in-flight lock so a hung
submit cannot block the session forever.

Fixes #83855

Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
Signed-off-by: Olympusbuildz <Olympus.roots@outlook.com>
This commit is contained in:
Olympusbuildz
2026-08-12 09:10:22 -07:00
committed by Teknium
parent 4ac12d53fb
commit 801fd0b3d8
5 changed files with 211 additions and 13 deletions
@@ -48,7 +48,11 @@ import {
truncateSubmitParams
} from '../session/hooks/use-prompt-actions/rewind'
import { useSubmitPrompt } from '../session/hooks/use-prompt-actions/submit'
import { type SubmitTextOptions } from '../session/hooks/use-prompt-actions/utils'
import {
markSessionRecentlyInterrupted,
shouldInterruptBeforeRewind,
type SubmitTextOptions
} from '../session/hooks/use-prompt-actions/utils'
import { upsertOptimisticSession } from '../session/hooks/use-session-actions/utils'
import type { ComposerScope } from './composer/scope'
@@ -258,6 +262,9 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
const cancelRun = useCallback(async () => {
const sessionId = runtimeIdRef.current
// Frontend busy clears immediately; gateway wind-down can lag (#83855).
markSessionRecentlyInterrupted(sessionId)
update(state => ({
...state,
messages: finalizeInterruptedMessages(state.messages, state.streamId),
@@ -456,13 +463,16 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
const interruptFirst = shouldInterruptBeforeRewind({
busy: readState()?.busy ?? false,
sessionId
})
update(state => applyRewindOptimistic(state, plan.sourceIndex))
try {
applySurvivorRowIds(
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy, plan.truncateMessageId, plan.truncateRowId)
await submitRewind(plan.text, plan.truncateOrdinal, interruptFirst, plan.truncateMessageId, plan.truncateRowId)
)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
@@ -487,13 +497,16 @@ export function useSessionTileActions({ runtimeId, scope, storedSessionId }: Ses
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
const wasBusy = readState()?.busy ?? false
const interruptFirst = shouldInterruptBeforeRewind({
busy: readState()?.busy ?? false,
sessionId
})
update(state => applyRewindOptimistic(state, plan.sourceIndex, plan.editedMessage))
try {
applySurvivorRowIds(
await submitRewind(plan.text, plan.truncateOrdinal, wasBusy, plan.truncateMessageId, plan.truncateRowId)
await submitRewind(plan.text, plan.truncateOrdinal, interruptFirst, plan.truncateMessageId, plan.truncateRowId)
)
} catch (err) {
update(state => ({ ...state, busy: false, awaitingResponse: false, messages }))
@@ -70,8 +70,10 @@ import {
friendlyRemoteAttachError,
type GatewayRequest,
inlineErrorMessage,
markSessionRecentlyInterrupted,
readFileDataUrlForAttach,
readImageForRemoteAttach,
shouldInterruptBeforeRewind,
type SubmitTextOptions,
withSessionNotFoundResume
} from './utils'
@@ -649,6 +651,10 @@ export function usePromptActions({
return
}
// Frontend busy clears immediately; gateway wind-down can lag. Mark so a
// fast edit/resend still interrupt-first instead of racing 4009 (#83855).
markSessionRecentlyInterrupted(sessionId)
updateSessionState(sessionId, state => {
const streamId = state.streamId
const messages = finalizeInterruptedMessages(state.messages, streamId)
@@ -908,6 +914,13 @@ export function usePromptActions({
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
// Capture before optimistic busy=true — otherwise interruptFirst is always
// true and idle restores wrongly interrupt (and Stop→edit misses cooldown).
const interruptFirst = shouldInterruptBeforeRewind({
busy: busyRef.current || $busy.get(),
sessionId
})
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
@@ -920,7 +933,7 @@ export function usePromptActions({
plan.text,
plan.truncateOrdinal,
plan.truncateMessageId,
busyRef.current || $busy.get(),
interruptFirst,
plan.truncateRowId
)
@@ -965,6 +978,12 @@ export function usePromptActions({
resetSessionBackground(sessionId)
clearPreviewArtifacts(sessionId)
// Before optimistic busy=true — see restoreToMessage (#83855).
const interruptFirst = shouldInterruptBeforeRewind({
busy: busyRef.current || $busy.get(),
sessionId
})
clearNotifications()
setMutableRef(busyRef, true)
setBusy(true)
@@ -977,7 +996,7 @@ export function usePromptActions({
plan.text,
plan.truncateOrdinal,
plan.truncateMessageId,
busyRef.current || $busy.get(),
interruptFirst,
plan.truncateRowId
)
@@ -38,12 +38,13 @@ import { resolveSessionProfile } from '../use-session-actions/utils'
import { finalizeInterruptedMessages } from './rewind'
import {
_submitInFlight,
acquireSubmitInFlight,
type GatewayRequest,
inlineErrorMessage,
isProviderSetupError,
isSessionBusyError,
isTargetSessionBusy,
releaseSubmitInFlight,
SessionRecoveryAborted,
type SubmitTextOptions,
withSessionBusyRetry,
@@ -296,17 +297,16 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// session switch; this per-session lock makes that safe.
const submitLockKey = targetStoredSessionId || sessionId || startingActiveSessionId || '__pending_new__'
if (_submitInFlight.has(submitLockKey)) {
if (!acquireSubmitInFlight(submitLockKey)) {
return false
}
_submitInFlight.add(submitLockKey)
let submitLockReleased = false
const releaseSubmitLock = () => {
if (!submitLockReleased) {
submitLockReleased = true
_submitInFlight.delete(submitLockKey)
releaseSubmitInFlight(submitLockKey)
}
}
@@ -1,11 +1,14 @@
import type { AppendMessage } from '@assistant-ui/react'
import { describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
acquireSubmitInFlight,
appendText,
base64FromDataUrl,
clearSessionRecentlyInterrupted,
clearSubmitInFlight,
friendlyRemoteAttachError,
type GatewayRequest,
imageFilenameFromPath,
@@ -13,15 +16,89 @@ import {
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
isSessionRecentlyInterrupted,
isSubmitInFlight,
markSessionRecentlyInterrupted,
RECENT_INTERRUPT_COOLDOWN_MS,
readFileDataUrlForAttach,
releaseSubmitInFlight,
renderRpcResult,
SessionRecoveryAborted,
shouldInterruptBeforeRewind,
slashStatusText,
SUBMIT_IN_FLIGHT_TTL_MS,
visibleUserIndexAtOrdinal,
visibleUserOrdinal,
withSessionNotFoundResume
} from './utils'
afterEach(() => {
clearSessionRecentlyInterrupted()
clearSubmitInFlight()
})
describe('recent interrupt cooldown', () => {
it('is true within the cooldown and false after expiry', () => {
const sessionId = 'sess-cooldown'
const t0 = 1_000_000
markSessionRecentlyInterrupted(sessionId, t0)
expect(isSessionRecentlyInterrupted(sessionId, t0)).toBe(true)
expect(isSessionRecentlyInterrupted(sessionId, t0 + RECENT_INTERRUPT_COOLDOWN_MS - 1)).toBe(true)
expect(isSessionRecentlyInterrupted(sessionId, t0 + RECENT_INTERRUPT_COOLDOWN_MS)).toBe(false)
})
it('returns false after mark + elapsed past cooldown', () => {
const sessionId = 'sess-elapsed'
const t0 = 5_000_000
markSessionRecentlyInterrupted(sessionId, t0)
expect(isSessionRecentlyInterrupted(sessionId, t0 + RECENT_INTERRUPT_COOLDOWN_MS + 1)).toBe(false)
})
it('shouldInterruptBeforeRewind is true when recently interrupted even if not busy', () => {
const sessionId = 'sess-edit-after-stop'
const t0 = 9_000_000
markSessionRecentlyInterrupted(sessionId, t0)
expect(shouldInterruptBeforeRewind({ busy: false, sessionId, now: t0 + 500 })).toBe(true)
expect(shouldInterruptBeforeRewind({ busy: false, sessionId, now: t0 + RECENT_INTERRUPT_COOLDOWN_MS + 1 })).toBe(
false
)
})
it('shouldInterruptBeforeRewind stays false for idle sessions with no recent interrupt', () => {
expect(shouldInterruptBeforeRewind({ busy: false, sessionId: 'idle-sess' })).toBe(false)
expect(shouldInterruptBeforeRewind({ busy: true, sessionId: 'busy-sess' })).toBe(true)
})
})
describe('submit in-flight TTL', () => {
it('blocks a second acquire while fresh and frees after TTL without explicit release', () => {
const key = 'lock-ttl'
const t0 = 2_000_000
expect(acquireSubmitInFlight(key, t0)).toBe(true)
expect(isSubmitInFlight(key, t0 + 1)).toBe(true)
expect(acquireSubmitInFlight(key, t0 + 1)).toBe(false)
expect(isSubmitInFlight(key, t0 + SUBMIT_IN_FLIGHT_TTL_MS)).toBe(false)
expect(acquireSubmitInFlight(key, t0 + SUBMIT_IN_FLIGHT_TTL_MS)).toBe(true)
})
it('release clears the lock immediately', () => {
const key = 'lock-release'
const t0 = 3_000_000
expect(acquireSubmitInFlight(key, t0)).toBe(true)
releaseSubmitInFlight(key)
expect(isSubmitInFlight(key, t0 + 1)).toBe(false)
expect(acquireSubmitInFlight(key, t0 + 1)).toBe(true)
})
})
describe('isSessionIdCandidate', () => {
it('accepts the timestamped and hex id forms', () => {
expect(isSessionIdCandidate('20260101_120000_abc123')).toBe(true)
@@ -259,12 +259,101 @@ export async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T
}
}
// After Stop, the renderer clears busy immediately while the gateway may still
// be winding down. Edit/restore that only checks busy then submits without
// interrupt-first and hits 4009 session busy. A short per-session cooldown
// keeps interrupt-first on for that window (#83855).
export const RECENT_INTERRUPT_COOLDOWN_MS = 3_000
const _recentlyInterruptedUntil = new Map<string, number>()
export function markSessionRecentlyInterrupted(sessionId: string, now = Date.now()): void {
if (!sessionId) {
return
}
_recentlyInterruptedUntil.set(sessionId, now + RECENT_INTERRUPT_COOLDOWN_MS)
}
export function isSessionRecentlyInterrupted(sessionId: string, now = Date.now()): boolean {
const until = _recentlyInterruptedUntil.get(sessionId)
if (until === undefined) {
return false
}
if (now >= until) {
_recentlyInterruptedUntil.delete(sessionId)
return false
}
return true
}
export function clearSessionRecentlyInterrupted(sessionId?: string): void {
if (sessionId) {
_recentlyInterruptedUntil.delete(sessionId)
return
}
_recentlyInterruptedUntil.clear()
}
/** Whether a rewind/edit should interrupt before submit — busy OR recent Stop. */
export function shouldInterruptBeforeRewind(opts: {
busy: boolean
sessionId: string
now?: number
}): boolean {
return opts.busy || isSessionRecentlyInterrupted(opts.sessionId, opts.now)
}
// Hard guard: at most one prompt.submit in flight per session. Every submit
// path — user Enter, queue drain, busy-retry, slash fallthrough — funnels
// through submitPromptText. Without this, a stalled turn (e.g. a context-bloated
// session whose first call hangs) let the SAME prompt launch several real turns
// at once (the "message stacked 5×" bug). Keyed by stored/active session id.
export const _submitInFlight = new Set<string>()
// Entries expire so a hung submit cannot permanently block the session (#83855).
export const SUBMIT_IN_FLIGHT_TTL_MS = 30_000
const _submitInFlightAt = new Map<string, number>()
export function isSubmitInFlight(key: string, now = Date.now()): boolean {
const acquiredAt = _submitInFlightAt.get(key)
if (acquiredAt === undefined) {
return false
}
if (now - acquiredAt >= SUBMIT_IN_FLIGHT_TTL_MS) {
_submitInFlightAt.delete(key)
return false
}
return true
}
/** Returns true when the lock was acquired; false when another fresh hold blocks. */
export function acquireSubmitInFlight(key: string, now = Date.now()): boolean {
if (isSubmitInFlight(key, now)) {
return false
}
_submitInFlightAt.set(key, now)
return true
}
export function releaseSubmitInFlight(key: string): void {
_submitInFlightAt.delete(key)
}
export function clearSubmitInFlight(): void {
_submitInFlightAt.clear()
}
export function base64FromDataUrl(dataUrl: string): string {
const comma = dataUrl.indexOf(',')