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:
Oliver Kieselbach
2026-08-21 22:30:29 +02:00
parent a2f448cad0
commit babc89c776
5 changed files with 338 additions and 10 deletions
@@ -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));
@@ -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