mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
fix(backend): park silent WhiteGlove Part-2 sessions as AwaitingUser instead of Stalled
fairstone.ca analysis 2026-08-21: after the reseal-reboot technicians routinely power devices off at the logon screen to box them. Both silence paths (2h maintenance sweep, agent stall probe) then labeled the expected parking state "Stalled - Agent silent", and stage 2 later reconciled it to Succeeded on the Part-1 whiteglove_complete with a reason claiming Account Setup completed. - EnrollmentTimeoutClassifier: shared pure gate IsWhiteGloveAwaitingUser (pre-provisioned + ResumedAt + Device Setup provisioned + zero user evidence since resume) with one canonical customer-facing reason; new rollup field HasEnrollmentComplete separates real completions from Part-1 whiteglove_complete - ClassifyTimedOutSession: gated sessions are AwaitingUser within grace and reconcile past grace with an honest WhiteGlove reason; on resumed sessions whiteglove_complete no longer counts as terminal-complete evidence (rule 2), so partial user progress falls through to the honest AwaitingUser/Incomplete rules - MaintenanceService stage 1 + session_stalled ingest mapping apply the same gate (fail-soft to Stalled); sweep audit now carries SessionsAwaitingUser - Callsites (stage 2, max-lifetime verdict, late-telemetry reconcile, legacy reclassification) pass IsPreProvisioned/ResumedAt through Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -471,4 +471,148 @@ public class EnrollmentTimeoutClassifierTests
|
||||
Assert.NotEqual(SessionStatus.Failed, status);
|
||||
}
|
||||
}
|
||||
|
||||
// -------- WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21) --------
|
||||
|
||||
private static readonly DateTime Resumed = Start.AddMinutes(20);
|
||||
|
||||
private static (SessionStatus, string) ClassifyWhiteGlove(
|
||||
IReadOnlyList<EnrollmentEvent> events, double hoursSinceStart = 6, int grace = 51)
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var now = Start.AddHours(hoursSinceStart);
|
||||
return EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, now, grace, isPreProvisioned: true, resumedAt: Resumed);
|
||||
}
|
||||
|
||||
/// <summary>The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in.</summary>
|
||||
private static EnrollmentEvent[] WgParkedEvents() => new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ExtractRollup_separates_enrollment_complete_from_whiteglove_complete()
|
||||
{
|
||||
var wgOnly = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("whiteglove_complete") });
|
||||
Assert.True(wgOnly.HasTerminalComplete);
|
||||
Assert.False(wgOnly.HasEnrollmentComplete);
|
||||
|
||||
var real = EnrollmentTimeoutClassifier.ExtractRollup(new[] { Evt("enrollment_complete") });
|
||||
Assert.True(real.HasTerminalComplete);
|
||||
Assert.True(real.HasEnrollmentComplete);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_requires_preprovisioned_and_resume_and_device_setup()
|
||||
{
|
||||
var parked = EnrollmentTimeoutClassifier.ExtractRollup(WgParkedEvents());
|
||||
Assert.True(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, false, Resumed));
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(parked, true, null));
|
||||
|
||||
// Powered off before Device Setup ever provisioned → not the parking state.
|
||||
var early = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp("ESP provisioning status: DeviceSetup — 3 of 4 subcategories completed"),
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(early, true, Resumed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WhiteGloveGate_any_user_evidence_or_terminal_disarms_it()
|
||||
{
|
||||
foreach (var evidence in new[]
|
||||
{
|
||||
Esp(AccountSetup15), // Account Setup progressed → user was there
|
||||
Evt("desktop_arrived"), // real-user desktop
|
||||
Evt("hello_provisioning_completed"), // positive Hello terminal
|
||||
Evt("enrollment_complete"), // real completion
|
||||
Evt("enrollment_failed"), // explicit failure
|
||||
})
|
||||
{
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), evidence,
|
||||
});
|
||||
Assert.False(EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(rollup, true, Resumed));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_within_grace_is_AwaitingUser()
|
||||
{
|
||||
// The fairstone shape: technician powers the device off at the logon screen minutes
|
||||
// after the reseal-reboot. Part-1 whiteglove_complete must NOT reconcile to Succeeded.
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, status);
|
||||
Assert.Contains("sealed or powered off awaiting the end user", reason);
|
||||
Assert.Contains("(Account Setup 0/5)", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_parked_past_grace_reconciles_with_honest_reason()
|
||||
{
|
||||
var (status, reason) = ClassifyWhiteGlove(WgParkedEvents(), hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("pre-provisioning (WhiteGlove Part 1) completed", reason);
|
||||
Assert.DoesNotContain("Account Setup completed", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_partial_user_progress_is_not_reconciled_on_part1_evidence()
|
||||
{
|
||||
// User signed in and Account Setup progressed to 1/5, then silence. The Part-1
|
||||
// whiteglove_complete previously reconciled this to Succeeded claiming "Account Setup
|
||||
// completed" — on a resumed session only a real enrollment_complete proves the outcome,
|
||||
// so this honest shape falls through to the AwaitingUser/Incomplete rules.
|
||||
var events = new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
};
|
||||
var (withinGrace, _) = ClassifyWhiteGlove(events, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.AwaitingUser, withinGrace);
|
||||
|
||||
var (pastGrace, reason) = ClassifyWhiteGlove(events, hoursSinceStart: 60, grace: 51);
|
||||
Assert.Equal(SessionStatus.Incomplete, pastGrace);
|
||||
Assert.Contains("1/5", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_with_desktop_and_hello_still_reconciles_via_user_evidence()
|
||||
{
|
||||
// Both Classic completion prerequisites in → the user provably finished; the
|
||||
// user-completed reconcile (rule 4) owns this, not the parking gate.
|
||||
var (status, reason) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup15),
|
||||
Evt("desktop_arrived"), Evt("hello_provisioning_completed"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
Assert.Contains("user completed setup", reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_whiteglove_part2_real_enrollment_complete_still_reconciles_to_Succeeded()
|
||||
{
|
||||
var (status, _) = ClassifyWhiteGlove(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05), Evt("enrollment_complete"),
|
||||
}, hoursSinceStart: 6);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Classify_unresumed_session_keeps_terminal_complete_reconcile()
|
||||
{
|
||||
// No resumedAt (plain sessions, or a sealed Part 1 whose Pending write was lost):
|
||||
// whiteglove_complete keeps reconciling to Succeeded exactly as before.
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(new[]
|
||||
{
|
||||
Esp(DeviceSetup44), Evt("whiteglove_complete"), Esp(AccountSetup05),
|
||||
});
|
||||
var (status, _) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, Start, Start.AddHours(6), 51, isPreProvisioned: true, resumedAt: null);
|
||||
Assert.Equal(SessionStatus.Succeeded, status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool AccountSetupAllSucceeded,
|
||||
bool HasExplicitFailure,
|
||||
bool HasTerminalComplete,
|
||||
bool HasEnrollmentComplete,
|
||||
bool HasAgentEmergencyBreak,
|
||||
bool DesktopArrived,
|
||||
bool HelloResolved,
|
||||
@@ -113,7 +114,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
bool deviceAll = false;
|
||||
int acctBestN = 0, acctBestM = 0;
|
||||
bool acctFallbackAll = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEmergencyBreak = false;
|
||||
bool hasFailure = false, hasComplete = false, hasEnrollmentComplete = false, hasEmergencyBreak = false;
|
||||
bool desktopArrived = false, helloResolved = false;
|
||||
bool realmJoinDetected = false, realmJoinResolved = false;
|
||||
bool sawHelloPolicyEnabled = false, sawHelloPolicyDisabled = false;
|
||||
@@ -135,7 +136,12 @@ namespace AutopilotMonitor.Functions.Services
|
||||
hasFailure = true;
|
||||
}
|
||||
else if (Eq(type, "enrollment_complete") || Eq(type, "whiteglove_complete"))
|
||||
{
|
||||
hasComplete = true;
|
||||
// whiteglove_complete only proves Part 1 (technician flow) — the WhiteGlove
|
||||
// awaiting-user gate needs to know whether a REAL enrollment completion exists.
|
||||
if (Eq(type, "enrollment_complete")) hasEnrollmentComplete = true;
|
||||
}
|
||||
else if (Eq(type, "agent_emergency_break"))
|
||||
hasEmergencyBreak = true;
|
||||
else if (Eq(type, "desktop_arrived"))
|
||||
@@ -217,6 +223,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
AccountSetupAllSucceeded: acctAll,
|
||||
HasExplicitFailure: hasFailure,
|
||||
HasTerminalComplete: hasComplete,
|
||||
HasEnrollmentComplete: hasEnrollmentComplete,
|
||||
HasAgentEmergencyBreak: hasEmergencyBreak,
|
||||
DesktopArrived: desktopArrived,
|
||||
HelloResolved: helloResolved,
|
||||
@@ -226,6 +233,46 @@ namespace AutopilotMonitor.Functions.Services
|
||||
RealmJoinResolved: realmJoinResolved);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21). After the
|
||||
/// reseal-reboot the session is BY DEFINITION in the user-driven final phase: the agent
|
||||
/// resumed (<c>whiteglove_resumed</c> → ResumedAt, the only writer of that column), the
|
||||
/// Part-2 Device ESP re-confirms in minutes, and the device sits at the logon screen.
|
||||
/// Technicians routinely power the machine off there to box it — well before the 10-min
|
||||
/// hybrid-login evidence window — so agent silence in this state means "sealed / powered
|
||||
/// off awaiting the end user", not a stall. The gate demands the full evidence set:
|
||||
/// pre-provisioned + resumed + Device Setup provisioned, and NO trace of a real user
|
||||
/// since the resume (no Account Setup progress, no real-user desktop, no Hello terminal,
|
||||
/// no enrollment completion) and no explicit failure. Any user evidence keeps the honest
|
||||
/// Stalled/timeout handling — a stall after the user showed up is a real stall.
|
||||
/// Shared by the maintenance sweep stage 1, the <c>session_stalled</c> ingest mapping and
|
||||
/// <see cref="ClassifyTimedOutSession"/> so all paths apply one evidence rule.
|
||||
/// </summary>
|
||||
public static bool IsWhiteGloveAwaitingUser(
|
||||
in EspProvisioningRollup rollup, bool isPreProvisioned, DateTime? resumedAt)
|
||||
=> isPreProvisioned
|
||||
&& resumedAt.HasValue
|
||||
&& rollup.DeviceSetupAllSucceeded
|
||||
&& !rollup.HasExplicitFailure
|
||||
&& !rollup.HasEnrollmentComplete
|
||||
&& rollup.AccountSetupSucceededCount == 0
|
||||
&& !rollup.DesktopArrived
|
||||
&& !rollup.HelloResolved;
|
||||
|
||||
/// <summary>
|
||||
/// Customer-facing reason for a WhiteGlove Part-2 session parked as AwaitingUser by the
|
||||
/// silence/stall paths. One authoring spot so the sweep, the ingest mapping and the
|
||||
/// timeout classifier show the identical text.
|
||||
/// </summary>
|
||||
public static string WhiteGloveAwaitingUserReason(in EspProvisioningRollup rollup)
|
||||
{
|
||||
var acct = rollup.AccountSetupTotal > 0
|
||||
? $" (Account Setup {rollup.AccountSetupSucceededCount}/{rollup.AccountSetupTotal})"
|
||||
: " (Account Setup not yet started)";
|
||||
return "Pre-provisioning (WhiteGlove) completed; no user sign-in since Part 2 resumed — " +
|
||||
$"device presumably sealed or powered off awaiting the end user{acct}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decide the honest terminal (or non-terminal) state for a timed-out session. See the
|
||||
/// decision table in the design note. Returns the target status and a human-readable
|
||||
@@ -242,17 +289,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
/// mechanism declared success too early" — session efbc17ff. Falls back to
|
||||
/// <paramref name="startedAtUtc"/> when unknown (same fallback the stalled-marker uses).
|
||||
/// </param>
|
||||
/// <param name="isPreProvisioned">Session's IsPreProvisioned flag (WhiteGlove Part-2 gate input).</param>
|
||||
/// <param name="resumedAt">Session's ResumedAt — set exclusively by the whiteglove_resumed ingest.</param>
|
||||
public static (SessionStatus Status, string Reason) ClassifyTimedOutSession(
|
||||
EspProvisioningRollup rollup, DateTime startedAtUtc, DateTime nowUtc, int graceHours,
|
||||
DateTime? lastEventAtUtc = null)
|
||||
DateTime? lastEventAtUtc = null, bool isPreProvisioned = false, DateTime? resumedAt = 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).
|
||||
if (rollup.HasExplicitFailure)
|
||||
return (SessionStatus.Failed, "Enrollment reported an explicit failure before timeout");
|
||||
|
||||
// 1b. WhiteGlove Part 2 with zero user evidence since the resume: the device is parked
|
||||
// between technician and end user (sealed box / powered off at the logon screen).
|
||||
// Must run BEFORE rule 2 — the Part-1 whiteglove_complete in the same event stream
|
||||
// sets HasTerminalComplete and would otherwise reconcile the session to Succeeded
|
||||
// with a reason claiming Account Setup completed, which never happened. Within
|
||||
// grace the honest label is AwaitingUser; past grace the Part-1 success stands as
|
||||
// the session outcome (the user phase is tracked by the session the device
|
||||
// registers when it is finally unboxed), stated with an honest WhiteGlove reason.
|
||||
if (IsWhiteGloveAwaitingUser(rollup, isPreProvisioned, resumedAt))
|
||||
{
|
||||
if ((nowUtc - startedAtUtc).TotalHours < graceHours)
|
||||
return (SessionStatus.AwaitingUser, WhiteGloveAwaitingUserReason(rollup));
|
||||
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
$"Reconciled at timeout: pre-provisioning (WhiteGlove Part 1) completed — no user sign-in observed within {graceHours}h grace",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
}
|
||||
|
||||
// 2. Account Setup fully succeeded (or a terminal completion event) → reconcile to success.
|
||||
if (rollup.AccountSetupAllSucceeded || rollup.HasTerminalComplete)
|
||||
// Once Part 2 has resumed, the Part-1 whiteglove_complete in the same stream no longer
|
||||
// proves the session's outcome — only a real enrollment_complete does. Without this,
|
||||
// a resumed session with partial user progress (e.g. Account Setup 1/5, then silence)
|
||||
// reconciled to Succeeded on Part-1 evidence, with a reason claiming Account Setup
|
||||
// completed; rules 4-6 below hold the honest verdicts for that shape.
|
||||
var terminalCompleteProvesOutcome = rollup.HasEnrollmentComplete
|
||||
|| (rollup.HasTerminalComplete && !resumedAt.HasValue);
|
||||
if (rollup.AccountSetupAllSucceeded || terminalCompleteProvesOutcome)
|
||||
return (SessionStatus.Succeeded, AppendReconcileTiming(
|
||||
"Reconciled at timeout: Account Setup completed (all subcategories succeeded / enrollment_complete observed)",
|
||||
startedAtUtc, nowUtc, lastEventAtUtc));
|
||||
|
||||
+57
-2
@@ -287,7 +287,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
|
||||
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp);
|
||||
rollup, effectiveStart, now, graceHours, session?.LastEventAt ?? c.LatestEventTimestamp,
|
||||
isPreProvisioned: session?.IsPreProvisioned == true, resumedAt: session?.ResumedAt);
|
||||
|
||||
// Keep the max-lifetime trigger visible: the Succeeded reasons already carry their own
|
||||
// silence-transparency clause, everything else gets the watchdog context appended.
|
||||
@@ -356,7 +357,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var now = DateTime.UtcNow;
|
||||
var effectiveStart = session.ResumedAt ?? session.StartedAt;
|
||||
var (targetStatus, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt ?? c.LatestEventTimestamp,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
|
||||
|
||||
if (targetStatus != SessionStatus.Succeeded)
|
||||
return;
|
||||
@@ -376,6 +378,49 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WhiteGlove Part-2 awaiting-user gate for the agent-reported stall
|
||||
/// (<c>session_stalled</c>). Pre-provisioned sessions with a ResumedAt whose event
|
||||
/// stream shows no user evidence since the resume are parked as AwaitingUser instead of
|
||||
/// Stalled — same evidence rule as the maintenance sweep
|
||||
/// (<see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/>). Returns true
|
||||
/// when the AwaitingUser write was applied; false (also on any error, fail-soft) lets
|
||||
/// the caller run the normal Stalled mapping.
|
||||
/// </summary>
|
||||
private async Task<bool> TryMapStallToWhiteGloveAwaitingUserAsync(
|
||||
IngestEventsRequest request, string sessionPrefix, EventClassification c)
|
||||
{
|
||||
try
|
||||
{
|
||||
var session = await _sessionRepo.GetSessionAsync(request.TenantId, request.SessionId);
|
||||
if (session?.IsPreProvisioned != true || !session.ResumedAt.HasValue)
|
||||
return false;
|
||||
|
||||
var sessionEvents = await _sessionRepo.GetSessionEventsAsync(
|
||||
request.TenantId, request.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(sessionEvents);
|
||||
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
|
||||
rollup, isPreProvisioned: true, session.ResumedAt))
|
||||
return false;
|
||||
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, SessionStatus.AwaitingUser,
|
||||
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
|
||||
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
|
||||
if (transitioned)
|
||||
_logger.LogInformation(
|
||||
"{SessionPrefix} Status: AwaitingUser (agent stall probe on WhiteGlove Part 2 with no user evidence since resume)",
|
||||
sessionPrefix);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
"{SessionPrefix} WhiteGlove awaiting-user gate failed for session_stalled; falling back to Stalled", sessionPrefix);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPeriodicOrStallEvent(string? eventType) => eventType switch
|
||||
{
|
||||
"performance_snapshot" => true,
|
||||
@@ -567,6 +612,16 @@ namespace AutopilotMonitor.Functions.Services
|
||||
|
||||
if (c.SessionStalledEvent != null)
|
||||
{
|
||||
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis 2026-08-21): the
|
||||
// agent-side stall probe also fires on a resumed Part-2 device that simply sits
|
||||
// at the logon screen with nobody signing in. With zero user evidence since the
|
||||
// resume that is the expected parking state between technician and end user, so
|
||||
// the honest label is AwaitingUser, not Stalled. Rare path (one probe batch per
|
||||
// stalling session), so the session + events reads are acceptable; fail-soft —
|
||||
// any error falls back to the plain Stalled mapping.
|
||||
if (await TryMapStallToWhiteGloveAwaitingUserAsync(request, sessionPrefix, c))
|
||||
return (statusTransitioned, whiteGloveStatusTransitioned, failureReason);
|
||||
|
||||
var stalledReason = "Agent reported stall after 60min without progress (stall_probe)";
|
||||
var stalledTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
request.TenantId, request.SessionId, SessionStatus.Stalled,
|
||||
|
||||
@@ -109,7 +109,8 @@ namespace AutopilotMonitor.Functions.Services
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
var effectiveStart = session.ResumedAt ?? session.StartedAt;
|
||||
var (target, reason) = EnrollmentTimeoutClassifier.ClassifyTimedOutSession(
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
|
||||
|
||||
if (target == SessionStatus.Failed)
|
||||
{
|
||||
|
||||
@@ -349,6 +349,7 @@ namespace AutopilotMonitor.Functions.Services
|
||||
tenantId, silenceCutoff, hardCutoff: cutoffTime);
|
||||
|
||||
int silentMarked = 0;
|
||||
int silentAwaitingUser = 0;
|
||||
foreach (var silent in silentSessions)
|
||||
{
|
||||
// The query is a permissive two-frame pre-filter; the decision is made
|
||||
@@ -360,6 +361,19 @@ namespace AutopilotMonitor.Functions.Services
|
||||
if (lastContactAt > silenceCutoff)
|
||||
continue; // provably alive on the server clock
|
||||
|
||||
// WhiteGlove Part-2 awaiting-user gate (fairstone.ca analysis
|
||||
// 2026-08-21): after the reseal-reboot the technician routinely powers
|
||||
// the device off at the logon screen to box it — that silence is the
|
||||
// expected parking state between technician and end user, not a stall.
|
||||
// Only pre-provisioned sessions with a ResumedAt pay the event read;
|
||||
// any user evidence since the resume falls through to Stalled.
|
||||
if (silent.IsPreProvisioned && silent.ResumedAt.HasValue
|
||||
&& await TryMarkWhiteGloveAwaitingUserAsync(silent))
|
||||
{
|
||||
silentAwaitingUser++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var silentMinutes = (int)(now - lastContactAt).TotalMinutes;
|
||||
await _sessionRepo.UpdateSessionStatusAsync(
|
||||
silent.TenantId,
|
||||
@@ -370,19 +384,20 @@ namespace AutopilotMonitor.Functions.Services
|
||||
silentMarked++;
|
||||
}
|
||||
|
||||
if (silentMarked > 0)
|
||||
if (silentMarked > 0 || silentAwaitingUser > 0)
|
||||
{
|
||||
totalSessionsMarkedStalled += silentMarked;
|
||||
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled (silence threshold: {agentSilenceHours}h)");
|
||||
_logger.LogInformation($"Tenant {tenantId}: Marked {silentMarked} agent-silent sessions as Stalled, {silentAwaitingUser} as AwaitingUser (WhiteGlove Part 2; silence threshold: {agentSilenceHours}h)");
|
||||
await _maintenanceRepo.LogAuditEntryAsync(
|
||||
tenantId,
|
||||
"SessionStalled",
|
||||
"Session",
|
||||
$"{silentMarked} sessions",
|
||||
$"{silentMarked + silentAwaitingUser} sessions",
|
||||
"System.Maintenance",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "SessionsMarkedStalled", silentMarked.ToString() },
|
||||
{ "SessionsAwaitingUser", silentAwaitingUser.ToString() },
|
||||
{ "AgentSilenceHours", agentSilenceHours.ToString() },
|
||||
{ "SilenceCutoff", silenceCutoff.ToString("yyyy-MM-ddTHH:mm:ss") }
|
||||
});
|
||||
@@ -454,7 +469,8 @@ 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, effectiveStart, now, graceHours, session.LastEventAt);
|
||||
rollup, effectiveStart, now, graceHours, session.LastEventAt,
|
||||
isPreProvisioned: session.IsPreProvisioned, resumedAt: session.ResumedAt);
|
||||
|
||||
// No-op if the verdict equals the current (non-terminal) state.
|
||||
if (targetStatus == session.Status)
|
||||
@@ -569,6 +585,44 @@ namespace AutopilotMonitor.Functions.Services
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies the WhiteGlove Part-2 awaiting-user gate to one agent-silent candidate
|
||||
/// (pre-provisioned + ResumedAt set — the caller pre-filters so only WhiteGlove Part-2
|
||||
/// runs pay the event read). Reads the session's events, distills the classifier rollup
|
||||
/// and, when <see cref="EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser"/> confirms
|
||||
/// there is no user evidence since the resume, parks the session as AwaitingUser instead
|
||||
/// of Stalled. Returns true when the AwaitingUser write happened; false (fail-soft, also
|
||||
/// on read errors) lets the caller fall through to the normal Stalled marker.
|
||||
/// </summary>
|
||||
private async Task<bool> TryMarkWhiteGloveAwaitingUserAsync(SessionSummary silent)
|
||||
{
|
||||
try
|
||||
{
|
||||
var events = await _sessionRepo.GetSessionEventsAsync(
|
||||
silent.TenantId, silent.SessionId, maxResults: 1000);
|
||||
var rollup = EnrollmentTimeoutClassifier.ExtractRollup(events);
|
||||
if (!EnrollmentTimeoutClassifier.IsWhiteGloveAwaitingUser(
|
||||
rollup, silent.IsPreProvisioned, silent.ResumedAt))
|
||||
return false;
|
||||
|
||||
var transitioned = await _sessionRepo.UpdateSessionStatusAsync(
|
||||
silent.TenantId,
|
||||
silent.SessionId,
|
||||
SessionStatus.AwaitingUser,
|
||||
failureReason: EnrollmentTimeoutClassifier.WhiteGloveAwaitingUserReason(rollup));
|
||||
if (transitioned)
|
||||
_logger.LogInformation(
|
||||
$"Session {silent.SessionId}: WhiteGlove Part 2 silent with no user evidence — parked as AwaitingUser instead of Stalled");
|
||||
return transitioned;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex,
|
||||
$"WhiteGlove awaiting-user gate failed for session {silent.SessionId}; falling back to Stalled marker");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the server-authored <c>session_timeout</c> event injected into the stream when the
|
||||
/// maintenance sweep graduates a stalled session to terminal Failed. Static + pure so the field
|
||||
|
||||
Reference in New Issue
Block a user