Harden incident forensics: catch fast heap bursts, cap feed-debug, journal retention, detect prior-run OOMs - #389
Merged
Conversation
The 2026-07-04 crash was a 15 s burst that took the main heap from ~40 MB to a mark-compact abort at ~2.55 GB — never crossing the 3 GiB watchdog threshold, sampled at a 30 s cadence that couldn't have fired during the fatal window, and driven by unbounded per-session feed-debug JSONL files. debug-retention printed its actions to console.warn only, so the always-on incident spine had no trace. The prior-run classifier fell back to force_quit_or_power_loss. Four narrow changes: * heapWatchdog: trip at 1.5 GiB (was 3 GiB) and sample every 5 s (was 30 s at rest). Adaptive fast-sample kicks in at 25 % of the heap limit (was 50 %) so 2 s cadence engages BEFORE the trip. Fully documented history of thresholds in the thick WHY comments. * debugRetention: setDebugRetentionJournal wires the always-on AppRunJournal so prune actions land in events.jsonl. Successful prunes (removed > 0) and prune failures are journaled; no-op prunes stay silent (retention is called from many hot paths on a 5 min cooldown). * feedDebugLog: 128 MiB hard cap per session's JSONL. When exceeded, writes one tombstone line and drops further appends for the session. Prevents a single pathological session from eating the 22 % feed-debug bucket cap. * previousRunClassifier + AppRunJournal: enable process.report. reportOnFatalError with directory pointing at the run dir, so V8 fatal aborts (OOM, ineffective mark-compact) leave a diagnostic JSON alongside events.jsonl. Classifier reads the top-level "trigger" field to route OOM/FatalError to a new main_oom_suspected classification, which the index caller treats as crash-severity. Deliberately NOT in this PR: * IPC backpressure for debug:append-feed-log — the true root fix. Filed separately because it has real design decisions (drop vs. block, per-session vs. global counter). * Retention bucket rebalancing — 22 % of ~13.8 GiB is generous but not obviously wrong; revisit once journaled prune actions inform the decision. Closes #388, helps close #368. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Juliusolsson05
added a commit
that referenced
this pull request
Jul 6, 2026
…view-fixes Fix confirmed findings from the #389 adversarial review
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #388. Related to #368, #370.
Why
The 2026-07-04 crash was a 15 s burst that took the main-process heap from ~40 MB to a V8 mark-compact abort at ~2.55 GB. Every preventive safety we had went silent:
ipc.handle.debug:append-feed-logat 55–867 ms each.pruned 1 artifacts (30.9 MiB) reason=performance-append budget=13.8GiBonly toconsole.warn; the always-on incident journal has no record.force_quit_or_power_lossbecause it can't distinguish a V8 OOM from a hard poweroff.What changed (6 files, ~370 lines)
src/main/performance/heapWatchdog.ts3 GiB → 1.5 GiB(still capped byheapLimit × 0.70). Steady-state Agent Code main sits at 30–400 MB across 34-hour runs, so 1.5 GiB is ~3× the ceiling — quiet under normal use, catches both the 1.2 GB and 2.5 GB failure modes.30 s → 5 s. Guarantees at least one sample during any ≥10 s burst.0.5 → 0.25of heap limit, so 2 s cadence engages before the new trip line instead of after it.src/main/storage/debugRetention.tssetDebugRetentionJournal(journal)sets a module-level sink.removed > 0) and prune failures nowjournal?.record({ area: 'storage.retention', name: 'debug_retention.prune' | 'debug_retention.prune_failed' }), withreason,removed,bytesFreed,budgetBytes,scannedBytes, andttlHoursindata.src/main/storage/feedDebugLog.tsMAX_FEED_DEBUG_FILE_BYTES = 128 MiB. Per-session in-memory counter is primed once fromstat()so it survives across restarts.{sessionId, __feedDebugCapped: true, capBytes, fileBytesAtCap, droppedEntriesSoFar, ts}) and drop the batch instead of half-writing it. All further batches short-circuit and only advance the drop counter.src/main/incident/AppRunJournal.tsstart()enablesprocess.report.reportOnFatalError = trueand setsprocess.report.directoryto the run dir. V8 fatal aborts (OOM, ineffective mark-compact,FATAL ERROR) now emit areport.<ts>.<pid>.<seq>.jsonright next toevents.jsonlwith heap statistics, native/JS stacks, and env info.process.reportaccess are caught so the flag can't gate journal start.src/main/incident/previousRunClassifier.tsmain_oom_suspected.findNodeDiagnosticReport(priorRunDir)scans the prior run dir forreport.*.json, reads a bounded 32 KB prefix, and extracts the top-level"trigger"field with a regex.OOMErrorandFatalErrormap tomain_oom_suspected; this branch runs first, so a specific attribution wins over both JS-incident and minidump signals.reportOnFatalErrorruns.app.prior_unclean_shutdownincident now includesnodeReportPathandnodeReportTrigger.src/main/index.tssetDebugRetentionJournal(appRunJournal)right afterinstallProcessCrashHooks. The initialscheduleDebugStoragePrune('incident-run-start')insideAppRunJournal.start()kicks off async I/O; the.thenhandler that would journal a non-empty prune runs aftersetDebugRetentionJournalexecutes, so the wiring is race-free.main_oom_suspectedas crash-like severity (error) in the prior-unclean-shutdown incident routing.Explicitly not in this PR
debug:append-feed-log— the real root fix (bound main's in-flight-bytes counter and signal renderer to drop). Will land as a separate PR with a design note; that one has actual choices (drop vs. block, per-session vs. global).Test plan
npm run dev; verify~/.config/agent-code/incidents/runs/<run>/getsmanifest.json, heartbeat, and events.jsonl as before, plus (on any prune) adebug_retention.pruneevent.clean-shutdownmarker on an old run and start Agent Code to forcestartupretention pass) and confirm the journal event appears inevents.jsonl.feed-debug/<session>.jsonland further appends stop.NODE_OPTIONS='--max-old-space-size=200'then run a heavy import) and confirm:report.*.jsonlands in the run dir with"trigger": "OOMError".incidents.jsonlrecordsapp.prior_unclean_shutdownwithreason: "main_oom_suspected"andnodeReportPathin the context.🤖 Generated with Claude Code