From 93b3db8b1f1efe590cbd1d64e051fc037fe6af89 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 18 Mar 2026 01:22:12 +0100 Subject: [PATCH] fix(error): add global error handler, fix stores and dispatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Global unhandledrejection handler with IPC+network filtering - Agent dispatcher heartbeat uses handleInfraError (was fire-and-forget) - All stores: layout, workspace, anchors, theme, plugins, machines, wake-scheduler — silent failures replaced with handleInfraError - initGlobalErrorHandler() called in App.svelte onMount --- src/App.svelte | 7 ++++++- src/lib/agent-dispatcher.ts | 13 +++++++------ src/lib/stores/anchors.svelte.ts | 9 +++++---- src/lib/stores/layout.svelte.ts | 15 ++++++++------- src/lib/stores/machines.svelte.ts | 3 ++- src/lib/stores/plugins.svelte.ts | 7 ++++--- src/lib/stores/theme.svelte.ts | 5 +++-- src/lib/stores/wake-scheduler.svelte.ts | 3 ++- src/lib/stores/workspace.svelte.ts | 21 +++++++++++---------- src/lib/utils/global-error-handler.ts | 25 +++++++++++++++++++++++++ 10 files changed, 73 insertions(+), 35 deletions(-) create mode 100644 src/lib/utils/global-error-handler.ts diff --git a/src/App.svelte b/src/App.svelte index 5ea1c15..4ed840e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,6 +18,8 @@ triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle, } from './lib/stores/workspace.svelte'; import { disableWakeScheduler } from './lib/stores/wake-scheduler.svelte'; + import { initGlobalErrorHandler } from './lib/utils/global-error-handler'; + import { handleInfraError } from './lib/utils/handle-error'; import { pruneSeen } from './lib/adapters/btmsg-bridge'; import { invoke } from '@tauri-apps/api/core'; @@ -94,6 +96,9 @@ } onMount(() => { + // Global unhandled rejection safety net + initGlobalErrorHandler(); + // Step 0: Theme initTheme(); getSetting('project_max_aspect').then(v => { @@ -114,7 +119,7 @@ // Step 2: Agent dispatcher startAgentDispatcher(); startHealthTick(); - pruneSeen().catch(() => {}); // housekeeping: remove stale seen_messages on startup + pruneSeen().catch(e => handleInfraError(e, 'app.pruneSeen')); // housekeeping: remove stale seen_messages on startup markStep(2); // Disable wake scheduler in test mode to prevent timer interference diff --git a/src/lib/agent-dispatcher.ts b/src/lib/agent-dispatcher.ts index 5f024f8..055ddb2 100644 --- a/src/lib/agent-dispatcher.ts +++ b/src/lib/agent-dispatcher.ts @@ -17,6 +17,7 @@ import { import { notify, addNotification } from './stores/notifications.svelte'; import { classifyError } from './utils/error-classifier'; import { tel } from './adapters/telemetry-bridge'; +import { handleInfraError } from './utils/handle-error'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; @@ -45,7 +46,7 @@ import type { AgentId } from './types/ids'; function syncBtmsgStopped(sessionId: SessionIdType): void { const projectId = getSessionProjectId(sessionId); if (projectId) { - setBtmsgAgentStatus(projectId as unknown as AgentId, 'stopped').catch(() => {}); + setBtmsgAgentStatus(projectId as unknown as AgentId, 'stopped').catch(e => handleInfraError(e, 'dispatcher.syncBtmsgStopped')); } } @@ -88,7 +89,7 @@ export async function startAgentDispatcher(): Promise { // Record heartbeat on any agent activity (best-effort, fire-and-forget) const hbProjectId = getSessionProjectId(sessionId); if (hbProjectId) { - recordHeartbeat(hbProjectId as unknown as AgentId).catch(() => {}); + recordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat')); } switch (msg.type) { @@ -97,7 +98,7 @@ export async function startAgentDispatcher(): Promise { recordSessionStart(sessionId); tel.info('agent_started', { sessionId }); if (hbProjectId) { - logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(() => {}); + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent started (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); } break; @@ -112,7 +113,7 @@ export async function startAgentDispatcher(): Promise { notify('success', `Agent ${sessionId.slice(0, 8)} completed`); addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); if (hbProjectId) { - logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(() => {}); + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent completed (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); } break; @@ -140,7 +141,7 @@ export async function startAgentDispatcher(): Promise { addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined); if (hbProjectId) { - logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(() => {}); + logAuditEvent(hbProjectId as unknown as AgentId, 'status_change', `Agent error (${classified.type}): ${errorMsg} (session ${sessionId.slice(0, 8)})`).catch(e => handleInfraError(e, 'dispatcher.auditLog')); } break; } @@ -333,7 +334,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record {}); + indexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage')); } } } diff --git a/src/lib/stores/anchors.svelte.ts b/src/lib/stores/anchors.svelte.ts index 4b4144a..2a7da2a 100644 --- a/src/lib/stores/anchors.svelte.ts +++ b/src/lib/stores/anchors.svelte.ts @@ -9,6 +9,7 @@ import { deleteSessionAnchor, updateAnchorType as updateAnchorTypeBridge, } from '../adapters/anchors-bridge'; +import { handleInfraError } from '../utils/handle-error'; // Per-project anchor state const projectAnchors = $state>(new Map()); @@ -62,7 +63,7 @@ export async function addAnchors(projectId: string, anchors: SessionAnchor[]): P try { await saveSessionAnchors(records); } catch (e) { - console.warn('Failed to persist anchors:', e); + handleInfraError(e, 'anchors.save'); } } @@ -74,7 +75,7 @@ export async function removeAnchor(projectId: string, anchorId: string): Promise try { await deleteSessionAnchor(anchorId); } catch (e) { - console.warn('Failed to delete anchor:', e); + handleInfraError(e, 'anchors.delete'); } } @@ -91,7 +92,7 @@ export async function changeAnchorType(projectId: string, anchorId: string, newT try { await updateAnchorTypeBridge(anchorId, newType); } catch (e) { - console.warn('Failed to update anchor type:', e); + handleInfraError(e, 'anchors.updateType'); } } @@ -115,7 +116,7 @@ export async function loadAnchorsForProject(projectId: string): Promise { autoAnchoredProjects.add(projectId); } } catch (e) { - console.warn('Failed to load anchors for project:', e); + handleInfraError(e, 'anchors.loadForProject'); } } diff --git a/src/lib/stores/layout.svelte.ts b/src/lib/stores/layout.svelte.ts index acfe905..e58ad5c 100644 --- a/src/lib/stores/layout.svelte.ts +++ b/src/lib/stores/layout.svelte.ts @@ -9,6 +9,7 @@ import { updateSessionGroup, type PersistedSession, } from '../adapters/session-bridge'; +import { handleInfraError } from '../utils/handle-error'; export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; @@ -46,14 +47,14 @@ function persistSession(pane: Pane): void { created_at: now, last_used_at: now, }; - saveSession(session).catch(e => console.warn('Failed to persist session:', e)); + saveSession(session).catch(e => handleInfraError(e, 'layout.persistSession')); } function persistLayout(): void { saveLayout({ preset: activePreset, pane_ids: panes.map(p => p.id), - }).catch(e => console.warn('Failed to persist layout:', e)); + }).catch(e => handleInfraError(e, 'layout.persistLayout')); } // --- Public API --- @@ -84,14 +85,14 @@ export function removePane(id: string): void { focusedPaneId = panes.length > 0 ? panes[0].id : null; } autoPreset(); - deleteSession(id).catch(e => console.warn('Failed to delete session:', e)); + deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession')); persistLayout(); } export function focusPane(id: string): void { focusedPaneId = id; panes = panes.map(p => ({ ...p, focused: p.id === id })); - touchSession(id).catch(e => console.warn('Failed to touch session:', e)); + touchSession(id).catch(e => handleInfraError(e, 'layout.touchSession')); } export function focusPaneByIndex(index: number): void { @@ -109,7 +110,7 @@ export function renamePaneTitle(id: string, title: string): void { const pane = panes.find(p => p.id === id); if (pane) { pane.title = title; - updateSessionTitle(id, title).catch(e => console.warn('Failed to update title:', e)); + updateSessionTitle(id, title).catch(e => handleInfraError(e, 'layout.updateTitle')); } } @@ -117,7 +118,7 @@ export function setPaneGroup(id: string, group: string): void { const pane = panes.find(p => p.id === id); if (pane) { pane.group = group || undefined; - updateSessionGroup(id, group).catch(e => console.warn('Failed to update group:', e)); + updateSessionGroup(id, group).catch(e => handleInfraError(e, 'layout.updateGroup')); } } @@ -156,7 +157,7 @@ export async function restoreFromDb(): Promise { focusPane(panes[0].id); } } catch (e) { - console.warn('Failed to restore sessions from DB:', e); + handleInfraError(e, 'layout.restoreFromDb'); } } diff --git a/src/lib/stores/machines.svelte.ts b/src/lib/stores/machines.svelte.ts index c035ce9..cfeeceb 100644 --- a/src/lib/stores/machines.svelte.ts +++ b/src/lib/stores/machines.svelte.ts @@ -15,6 +15,7 @@ import { type RemoteMachineInfo, } from '../adapters/remote-bridge'; import { notify } from './notifications.svelte'; +import { handleInfraError } from '../utils/handle-error'; export interface Machine extends RemoteMachineInfo {} @@ -32,7 +33,7 @@ export async function loadMachines(): Promise { try { machines = await listRemoteMachines(); } catch (e) { - console.warn('Failed to load remote machines:', e); + handleInfraError(e, 'machines.load'); } } diff --git a/src/lib/stores/plugins.svelte.ts b/src/lib/stores/plugins.svelte.ts index fa10463..aaa48e4 100644 --- a/src/lib/stores/plugins.svelte.ts +++ b/src/lib/stores/plugins.svelte.ts @@ -7,6 +7,7 @@ import type { PluginMeta } from '../adapters/plugins-bridge'; import { discoverPlugins } from '../adapters/plugins-bridge'; import { getSetting, setSetting } from '../adapters/settings-bridge'; import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host'; +import { handleInfraError } from '../utils/handle-error'; import type { GroupId, AgentId } from '../types/ids'; // --- Plugin command registry (for CommandPalette) --- @@ -65,7 +66,7 @@ class PluginEventBusImpl { try { cb(data); } catch (e) { - console.error(`Plugin event handler error for '${event}':`, e); + handleInfraError(e, `plugins.eventHandler(${event})`); } } } @@ -140,7 +141,7 @@ async function loadSinglePlugin( ); } catch (e) { const errorMsg = e instanceof Error ? e.message : String(e); - console.error(`Failed to load plugin '${entry.meta.id}':`, errorMsg); + handleInfraError(e, `plugins.loadPlugin(${entry.meta.id})`); pluginEntries = pluginEntries.map(e => e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : e, ); @@ -161,7 +162,7 @@ export async function loadAllPlugins(groupId?: GroupId, agentId?: AgentId): Prom try { discovered = await discoverPlugins(); } catch (e) { - console.error('Failed to discover plugins:', e); + handleInfraError(e, 'plugins.discover'); pluginEntries = []; return; } diff --git a/src/lib/stores/theme.svelte.ts b/src/lib/stores/theme.svelte.ts index 8c32966..74c4293 100644 --- a/src/lib/stores/theme.svelte.ts +++ b/src/lib/stores/theme.svelte.ts @@ -1,6 +1,7 @@ // Theme store — persists theme selection via settings bridge import { getSetting, setSetting } from '../adapters/settings-bridge'; +import { handleInfraError } from '../utils/handle-error'; import { type ThemeId, type CatppuccinFlavor, @@ -47,14 +48,14 @@ export async function setTheme(theme: ThemeId): Promise { try { cb(); } catch (e) { - console.error('Theme change callback error:', e); + handleInfraError(e, 'theme.changeCallback'); } } try { await setSetting('theme', theme); } catch (e) { - console.error('Failed to persist theme setting:', e); + handleInfraError(e, 'theme.persistSetting'); } } diff --git a/src/lib/stores/wake-scheduler.svelte.ts b/src/lib/stores/wake-scheduler.svelte.ts index 1ccc512..7c6a7e3 100644 --- a/src/lib/stores/wake-scheduler.svelte.ts +++ b/src/lib/stores/wake-scheduler.svelte.ts @@ -9,6 +9,7 @@ import { getAllWorkItems } from './workspace.svelte'; import { listTasks } from '../adapters/bttask-bridge'; import { getAgentSession } from './agents.svelte'; import { logAuditEvent } from '../adapters/audit-bridge'; +import { handleInfraError } from '../utils/handle-error'; import type { GroupId } from '../types/ids'; // --- Types --- @@ -265,5 +266,5 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise { reg.agentId, 'wake_event', `Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`, - ).catch(() => {}); + ).catch(e => handleInfraError(e, 'wake-scheduler.auditLog')); } diff --git a/src/lib/stores/workspace.svelte.ts b/src/lib/stores/workspace.svelte.ts index 1f4aa24..dab038c 100644 --- a/src/lib/stores/workspace.svelte.ts +++ b/src/lib/stores/workspace.svelte.ts @@ -1,4 +1,5 @@ import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; +import { handleInfraError } from '../utils/handle-error'; import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups'; import { agentToProject } from '../types/groups'; import { clearAllAgentSessions } from '../stores/agents.svelte'; @@ -200,7 +201,7 @@ export async function switchGroup(groupId: string): Promise { // Persist active group if (groupsConfig) { groupsConfig.activeGroupId = groupId; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } } @@ -230,7 +231,7 @@ export async function loadWorkspace(initialGroupId?: string): Promise { // Register all agents from config into btmsg database // (creates agent records, contact permissions, review channels) - registerAgents(config).catch(e => console.warn('Failed to register agents:', e)); + registerAgents(config).catch(e => handleInfraError(e, 'workspace.registerAgents')); // CLI --group flag takes priority, then explicit param, then persisted let cliGroup: string | null = null; @@ -255,7 +256,7 @@ export async function loadWorkspace(initialGroupId?: string): Promise { activeProjectId = projects[0].id; } } catch (e) { - console.warn('Failed to load groups config:', e); + handleInfraError(e, 'workspace.loadWorkspace'); groupsConfig = { version: 1, groups: [], activeGroupId: '' }; } } @@ -264,7 +265,7 @@ export async function saveWorkspace(): Promise { if (!groupsConfig) return; await saveGroups(groupsConfig); // Re-register agents after config changes (new agents, permission updates) - registerAgents(groupsConfig).catch(e => console.warn('Failed to register agents:', e)); + registerAgents(groupsConfig).catch(e => handleInfraError(e, 'workspace.registerAgents')); } // --- Group/project mutation --- @@ -275,7 +276,7 @@ export function addGroup(group: GroupConfig): void { ...groupsConfig, groups: [...groupsConfig.groups, group], }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } export function removeGroup(groupId: string): void { @@ -288,7 +289,7 @@ export function removeGroup(groupId: string): void { activeGroupId = groupsConfig.groups[0]?.id ?? ''; activeProjectId = null; } - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } export function updateProject(groupId: string, projectId: string, updates: Partial): void { @@ -306,7 +307,7 @@ export function updateProject(groupId: string, projectId: string, updates: Parti }; }), }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } export function addProject(groupId: string, project: ProjectConfig): void { @@ -320,7 +321,7 @@ export function addProject(groupId: string, project: ProjectConfig): void { return { ...g, projects: [...g.projects, project] }; }), }; - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } export function removeProject(groupId: string, projectId: string): void { @@ -335,7 +336,7 @@ export function removeProject(groupId: string, projectId: string): void { if (activeProjectId === projectId) { activeProjectId = null; } - saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } export function updateAgent(groupId: string, agentId: string, updates: Partial): void { @@ -353,5 +354,5 @@ export function updateAgent(groupId: string, agentId: string, updates: Partial console.warn('Failed to save groups:', e)); + saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups')); } diff --git a/src/lib/utils/global-error-handler.ts b/src/lib/utils/global-error-handler.ts new file mode 100644 index 0000000..a4c3d92 --- /dev/null +++ b/src/lib/utils/global-error-handler.ts @@ -0,0 +1,25 @@ +import { extractErrorMessage } from './extract-error-message'; +import { classifyError } from './error-classifier'; +import { notify } from '../stores/notifications.svelte'; +import { tel } from '../adapters/telemetry-bridge'; + +let initialized = false; + +export function initGlobalErrorHandler(): void { + if (initialized) return; + initialized = true; + + window.addEventListener('unhandledrejection', (event) => { + const msg = extractErrorMessage(event.reason); + const classified = classifyError(msg); + + tel.error('unhandled_rejection', { reason: msg, type: classified.type }); + + // Don't toast infrastructure/connectivity errors — they're handled contextually + if (classified.type !== 'network' && classified.type !== 'ipc') { + notify('error', `Unexpected error: ${classified.message}`); + } + + event.preventDefault(); + }); +}