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:
parent
8b3b0ab720
commit
93b3db8b1f
10 changed files with 73 additions and 35 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
|
|
|
|||
25
src/lib/utils/global-error-handler.ts
Normal file
25
src/lib/utils/global-error-handler.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue