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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-07-14 20:35:50 +02:00
parent f0a5e550d3
commit 87cf893b03
5 changed files with 141 additions and 11 deletions
@@ -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()
{
@@ -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
/// </summary>
/// <param name="rollup">Facts from <see cref="ExtractRollup"/>.</param>
/// <param name="startedAtUtc">Session start (grace is measured from here).</param>
/// <param name="nowUtc">Sweep timestamp.</param>
/// <param name="nowUtc">Sweep timestamp — also the moment the platform declares the verdict.</param>
/// <param name="graceHours">Grace window before AwaitingUser graduates to Incomplete.</param>
/// <param name="lastEventAtUtc">
/// 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
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
/// </param>
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");
}
/// <summary>
/// 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 (<paramref name="nowUtc"/> = 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 <paramref name="lastEventAtUtc"/>, falling back to
/// <paramref name="startedAtUtc"/> when the last-contact time is unknown.
/// </summary>
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.";
}
/// <summary>Compact human duration ("5h 6m", "45m", "2d 3h"). Negative/zero → "0m".</summary>
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);
@@ -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)
@@ -68,10 +68,13 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
<svg className="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
<strong>Backend reconciled:</strong> {session.reconcileReason} — this success was declared
by the platform, not reported by the agent on the device.
</span>
<div>
<span>
<strong>Backend reconciled:</strong> {session.reconcileReason} — this success was declared
by the platform, not reported by the agent on the device.
</span>
<ReconcileTimingFacts session={session} />
</div>
</div>
)}
{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 (
<dl className="mt-2 flex flex-wrap gap-x-5 gap-y-1 text-xs text-sky-700 dark:text-sky-300">
{lastContact && (
<div className="flex gap-1.5">
<dt className="font-medium">Last agent contact:</dt>
<dd>{lastContact}</dd>
</div>
)}
{flaggedSilent && (
<div className="flex gap-1.5">
<dt className="font-medium">First flagged silent:</dt>
<dd>
{flaggedSilent}
{silenceGap && <span className="opacity-80"> ({silenceGap} of silence)</span>}
</dd>
</div>
)}
</dl>
);
}
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
@@ -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;