mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-13 09:01:54 +00:00
fix(desktop): never surface "session busy" — retry every submit past it
"Session busy" (4009) is the gateway's concurrency guard, not a user-facing error. The queue already covers the deliberate "type while busy" case, so the only leak was a submit racing the settle edge. Generalize the rewind path's busy-retry into a shared `withSessionBusyRetry` and wrap every `prompt.submit` (fresh send, session-resume resubmit, and rewind) so a transient busy is ridden out within a bounded deadline and the call lands silently. The fromQueue swallow stays as a backstop for the pathological >deadline case.
This commit is contained in:
parent
f23a4b7bb3
commit
18916376f1
2 changed files with 44 additions and 40 deletions
|
|
@ -364,10 +364,10 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('a fromQueue drain that hits "session busy" stays quiet (no error bubble) and a retry sends it', async () => {
|
||||
// The drain can fire on the settle edge before the gateway has fully wound
|
||||
// the turn down → transient 4009. It must not append a red "session busy"
|
||||
// bubble; the entry stays queued and the next idle retry succeeds.
|
||||
it('rides out a transient "session busy" so the user never sees it (retries, no error bubble)', async () => {
|
||||
// A submit racing the settle edge can hit a transient 4009 before the turn
|
||||
// has fully wound down. It must be invisible: retried in place until the
|
||||
// gateway accepts, never a red "session busy" bubble.
|
||||
let attempt = 0
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
|
|
@ -392,15 +392,12 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
|||
/>
|
||||
)
|
||||
|
||||
const first = await handle!.submitText('queued during a turn', { fromQueue: true })
|
||||
expect(first).toBe(false)
|
||||
expect(await handle!.submitText('sent while settling')).toBe(true)
|
||||
expect(attempt).toBe(2) // rode past the busy on the second try
|
||||
// No assistant-error message was appended for the transient busy.
|
||||
expect(seeds.some(s => Array.isArray(s.messages) && (s.messages as { error?: string }[]).some(m => m.error))).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
const second = await handle!.submitText('queued during a turn', { fromQueue: true })
|
||||
expect(second).toBe(true)
|
||||
})
|
||||
|
||||
it('a normal (non-queue) submit still respects the busyRef guard', async () => {
|
||||
|
|
@ -879,7 +876,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
|
|||
const requestGateway = vi.fn(async (method: string) => {
|
||||
calls.push(method)
|
||||
if (method === 'prompt.submit') {
|
||||
throw new Error('session busy')
|
||||
throw new Error('gateway exploded')
|
||||
}
|
||||
return {} as never
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,10 +118,12 @@ function isSessionNotFoundError(error: unknown): boolean {
|
|||
}
|
||||
|
||||
// The gateway refuses prompt.submit while a turn is running (4009 "session
|
||||
// busy"). Edit/restore (revert) can fire mid-turn, so they interrupt first then
|
||||
// retry the submit until the cooperative interrupt has wound the turn down.
|
||||
const REWIND_INTERRUPT_TIMEOUT_MS = 6_000
|
||||
const REWIND_RETRY_INTERVAL_MS = 150
|
||||
// busy"). It's a transient concurrency guard, never a user-facing error: a
|
||||
// submit racing the settle edge (or a rewind interrupting mid-turn) just waits
|
||||
// a beat for the turn to wind down, then lands. Bounded so a genuinely stuck
|
||||
// turn still surfaces eventually.
|
||||
const SESSION_BUSY_RETRY_TIMEOUT_MS = 6_000
|
||||
const SESSION_BUSY_RETRY_INTERVAL_MS = 150
|
||||
|
||||
function isSessionBusyError(error: unknown): boolean {
|
||||
return /session busy/i.test(error instanceof Error ? error.message : String(error))
|
||||
|
|
@ -129,6 +131,26 @@ function isSessionBusyError(error: unknown): boolean {
|
|||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
// Retry a gateway call across transient "session busy" so it never reaches the
|
||||
// user — the turn settles within the deadline and the call lands.
|
||||
async function withSessionBusyRetry<T>(call: () => Promise<T>): Promise<T> {
|
||||
const deadline = Date.now() + SESSION_BUSY_RETRY_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
return await call()
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(SESSION_BUSY_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function base64FromDataUrl(dataUrl: string): string {
|
||||
const comma = dataUrl.indexOf(',')
|
||||
|
||||
|
|
@ -683,7 +705,7 @@ export function usePromptActions({
|
|||
let submitErr: unknown = null
|
||||
|
||||
try {
|
||||
await requestGateway('prompt.submit', { session_id: sessionId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: sessionId, text }))
|
||||
} catch (firstErr) {
|
||||
if (isSessionNotFoundError(firstErr) && selectedStoredSessionIdRef.current) {
|
||||
// Re-register the session in the gateway and get a fresh live ID.
|
||||
|
|
@ -695,7 +717,7 @@ export function usePromptActions({
|
|||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
await requestGateway('prompt.submit', { session_id: recoveredId, text })
|
||||
await withSessionBusyRetry(() => requestGateway('prompt.submit', { session_id: recoveredId, text }))
|
||||
} else {
|
||||
submitErr = firstErr
|
||||
}
|
||||
|
|
@ -1460,9 +1482,8 @@ export function usePromptActions({
|
|||
// text is submitted as a fresh turn. Callers confirm before invoking; errors
|
||||
// are rethrown so the confirmation dialog can surface them inline.
|
||||
// Submit a rewind (truncate-before-ordinal + resubmit). Because edit/restore
|
||||
// can fire while a turn is streaming, interrupt the live turn first, then
|
||||
// retry the submit until the gateway stops reporting "session busy" — the
|
||||
// interrupt is cooperative, so the running turn takes a beat to wind down.
|
||||
// can fire while a turn is streaming, interrupt the live turn first — the
|
||||
// cooperative interrupt takes a beat, so the shared busy-retry rides it out.
|
||||
const submitRewindPrompt = useCallback(
|
||||
async (sessionId: string, text: string, truncateOrdinal: number | undefined, wasRunning: boolean) => {
|
||||
if (wasRunning) {
|
||||
|
|
@ -1473,27 +1494,13 @@ export function usePromptActions({
|
|||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + REWIND_INTERRUPT_TIMEOUT_MS
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
|
||||
return
|
||||
} catch (err) {
|
||||
if (isSessionBusyError(err) && Date.now() < deadline) {
|
||||
await sleep(REWIND_RETRY_INTERVAL_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', {
|
||||
session_id: sessionId,
|
||||
text,
|
||||
...(truncateOrdinal !== undefined && { truncate_before_user_ordinal: truncateOrdinal })
|
||||
})
|
||||
)
|
||||
},
|
||||
[requestGateway]
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue