diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
index fdedf2ce..2ff78a7e 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs
@@ -205,13 +205,31 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
}
///
- /// Called when WhiteGlove (Pre-Provisioning) completes successfully.
- /// Emits the whiteglove_complete event. The MonitoringService handles
- /// the actual agent shutdown upon seeing this event type.
+ /// Called when WhiteGlove (Pre-Provisioning) completes successfully (event-handler signature).
+ /// Subscribed to EspAndHelloTracker.WhiteGloveCompleted. Delegates to CompleteWhiteGlove
+ /// which contains the actual emission pipeline.
+ ///
+ private void OnWhiteGloveCompleted(object sender, EventArgs e)
+ {
+ var wgSource = sender?.GetType().Name ?? "unknown";
+ CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false);
+ }
+
+ ///
+ /// Core WhiteGlove completion pipeline: emits the whiteglove_complete terminal event,
+ /// the decision_process_completion audit record (unless the caller already emitted one),
+ /// stops the summary timer, and updates the shadow state machine. MonitoringService handles
+ /// the actual agent shutdown upon seeing whiteglove_complete.
/// Idempotent: fire-once per session instance. A second invocation (e.g. Shell-Core
/// watcher fires after the signal-correlated detector already matched) is a no-op.
///
- private void OnWhiteGloveCompleted(object sender, EventArgs e)
+ /// Decision source identifier (caller type or descriptor).
+ ///
+ /// Set to true when the caller already emitted a decision_process_completion for this
+ /// terminal (e.g. the WG-redirect guard inside TryEmitEnrollmentComplete). Prevents the
+ /// double-decision pattern that loses the upstream decisionChain under a second record.
+ ///
+ private void CompleteWhiteGlove(string source, bool decisionAlreadyEmitted)
{
lock (_stateLock)
{
@@ -223,18 +241,21 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_signalCorrelatedWhiteGloveTriggered = true;
}
- var wgSource = sender?.GetType().Name ?? "unknown";
- _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {wgSource})");
+ _logger.Info($"EnrollmentTracker: WhiteGlove pre-provisioning completed (source: {source})");
- // Emit decision process before the terminal event
- EmitDecisionProcess("whiteglove_complete", wgSource, "pending_part1", new List
+ // Decision audit: only emit if the caller hasn't already recorded the decision that led here.
+ // Keeps the invariant "one terminal event = one decision_process_completion".
+ if (!decisionAlreadyEmitted)
{
- $"trigger={wgSource}",
- (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed",
- (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
- _aadJoinedWithUser ? "user_joined" : "no_user_joined",
- _fooUserDetected ? "foouser_detected" : "no_foouser"
- });
+ EmitDecisionProcess("whiteglove_complete", source, "pending_part1", new List
+ {
+ $"trigger={source}",
+ (_espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false) ? "registry_confirmed" : "registry_not_confirmed",
+ (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
+ _aadJoinedWithUser ? "user_joined" : "no_user_joined",
+ _fooUserDetected ? "foouser_detected" : "no_foouser"
+ });
+ }
// Stop summary timer — no more app tracking needed
_summaryTimer?.Change(System.Threading.Timeout.Infinite, System.Threading.Timeout.Infinite);
@@ -1128,17 +1149,22 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
$"with no user joined and no desktop arrived — redirecting to WhiteGlove Part 1 " +
$"(source={source}, aadJoinedWithUser={_aadJoinedWithUser}, desktopArrived={desktopArrived})");
+ // Consolidated decision record: includes the upstream source AND the WG-context
+ // signals that used to live in a second (now-suppressed) decision record.
EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List
{
$"original_source={source}",
"save_whiteglove_succeeded=true",
+ (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen",
"aad_joined_with_user=false",
"desktop_arrived=false",
+ _fooUserDetected ? "foouser_detected" : "no_foouser",
"redirect: enrollment_complete -> whiteglove_complete"
});
- OnWhiteGloveCompleted(this, EventArgs.Empty);
- ShadowProcessTrigger("whiteglove_complete");
+ // Decision is already recorded above — CompleteWhiteGlove must not emit a second one.
+ // ShadowProcessTrigger("whiteglove_complete") is called inside CompleteWhiteGlove, so no extra call here.
+ CompleteWhiteGlove(source, decisionAlreadyEmitted: true);
return;
}
diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
index 4cb78731..82fb122b 100644
--- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
+++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs
@@ -249,10 +249,13 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment
_stateData.CompletionState = _completionSm.CurrentState.ToString();
}
- // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted
+ // Compare terminal state: shadow SM reached terminal vs actual _enrollmentCompleteEmitted.
+ // Both sides must use the same definition of "terminal": the actual flag is set for
+ // enrollment_complete, enrollment_failed AND the WG-redirect path — so shadow must match
+ // that by accepting Completed, Failed and WhiteGloveCompleted as terminal states.
bool actualTerminal;
lock (_stateLock) { actualTerminal = _enrollmentCompleteEmitted; }
- bool shadowTerminal = _completionSm.CurrentState == EnrollmentCompletionState.Completed;
+ bool shadowTerminal = _completionSm.CurrentState.IsTerminal();
if (actualTerminal != shadowTerminal)
{
diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
index b70608fe..bec4b5cd 100644
--- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
+++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt
@@ -1 +1 @@
-1023
+1025
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
index d3d493ea..79231386 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Ingest/IngestEventsFunction.Processing.cs
@@ -709,8 +709,12 @@ namespace AutopilotMonitor.Functions.Functions.Ingest
}
else if (c.WhiteGloveEvent != null)
{
+ // The whiteglove_complete event carries Phase=Complete as a convention
+ // (agent signalling "terminal reached"). Persisting that literally as Session.CurrentPhase
+ // contradicts Status=Pending and tricks the UI timeline into rendering user-enrollment
+ // phases as completed. Cap to AppsDevice (last pre-provisioning phase actually reached).
whiteGloveStatusTransitioned = await _sessionRepo.UpdateSessionStatusAsync(
- request.TenantId, request.SessionId, SessionStatus.Pending, c.WhiteGloveEvent.Phase,
+ request.TenantId, request.SessionId, SessionStatus.Pending, EnrollmentPhase.AppsDevice,
earliestEventTimestamp: c.EarliestEventTimestamp, latestEventTimestamp: c.LatestEventTimestamp,
isPreProvisioned: true, isUserDriven: false);
diff --git a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx
index 732572ec..4cfc3938 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/[sessionId]/components/PhaseTimeline.tsx
@@ -265,6 +265,12 @@ export default function PhaseTimeline({ currentPhase, completedPhases, events =
const effectiveCurrentPhase = (() => {
if (sessionStatus === 'Succeeded') return currentPhase;
if (sessionStatus === 'Failed') return failurePhase !== null ? failurePhase : currentPhase;
+ // WG Part 1 (sealed, awaiting user): trust the already-capped maxEventPhase.
+ // Older sessions may still carry currentPhase=7 (Complete) from before the backend cap;
+ // without this guard the `>=` comparison below would ignore the cap and mark all user phases as completed.
+ if (sessionStatus === 'Pending' && hasWhiteGloveComplete && !hasWhiteGloveResumed) {
+ return maxEventPhase >= 0 ? maxEventPhase : currentPhase;
+ }
if (maxEventPhase < 0) return currentPhase;
return phaseIndex(maxEventPhase) >= phaseIndex(currentPhase) ? maxEventPhase : currentPhase;
})();