diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
index eb4eec16..b67cb090 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Core/MonitoringService.cs
@@ -104,6 +104,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
private bool _isFirstConfigApply = true;
private int? _deferredSecurityAuditConfigVersion; // deferred until after agent_started
+ // Runtime self-update trigger (hash mismatch recovery)
+ // Set by the agent host (Program.cs) to inject the SelfUpdater implementation since
+ // .Core cannot reference the .Agent exe project. Once-per-process guard via Interlocked.
+ private static int _integrityUpdateAttempted; // 0 = not yet, 1 = attempted
+ ///
+ /// Callback invoked by the runtime hash-mismatch path to trigger a forced self-update.
+ /// Host wires this to SelfUpdater.CheckAndApplyUpdateAsync(..., forceUpdate: true,
+ /// triggerReason: "runtime_hash_mismatch", downloadTimeoutMsOverride: 60_000).
+ /// Receives the backend-provided ZIP SHA-256 hash so the updater can use it as the
+ /// trusted-channel integrity source.
+ ///
+ public Func RuntimeSelfUpdateTriggerAsync { get; set; }
+
public MonitoringService(AgentConfiguration configuration, AgentLogger logger, string agentVersion,
string previousExitType = "first_run", string previousCrashException = null,
DateTime? lastBootTimeUtc = null)
@@ -288,6 +301,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
// If the agent was self-updated on the previous run, emit a timeline event
EmitSelfUpdateEventIfMarkerExists();
+ // If the startup self-update was skipped (network timeout etc.), emit telemetry
+ // so we can measure how often this happens across the fleet.
+ EmitSelfUpdateSkippedEventIfMarkerExists();
+
// Emit deferred security_audit (initial UnrestrictedMode) now that agent_started has its sequence
if (_deferredSecurityAuditConfigVersion.HasValue)
{
@@ -604,6 +621,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
_ = _emergencyReporter?.TrySendAsync(
AgentErrorType.IntegrityCheckFailed,
$"Binary hash mismatch: expected={expectedHash.Substring(0, 12)}..., actual={actualHash.Substring(0, 12)}...");
+
+ // Trigger a forced self-update on hash mismatch (once per process).
+ // Runs as fire-and-forget on a background task so monitoring keeps running —
+ // on success, SelfUpdater calls Environment.Exit via RestartAgent;
+ // on failure we log and continue with the stale binary until next restart.
+ TriggerSelfUpdateOnMismatch();
}
}
catch (Exception ex)
@@ -612,6 +635,44 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
}
+ ///
+ /// Invokes the host-provided delegate on a
+ /// background task. Guarded by _integrityUpdateAttempted so we only try once
+ /// per agent process (prevents download loops if the update repeatedly fails).
+ ///
+ private void TriggerSelfUpdateOnMismatch()
+ {
+ if (Interlocked.Exchange(ref _integrityUpdateAttempted, 1) == 1)
+ {
+ _logger.Info("Self-update: already attempted in this process — skipping");
+ return;
+ }
+
+ if (RuntimeSelfUpdateTriggerAsync == null)
+ {
+ _logger.Warning("Self-update: no RuntimeSelfUpdateTriggerAsync wired by host — cannot recover from hash mismatch");
+ return;
+ }
+
+ // Hand the ZIP hash from the current config to the updater as trusted-channel source.
+ // Null is acceptable — SelfUpdater falls back to the version.json hash.
+ var zipHash = _remoteConfigService?.CurrentConfig?.LatestAgentSha256;
+
+ Task.Run(async () =>
+ {
+ try
+ {
+ _logger.Info("Self-update: triggering forced update due to runtime hash mismatch");
+ await RuntimeSelfUpdateTriggerAsync(zipHash).ConfigureAwait(false);
+ _logger.Warning("Self-update: returned without restart — update did not apply, agent continues with stale binary");
+ }
+ catch (Exception ex)
+ {
+ _logger.Error("Self-update: unexpected failure during forced update", ex);
+ }
+ });
+ }
+
private void ApplyRuntimeSettingsFromRemoteConfig()
{
var config = _remoteConfigService?.CurrentConfig;
@@ -729,6 +790,9 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
///
/// Reads the self-update marker file left by SelfUpdater, emits an agent_self_updated event,
/// and deletes the marker. Best-effort — failures are logged but never block startup.
+ /// Includes phase timings, trigger reason, and a computed downtime (new process startup
+ /// time minus old process exit time) so we can aggregate update duration and event-loss
+ /// window across the fleet.
///
private void EmitSelfUpdateEventIfMarkerExists()
{
@@ -739,33 +803,76 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
return;
var json = File.ReadAllText(markerPath);
- var marker = Newtonsoft.Json.JsonConvert.DeserializeObject>(json);
- if (marker == null)
+ var marker = Newtonsoft.Json.Linq.JObject.Parse(json);
+
+ var previousVersion = (string)marker["previousVersion"] ?? "unknown";
+ var newVersion = (string)marker["newVersion"] ?? "unknown";
+ var updatedAtUtc = (string)marker["updatedAtUtc"] ?? "unknown";
+ var triggerReason = (string)marker["triggerReason"] ?? "startup";
+ var exitAtUtc = (string)marker["exitAtUtc"];
+
+ long? ReadLong(string key)
{
- TryDeleteMarker(markerPath);
- return;
+ var token = marker[key];
+ if (token == null || token.Type == Newtonsoft.Json.Linq.JTokenType.Null)
+ return null;
+ try { return token.ToObject(); }
+ catch { return null; }
}
- marker.TryGetValue("previousVersion", out var previousVersion);
- marker.TryGetValue("newVersion", out var newVersion);
- marker.TryGetValue("updatedAtUtc", out var updatedAtUtc);
+ var versionCheckMs = ReadLong("versionCheckMs");
+ var downloadMs = ReadLong("downloadMs");
+ var zipSizeBytes = ReadLong("zipSizeBytes");
+ var verifyMs = ReadLong("verifyMs");
+ var extractMs = ReadLong("extractMs");
+ var swapMs = ReadLong("swapMs");
+ var totalUpdateMs = ReadLong("totalUpdateMs");
- _logger.Info($"Self-update detected: {previousVersion} → {newVersion}");
+ // Downtime = new process start (_agentStartTimeUtc) minus old process exit.
+ // Measured across the PowerShell Wait-Process gap + new agent startup up to
+ // MonitoringService init. Closest proxy for "events potentially missed".
+ long? downtimeMs = null;
+ if (!string.IsNullOrEmpty(exitAtUtc) &&
+ DateTime.TryParse(exitAtUtc, System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.RoundtripKind, out var exitDt))
+ {
+ downtimeMs = (long)(_agentStartTimeUtc - exitDt).TotalMilliseconds;
+ }
+
+ _logger.Info($"Self-update detected: {previousVersion} → {newVersion} (trigger={triggerReason}, downtimeMs={downtimeMs?.ToString() ?? "n/a"})");
+
+ var data = new Dictionary
+ {
+ { "previousVersion", previousVersion },
+ { "newVersion", newVersion },
+ { "updatedAtUtc", updatedAtUtc },
+ { "triggerReason", triggerReason }
+ };
+ if (exitAtUtc != null) data["exitAtUtc"] = exitAtUtc;
+ if (versionCheckMs.HasValue) data["versionCheckMs"] = versionCheckMs.Value;
+ if (downloadMs.HasValue) data["downloadMs"] = downloadMs.Value;
+ if (zipSizeBytes.HasValue) data["zipSizeBytes"] = zipSizeBytes.Value;
+ if (verifyMs.HasValue) data["verifyMs"] = verifyMs.Value;
+ if (extractMs.HasValue) data["extractMs"] = extractMs.Value;
+ if (swapMs.HasValue) data["swapMs"] = swapMs.Value;
+ if (totalUpdateMs.HasValue) data["totalUpdateMs"] = totalUpdateMs.Value;
+ if (downtimeMs.HasValue) data["downtimeMs"] = downtimeMs.Value;
+
+ // Runtime-triggered updates are bumped to Warning so they stand out in the UI —
+ // startup updates stay Info because they are the normal rollout path.
+ var severity = string.Equals(triggerReason, "runtime_hash_mismatch", StringComparison.Ordinal)
+ ? EventSeverity.Warning
+ : EventSeverity.Info;
EmitEvent(new EnrollmentEvent
{
SessionId = _configuration.SessionId,
TenantId = _configuration.TenantId,
EventType = Constants.EventTypes.AgentSelfUpdated,
- Severity = EventSeverity.Info,
+ Severity = severity,
Source = "Agent",
- Message = $"Agent self-updated from {previousVersion} to {newVersion}",
- Data = new Dictionary
- {
- { "previousVersion", previousVersion ?? "unknown" },
- { "newVersion", newVersion ?? "unknown" },
- { "updatedAtUtc", updatedAtUtc ?? "unknown" }
- },
+ Message = $"Agent self-updated from {previousVersion} to {newVersion} (trigger={triggerReason})",
+ Data = data,
ImmediateUpload = true
});
@@ -777,6 +884,60 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Core
}
}
+ ///
+ /// Reads the self-update-skipped marker file left by SelfUpdater (startup trigger path only),
+ /// emits an agent_self_update_skipped event, and deletes the marker. Best-effort — failures
+ /// are logged but never block startup.
+ ///
+ private void EmitSelfUpdateSkippedEventIfMarkerExists()
+ {
+ try
+ {
+ var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
+ if (!File.Exists(markerPath))
+ return;
+
+ var json = File.ReadAllText(markerPath);
+ var marker = Newtonsoft.Json.Linq.JObject.Parse(json);
+
+ var reason = (string)marker["reason"] ?? "unknown";
+ var currentVersion = (string)marker["currentVersion"] ?? "unknown";
+ var latestVersion = (string)marker["latestVersion"];
+ var skippedAtUtc = (string)marker["skippedAtUtc"] ?? "unknown";
+ var errorDetail = (string)marker["errorDetail"] ?? string.Empty;
+
+ _logger.Warning($"Startup self-update skipped: reason={reason}, current={currentVersion}, latest={latestVersion ?? "n/a"}");
+
+ var data = new Dictionary
+ {
+ { "reason", reason },
+ { "currentVersion", currentVersion },
+ { "skippedAtUtc", skippedAtUtc },
+ { "errorDetail", errorDetail }
+ };
+ if (latestVersion != null)
+ data["latestVersion"] = latestVersion;
+
+ EmitEvent(new EnrollmentEvent
+ {
+ SessionId = _configuration.SessionId,
+ TenantId = _configuration.TenantId,
+ EventType = Constants.EventTypes.AgentSelfUpdateSkipped,
+ Severity = EventSeverity.Warning,
+ Source = "Agent",
+ Message = $"Startup self-update skipped (reason={reason})",
+ Data = data,
+ ImmediateUpload = true
+ });
+
+ TryDeleteMarker(markerPath);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning($"Could not process self-update-skipped marker: {ex.Message}");
+ }
+ }
+
private static void TryDeleteMarker(string path)
{
try { File.Delete(path); } catch { }
diff --git a/src/Agent/AutopilotMonitor.Agent/Program.cs b/src/Agent/AutopilotMonitor.Agent/Program.cs
index 4e526542..81cb7380 100644
--- a/src/Agent/AutopilotMonitor.Agent/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent/Program.cs
@@ -325,6 +325,24 @@ namespace AutopilotMonitor.Agent
using (var service = new MonitoringService(config, logger, agentVersion, previousExitType, previousCrashException, lastBootUtc))
{
+ // Wire the runtime self-update trigger: on hash mismatch, MonitoringService
+ // invokes SelfUpdater with forceUpdate=true and a longer download timeout
+ // (60s, vs. 10s at startup) because monitoring is already running and we
+ // know an update is required.
+ service.RuntimeSelfUpdateTriggerAsync = zipHash =>
+ {
+ if (!string.IsNullOrEmpty(zipHash))
+ SelfUpdater.BackendExpectedSha256 = zipHash;
+
+ return SelfUpdater.CheckAndApplyUpdateAsync(
+ currentVersion: agentVersion,
+ agentDir: agentDir,
+ consoleMode: consoleMode,
+ forceUpdate: true,
+ triggerReason: "runtime_hash_mismatch",
+ downloadTimeoutMsOverride: 60000);
+ };
+
service.Start();
if (consoleMode)
diff --git a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs b/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs
index d1e2780d..5f6b0095 100644
--- a/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs
+++ b/src/Agent/AutopilotMonitor.Agent/SelfUpdater.cs
@@ -91,7 +91,26 @@ namespace AutopilotMonitor.Agent
/// On success, restarts the process and never returns.
/// On any failure, returns normally so the current version continues.
///
- public static async Task CheckAndApplyUpdateAsync(string currentVersion, string agentDir, bool consoleMode)
+ ///
+ /// When true, bypasses the IsNewerVersion check — used by the runtime-hash-mismatch trigger
+ /// to cover hotfixes that reuse the same version number.
+ ///
+ ///
+ /// "startup" (default) or "runtime_hash_mismatch". Recorded in the marker file so the
+ /// agent_self_updated event can distinguish the two paths.
+ ///
+ ///
+ /// Overrides the ZIP download timeout. Startup path leaves null (fast-fail, 10s) to preserve
+ /// Autopilot boot speed. Runtime trigger passes 60000 because monitoring is already running
+ /// and we know an update is required.
+ ///
+ public static async Task CheckAndApplyUpdateAsync(
+ string currentVersion,
+ string agentDir,
+ bool consoleMode,
+ bool forceUpdate = false,
+ string triggerReason = "startup",
+ int? downloadTimeoutMsOverride = null)
{
Action log = msg =>
{
@@ -99,26 +118,54 @@ namespace AutopilotMonitor.Agent
if (consoleMode) Console.WriteLine(msg);
};
+ var isStartupTrigger = string.Equals(triggerReason, "startup", StringComparison.Ordinal);
+ var downloadTimeoutMs = downloadTimeoutMsOverride ?? DownloadTimeoutMs;
+ var totalSw = Stopwatch.StartNew();
+ long versionCheckMs = 0, downloadMs = 0, verifyMs = 0, extractMs = 0, swapMs = 0;
+ long zipSizeBytes = 0;
+ string latestVersion = null;
+
try
{
// Step 1: Fetch version.json (1s timeout)
- var (latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
- if (latestVersion == null)
- return; // Could not determine latest version — continue with current
+ var versionSw = Stopwatch.StartNew();
+ string manifestSha256;
+ (latestVersion, manifestSha256) = await GetLatestVersionAsync(log);
+ versionSw.Stop();
+ versionCheckMs = versionSw.ElapsedMilliseconds;
- // Step 2: Compare versions
- if (!IsNewerVersion(currentVersion, latestVersion))
+ if (latestVersion == null)
{
- log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
- return;
+ if (isStartupTrigger)
+ WriteSkipMarker("version_check_failed", currentVersion, null, "version.json fetch failed or timed out", log);
+ return; // Could not determine latest version — continue with current
}
- log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
+ // Step 2: Compare versions (skipped when forceUpdate=true)
+ if (!forceUpdate && !IsNewerVersion(currentVersion, latestVersion))
+ {
+ log($"Self-update: current version {currentVersion} is up to date (latest: {latestVersion})");
+ return; // happy path — no skip marker
+ }
- // Step 3: Download ZIP (10s timeout)
+ if (forceUpdate)
+ log($"Self-update: FORCE mode (trigger={triggerReason}) — current={currentVersion}, latest={latestVersion}, skipping version check");
+ else
+ log($"Self-update: newer version available — current={currentVersion}, latest={latestVersion}");
+
+ // Step 3: Download ZIP (timeout = downloadTimeoutMs)
var zipPath = Path.Combine(Path.GetTempPath(), "AutopilotMonitor-Agent-Update.zip");
- if (!await DownloadZipAsync(zipPath, log))
+ var downloadTimerSw = Stopwatch.StartNew();
+ var downloadOk = await DownloadZipAsync(zipPath, downloadTimeoutMs, log);
+ downloadTimerSw.Stop();
+ downloadMs = downloadTimerSw.ElapsedMilliseconds;
+ if (!downloadOk)
+ {
+ if (isStartupTrigger)
+ WriteSkipMarker("download_failed", currentVersion, latestVersion, $"ZIP download failed within {downloadTimeoutMs}ms", log);
return;
+ }
+ try { zipSizeBytes = new FileInfo(zipPath).Length; } catch { /* best-effort */ }
// Step 3b: Verify SHA-256 integrity (backend hash has priority over version.json hash)
string expectedSha256;
@@ -137,16 +184,31 @@ namespace AutopilotMonitor.Agent
expectedSha256 = null;
}
- if (!VerifyZipIntegrity(zipPath, expectedSha256, log))
+ var verifySw = Stopwatch.StartNew();
+ var integrityOk = VerifyZipIntegrity(zipPath, expectedSha256, log);
+ verifySw.Stop();
+ verifyMs = verifySw.ElapsedMilliseconds;
+
+ if (!integrityOk)
{
CleanupStaging(null, zipPath);
+ if (isStartupTrigger)
+ WriteSkipMarker("integrity_mismatch", currentVersion, latestVersion, "SHA-256 mismatch on downloaded ZIP", log);
return;
}
// Step 4: Extract to staging directory
var stagingDir = Environment.ExpandEnvironmentVariables(Constants.AgentUpdateStagingDir);
- if (!ExtractToStaging(zipPath, stagingDir, log))
+ var extractSw = Stopwatch.StartNew();
+ var extractOk = ExtractToStaging(zipPath, stagingDir, log);
+ extractSw.Stop();
+ extractMs = extractSw.ElapsedMilliseconds;
+ if (!extractOk)
+ {
+ if (isStartupTrigger)
+ WriteSkipMarker("extract_failed", currentVersion, latestVersion, "ZIP extraction failed", log);
return;
+ }
// Step 5: Validate staging
var stagedExe = Path.Combine(stagingDir, "AutopilotMonitor.Agent.exe");
@@ -154,13 +216,21 @@ namespace AutopilotMonitor.Agent
{
log("Self-update: staging validation failed — AutopilotMonitor.Agent.exe not found in ZIP");
CleanupStaging(stagingDir, zipPath);
+ if (isStartupTrigger)
+ WriteSkipMarker("extract_failed", currentVersion, latestVersion, "AutopilotMonitor.Agent.exe not found in ZIP", log);
return;
}
// Step 6: Swap files (rename locked → .old, copy new)
- if (!SwapFiles(agentDir, stagingDir, log))
+ var swapTimerSw = Stopwatch.StartNew();
+ var swapOk = SwapFiles(agentDir, stagingDir, log);
+ swapTimerSw.Stop();
+ swapMs = swapTimerSw.ElapsedMilliseconds;
+ if (!swapOk)
{
CleanupStaging(stagingDir, zipPath);
+ if (isStartupTrigger)
+ WriteSkipMarker("swap_failed", currentVersion, latestVersion, "file swap failed", log);
return;
}
@@ -168,7 +238,19 @@ namespace AutopilotMonitor.Agent
CleanupStaging(stagingDir, zipPath);
// Step 8: Write marker file so the new agent can emit an event on next startup
- WriteSelfUpdateMarker(currentVersion, latestVersion, log);
+ totalSw.Stop();
+ WriteSelfUpdateMarker(
+ previousVersion: currentVersion,
+ newVersion: latestVersion,
+ triggerReason: triggerReason,
+ versionCheckMs: versionCheckMs,
+ downloadMs: downloadMs,
+ zipSizeBytes: zipSizeBytes,
+ verifyMs: verifyMs,
+ extractMs: extractMs,
+ swapMs: swapMs,
+ totalUpdateMs: totalSw.ElapsedMilliseconds,
+ log: log);
// Step 9: Restart via PowerShell Wait-Process
log($"Self-update: files swapped successfully, restarting agent (v{latestVersion})...");
@@ -179,6 +261,10 @@ namespace AutopilotMonitor.Agent
catch (Exception ex)
{
log($"Self-update: unexpected error — {ex.Message}. Continuing with current version.");
+ if (isStartupTrigger)
+ {
+ try { WriteSkipMarker("unexpected_error", currentVersion, latestVersion, ex.Message, log); } catch { }
+ }
}
}
@@ -300,7 +386,7 @@ namespace AutopilotMonitor.Agent
///
/// Downloads the agent ZIP to a temp path. Returns false on failure.
///
- private static async Task DownloadZipAsync(string zipPath, Action log)
+ private static async Task DownloadZipAsync(string zipPath, int timeoutMs, Action log)
{
try
{
@@ -311,7 +397,7 @@ namespace AutopilotMonitor.Agent
File.Delete(zipPath);
using (var handler = new HttpClientHandler())
- using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(DownloadTimeoutMs) })
+ using (var client = new HttpClient(handler) { Timeout = TimeSpan.FromMilliseconds(timeoutMs) })
{
using (var response = await client.GetAsync(zipUrl))
{
@@ -328,7 +414,7 @@ namespace AutopilotMonitor.Agent
}
catch (TaskCanceledException)
{
- log("Self-update: ZIP download timed out (10s) — aborting update");
+ log($"Self-update: ZIP download timed out ({timeoutMs}ms) — aborting update");
try { File.Delete(zipPath); } catch { }
return false;
}
@@ -467,8 +553,21 @@ namespace AutopilotMonitor.Agent
///
/// Writes a small JSON marker so the next agent startup can emit an agent_self_updated event.
/// Best-effort — failure here must not block the update.
+ /// Includes phase timings, trigger reason, and exit timestamp so the downstream event
+ /// can carry aggregable telemetry (downtime, total update duration, etc.).
///
- private static void WriteSelfUpdateMarker(string previousVersion, string newVersion, Action log)
+ private static void WriteSelfUpdateMarker(
+ string previousVersion,
+ string newVersion,
+ string triggerReason,
+ long versionCheckMs,
+ long downloadMs,
+ long zipSizeBytes,
+ long verifyMs,
+ long extractMs,
+ long swapMs,
+ long totalUpdateMs,
+ Action log)
{
try
{
@@ -477,8 +576,26 @@ namespace AutopilotMonitor.Agent
if (!Directory.Exists(dir))
Directory.CreateDirectory(dir);
- var json = $"{{\"previousVersion\":\"{previousVersion}\",\"newVersion\":\"{newVersion}\",\"updatedAtUtc\":\"{DateTime.UtcNow:O}\"}}";
- File.WriteAllText(markerPath, json);
+ var now = DateTime.UtcNow;
+ var payload = new JObject
+ {
+ ["previousVersion"] = previousVersion ?? "unknown",
+ ["newVersion"] = newVersion ?? "unknown",
+ ["triggerReason"] = triggerReason ?? "startup",
+ ["updatedAtUtc"] = now.ToString("O"),
+ // exitAtUtc is written here — actual Environment.Exit happens a few hundred ms later
+ // via PowerShell Wait-Process, which is close enough for downtime telemetry.
+ ["exitAtUtc"] = now.ToString("O"),
+ ["versionCheckMs"] = versionCheckMs,
+ ["downloadMs"] = downloadMs,
+ ["zipSizeBytes"] = zipSizeBytes,
+ ["verifyMs"] = verifyMs,
+ ["extractMs"] = extractMs,
+ ["swapMs"] = swapMs,
+ ["totalUpdateMs"] = totalUpdateMs
+ };
+
+ File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
log("Self-update: marker file written for next-startup event");
}
catch (Exception ex)
@@ -487,6 +604,41 @@ namespace AutopilotMonitor.Agent
}
}
+ ///
+ /// Writes a small JSON marker so the next agent startup can emit an agent_self_update_skipped
+ /// event. Only called on the startup trigger path (runtime-triggered failures are logged
+ /// normally because the full logger is already up). Best-effort — never throws.
+ ///
+ private static void WriteSkipMarker(string reason, string currentVersion, string latestVersion, string errorDetail, Action log)
+ {
+ try
+ {
+ var markerPath = Environment.ExpandEnvironmentVariables(Constants.SelfUpdateSkippedMarkerFile);
+ var dir = Path.GetDirectoryName(markerPath);
+ if (!Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ var detail = errorDetail ?? string.Empty;
+ if (detail.Length > 200) detail = detail.Substring(0, 200);
+
+ var payload = new JObject
+ {
+ ["reason"] = reason ?? "unknown",
+ ["currentVersion"] = currentVersion ?? "unknown",
+ ["latestVersion"] = latestVersion, // may be null if version check failed
+ ["skippedAtUtc"] = DateTime.UtcNow.ToString("O"),
+ ["errorDetail"] = detail
+ };
+
+ File.WriteAllText(markerPath, payload.ToString(Newtonsoft.Json.Formatting.None));
+ log($"Self-update: skip marker written (reason={reason})");
+ }
+ catch (Exception ex)
+ {
+ log($"Self-update: could not write skip marker — {ex.Message} (non-critical)");
+ }
+ }
+
///
/// Cleans up staging directory and temp ZIP file.
///
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index 4fdb2984..a80d4992 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-887
+889
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index f1e55a18..6040f5b6 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -35,6 +35,14 @@ namespace AutopilotMonitor.Shared
///
public const string SelfUpdateMarkerFile = @"%ProgramData%\AutopilotMonitor\State\self-update-info.json";
+ ///
+ /// Marker file written by SelfUpdater when the startup update path is skipped (network timeout,
+ /// download failure, integrity mismatch, etc.); read by MonitoringService on next startup to emit
+ /// an agent_self_update_skipped event. Only written on the startup trigger path — runtime-triggered
+ /// failures are logged normally because the full logger is already up.
+ ///
+ public const string SelfUpdateSkippedMarkerFile = @"%ProgramData%\AutopilotMonitor\State\self-update-skipped.json";
+
///
/// Default path for the IME pattern match log file (debugging/diagnostics)
///
@@ -168,6 +176,7 @@ namespace AutopilotMonitor.Shared
public const string SoftwareInventoryAnalysis = "software_inventory_analysis";
public const string VulnerabilityReport = "vulnerability_report";
public const string AgentSelfUpdated = "agent_self_updated";
+ public const string AgentSelfUpdateSkipped = "agent_self_update_skipped";
}
// -----------------------------------------------------------------------