From fd73f5abfb32af4ea8eaf75f9fe6bf680e9ec803 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 16 Apr 2026 22:31:35 +0200 Subject: [PATCH] prevent doubel completion_decision_process events, fix phase timeline --- .../EnrollmentTracker.CompletionLogic.cs | 58 ++++++++++++++----- .../Enrollment/EnrollmentTracker.cs | 7 ++- .../AutopilotMonitor.Agent/buildcounter.txt | 2 +- .../Ingest/IngestEventsFunction.Processing.cs | 6 +- .../[sessionId]/components/PhaseTimeline.tsx | 6 ++ 5 files changed, 59 insertions(+), 20 deletions(-) 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; })();