diff --git a/backend/apps/help/bundle.py b/backend/apps/help/bundle.py index 13037c6f..48e7caf1 100644 --- a/backend/apps/help/bundle.py +++ b/backend/apps/help/bundle.py @@ -91,6 +91,26 @@ def p_count_dir(path: str) -> int: return 0 +@typechecked +def p_recent_crash_reports(limit: int = 3) -> str: + """The newest Electron crash reports (ENG-102), metadata only: the backend-log tail inside each + report is dropped here because this bundle already carries its own scrubbed tail.""" + try: + crash_dir = os.path.join(os.path.dirname(DATA_ROOT), "crash-reports") + files = sorted((f for f in os.listdir(crash_dir) if f.endswith(".json")), reverse=True)[:limit] + if not files: + return "(none)" + out: List[str] = [] + for name in files: + with open(os.path.join(crash_dir, name), "r", encoding="utf-8") as fh: + data = json.load(fh) + data.pop("backendLogTail", None) + out.append(json.dumps(data, indent=2, default=str)) + return "\n".join(out) + except Exception: + return "(none)" + + @typechecked def p_build_report(req: BundleRequest) -> str: from backend.apps.settings.store import load_settings @@ -135,6 +155,11 @@ def p_build_report(req: BundleRequest) -> str: p_log_tail(), "```", "", + "## Recent crashes", + "```json", + p_recent_crash_reports(), + "```", + "", ] return "\n".join(lines) diff --git a/electron/crashReports.js b/electron/crashReports.js new file mode 100644 index 00000000..6e41b706 --- /dev/null +++ b/electron/crashReports.js @@ -0,0 +1,94 @@ +// ENG-102: a crash without a report blinds every other crash bug. Each fatal signal writes one +// JSON report (metadata + the backend log tail) into userData/crash-reports and, when possible, +// tells the user where it landed. Renderer/GPU deaths and main-process throws all route here. +const path = require('path'); +const fs = require('fs'); + +const MAX_REPORTS = 30; +const LOG_TAIL_BYTES = 64 * 1024; + +let p_app = null; +let p_notify = null; + +function init(app, notifyFn) { + p_app = app; + p_notify = notifyFn || null; +} + +function reportsDir() { + const dir = path.join(p_app.getPath('userData'), 'crash-reports'); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +function backendLogTail() { + try { + const logPath = path.join(p_app.getPath('userData'), 'data', 'backend.log'); + const size = fs.statSync(logPath).size; + const fd = fs.openSync(logPath, 'r'); + const start = Math.max(0, size - LOG_TAIL_BYTES); + const buf = Buffer.alloc(size - start); + fs.readSync(fd, buf, 0, buf.length, start); + fs.closeSync(fd); + return buf.toString('utf8'); + } catch (_) { + return ''; + } +} + +function prune(dir) { + try { + const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); + while (files.length > MAX_REPORTS) fs.unlinkSync(path.join(dir, files.shift())); + } catch (_) {} +} + +function writeCrashReport(kind, details) { + try { + const dir = reportsDir(); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const file = path.join(dir, `crash-${stamp}-${kind}.json`); + const report = { + kind, + at: new Date().toISOString(), + appVersion: p_app.getVersion(), + platform: process.platform, + arch: process.arch, + electron: process.versions.electron, + details, + backendLogTail: backendLogTail(), + }; + fs.writeFileSync(file, JSON.stringify(report, null, 2)); + prune(dir); + if (p_notify) { + p_notify({ + title: 'OpenSwarm hit a problem', + body: 'A crash report was saved. Help > Report a bug attaches it automatically.', + }); + } + return file; + } catch (err) { + console.error('[crash-reports] failed to write report:', err && err.message); + return null; + } +} + +// Reports written since the previous launch; the renderer surfaces "last session crashed". +function unseenReports() { + try { + const dir = reportsDir(); + const marker = path.join(dir, '.last-seen'); + let last = 0; + try { last = fs.statSync(marker).mtimeMs; } catch (_) {} + const fresh = fs.readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => path.join(dir, f)) + .filter((p) => { try { return fs.statSync(p).mtimeMs > last; } catch (_) { return false; } }); + fs.writeFileSync(marker, String(Date.now())); + return fresh; + } catch (_) { + return []; + } +} + +module.exports = { init, writeCrashReport, unseenReports }; diff --git a/electron/main.js b/electron/main.js index 1816a2fc..bb4ad227 100644 --- a/electron/main.js +++ b/electron/main.js @@ -88,8 +88,11 @@ try { } // Capture every main-process throw we can. Without these, a throw inside an IPC handler or BrowserWindow event listener can die silently and look indistinguishable from a renderer crash in the trace. +const crashReports = require('./crashReports'); +crashReports.init(app, null); process.on('uncaughtException', (err) => { console.error('[diag][main:uncaughtException]', err && err.stack || err); + crashReports.writeCrashReport('main-uncaught-exception', { message: String(err && err.message || err), stack: String(err && err.stack || '') }); }); process.on('unhandledRejection', (reason) => { console.error('[diag][main:unhandledRejection]', reason && reason.stack || reason); @@ -98,6 +101,10 @@ process.on('unhandledRejection', (reason) => { // child-process-gone fires for GPU/utility/renderer process deaths. The GPU one is especially useful: a GPU crash forces the renderer to recover its compositor, and that recovery can itself crash on Windows. app.on('child-process-gone', (_event, details) => { console.error('[diag][main:child-process-gone]', JSON.stringify(details)); + // Clean exits and user kills are not crashes; reporting them would bury the real ones. + if (details && details.reason && details.reason !== 'clean-exit' && details.reason !== 'killed') { + crashReports.writeCrashReport('child-process-gone', details); + } }); // Platform-split auto-updater: electron-updater on Mac (full-featured), Electron's // built-in autoUpdater on Windows (Squirrel.Windows target; electron-updater dropped Squirrel). @@ -1199,6 +1206,7 @@ function markBackendReady() { // Read lazily: mainWindow is replaced by recreateMainWindow, so a captured value goes stale. workflowsLifecycle.setNotificationTarget(() => mainWindow); workflowsLifecycle.startPolling(); + crashReports.init(app, (payload) => { try { workflowsLifecycle.showNativeNotification(payload); } catch (_) {} }); } catch (_) {} try { connectMainBridge(); } catch (_) {} } diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx index 4480177a..c2499975 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralInterface.tsx @@ -50,25 +50,30 @@ const GeneralInterface: React.FC<{ - - - Text size - Scales all text across the app. Layout stays intact. + + Text size + Scales all text across the app. Layout stays intact. + + setForm({ ...form, ui_font_scale: v as number })} + min={0.8} + max={1.35} + step={0.05} + valueLabelDisplay="auto" + valueLabelFormat={(v) => `${Math.round(v * 100)}%`} + marks={[ + { value: 0.8, label: 'Small' }, + { value: 1, label: 'Default' }, + { value: 1.35, label: 'Large' }, + ]} + sx={{ + color: c.accent.primary, + '& .MuiSlider-markLabel': { color: c.text.tertiary, fontSize: '0.6875rem' }, + '& .MuiSlider-valueLabel': { bgcolor: c.accent.primary }, + }} + /> - { if (v) setForm({ ...form, ui_font_scale: v }); }} - size="small" - sx={toggleGroupSx} - > - Tiny - Small - Default - Large - Larger - Largest - diff --git a/frontend/src/shared/ws/WebSocketManager.ts b/frontend/src/shared/ws/WebSocketManager.ts index f671b9cd..61484255 100644 --- a/frontend/src/shared/ws/WebSocketManager.ts +++ b/frontend/src/shared/ws/WebSocketManager.ts @@ -409,6 +409,17 @@ class WebSocketManager { if (data.status === 'running' && session_id) { store.dispatch(trackAgentNotification(session_id)); } + // Native OS notification when an agent finishes while the user is elsewhere: workflows already had this; long chat tasks deserve the same "it's done" tap on both platforms. Sub-agents stay silent (their parent's finish is the story). + if (data.status === 'completed' && session_id && document.hidden) { + const p_sess2 = data.session ?? store.getState().agents.sessions[session_id]; + if (p_sess2 && !p_sess2.parent_session_id && p_sess2.mode !== 'browser-agent') { + void (window as any).openswarm?.notify?.({ + title: 'Agent finished', + body: (p_sess2.name && p_sess2.name !== 'Untitled' ? p_sess2.name : 'Your task is done.').slice(0, 200), + deepLink: `openswarm://session/${session_id}`, + }); + } + } // An AppAgent driving an app card announces itself only via this status event (no card_added like browsers), so light the app card here. Keyed by the parent chat like browser glows, so the same terminal fade below clears it. const p_sess = data.session;