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,
} 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

View file

@ -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<void> {
// 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<void> {
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<void> {
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<void> {
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<string, unknow
// Index searchable text content into FTS5 search database
for (const msg of mainMessages) {
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,
updateAnchorType as updateAnchorTypeBridge,
} from '../adapters/anchors-bridge';
import { handleInfraError } from '../utils/handle-error';
// Per-project anchor state
const projectAnchors = $state<Map<string, SessionAnchor[]>>(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<void> {
autoAnchoredProjects.add(projectId);
}
} catch (e) {
console.warn('Failed to load anchors for project:', e);
handleInfraError(e, 'anchors.loadForProject');
}
}

View file

@ -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<void> {
focusPane(panes[0].id);
}
} catch (e) {
console.warn('Failed to restore sessions from DB:', e);
handleInfraError(e, 'layout.restoreFromDb');
}
}

View file

@ -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<void> {
try {
machines = await listRemoteMachines();
} 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 { 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;
}

View file

@ -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<void> {
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');
}
}

View file

@ -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<void> {
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'));
}

View file

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