diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Diagnostics/PendingCrashReporterTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Diagnostics/PendingCrashReporterTests.cs
new file mode 100644
index 00000000..6deac8e6
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Diagnostics/PendingCrashReporterTests.cs
@@ -0,0 +1,185 @@
+#nullable enable
+using System;
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Diagnostics;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Diagnostics
+{
+ ///
+ /// Event-shape tests for the previous_crash_detected builder. The dump-writing path is
+ /// integration-only (P/Invoke into dbghelp.dll, hard to unit-test) and covered by the
+ /// V2-agent test-VM smoke run.
+ ///
+ public sealed class PendingCrashReporterTests
+ {
+ private static AgentConfiguration Config() => new AgentConfiguration
+ {
+ SessionId = "S1",
+ TenantId = "T1",
+ };
+
+ [Fact]
+ public void BuildEvent_emits_warning_with_full_metadata()
+ {
+ var record = new CrashRecord
+ {
+ CrashedAt = new DateTime(2026, 5, 28, 10, 15, 30, DateTimeKind.Utc),
+ SessionId = "previous-session-id",
+ TenantId = "previous-tenant",
+ AgentVersion = "2.0.999",
+ Trigger = "AppDomain.UnhandledException",
+ ExceptionType = "System.NullReferenceException",
+ ExceptionMessage = "Object reference not set",
+ StackTrace = "at FooBar()",
+ DumpFilePath = "20260528_101530_abc.dmp",
+ DumpFileSizeBytes = 12345,
+ DumpWriteSucceeded = true,
+ };
+
+ var evt = PendingCrashReporter.BuildEvent(Config(), record);
+
+ Assert.Equal("previous_crash_detected", evt.EventType);
+ Assert.Equal(EventSeverity.Warning, evt.Severity);
+ Assert.Equal("PendingCrashReporter", evt.Source);
+ Assert.Equal(EnrollmentPhase.Unknown, evt.Phase);
+ Assert.True(evt.ImmediateUpload);
+ Assert.Contains("System.NullReferenceException", evt.Message);
+ Assert.Contains("dump captured", evt.Message);
+
+ Assert.Equal("previous-session-id", evt.Data["previousSessionId"]);
+ Assert.Equal("previous-tenant", evt.Data["previousTenantId"]);
+ Assert.Equal("2.0.999", evt.Data["previousAgentVersion"]);
+ Assert.Equal("AppDomain.UnhandledException", evt.Data["trigger"]);
+ Assert.Equal("System.NullReferenceException", evt.Data["exceptionType"]);
+ Assert.Equal("20260528_101530_abc.dmp", evt.Data["dumpFilePath"]);
+ Assert.Equal((long)12345, evt.Data["dumpFileSizeBytes"]);
+ Assert.Equal(true, evt.Data["dumpWriteSucceeded"]);
+ }
+
+ [Fact]
+ public void BuildEvent_handles_missing_dump_gracefully()
+ {
+ var record = new CrashRecord
+ {
+ CrashedAt = DateTime.UtcNow,
+ Trigger = "TaskScheduler.UnobservedTaskException",
+ ExceptionType = "System.AggregateException",
+ ExceptionMessage = "outer",
+ DumpWriteSucceeded = false,
+ };
+
+ var evt = PendingCrashReporter.BuildEvent(Config(), record);
+
+ Assert.Equal(EventSeverity.Warning, evt.Severity);
+ Assert.Contains("no dump", evt.Message);
+ Assert.Equal("(no dump)", evt.Data["dumpFilePath"]);
+ Assert.Equal(false, evt.Data["dumpWriteSucceeded"]);
+ }
+
+ [Fact]
+ public void BuildEvent_handles_null_optional_fields()
+ {
+ var record = new CrashRecord
+ {
+ CrashedAt = DateTime.UtcNow,
+ Trigger = "AppDomain.UnhandledException",
+ ExceptionType = null,
+ ExceptionMessage = null,
+ StackTrace = null,
+ SessionId = null,
+ TenantId = null,
+ AgentVersion = null,
+ };
+
+ var evt = PendingCrashReporter.BuildEvent(Config(), record);
+
+ Assert.Equal("unknown", evt.Data["previousSessionId"]);
+ Assert.Equal("unknown", evt.Data["previousTenantId"]);
+ Assert.Equal("unknown", evt.Data["previousAgentVersion"]);
+ Assert.Equal("unknown", evt.Data["exceptionType"]);
+ Assert.Equal("", evt.Data["exceptionMessage"]);
+ Assert.Equal("", evt.Data["stackTrace"]);
+ }
+
+ [Fact]
+ public void CrashRecord_roundtrips_through_newtonsoft()
+ {
+ var record = new CrashRecord
+ {
+ CrashedAt = new DateTime(2026, 5, 28, 9, 0, 0, DateTimeKind.Utc),
+ SessionId = "sid",
+ Trigger = "AppDomain.UnhandledException",
+ ExceptionType = "X",
+ ExceptionMessage = "m",
+ DumpWriteSucceeded = true,
+ };
+
+ var json = JsonConvert.SerializeObject(record);
+ var back = JsonConvert.DeserializeObject(json);
+
+ Assert.NotNull(back);
+ Assert.Equal(record.SessionId, back!.SessionId);
+ Assert.Equal(record.Trigger, back.Trigger);
+ Assert.Equal(record.ExceptionType, back.ExceptionType);
+ Assert.True(back.DumpWriteSucceeded);
+ }
+
+ // ---------------------------------------------------------------- Retention
+
+ [Fact]
+ public void ApplyRetention_deletes_files_older_than_max_age()
+ {
+ var tmp = Path.Combine(Path.GetTempPath(), "PCR-Retention-" + Guid.NewGuid().ToString("N").Substring(0, 8));
+ Directory.CreateDirectory(tmp);
+ try
+ {
+ var oldFile = Path.Combine(tmp, "old.dmp");
+ File.WriteAllText(oldFile, "x");
+ File.SetCreationTimeUtc(oldFile, DateTime.UtcNow - TimeSpan.FromDays(30));
+
+ var newFile = Path.Combine(tmp, "new.dmp");
+ File.WriteAllText(newFile, "y");
+
+ CrashDumpCapture.ApplyRetention(tmp);
+
+ Assert.False(File.Exists(oldFile));
+ Assert.True(File.Exists(newFile));
+ }
+ finally
+ {
+ try { Directory.Delete(tmp, recursive: true); } catch { }
+ }
+ }
+
+ [Fact]
+ public void ApplyRetention_keeps_only_max_retained_dumps()
+ {
+ var tmp = Path.Combine(Path.GetTempPath(), "PCR-Retention-" + Guid.NewGuid().ToString("N").Substring(0, 8));
+ Directory.CreateDirectory(tmp);
+ try
+ {
+ // Create MaxRetainedCrashes + 3 dumps, each older than the next
+ var baseTime = DateTime.UtcNow - TimeSpan.FromHours(8);
+ for (int i = 0; i < CrashDumpCapture.MaxRetainedCrashes + 3; i++)
+ {
+ var p = Path.Combine(tmp, $"dump-{i}.dmp");
+ File.WriteAllText(p, "x");
+ File.SetCreationTimeUtc(p, baseTime.AddMinutes(i));
+ }
+
+ CrashDumpCapture.ApplyRetention(tmp);
+
+ var remaining = Directory.GetFiles(tmp, "*.dmp");
+ Assert.Equal(CrashDumpCapture.MaxRetainedCrashes, remaining.Length);
+ }
+ finally
+ {
+ try { Directory.Delete(tmp, recursive: true); } catch { }
+ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs
new file mode 100644
index 00000000..6a57f6a1
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashDumpCapture.cs
@@ -0,0 +1,165 @@
+#nullable enable
+using System;
+using System.IO;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Diagnostics
+{
+ ///
+ /// Registers handlers for and
+ /// . When fired, synchronously writes
+ /// a MiniDump (via ) and a JSON sibling
+ /// to . picks them up on the
+ /// next agent start and emits a previous_crash_detected timeline event.
+ ///
+ /// All work is synchronous and fail-soft — the process is about to die so there's no
+ /// async window. , SIGKILL and OS force-terminate
+ /// are accepted limitations (no dump possible).
+ ///
+ ///
+ public static class CrashDumpCapture
+ {
+ public const string CrashesDirectoryName = "Crashes";
+ public const int MaxRetainedCrashes = 5;
+ public static readonly TimeSpan MaxCrashAge = TimeSpan.FromDays(7);
+
+ private static int _registered;
+ private static string _crashesDir = string.Empty;
+ private static string _sessionId = string.Empty;
+ private static string _tenantId = string.Empty;
+ private static string _agentVersion = string.Empty;
+
+ ///
+ /// Idempotent. Registers the global exception handlers. Subsequent calls update the
+ /// captured session/tenant labels but do not re-register handlers.
+ ///
+ public static void RegisterHandlers(
+ string programDataDirectory,
+ string sessionId,
+ string tenantId,
+ string agentVersion)
+ {
+ _crashesDir = Path.Combine(programDataDirectory, CrashesDirectoryName);
+ _sessionId = sessionId ?? string.Empty;
+ _tenantId = tenantId ?? string.Empty;
+ _agentVersion = agentVersion ?? string.Empty;
+
+ if (Interlocked.Exchange(ref _registered, 1) == 1) return;
+
+ AppDomain.CurrentDomain.UnhandledException += OnAppDomainUnhandledException;
+ TaskScheduler.UnobservedTaskException += OnUnobservedTaskException;
+ }
+
+ /// Folder where crash artefacts are written. Empty until runs.
+ public static string CrashesDirectory => _crashesDir;
+
+ private static void OnAppDomainUnhandledException(object sender, UnhandledExceptionEventArgs e)
+ {
+ try
+ {
+ if (e?.ExceptionObject is Exception ex)
+ {
+ WriteCrashArtefacts(ex, trigger: "AppDomain.UnhandledException");
+ }
+ }
+ catch { /* nowhere left to log */ }
+ }
+
+ private static void OnUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
+ {
+ try
+ {
+ if (e?.Exception != null)
+ {
+ WriteCrashArtefacts(e.Exception, trigger: "TaskScheduler.UnobservedTaskException");
+ e.SetObserved();
+ }
+ }
+ catch { /* nowhere left to log */ }
+ }
+
+ private static void WriteCrashArtefacts(Exception ex, string trigger)
+ {
+ try
+ {
+ Directory.CreateDirectory(_crashesDir);
+ }
+ catch { return; }
+
+ var timestamp = DateTime.UtcNow;
+ var stem = $"{timestamp:yyyyMMdd_HHmmss}_{Guid.NewGuid():N}".Substring(0, 23);
+ var dumpPath = Path.Combine(_crashesDir, stem + ".dmp");
+ var jsonPath = Path.Combine(_crashesDir, stem + ".json");
+
+ bool dumpOk = MiniDumpWriter.TryWriteDump(dumpPath);
+ long dumpSize = 0;
+ if (dumpOk)
+ {
+ try { dumpSize = new FileInfo(dumpPath).Length; } catch { }
+ }
+
+ var record = new CrashRecord
+ {
+ CrashedAt = timestamp,
+ SessionId = _sessionId,
+ TenantId = _tenantId,
+ AgentVersion = _agentVersion,
+ Trigger = trigger,
+ ExceptionType = ex.GetType().FullName,
+ ExceptionMessage = Truncate(ex.Message, 2000),
+ StackTrace = Truncate(ex.ToString(), 8000),
+ DumpFilePath = dumpOk ? Path.GetFileName(dumpPath) : null,
+ DumpFileSizeBytes = dumpOk ? dumpSize : (long?)null,
+ DumpWriteSucceeded = dumpOk,
+ };
+
+ try
+ {
+ File.WriteAllText(jsonPath, JsonConvert.SerializeObject(record, Formatting.Indented));
+ }
+ catch { /* nowhere left to log */ }
+ }
+
+ private static string? Truncate(string? s, int maxLen) =>
+ s == null ? null : (s.Length <= maxLen ? s : s.Substring(0, maxLen) + "…[truncated]");
+
+ ///
+ /// Apply retention policy: keep at most dumps and delete
+ /// anything older than . Called from
+ /// after it has emitted pending records.
+ ///
+ internal static void ApplyRetention(string crashesDir)
+ {
+ if (!Directory.Exists(crashesDir)) return;
+
+ try
+ {
+ var cutoff = DateTime.UtcNow - MaxCrashAge;
+ foreach (var f in Directory.GetFiles(crashesDir))
+ {
+ try
+ {
+ if (File.GetCreationTimeUtc(f) < cutoff) File.Delete(f);
+ }
+ catch { /* keep going */ }
+ }
+
+ var dumps = Directory.GetFiles(crashesDir, "*.dmp");
+ if (dumps.Length > MaxRetainedCrashes)
+ {
+ Array.Sort(dumps, (a, b) => File.GetCreationTimeUtc(a).CompareTo(File.GetCreationTimeUtc(b)));
+ var toDelete = dumps.Length - MaxRetainedCrashes;
+ for (int i = 0; i < toDelete; i++)
+ {
+ try { File.Delete(dumps[i]); } catch { }
+ try { File.Delete(Path.ChangeExtension(dumps[i], ".json")); } catch { }
+ }
+ }
+ }
+ catch { /* retention is best-effort */ }
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs
new file mode 100644
index 00000000..9e2cfd45
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/CrashRecord.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using System;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Diagnostics
+{
+ ///
+ /// Serializable record of a crash captured by .
+ /// One JSON file per crash sits next to the dump in %ProgramData%\AutopilotMonitor\Crashes\
+ /// until picks it up on the next agent start and emits the
+ /// previous_crash_detected event.
+ ///
+ public sealed class CrashRecord
+ {
+ [JsonProperty("crashedAt")]
+ public DateTime CrashedAt { get; set; }
+
+ [JsonProperty("sessionId")]
+ public string? SessionId { get; set; }
+
+ [JsonProperty("tenantId")]
+ public string? TenantId { get; set; }
+
+ [JsonProperty("agentVersion")]
+ public string? AgentVersion { get; set; }
+
+ [JsonProperty("trigger")]
+ public string Trigger { get; set; } = string.Empty;
+
+ [JsonProperty("exceptionType")]
+ public string? ExceptionType { get; set; }
+
+ [JsonProperty("exceptionMessage")]
+ public string? ExceptionMessage { get; set; }
+
+ [JsonProperty("stackTrace")]
+ public string? StackTrace { get; set; }
+
+ [JsonProperty("dumpFilePath")]
+ public string? DumpFilePath { get; set; }
+
+ [JsonProperty("dumpFileSizeBytes")]
+ public long? DumpFileSizeBytes { get; set; }
+
+ [JsonProperty("dumpWriteSucceeded")]
+ public bool DumpWriteSucceeded { get; set; }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/MiniDumpWriter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/MiniDumpWriter.cs
new file mode 100644
index 00000000..8117f8d0
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/MiniDumpWriter.cs
@@ -0,0 +1,74 @@
+#nullable enable
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace AutopilotMonitor.Agent.V2.Core.Diagnostics
+{
+ ///
+ /// Thin P/Invoke wrapper around MiniDumpWriteDump from dbghelp.dll.
+ /// Writes a compact MiniDump of the current process so the managed stack and key
+ /// metadata can be recovered post-mortem.
+ ///
+ /// Designed to run from inside an handler —
+ /// fully synchronous, swallows all exceptions, returns false on any failure.
+ ///
+ ///
+ public static class MiniDumpWriter
+ {
+ ///
+ /// Dump flags chosen for a small-but-useful dump (~5-20 MB for the agent):
+ /// data-segs (globals), handles (loaded modules), thread-info (state/priority/affinity),
+ /// unloaded-modules (for module-load failures). No full memory — that would push
+ /// the dump to 100+ MB and break diagnostics-upload size budgets.
+ ///
+ private const uint MiniDumpFlags =
+ 0x00000001 | // MiniDumpWithDataSegs
+ 0x00000004 | // MiniDumpWithHandleData
+ 0x00001000 | // MiniDumpWithThreadInfo
+ 0x00000020; // MiniDumpWithUnloadedModules
+
+ public static bool TryWriteDump(string dumpFilePath)
+ {
+ if (string.IsNullOrEmpty(dumpFilePath)) return false;
+
+ FileStream? fs = null;
+ try
+ {
+ var dir = Path.GetDirectoryName(dumpFilePath);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ fs = new FileStream(dumpFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
+ var process = Process.GetCurrentProcess();
+ return MiniDumpWriteDump(
+ process.Handle,
+ (uint)process.Id,
+ fs.SafeFileHandle.DangerousGetHandle(),
+ MiniDumpFlags,
+ IntPtr.Zero,
+ IntPtr.Zero,
+ IntPtr.Zero);
+ }
+ catch
+ {
+ return false;
+ }
+ finally
+ {
+ try { fs?.Dispose(); } catch { }
+ }
+ }
+
+ [DllImport("dbghelp.dll", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool MiniDumpWriteDump(
+ IntPtr hProcess,
+ uint processId,
+ IntPtr hFile,
+ uint dumpType,
+ IntPtr expParam,
+ IntPtr userStreamParam,
+ IntPtr callbackParam);
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs
new file mode 100644
index 00000000..8d64e20d
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Diagnostics/PendingCrashReporter.cs
@@ -0,0 +1,124 @@
+#nullable enable
+using System;
+using System.Collections.Generic;
+using System.IO;
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Agent.V2.Core.Orchestration;
+using AutopilotMonitor.Shared.Models;
+using Newtonsoft.Json;
+
+namespace AutopilotMonitor.Agent.V2.Core.Diagnostics
+{
+ ///
+ /// Scans at agent start, emits one
+ /// previous_crash_detected event per pending , then
+ /// applies the retention policy. The dump files themselves stay on disk so that a
+ /// diagnostics-upload (customer opt-in) can collect them via the Crashes/ folder.
+ ///
+ public static class PendingCrashReporter
+ {
+ public static void ScanAndEmit(
+ AgentConfiguration configuration,
+ string programDataDirectory,
+ AgentLogger logger,
+ InformationalEventPost post)
+ {
+ if (configuration == null) throw new ArgumentNullException(nameof(configuration));
+ if (logger == null) throw new ArgumentNullException(nameof(logger));
+ if (post == null) throw new ArgumentNullException(nameof(post));
+
+ var crashesDir = Path.Combine(programDataDirectory, CrashDumpCapture.CrashesDirectoryName);
+ if (!Directory.Exists(crashesDir)) return;
+
+ string[] recordFiles;
+ try { recordFiles = Directory.GetFiles(crashesDir, "*.json"); }
+ catch (Exception ex)
+ {
+ logger.Warning($"PendingCrashReporter: enumerate crashes dir threw: {ex.Message}");
+ return;
+ }
+
+ foreach (var recordFile in recordFiles)
+ {
+ CrashRecord? record = TryReadRecord(recordFile, logger);
+ if (record == null)
+ {
+ TryDelete(recordFile);
+ continue;
+ }
+
+ try
+ {
+ post.Emit(BuildEvent(configuration, record));
+ // Mark JSON consumed so we don't re-emit on next start. The .dmp stays for upload.
+ TryDelete(recordFile);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"PendingCrashReporter: emit failed for '{recordFile}': {ex.Message}");
+ }
+ }
+
+ CrashDumpCapture.ApplyRetention(crashesDir);
+ }
+
+ private static CrashRecord? TryReadRecord(string path, AgentLogger logger)
+ {
+ try
+ {
+ var json = File.ReadAllText(path);
+ return JsonConvert.DeserializeObject(json);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"PendingCrashReporter: parse failed for '{path}': {ex.Message}");
+ return null;
+ }
+ }
+
+ private static void TryDelete(string path)
+ {
+ try { File.Delete(path); } catch { }
+ }
+
+ // ----------------------------------------------------------- Event builder (internal for tests)
+
+ internal static EnrollmentEvent BuildEvent(AgentConfiguration configuration, CrashRecord record) =>
+ new EnrollmentEvent
+ {
+ SessionId = configuration.SessionId,
+ TenantId = configuration.TenantId,
+ EventType = "previous_crash_detected",
+ Severity = EventSeverity.Warning,
+ Source = "PendingCrashReporter",
+ Phase = EnrollmentPhase.Unknown,
+ Timestamp = DateTime.UtcNow,
+ Message = BuildMessage(record),
+ Data = new Dictionary
+ {
+ { "crashedAt", record.CrashedAt.ToString("o") },
+ { "previousSessionId", (object?)record.SessionId ?? "unknown" },
+ { "previousTenantId", (object?)record.TenantId ?? "unknown" },
+ { "previousAgentVersion", (object?)record.AgentVersion ?? "unknown" },
+ { "trigger", record.Trigger ?? "unknown" },
+ { "exceptionType", (object?)record.ExceptionType ?? "unknown" },
+ { "exceptionMessage", (object?)record.ExceptionMessage ?? "" },
+ { "stackTrace", (object?)record.StackTrace ?? "" },
+ { "dumpFilePath", (object?)record.DumpFilePath ?? "(no dump)" },
+ { "dumpFileSizeBytes", (object?)record.DumpFileSizeBytes ?? 0 },
+ { "dumpWriteSucceeded", record.DumpWriteSucceeded },
+ },
+ ImmediateUpload = true,
+ };
+
+ private static string BuildMessage(CrashRecord record)
+ {
+ var type = record.ExceptionType ?? "unknown";
+ var dumpInfo = record.DumpWriteSucceeded
+ ? $"dump captured ({record.DumpFileSizeBytes ?? 0} bytes)"
+ : "no dump";
+ return $"Previous agent process crashed: {type} via {record.Trigger}; {dumpInfo}";
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index 34c57ec0..91760884 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -122,6 +122,16 @@ namespace AutopilotMonitor.Agent.V2
// shutdown during self-update still produces a "clean" classification.
RegisterCleanExitMarker(dataDirectory);
+ // Register crash-dump handlers as early as possible — before SelfUpdate, before
+ // any other startup work — so an unhandled exception anywhere downstream still
+ // produces a MiniDump + CrashRecord. SessionId/TenantId are unknown at this point;
+ // they're filled in later by AgentRuntimeHost via a second RegisterHandlers call.
+ AutopilotMonitor.Agent.V2.Core.Diagnostics.CrashDumpCapture.RegisterHandlers(
+ programDataDirectory: dataDirectory,
+ sessionId: "(pre-runtime)",
+ tenantId: "(pre-runtime)",
+ agentVersion: GetAgentVersion());
+
// Startup self-update. Legacy parity: cleanup leftover .old files, load cached
// backend hash / downgrade policy, then attempt the update. Failures here never
// abort startup — we prefer to run the current version than delay.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
index f41af269..18beb883 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeHost.cs
@@ -2,6 +2,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Agent.V2.Core.Diagnostics;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime;
using AutopilotMonitor.Agent.V2.Core.Orchestration;
@@ -558,6 +559,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
{
logger.Warning($"StartupEnvironmentProbes: outer exception: {ex.Message}");
}
+
+ // PR2 — Crash-dump capture: refresh the handler labels with the now-known
+ // session/tenant, then drain pending crash records from previous runs
+ // into the timeline as previous_crash_detected events.
+ try
+ {
+ var programData = Environment.ExpandEnvironmentVariables(@"%ProgramData%\AutopilotMonitor");
+ CrashDumpCapture.RegisterHandlers(
+ programDataDirectory: programData,
+ sessionId: agentConfig.SessionId ?? string.Empty,
+ tenantId: agentConfig.TenantId ?? string.Empty,
+ agentVersion: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown");
+
+ PendingCrashReporter.ScanAndEmit(agentConfig, programData, logger, lifecyclePost);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning($"PendingCrashReporter: outer exception: {ex.Message}");
+ }
});
logger.Info($"V2 agent runtime ready (session={agentConfig.SessionId}, tenant={agentConfig.TenantId}).");
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
index 7c890ad8..a2fa28f5 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt
@@ -1 +1 @@
-894
+898