[eric] shell: crashes write a report the bug bundle attaches, agents finishing off-screen tap the OS, text size is a slider

This commit is contained in:
ciregenz
2026-08-03 22:31:53 -07:00
parent 198f951798
commit dcffaa5067
5 changed files with 161 additions and 18 deletions
+25
View File
@@ -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)
+94
View File
@@ -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 };
+8
View File
@@ -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 (_) {}
}
@@ -50,25 +50,30 @@ const GeneralInterface: React.FC<{
</ToggleButtonGroup>
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('ui_font_scale', 'Text size', 'Interface', 'Scales all text across the app; layout stays intact.')}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Text size</Typography>
<Typography sx={descSx}>Scales all text across the app. Layout stays intact.</Typography>
<Box sx={rowSx} {...settingSelectAttrs('ui_font_scale', 'Text size', 'Interface', 'Scales all text across the app; layout stays intact.')}>
<Typography sx={labelSx}>Text size</Typography>
<Typography sx={{ ...descSx, mb: 1 }}>Scales all text across the app. Layout stays intact.</Typography>
<Box sx={{ px: 1 }}>
<Slider
value={form.ui_font_scale ?? 1}
onChange={(_, v) => 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 },
}}
/>
</Box>
<ToggleButtonGroup
value={form.ui_font_scale ?? 1}
exclusive
onChange={(_, v) => { if (v) setForm({ ...form, ui_font_scale: v }); }}
size="small"
sx={toggleGroupSx}
>
<ToggleButton value={0.8}>Tiny</ToggleButton>
<ToggleButton value={0.9}>Small</ToggleButton>
<ToggleButton value={1}>Default</ToggleButton>
<ToggleButton value={1.1}>Large</ToggleButton>
<ToggleButton value={1.2}>Larger</ToggleButton>
<ToggleButton value={1.35}>Largest</ToggleButton>
</ToggleButtonGroup>
</Box>
<Box sx={inlineRowSx} {...settingSelectAttrs('voice_hold_to_talk', 'Dictation', 'Interface', 'Hold to talk, or tap to start and stop.')}>
@@ -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;