fix(desktop): toast when a send fails because the disk is full

Map ENOSPC / SQLITE_FULL / "disk full" error strings through notifyError to a
clear free-space toast, and fire it from rejected prompt.submit, gateway error
events, and terminal failure frames so a full disk never looks like silence.
This commit is contained in:
Brooklyn Nicholson 2026-08-01 00:27:17 -05:00
parent 24f346ee77
commit 69902c203e
10 changed files with 64 additions and 2 deletions

View File

@ -34,7 +34,7 @@ import {
setChangeEventsAvailable
} from '@/store/live-sync'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { isDiskFullErrorMessage, notify, notifyError } from '@/store/notifications'
import { requestDesktopOnboarding, requestDesktopOnboardingForCredentialWarning } from '@/store/onboarding'
import { revealDesktopPane } from '@/store/pane-focus'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
@ -1161,6 +1161,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isDiskFullErrorMessage(errorMessage)) {
notifyError(new Error(errorMessage), translateNow('notifications.errors.diskFull'))
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the

View File

@ -22,6 +22,7 @@ import {
} from '@/lib/generated-images'
import { parseTodos } from '@/lib/todos'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { isDiskFullErrorMessage, notifyError } from '@/store/notifications'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { upsertSubagent } from '@/store/subagents'
import { setSessionTodos } from '@/store/todos'
@ -599,6 +600,16 @@ export function useMessageStream({
}
})
// Persistence / mid-turn disk-full failures land as a terminal frame with
// an error string, not a rejected prompt.submit. Toast them here so a
// full disk never looks like a silent no-reply. Only fire on actual
// failure signals — never on a healthy reply that happens to say
// "disk full".
const diskFullSignal = failure?.error || (failure ? text : '')
if (diskFullSignal && isDiskFullErrorMessage(diskFullSignal)) {
notifyError(new Error(diskFullSignal), translateNow('notifications.errors.diskFull'))
}
scheduleSessionsRefresh()
if (compactedTurnRef.current.delete(sessionId)) {

View File

@ -117,6 +117,7 @@ export const ar = defineLocale({
errors: {
elevenLabsNeedsKey: 'يتطلب ElevenLabs STT المفتاح ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'رفض ElevenLabs مفتاح API (401).',
diskFull: 'القرص ممتلئ — حرّر مساحة ثم أعد المحاولة.',
methodNotAllowed: 'رفضت خلفية سطح المكتب هذا الطلب (405 Method Not Allowed). جرب إعادة تشغيل Hermes Desktop.',
microphonePermission: 'تم رفض إذن الميكروفون.',
openaiRejectedApiKey: 'رفض OpenAI مفتاح API.',

View File

@ -132,6 +132,7 @@ export const en: Translations = {
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
diskFull: 'Disk full — free some space, then try again.',
gatewayAuthFailed: 'Gateway authentication failed — check your API_SERVER_KEY.',
methodNotAllowed:
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',

View File

@ -133,6 +133,7 @@ export const ja = defineLocale({
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT には ELEVENLABS_API_KEY が必要です。',
elevenLabsRejectedKey: 'ElevenLabs が API キーを拒否しました (401)。',
diskFull: 'ディスク容量不足です — 空きを作ってからもう一度お試しください。',
gatewayAuthFailed: 'ゲートウェイ認証に失敗しました — API_SERVER_KEY を確認してください。',
methodNotAllowed:
'デスクトップバックエンドがそのリクエストを拒否しました (405 Method Not Allowed)。Hermes Desktop を再起動してください。',

View File

@ -174,6 +174,7 @@ export interface Translations {
errors: {
elevenLabsNeedsKey: string
elevenLabsRejectedKey: string
diskFull: string
gatewayAuthFailed: string
methodNotAllowed: string
microphonePermission: string

View File

@ -129,6 +129,7 @@ export const zhHant = defineLocale({
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒絕了該 API 金鑰 (401)。',
diskFull: '磁碟已滿 — 請騰出一些空間後再試。',
gatewayAuthFailed: '閘道認證失敗 — 請檢查你的 API_SERVER_KEY。',
methodNotAllowed: '桌面後端拒絕了該請求 (405 Method Not Allowed)。請嘗試重新啟動 Hermes Desktop。',
microphonePermission: '麥克風權限已被拒絕。',

View File

@ -129,6 +129,7 @@ export const zh: Translations = {
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT 需要 ELEVENLABS_API_KEY。',
elevenLabsRejectedKey: 'ElevenLabs 拒绝了该 API key (401)。',
diskFull: '磁盘已满 — 请腾出一些空间后再试。',
gatewayAuthFailed: '网关认证失败 — 请检查你的 API_SERVER_KEY。',
methodNotAllowed: '桌面后端拒绝了该请求 (405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',

View File

@ -1,6 +1,6 @@
import { beforeEach, expect, test } from 'vitest'
import { $notifications, clearNotifications, notifyError } from './notifications'
import { $notifications, clearNotifications, isDiskFullErrorMessage, notifyError } from './notifications'
beforeEach(() => {
clearNotifications()
@ -32,3 +32,26 @@ test('provider invalid_api_key error still maps to the OpenAI summary', () => {
expect(lastMessage()).toMatch(/OpenAI rejected the API key/i)
})
test('disk-full / ENOSPC errors toast a free-space message', () => {
expect(isDiskFullErrorMessage('OSError: [Errno 28] No space left on device')).toBe(true)
expect(isDiskFullErrorMessage('sqlite3.OperationalError: database or disk is full')).toBe(true)
expect(isDiskFullErrorMessage('disk full: session storage could not be written — free some disk space')).toBe(true)
expect(isDiskFullErrorMessage('This is often a full disk — free some space')).toBe(true)
expect(isDiskFullErrorMessage('session storage could not be written: permission denied')).toBe(false)
expect(isDiskFullErrorMessage('network timeout')).toBe(false)
notifyError(new Error('OSError: [Errno 28] No space left on device: state.db'), 'Prompt failed')
expect(lastMessage()).toMatch(/Disk full/i)
expect(lastMessage()).toMatch(/free some space/i)
})
test('session storage write failure is treated as disk-full class', () => {
notifyError(
new Error('disk full: session storage could not be written — free some disk space and try again'),
'Prompt failed'
)
expect(lastMessage()).toMatch(/Disk full/i)
})

View File

@ -76,7 +76,27 @@ function cleanErrorText(value: string) {
return value.replace(/^Error:\s*/, '').trim()
}
/** True when an error string is a disk-full / ENOSPC / SQLITE_FULL failure. */
export function isDiskFullErrorMessage(message: string): boolean {
return (
/no space left on device/i.test(message) ||
/not enough space/i.test(message) ||
/database or disk is full/i.test(message) ||
/\bENOSPC\b/i.test(message) ||
/disk full/i.test(message) ||
/full disk/i.test(message)
)
}
const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [
{
// Disk full / ENOSPC — session DB write, backend crash, or any path that
// bubbles "no space left" / SQLITE_FULL through notifyError. Match before
// generic length truncation so the user gets a clear "free space" toast
// instead of a silent send or a raw errno dump.
test: isDiskFullErrorMessage,
summarize: () => translateNow('notifications.errors.diskFull')
},
{
test: msg => /['"]code['"]\s*:\s*['"]gateway_auth_failed['"]/i.test(msg),
summarize: () => translateNow('notifications.errors.gatewayAuthFailed')