From 87cf893b030d0adf11e19e3daf289a93226874b4 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 14 Jul 2026 20:35:50 +0200 Subject: [PATCH] feat(reconcile): surface silence duration + decision time in timeout reconcile The "Backend reconciled" badge showed only the reason text with no timestamps, so operators/customers could not tell "user finished setup and powered the device off" apart from "the mechanism declared success too early" (session efbc17ff): last-contact, silence window and the exact decision moment were all invisible, and the stage-1 "Agent silent for Nmin" note is wiped by the Succeeded-write reconcile hygiene. - EnrollmentTimeoutClassifier.ClassifyTimedOutSession takes an optional lastEventAtUtc and appends a timing clause to the two Succeeded-reconcile reasons (AccountSetup-complete + desktop/Hello): last agent contact, silence duration, and the sweep's declaration time. Lives in the reason string so it survives the hygiene and reaches badge/MCP/exports. - MaintenanceService passes session.LastEventAt through. - Web: add Session.stalledAt; reconcile banner renders structured UTC anchors (last agent contact + first-flagged-silent with the observed silence gap). Verified: build 0 errors; classifier 36/36 (2 new timing tests); maintenance/status/projection 160/160; web tsc clean; vitest 557/557. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../EnrollmentTimeoutClassifierTests.cs | 35 +++++++++++ .../Services/EnrollmentTimeoutClassifier.cs | 52 ++++++++++++++-- .../Services/MaintenanceService.cs | 2 +- .../components/SessionInfoCard.tsx | 61 +++++++++++++++++-- .../autopilot-monitor-web/types/session.ts | 2 + 5 files changed, 141 insertions(+), 11 deletions(-) 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;