diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs
index 19c6ad57..195b51a1 100644
--- a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs
@@ -205,6 +205,41 @@ public class EnrollmentTimeoutClassifierTests
Assert.Contains("RealmJoin", reason);
}
+ [Fact]
+ public void Classify_reconcile_reason_carries_silence_timing_transparency()
+ {
+ // Transparency (session efbc17ff): the reconcile reason must name the last agent
+ // contact, the silence duration, and the exact moment the platform declared success —
+ // so a customer can tell "user powered the device off" apart from "declared too early".
+ var lastEvent = Start.AddMinutes(37); // agent last reported 37 min after start
+ var now = Start.AddHours(5); // sweep declared success 5h after start
+ var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
+ {
+ Esp(DeviceSetup44), Esp(AccountSetup15),
+ Evt("desktop_arrived"), Evt("hello_provisioning_completed"), Evt("realmjoin_detected"),
+ });
+ var (status, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
+ rollup, Start, now, graceHours: 72, lastEventAtUtc: lastEvent);
+
+ Assert.Equal(SessionStatus.Succeeded, status);
+ Assert.Contains("RealmJoin", reason); // core verdict preserved
+ Assert.Contains($"Agent last reported {lastEvent:yyyy-MM-dd HH:mm} UTC", reason);
+ Assert.Contains("silent ~4h 23m", reason); // 5h - 37m
+ Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
+ }
+
+ [Fact]
+ public void Classify_reconcile_reason_timing_falls_back_to_start_when_last_event_unknown()
+ {
+ // No last-contact time → anchor on StartedAt (same fallback the stalled-marker uses),
+ // and the suffix is still emitted so the badge is never left timestamp-less.
+ var now = Start.AddHours(6);
+ var (_, reason) = Classify(new[] { Esp(DeviceSetup44), Esp(AccountSetup55) }, hoursSinceStart: 6);
+ Assert.Contains($"Agent last reported {Start:yyyy-MM-dd HH:mm} UTC", reason);
+ Assert.Contains("silent ~6h 0m", reason);
+ Assert.Contains($"declared this success at {now:yyyy-MM-dd HH:mm} UTC", reason);
+ }
+
[Fact]
public void Classify_desktop_plus_hello_without_realmjoin_reconciles_to_Succeeded()
{
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
index c3161e68..149c5d15 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared.Models;
@@ -216,10 +217,18 @@ namespace AutopilotMonitor.Functions.Services
///
/// Facts from .
/// Session start (grace is measured from here).
- /// Sweep timestamp.
+ /// Sweep timestamp — also the moment the platform declares the verdict.
/// Grace window before AwaitingUser graduates to Incomplete.
+ ///
+ /// Last event the agent reported (last backend contact). Used only to make the two
+ /// Succeeded-reconcile reasons transparent about the silence: without it operators (and
+ /// customers) cannot tell "user finished and powered the device off" apart from "the
+ /// mechanism declared success too early" — session efbc17ff. Falls back to
+ /// when unknown (same fallback the stalled-marker uses).
+ ///
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
- EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours)
+ EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
+ DateTime? lastEventAtUtc = null)
{
// 1. An explicit terminal failure event is a real failure (defensive — such a session
// would normally already be terminal via ingest and not reach the sweep).
@@ -228,8 +237,9 @@ namespace AutopilotMonitor.Functions.Services
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
- return (SessionStatus.Succeeded,
- "Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)");
+ return (SessionStatus.Succeeded, AppendReconcileTiming(
+ "Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
+ startedAtUtc, nowUtc, lastEventAtUtc));
// 3. The agent's absolute-age emergency break fired — the agent has cleaned up and exited, so
// nothing more will ever arrive for this session. Terminalize NOW (skip the AwaitingUser grace):
@@ -269,8 +279,9 @@ namespace AutopilotMonitor.Functions.Services
var detail = rollup.RealmJoinDetected && !rollup.RealmJoinResolved
? "RealmJoin deployment never reported completion before the agent went silent"
: "agent went silent before reporting completion";
- return (SessionStatus.Succeeded,
- $"Reconciled at timeout: user completed setup ({evidence}) — {detail}");
+ return (SessionStatus.Succeeded, AppendReconcileTiming(
+ $"Reconciled at timeout: user completed setup ({evidence}) — {detail}",
+ startedAtUtc, nowUtc, lastEventAtUtc));
}
// 5. Device Setup fully provisioned (device is AADJ + MDM-enrolled), user phase pending.
@@ -296,6 +307,35 @@ namespace AutopilotMonitor.Functions.Services
"No Device Setup completion or explicit failure signal observed before timeout");
}
+ ///
+ /// Append the silence-transparency clause to a Succeeded-reconcile reason. Names the last
+ /// agent contact, how long the session was silent, and the exact moment the platform
+ /// declared the verdict ( = the sweep timestamp). This is the one
+ /// field that survives the reconcile hygiene (which wipes FailureReason on a Succeeded
+ /// write), so the numbers must live here to reach the badge / MCP / exports — see
+ /// session efbc17ff. Anchors to , falling back to
+ /// when the last-contact time is unknown.
+ ///
+ private static string AppendReconcileTiming(
+ string reason, DateTime startedAtUtc, DateTime nowUtc, DateTime? lastEventAtUtc)
+ {
+ var lastSeen = lastEventAtUtc ?? startedAtUtc;
+ var silence = HumanizeDuration(nowUtc - lastSeen);
+ return $"{reason}. Agent last reported {lastSeen.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC; " +
+ $"silent ~{silence} before the platform declared this success at " +
+ $"{nowUtc.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture)} UTC.";
+ }
+
+ /// Compact human duration ("5h 6m", "45m", "2d 3h"). Negative/zero → "0m".
+ private static string HumanizeDuration(TimeSpan d)
+ {
+ if (d <= TimeSpan.Zero) return "0m";
+ int days = d.Days, hours = d.Hours, minutes = d.Minutes;
+ if (days > 0) return $"{days}d {hours}h";
+ if (hours > 0) return $"{hours}h {minutes}m";
+ return $"{minutes}m";
+ }
+
private static bool Eq(string a, string b) =>
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
index 8048cf4f..584f0678 100644
--- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs
@@ -375,7 +375,7 @@ namespace AutopilotMonitor.Functions.Services
// Device Setup → Incomplete. Never Failed without an explicit failure signal.
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
- rollup, session.StartedAt, now, graceHours);
+ rollup, session.StartedAt, now, graceHours, session.LastEventAt);
// No-op if the verdict equals the current (non-terminal) state.
if (targetStatus == session.Status)
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
index 154cf446..7cc6caaf 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/SessionInfoCard.tsx
@@ -68,10 +68,13 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
-
- Backend reconciled: {session.reconcileReason} — this success was declared
- by the platform, not reported by the agent on the device.
-
+
+
+ Backend reconciled: {session.reconcileReason} — this success was declared
+ by the platform, not reported by the agent on the device.
+
+
+
)}
{ntpOffset && Math.abs(ntpOffset.offsetSeconds) > 30 && (
@@ -171,6 +174,56 @@ function InfoItem({ label, value, copyText, tooltip }: { label: string; value: R
);
}
+// Compact human silence duration ("5h 6m", "45m", "2d 3h") from milliseconds.
+function formatSilence(ms: number): string {
+ const totalMin = Math.max(0, Math.round(ms / 60000));
+ const d = Math.floor(totalMin / 1440);
+ const h = Math.floor((totalMin % 1440) / 60);
+ const m = totalMin % 60;
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
+// Structured, scannable timing facts under the "Backend reconciled" banner: the reconcile
+// reason names the full silence-to-declaration window in prose; these chips give the customer
+// the raw UTC anchors — last agent contact and when the sweep first flagged the silence — so
+// "user finished and powered off" is distinguishable from "declared too early" (session efbc17ff).
+function ReconcileTimingFacts({ session }: { session: Session }) {
+ const fmtUtc = (iso?: string) =>
+ iso
+ ? `${new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short", timeZone: "UTC" })} UTC`
+ : null;
+ const lastContact = fmtUtc(session.lastEventAt);
+ const flaggedSilent = fmtUtc(session.stalledAt);
+ const silenceGap =
+ session.lastEventAt && session.stalledAt
+ ? formatSilence(new Date(session.stalledAt).getTime() - new Date(session.lastEventAt).getTime())
+ : null;
+
+ if (!lastContact && !flaggedSilent) return null;
+
+ return (
+
+ {lastContact && (
+
+
Last agent contact:
+
{lastContact}
+
+ )}
+ {flaggedSilent && (
+
+
First flagged silent:
+
+ {flaggedSilent}
+ {silenceGap && ({silenceGap} of silence)}
+
+
+ )}
+
+ );
+}
+
function StatusBadge({ status, failureReason, failureSource, adminMarkedAction, reconcileReason }: { status: string; failureReason?: string; failureSource?: string; adminMarkedAction?: string; reconcileReason?: string }) {
// Delegate the status pill (+ timeout affordance + "manual"/"reconciled" badges) to the shared,
// canonical SessionStatusBadge so this page picks up every status — incl. the AwaitingUser/Incomplete
diff --git a/src/Web/autopilot-monitor-web/types/session.ts b/src/Web/autopilot-monitor-web/types/session.ts
index 7a8b9f60..10e0c6c3 100644
--- a/src/Web/autopilot-monitor-web/types/session.ts
+++ b/src/Web/autopilot-monitor-web/types/session.ts
@@ -23,6 +23,8 @@ export interface Session {
enrollmentType?: string; // "v1" | "v2" — absent for sessions before this feature
diagnosticsBlobName?: string;
lastEventAt?: string;
+ /** Set when the maintenance sweep first observed the agent had gone silent (2h+ no events). Surfaced in the reconcile banner so the silence window is transparent. */
+ stalledAt?: string;
isPreProvisioned?: boolean;
isHybridJoin?: boolean;
isUserDriven?: boolean;