From babc89c7763ff4cbce6e0e6152b286d3891a6c73 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 21 Aug 2026 22:30:29 +0200 Subject: [PATCH] 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 --- .../EnrollmentTimeoutClassifierTests.cs | 144 ++++++++++++++++++ .../Services/EnrollmentTimeoutClassifier.cs | 80 +++++++++- .../EventIngestProcessor.Classification.cs | 59 ++++++- .../Services/LegacyReclassificationService.cs | 3 +- .../Services/MaintenanceService.cs | 62 +++++++- 5 files changed, 338 insertions(+), 10 deletions(-) diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs index 0f5b11cc..7093d4d7 100644 --- a/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs +++ b/src/Backend/AutopilotMonitor.Functions.Tests/EnrollmentTimeoutClassifierTests.cs @@ -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 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); + } + + /// The fairstone event shape: Part 1 sealed, Part 2 resumed, nobody signed in. + 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); + } } diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs index 92343fcb..ec71c04b 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EnrollmentTimeoutClassifier.cs @@ -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); } + /// + /// 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 (whiteglove_resumed → 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 session_stalled ingest mapping and + /// so all paths apply one evidence rule. + /// + 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; + + /// + /// 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. + /// + 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}"; + } + /// /// 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 /// when unknown (same fallback the stalled-marker uses). /// + /// Session's IsPreProvisioned flag (WhiteGlove Part-2 gate input). + /// Session's ResumedAt — set exclusively by the whiteglove_resumed ingest. 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)); diff --git a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs index 8a4e5078..3bec25b3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/EventIngestProcessor.Classification.cs @@ -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 } } + /// + /// WhiteGlove Part-2 awaiting-user gate for the agent-reported stall + /// (session_stalled). 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 + /// (). Returns true + /// when the AwaitingUser write was applied; false (also on any error, fail-soft) lets + /// the caller run the normal Stalled mapping. + /// + private async Task 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, diff --git a/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs b/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs index 27c920e4..5176da95 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/LegacyReclassificationService.cs @@ -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) { diff --git a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs index 23aefab1..2c85cda3 100644 --- a/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs +++ b/src/Backend/AutopilotMonitor.Functions/Services/MaintenanceService.cs @@ -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 { { "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 } } + /// + /// 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 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. + /// + private async Task 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; + } + } + /// /// Builds the server-authored session_timeout event injected into the stream when the /// maintenance sweep graduates a stalled session to terminal Failed. Static + pure so the field