fix(error): add global error handler, fix stores and dispatcher

- 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
This commit is contained in:
Hibryda 2026-03-18 01:22:12 +01:00
parent 8b3b0ab720
commit 93b3db8b1f
10 changed files with 73 additions and 35 deletions

View file

@ -18,6 +18,8 @@
triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle, triggerFocusFlash, emitProjectTabSwitch, emitTerminalToggle,
} from './lib/stores/workspace.svelte'; } from './lib/stores/workspace.svelte';
import { disableWakeScheduler } from './lib/stores/wake-scheduler.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 { pruneSeen } from './lib/adapters/btmsg-bridge';
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
@ -94,6 +96,9 @@
} }
onMount(() => { onMount(() => {
// Global unhandled rejection safety net
initGlobalErrorHandler();
// Step 0: Theme // Step 0: Theme
initTheme(); initTheme();
getSetting('project_max_aspect').then(v => { getSetting('project_max_aspect').then(v => {
@ -114,7 +119,7 @@
// Step 2: Agent dispatcher // Step 2: Agent dispatcher
startAgentDispatcher(); startAgentDispatcher();
startHealthTick(); 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); markStep(2);
// Disable wake scheduler in test mode to prevent timer interference // Disable wake scheduler in test mode to prevent timer interference

View file

@ -17,6 +17,7 @@ import {
import { notify, addNotification } from './stores/notifications.svelte'; import { notify, addNotification } from './stores/notifications.svelte';
import { classifyError } from './utils/error-classifier'; import { classifyError } from './utils/error-classifier';
import { tel } from './adapters/telemetry-bridge'; import { tel } from './adapters/telemetry-bridge';
import { handleInfraError } from './utils/handle-error';
import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte'; import { recordActivity, recordToolDone, recordTokenSnapshot } from './stores/health.svelte';
import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte'; import { recordFileWrite, clearSessionWrites, setSessionWorktree } from './stores/conflicts.svelte';
import { extractWritePaths, extractWorktreePath } from './utils/tool-files'; import { extractWritePaths, extractWorktreePath } from './utils/tool-files';
@ -45,7 +46,7 @@ import type { AgentId } from './types/ids';
function syncBtmsgStopped(sessionId: SessionIdType): void { function syncBtmsgStopped(sessionId: SessionIdType): void {
const projectId = getSessionProjectId(sessionId); const projectId = getSessionProjectId(sessionId);
if (projectId) { 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<void> {
// Record heartbeat on any agent activity (best-effort, fire-and-forget) // Record heartbeat on any agent activity (best-effort, fire-and-forget)
const hbProjectId = getSessionProjectId(sessionId); const hbProjectId = getSessionProjectId(sessionId);
if (hbProjectId) { if (hbProjectId) {
recordHeartbeat(hbProjectId as unknown as AgentId).catch(() => {}); recordHeartbeat(hbProjectId as unknown as AgentId).catch(e => handleInfraError(e, 'dispatcher.heartbeat'));
} }
switch (msg.type) { switch (msg.type) {
@ -97,7 +98,7 @@ export async function startAgentDispatcher(): Promise<void> {
recordSessionStart(sessionId); recordSessionStart(sessionId);
tel.info('agent_started', { sessionId }); tel.info('agent_started', { sessionId });
if (hbProjectId) { 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; break;
@ -112,7 +113,7 @@ export async function startAgentDispatcher(): Promise<void> {
notify('success', `Agent ${sessionId.slice(0, 8)} completed`); notify('success', `Agent ${sessionId.slice(0, 8)} completed`);
addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined); addNotification('Agent complete', `Session ${sessionId.slice(0, 8)} finished`, 'agent_complete', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) { 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; break;
@ -140,7 +141,7 @@ export async function startAgentDispatcher(): Promise<void> {
addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined); addNotification('Agent error', classified.message, 'agent_error', getSessionProjectId(sessionId) ?? undefined);
if (hbProjectId) { 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; break;
} }
@ -333,7 +334,7 @@ function handleAgentEvent(sessionId: SessionIdType, event: Record<string, unknow
// Index searchable text content into FTS5 search database // Index searchable text content into FTS5 search database
for (const msg of mainMessages) { for (const msg of mainMessages) {
if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) { if (msg.type === 'text' && typeof msg.content === 'string' && msg.content.trim()) {
indexMessage(sessionId, 'assistant', msg.content).catch(() => {}); indexMessage(sessionId, 'assistant', msg.content).catch(e => handleInfraError(e, 'dispatcher.indexMessage'));
} }
} }
} }

View file

@ -9,6 +9,7 @@ import {
deleteSessionAnchor, deleteSessionAnchor,
updateAnchorType as updateAnchorTypeBridge, updateAnchorType as updateAnchorTypeBridge,
} from '../adapters/anchors-bridge'; } from '../adapters/anchors-bridge';
import { handleInfraError } from '../utils/handle-error';
// Per-project anchor state // Per-project anchor state
const projectAnchors = $state<Map<string, SessionAnchor[]>>(new Map()); const projectAnchors = $state<Map<string, SessionAnchor[]>>(new Map());
@ -62,7 +63,7 @@ export async function addAnchors(projectId: string, anchors: SessionAnchor[]): P
try { try {
await saveSessionAnchors(records); await saveSessionAnchors(records);
} catch (e) { } 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 { try {
await deleteSessionAnchor(anchorId); await deleteSessionAnchor(anchorId);
} catch (e) { } 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 { try {
await updateAnchorTypeBridge(anchorId, newType); await updateAnchorTypeBridge(anchorId, newType);
} catch (e) { } 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<void> {
autoAnchoredProjects.add(projectId); autoAnchoredProjects.add(projectId);
} }
} catch (e) { } catch (e) {
console.warn('Failed to load anchors for project:', e); handleInfraError(e, 'anchors.loadForProject');
} }
} }

View file

@ -9,6 +9,7 @@ import {
updateSessionGroup, updateSessionGroup,
type PersistedSession, type PersistedSession,
} from '../adapters/session-bridge'; } from '../adapters/session-bridge';
import { handleInfraError } from '../utils/handle-error';
export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack'; export type LayoutPreset = '1-col' | '2-col' | '3-col' | '2x2' | 'master-stack';
@ -46,14 +47,14 @@ function persistSession(pane: Pane): void {
created_at: now, created_at: now,
last_used_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 { function persistLayout(): void {
saveLayout({ saveLayout({
preset: activePreset, preset: activePreset,
pane_ids: panes.map(p => p.id), pane_ids: panes.map(p => p.id),
}).catch(e => console.warn('Failed to persist layout:', e)); }).catch(e => handleInfraError(e, 'layout.persistLayout'));
} }
// --- Public API --- // --- Public API ---
@ -84,14 +85,14 @@ export function removePane(id: string): void {
focusedPaneId = panes.length > 0 ? panes[0].id : null; focusedPaneId = panes.length > 0 ? panes[0].id : null;
} }
autoPreset(); autoPreset();
deleteSession(id).catch(e => console.warn('Failed to delete session:', e)); deleteSession(id).catch(e => handleInfraError(e, 'layout.deleteSession'));
persistLayout(); persistLayout();
} }
export function focusPane(id: string): void { export function focusPane(id: string): void {
focusedPaneId = id; focusedPaneId = id;
panes = panes.map(p => ({ ...p, focused: p.id === 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 { 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); const pane = panes.find(p => p.id === id);
if (pane) { if (pane) {
pane.title = title; 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); const pane = panes.find(p => p.id === id);
if (pane) { if (pane) {
pane.group = group || undefined; 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<void> {
focusPane(panes[0].id); focusPane(panes[0].id);
} }
} catch (e) { } catch (e) {
console.warn('Failed to restore sessions from DB:', e); handleInfraError(e, 'layout.restoreFromDb');
} }
} }

View file

@ -15,6 +15,7 @@ import {
type RemoteMachineInfo, type RemoteMachineInfo,
} from '../adapters/remote-bridge'; } from '../adapters/remote-bridge';
import { notify } from './notifications.svelte'; import { notify } from './notifications.svelte';
import { handleInfraError } from '../utils/handle-error';
export interface Machine extends RemoteMachineInfo {} export interface Machine extends RemoteMachineInfo {}
@ -32,7 +33,7 @@ export async function loadMachines(): Promise<void> {
try { try {
machines = await listRemoteMachines(); machines = await listRemoteMachines();
} catch (e) { } catch (e) {
console.warn('Failed to load remote machines:', e); handleInfraError(e, 'machines.load');
} }
} }

View file

@ -7,6 +7,7 @@ import type { PluginMeta } from '../adapters/plugins-bridge';
import { discoverPlugins } from '../adapters/plugins-bridge'; import { discoverPlugins } from '../adapters/plugins-bridge';
import { getSetting, setSetting } from '../adapters/settings-bridge'; import { getSetting, setSetting } from '../adapters/settings-bridge';
import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host'; import { loadPlugin, unloadPlugin, unloadAllPlugins, getLoadedPlugins } from '../plugins/plugin-host';
import { handleInfraError } from '../utils/handle-error';
import type { GroupId, AgentId } from '../types/ids'; import type { GroupId, AgentId } from '../types/ids';
// --- Plugin command registry (for CommandPalette) --- // --- Plugin command registry (for CommandPalette) ---
@ -65,7 +66,7 @@ class PluginEventBusImpl {
try { try {
cb(data); cb(data);
} catch (e) { } 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) { } catch (e) {
const errorMsg = e instanceof Error ? e.message : String(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 => pluginEntries = pluginEntries.map(e =>
e.meta.id === entry.meta.id ? { ...e, status: 'error' as PluginStatus, error: errorMsg } : 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 { try {
discovered = await discoverPlugins(); discovered = await discoverPlugins();
} catch (e) { } catch (e) {
console.error('Failed to discover plugins:', e); handleInfraError(e, 'plugins.discover');
pluginEntries = []; pluginEntries = [];
return; return;
} }

View file

@ -1,6 +1,7 @@
// Theme store — persists theme selection via settings bridge // Theme store — persists theme selection via settings bridge
import { getSetting, setSetting } from '../adapters/settings-bridge'; import { getSetting, setSetting } from '../adapters/settings-bridge';
import { handleInfraError } from '../utils/handle-error';
import { import {
type ThemeId, type ThemeId,
type CatppuccinFlavor, type CatppuccinFlavor,
@ -47,14 +48,14 @@ export async function setTheme(theme: ThemeId): Promise<void> {
try { try {
cb(); cb();
} catch (e) { } catch (e) {
console.error('Theme change callback error:', e); handleInfraError(e, 'theme.changeCallback');
} }
} }
try { try {
await setSetting('theme', theme); await setSetting('theme', theme);
} catch (e) { } catch (e) {
console.error('Failed to persist theme setting:', e); handleInfraError(e, 'theme.persistSetting');
} }
} }

View file

@ -9,6 +9,7 @@ import { getAllWorkItems } from './workspace.svelte';
import { listTasks } from '../adapters/bttask-bridge'; import { listTasks } from '../adapters/bttask-bridge';
import { getAgentSession } from './agents.svelte'; import { getAgentSession } from './agents.svelte';
import { logAuditEvent } from '../adapters/audit-bridge'; import { logAuditEvent } from '../adapters/audit-bridge';
import { handleInfraError } from '../utils/handle-error';
import type { GroupId } from '../types/ids'; import type { GroupId } from '../types/ids';
// --- Types --- // --- Types ---
@ -265,5 +266,5 @@ async function evaluateAndEmit(reg: ManagerRegistration): Promise<void> {
reg.agentId, reg.agentId,
'wake_event', 'wake_event',
`Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`, `Auto-wake triggered (strategy=${reg.strategy}, mode=${mode}, score=${evaluation.totalScore.toFixed(2)})`,
).catch(() => {}); ).catch(e => handleInfraError(e, 'wake-scheduler.auditLog'));
} }

View file

@ -1,4 +1,5 @@
import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge';
import { handleInfraError } from '../utils/handle-error';
import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups'; import type { GroupsFile, GroupConfig, ProjectConfig, GroupAgentConfig } from '../types/groups';
import { agentToProject } from '../types/groups'; import { agentToProject } from '../types/groups';
import { clearAllAgentSessions } from '../stores/agents.svelte'; import { clearAllAgentSessions } from '../stores/agents.svelte';
@ -200,7 +201,7 @@ export async function switchGroup(groupId: string): Promise<void> {
// Persist active group // Persist active group
if (groupsConfig) { if (groupsConfig) {
groupsConfig.activeGroupId = groupId; 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<void> {
// Register all agents from config into btmsg database // Register all agents from config into btmsg database
// (creates agent records, contact permissions, review channels) // (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 // CLI --group flag takes priority, then explicit param, then persisted
let cliGroup: string | null = null; let cliGroup: string | null = null;
@ -255,7 +256,7 @@ export async function loadWorkspace(initialGroupId?: string): Promise<void> {
activeProjectId = projects[0].id; activeProjectId = projects[0].id;
} }
} catch (e) { } catch (e) {
console.warn('Failed to load groups config:', e); handleInfraError(e, 'workspace.loadWorkspace');
groupsConfig = { version: 1, groups: [], activeGroupId: '' }; groupsConfig = { version: 1, groups: [], activeGroupId: '' };
} }
} }
@ -264,7 +265,7 @@ export async function saveWorkspace(): Promise<void> {
if (!groupsConfig) return; if (!groupsConfig) return;
await saveGroups(groupsConfig); await saveGroups(groupsConfig);
// Re-register agents after config changes (new agents, permission updates) // 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 --- // --- Group/project mutation ---
@ -275,7 +276,7 @@ export function addGroup(group: GroupConfig): void {
...groupsConfig, ...groupsConfig,
groups: [...groupsConfig.groups, group], 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 { export function removeGroup(groupId: string): void {
@ -288,7 +289,7 @@ export function removeGroup(groupId: string): void {
activeGroupId = groupsConfig.groups[0]?.id ?? ''; activeGroupId = groupsConfig.groups[0]?.id ?? '';
activeProjectId = null; 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<ProjectConfig>): void { export function updateProject(groupId: string, projectId: string, updates: Partial<ProjectConfig>): 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 { 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] }; 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 { export function removeProject(groupId: string, projectId: string): void {
@ -335,7 +336,7 @@ export function removeProject(groupId: string, projectId: string): void {
if (activeProjectId === projectId) { if (activeProjectId === projectId) {
activeProjectId = null; 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<GroupAgentConfig>): void { export function updateAgent(groupId: string, agentId: string, updates: Partial<GroupAgentConfig>): void {
@ -353,5 +354,5 @@ export function updateAgent(groupId: string, agentId: string, updates: Partial<G
}; };
}), }),
}; };
saveGroups(groupsConfig).catch(e => console.warn('Failed to save groups:', e)); saveGroups(groupsConfig).catch(e => handleInfraError(e, 'workspace.saveGroups'));
} }

View file

@ -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();
});
}