diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/CompletionWaitingTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/CompletionWaitingTests.cs new file mode 100644 index 00000000..0c1233f1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/CompletionWaitingTests.cs @@ -0,0 +1,396 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Serialization; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; +using Xunit; + +namespace AutopilotMonitor.DecisionCore.Tests +{ + /// + /// Liveness plan PR2 — completion_waiting observability. Whenever the engine blocks + /// or defers a completion attempt it says what it is still waiting on; the + /// CompletionWaitingFingerprint state fact makes the event state-change-only (no + /// repeats while the missing-set is unchanged, a new event when the set changes). + /// Coverage: + /// + /// 1ec8f4c6 replay: blocked completion attempts list exactly the missing + /// prerequisites (satisfied facts are not listed). + /// Fingerprint dedupe: same missing-set → no second event; set change → new event. + /// RealmJoin defer → realmjoin_resolution listed. + /// HelloSafety-timeout / AwaitingDesktop / no-promote emission sites. + /// CompletionWaitingFingerprint serialization compatibility (roundtrip + + /// legacy snapshot without the field). + /// + /// + public sealed class CompletionWaitingTests + { + private static readonly DateTime T0 = new DateTime(2026, 6, 12, 9, 0, 0, DateTimeKind.Utc); + + private static DecisionSignal MakeSignal( + long ordinal, + DecisionSignalKind kind, + DateTime occurredAtUtc, + IReadOnlyDictionary? payload = null, + string sourceOrigin = "test") + { + return new DecisionSignal( + sessionSignalOrdinal: ordinal, + sessionTraceOrdinal: ordinal, + kind: kind, + kindSchemaVersion: 1, + occurredAtUtc: occurredAtUtc, + sourceOrigin: sourceOrigin, + evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"), + payload: payload); + } + + private static DecisionSignal DeadlineFired(long ordinal, DateTime occurredAtUtc, string deadlineName) => + MakeSignal(ordinal, DecisionSignalKind.DeadlineFired, occurredAtUtc, + new Dictionary { [SignalPayloadKeys.Deadline] = deadlineName }); + + private static List CompletionWaitingEffects(DecisionStep step) => + step.Effects.Where(e => + e.Kind == DecisionEffectKind.EmitEventTimelineEntry + && e.Parameters != null + && e.Parameters.TryGetValue("eventType", out var et) + && et == "completion_waiting").ToList(); + + /// + /// Classic session driven to "AccountSetup entered, Hello policy known" — the common + /// prefix of the blocked-completion scenarios. + /// + private static DecisionState SetupAccountSetupSession( + DecisionEngine engine, + bool helloPolicyDisabled = true) + { + var state = DecisionState.CreateInitial("sess-waiting", "tenant-waiting", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), + new Dictionary + { + [SignalPayloadKeys.SkipUserEsp] = "false", + [SignalPayloadKeys.SkipDeviceEsp] = "false", + })).NewState; + if (helloPolicyDisabled) + { + state = engine.Reduce(state, MakeSignal( + 8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1), + new Dictionary { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState; + } + state = engine.Reduce(state, MakeSignal( + 10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal( + 20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + return state; + } + + // ===================================================== 1ec8f4c6 replay ==== + + [Fact] + public void DesktopFirst_BlockedFastPath_EmitsWaiting_ListingOnlyTheMissingGate() + { + // 1ec8f4c6 shape: Hello disabled, desktop arrives mid-AccountSetup, strong gate + // (AccountSetupProvisioningSucceeded) unsatisfied. The blocked attempt must list + // exactly the gate — hello (policy disabled) and desktop (just recorded) are NOT + // missing. + var engine = new DecisionEngine(); + var state = SetupAccountSetupSession(engine); + + var step = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.DesktopArrived, T0.AddMinutes(18))); + + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal("account_setup_provisioning_complete", waiting.Parameters!["missingPrerequisites"]); + Assert.Equal("DesktopArrived", waiting.Parameters!["trigger"]); + Assert.Equal("Info", waiting.Parameters!["severity"]); + Assert.Equal( + "account_setup_provisioning_complete", + step.NewState.CompletionWaitingFingerprint!.Value); + } + + [Fact] + public void GuardBlockedFinalExit_SameMissingSet_IsDeduped_ButStillArmsBackstop() + { + // After the desktop-first emission, the guard-blocked esp_exiting computes the SAME + // missing-set → fingerprint unchanged → no second completion_waiting. The + // AdvisoryCompletion backstop (b0f7e0fc) still arms. + var engine = new DecisionEngine(); + var state = SetupAccountSetupSession(engine); + state = engine.Reduce(state, MakeSignal(30, DecisionSignalKind.DesktopArrived, T0.AddMinutes(18))).NewState; + + var exitStep = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.EspExiting, T0.AddMinutes(20))); + + Assert.Empty(CompletionWaitingEffects(exitStep)); + Assert.Contains(exitStep.NewState.Deadlines, d => d.Name == DeadlineNames.AdvisoryCompletion); + Assert.Equal( + "account_setup_provisioning_complete", + exitStep.NewState.CompletionWaitingFingerprint!.Value); + } + + [Fact] + public void GuardBlockedFinalExit_FirstEmission_CarriesResolutionDeadlineDueAt() + { + // No prior desktop → the guard-blocked exit is the first blocked attempt. The event + // lists gate + desktop and carries the AdvisoryCompletion window's due-time. + var engine = new DecisionEngine(); + var state = SetupAccountSetupSession(engine); + + var step = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.EspExiting, T0.AddMinutes(20))); + + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal( + "account_setup_provisioning_complete,desktop_arrival", + waiting.Parameters!["missingPrerequisites"]); + Assert.Equal("EspExiting:GuardBlocked", waiting.Parameters!["trigger"]); + + var backstop = step.NewState.Deadlines.Single(d => d.Name == DeadlineNames.AdvisoryCompletion); + Assert.Equal(backstop.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]); + Assert.Contains(DeadlineNames.AdvisoryCompletion, waiting.Parameters!["armedDeadlines"]); + } + + [Fact] + public void PreAccountSetup_HandoffExit_EmitsNothing() + { + // The Device→Account handoff exit is normal flow, not a completion attempt. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-handoff", "tenant-handoff", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + + var step = engine.Reduce(state, MakeSignal(15, DecisionSignalKind.EspExiting, T0.AddMinutes(16))); + + Assert.Empty(CompletionWaitingEffects(step)); + Assert.Null(step.NewState.CompletionWaitingFingerprint); + } + + // ===================================================== fingerprint dedupe ==== + + [Fact] + public void MissingSetChange_EmitsNewEvent_WithReducedSet() + { + // Hello ENABLED flow: blocked exit lists gate+hello+desktop; when Desktop then + // arrives the set shrinks → a NEW completion_waiting fires with the reduced set. + var engine = new DecisionEngine(); + var state = SetupAccountSetupSession(engine, helloPolicyDisabled: false); + + var exitStep = engine.Reduce(state, MakeSignal(40, DecisionSignalKind.EspExiting, T0.AddMinutes(20))); + var first = Assert.Single(CompletionWaitingEffects(exitStep)); + Assert.Equal( + "account_setup_provisioning_complete,hello_resolution,desktop_arrival", + first.Parameters!["missingPrerequisites"]); + + var desktopStep = engine.Reduce( + exitStep.NewState, MakeSignal(50, DecisionSignalKind.DesktopArrived, T0.AddMinutes(22))); + var second = Assert.Single(CompletionWaitingEffects(desktopStep)); + Assert.Equal( + "account_setup_provisioning_complete,hello_resolution", + second.Parameters!["missingPrerequisites"]); + Assert.Equal( + "account_setup_provisioning_complete,hello_resolution", + desktopStep.NewState.CompletionWaitingFingerprint!.Value); + } + + // ===================================================== realmjoin defer ==== + + [Fact] + public void RealmJoinDefer_ListsRealmJoinResolution() + { + // SkipUserEsp flow → genuine final exit promotes to AwaitingHello; an active + // RealmJoin deployment then closes the completion gate. Hello resolution with + // Desktop already in defers — the event lists exactly realmjoin_resolution. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-rj", "tenant-rj", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), + new Dictionary + { + [SignalPayloadKeys.SkipUserEsp] = "true", + [SignalPayloadKeys.SkipDeviceEsp] = "false", + })).NewState; + state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspExiting, T0.AddMinutes(10))).NewState; + state = engine.Reduce(state, MakeSignal( + 15, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(11), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.DesktopArrived, T0.AddMinutes(12))).NewState; + + var step = engine.Reduce(state, MakeSignal( + 30, DecisionSignalKind.HelloResolved, T0.AddMinutes(13), + new Dictionary { [SignalPayloadKeys.HelloOutcome] = "Success" })); + + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal("realmjoin_resolution", waiting.Parameters!["missingPrerequisites"]); + Assert.Contains("RealmJoinGateClosed", waiting.Parameters!["trigger"]); + } + + // ===================================================== other emission sites ==== + + [Fact] + public void HelloSafetyTimeout_AwaitingDesktop_EmitsWaitingOnDesktop() + { + // SkipUserEsp flow, Hello policy unknown: final exit → AwaitingHello + HelloSafety. + // The timeout synthesises Hello=Timeout; Desktop is still missing → event. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-hs", "tenant-hs", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), + new Dictionary + { + [SignalPayloadKeys.SkipUserEsp] = "true", + [SignalPayloadKeys.SkipDeviceEsp] = "false", + })).NewState; + state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspExiting, T0.AddMinutes(10))).NewState; + Assert.Equal(SessionStage.AwaitingHello, state.Stage); + + var step = engine.Reduce(state, DeadlineFired(20, T0.AddMinutes(15), DeadlineNames.HelloSafety)); + + Assert.Equal(SessionStage.AwaitingDesktop, step.NewState.Stage); + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]); + Assert.Equal("AwaitingDesktop", waiting.Parameters!["stage"]); + } + + [Fact] + public void HelloResolved_DesktopStillMissing_EmitsWaitingOnDesktop() + { + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-hr", "tenant-hr", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 5, DecisionSignalKind.EspConfigDetected, T0.AddMinutes(1), + new Dictionary + { + [SignalPayloadKeys.SkipUserEsp] = "true", + [SignalPayloadKeys.SkipDeviceEsp] = "false", + })).NewState; + state = engine.Reduce(state, MakeSignal(10, DecisionSignalKind.EspExiting, T0.AddMinutes(10))).NewState; + + var step = engine.Reduce(state, MakeSignal( + 20, DecisionSignalKind.HelloResolved, T0.AddMinutes(12), + new Dictionary { [SignalPayloadKeys.HelloOutcome] = "Success" })); + + Assert.Equal(SessionStage.AwaitingDesktop, step.NewState.Stage); + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal("desktop_arrival", waiting.Parameters!["missingPrerequisites"]); + } + + [Fact] + public void AccountSetupProvisioningComplete_NoPromote_EmitsWaitingOnHelloAndDesktop() + { + // Typical ordering: the strong gate resolves BEFORE the final esp_exiting (no + // EspFinalExit / FinalizingEntered yet) → no promotion. Hello policy unknown. + var engine = new DecisionEngine(); + var state = DecisionState.CreateInitial("sess-np", "tenant-np", T0); + state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0)).NewState; + state = engine.Reduce(state, MakeSignal( + 10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2), + new Dictionary { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState; + state = engine.Reduce(state, MakeSignal( + 20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17), + new Dictionary { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState; + + var step = engine.Reduce(state, MakeSignal( + 30, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(20))); + + var waiting = Assert.Single(CompletionWaitingEffects(step)); + Assert.Equal("hello_resolution,desktop_arrival", waiting.Parameters!["missingPrerequisites"]); + Assert.Equal("AccountSetupProvisioningComplete:NoPromote", waiting.Parameters!["trigger"]); + } + + // ===================================================== helper unit checks ==== + + [Fact] + public void BuildMissingCompletionPrerequisites_FreshState_ListsAllButOpenGates() + { + var state = DecisionState.CreateInitial("s", "t", T0); + var missing = DecisionEngine.BuildMissingCompletionPrerequisites(state); + + // RealmJoin never detected → its gate is open → not listed. + Assert.Equal( + new[] { "account_setup_provisioning_complete", "hello_resolution", "desktop_arrival" }, + missing); + } + + [Fact] + public void BuildMissingCompletionPrerequisites_AllSatisfied_IsEmpty() + { + var builder = DecisionState.CreateInitial("s", "t", T0).ToBuilder(); + builder.AccountSetupProvisioningSucceededUtc = new SignalFact(T0.AddMinutes(20), 10); + builder.HelloResolvedUtc = new SignalFact(T0.AddMinutes(21), 11); + builder.DesktopArrivedUtc = new SignalFact(T0.AddMinutes(22), 12); + + Assert.Empty(DecisionEngine.BuildMissingCompletionPrerequisites(builder.Build())); + } + + // ================================================== serialization compat ==== + + [Fact] + public void StateSerializer_Roundtrip_PreservesCompletionWaitingFingerprint() + { + var builder = DecisionState.CreateInitial("sess-ser", "tenant-ser", T0).ToBuilder(); + builder.CompletionWaitingFingerprint = new SignalFact( + "hello_resolution,desktop_arrival", sourceSignalOrdinal: 40); + var state = builder.Build(); + + var roundtripped = StateSerializer.Deserialize(StateSerializer.Serialize(state)); + + Assert.NotNull(roundtripped.CompletionWaitingFingerprint); + Assert.Equal("hello_resolution,desktop_arrival", roundtripped.CompletionWaitingFingerprint!.Value); + Assert.Equal(40, roundtripped.CompletionWaitingFingerprint!.SourceSignalOrdinal); + } + + [Fact] + public void Deserialize_v4Snapshot_withoutCompletionWaitingFingerprint_field_yieldsNullFact() + { + // Rollout-safety: CompletionWaitingFingerprint is an additive nullable field + // appended to the end of the DecisionState ctor. Persisted snapshots from versions + // before 2026-06-12 do not contain the property; rehydration must succeed. + const string priorV4Json = @"{ + ""SessionId"": ""sess-rehydrate-cw"", + ""TenantId"": ""tenant-rehydrate-cw"", + ""Stage"": ""EspAccountSetup"", + ""Outcome"": null, + ""CurrentEnrollmentPhase"": null, + ""DeviceSetupEnteredUtc"": null, + ""AccountSetupEnteredUtc"": null, + ""FinalizingEnteredUtc"": null, + ""AccountSetupProvisioningSucceededUtc"": null, + ""EspFinalExitUtc"": null, + ""DesktopArrivedUtc"": null, + ""HelloResolvedUtc"": null, + ""SystemRebootUtc"": null, + ""HelloOutcome"": null, + ""ImeMatchedPatternId"": null, + ""Deadlines"": [], + ""LastAppliedSignalOrdinal"": 42, + ""StepIndex"": 100, + ""AppInstallFacts"": null, + ""ScenarioProfile"": null, + ""ScenarioObservations"": null, + ""ClassifierOutcomes"": null, + ""HelloPolicyEnabled"": null, + ""AgentBootUtc"": ""2026-06-12T08:00:00Z"", + ""LastFailureTrigger"": null, + ""RealmJoinFacts"": null, + ""DeviceSetupResolvedUtc"": null, + ""EspAdvisoryFailureRecordedUtc"": null, + ""ImeUserSessionCompletedUtc"": null, + ""SchemaVersion"": ""v4"" + }"; + + var deserialized = StateSerializer.Deserialize(priorV4Json); + + Assert.Null(deserialized.CompletionWaitingFingerprint); + Assert.Equal("sess-rehydrate-cw", deserialized.SessionId); + Assert.Equal(SessionStage.EspAccountSetup, deserialized.Stage); + } + } +} diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs index 05b9dfa1..800eccc4 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/DecisionStateSnapshotBuilderTests.cs @@ -110,7 +110,9 @@ namespace AutopilotMonitor.DecisionCore.Tests // terminal failures → 15. // Session 8bc1180f (2026-06-12) added `imeUserSessionCompletedUtc` as the // IME-evidence anchor for the AdvisoryCompletion conjunction → 16. - Assert.Equal(16, facts.Count); + // Liveness plan PR2 (2026-06-12) added `completionWaitingFingerprint` as the + // state-change-only dedupe anchor for the completion_waiting event → 17. + Assert.Equal(17, facts.Count); Assert.All(facts.Values, v => Assert.Null(v)); } @@ -228,10 +230,12 @@ namespace AutopilotMonitor.DecisionCore.Tests // 2026-05-22) added `espAdvisoryFailureRecordedUtc` as the fire-once gate // for downgraded ESP terminal failures → 15. Session 8bc1180f (2026-06-12) // added `imeUserSessionCompletedUtc` as the IME-evidence anchor for the - // AdvisoryCompletion conjunction → 16. If this number ever changes, both + // AdvisoryCompletion conjunction → 16. Liveness plan PR2 (2026-06-12) added + // `completionWaitingFingerprint` as the state-change-only dedupe anchor for + // the completion_waiting event → 17. If this number ever changes, both // the count expectation AND the actual snapshot output need to evolve in // lockstep. - Assert.Equal(16, expectedFactKeys.Count); + Assert.Equal(17, expectedFactKeys.Count); var snapshot = DecisionStateSnapshotBuilder.Build(DecisionState.CreateInitial("s", "t")); var facts = (Dictionary)snapshot["facts"]!; diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index ef699813..78477d9f 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -24,6 +24,7 @@ export const EVENT_TYPES_CATALOG = { 'esp_exiting', 'esp_config_detected', 'completion_check', + 'completion_waiting', 'enrollment_complete', 'enrollment_failed', 'desktop_arrived', diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 99f5b6d0..c687d399 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -175,6 +175,34 @@ namespace AutopilotMonitor.DecisionCore.Engine }; } + // Liveness plan PR2: a post-AccountSetup guard-blocked exit is a blocked + // completion attempt — say what the engine is still waiting on (state-change- + // only via the fingerprint fact). The resolution window's due-time rides along + // so consumers see when the AdvisoryCompletion backstop will resolve the + // session either way. Pre-AccountSetup handoff exits stay silent — they are + // the normal Device→Account transition, not a completion attempt. + if (state.AccountSetupEnteredUtc != null) + { + Dictionary? waitingExtra = null; + foreach (var d in builder.Deadlines) + { + if (d.Name == DeadlineNames.AdvisoryCompletion) + { + waitingExtra = new Dictionary(1) + { + ["resolutionDeadlineDueAtUtc"] = d.DueAtUtc.ToString("o"), + }; + break; + } + } + + var waitingEffect = BuildCompletionWaitingEffect( + state, builder, signal, + trigger: nameof(DecisionSignalKind.EspExiting) + ":GuardBlocked", + extraData: waitingExtra); + exitEffects = AppendEffect(exitEffects, waitingEffect); + } + var noopTransition = BuildTakenTransition( before: state, signal: signal, @@ -272,6 +300,12 @@ namespace AutopilotMonitor.DecisionCore.Engine } builder.WithStage(SessionStage.AwaitingDesktop); + + // Liveness plan PR2: Hello is in but Desktop is not — a blocked completion attempt. + // Computed on the builder so the just-recorded Hello facts are not listed as missing. + var waitingEffect = BuildCompletionWaitingEffect( + state, builder, signal, trigger: nameof(DecisionSignalKind.HelloResolved)); + var newState = builder.Build(); var transition = BuildTakenTransition( @@ -284,7 +318,7 @@ namespace AutopilotMonitor.DecisionCore.Engine var effects = helloSafetyCancelEffect != null ? new[] { helloSafetyCancelEffect } : Array.Empty(); - return new DecisionStep(newState, transition, effects); + return new DecisionStep(newState, transition, AppendEffect(effects, waitingEffect)); } /// @@ -431,6 +465,14 @@ namespace AutopilotMonitor.DecisionCore.Engine // Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until // HelloResolved arrives. builder.WithStage(state.Stage); + + // Liveness plan PR2: Desktop is in but completion is still blocked (Hello pending, + // or the Hello-disabled fast-path's strong gate is not satisfied) — surface the + // missing prerequisites. Covers both the gate-false fast-path and the plain + // desktop-first ordering; the fingerprint dedupes against earlier emissions. + var waitingEffect = BuildCompletionWaitingEffect( + state, builder, signal, trigger: nameof(DecisionSignalKind.DesktopArrived)); + var newState = builder.Build(); var transition = BuildTakenTransition( @@ -440,7 +482,10 @@ namespace AutopilotMonitor.DecisionCore.Engine nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.DesktopArrived)); - return new DecisionStep(newState, transition, Array.Empty()); + return new DecisionStep( + newState, + transition, + waitingEffect != null ? new[] { waitingEffect } : Array.Empty()); } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs index 510c5f8c..a5db9a14 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using AutopilotMonitor.DecisionCore.Signals; using AutopilotMonitor.DecisionCore.State; +using SharedConstants = AutopilotMonitor.Shared.Constants; namespace AutopilotMonitor.DecisionCore.Engine { @@ -111,14 +112,147 @@ namespace AutopilotMonitor.DecisionCore.Engine // Gate closed: defer Finalizing. Stay in the current stage; the gate's resolved/ // timeout handler routes back through completion once it re-opens. + // + // Liveness plan PR2: surface what completion is waiting on (state-change-only — + // the fingerprint fact dedupes repeats of the same missing-set). + var deferTrigger = trigger + ":" + closedGate + "Closed"; + var waitingEffect = BuildCompletionWaitingEffect(state, preparedBuilder, signal, deferTrigger); + var deferredState = preparedBuilder.Build(); var deferredTransition = BuildTakenTransition( before: state, signal: signal, toStage: state.Stage, nextStepIndex: nextStepIndex, - trigger: trigger + ":" + closedGate + "Closed"); - return new DecisionStep(deferredState, deferredTransition, MaterializeEffects(leadingEffects)); + trigger: deferTrigger); + return new DecisionStep( + deferredState, + deferredTransition, + AppendEffect(MaterializeEffects(leadingEffects), waitingEffect)); + } + + // ====================================================== completion_waiting (PR2) + + /// Stable literals for completion_waiting's missingPrerequisites data field. + internal static class CompletionPrerequisites + { + public const string AccountSetupProvisioningComplete = "account_setup_provisioning_complete"; + public const string HelloResolution = "hello_resolution"; + public const string DesktopArrival = "desktop_arrival"; + public const string RealmJoinResolution = "realmjoin_resolution"; + } + + /// + /// Compute the ordered list of completion prerequisites the engine is still waiting on + /// for . Liveness plan PR2 — feeds the completion_waiting + /// event's missingPrerequisites field and the dedupe fingerprint. + /// + internal static List BuildMissingCompletionPrerequisites(DecisionState state) => + BuildMissingCompletionPrerequisitesCore( + accountSetupProvisioned: state.AccountSetupProvisioningSucceededUtc != null, + skipUserEsp: state.ScenarioObservations.SkipUserEsp?.Value == true, + helloResolved: state.HelloResolvedUtc != null, + helloPolicyDisabled: state.HelloPolicyEnabled?.Value == false, + desktopArrived: state.DesktopArrivedUtc != null, + realmJoinGateOpen: RealmJoinGateOpen(state)); + + private static List BuildMissingCompletionPrerequisitesCore( + bool accountSetupProvisioned, + bool skipUserEsp, + bool helloResolved, + bool helloPolicyDisabled, + bool desktopArrived, + bool realmJoinGateOpen) + { + var missing = new List(4); + if (!accountSetupProvisioned && !skipUserEsp) + missing.Add(CompletionPrerequisites.AccountSetupProvisioningComplete); + if (!helloResolved && !helloPolicyDisabled) + missing.Add(CompletionPrerequisites.HelloResolution); + if (!desktopArrived) + missing.Add(CompletionPrerequisites.DesktopArrival); + if (!realmJoinGateOpen) + missing.Add(CompletionPrerequisites.RealmJoinResolution); + return missing; + } + + /// + /// Build the completion_waiting timeline effect for a blocked / deferred + /// completion attempt, or null when nothing should be emitted. Liveness plan PR2. + /// + /// The missing-prerequisites set is computed from the 's + /// in-flight facts (so facts the current handler just recorded — e.g. a freshly resolved + /// Hello — are not listed as missing). State-change-only by construction: when the + /// comma-joined set equals on + /// the state, no event is emitted. Otherwise the fingerprint + /// fact is advanced on the builder and the effect is returned. An empty missing-set + /// (e.g. a duplicate signal on an already-satisfied state) emits nothing. + /// + /// + private static DecisionEffect? BuildCompletionWaitingEffect( + DecisionState before, + DecisionStateBuilder builder, + DecisionSignal signal, + string trigger, + IReadOnlyDictionary? extraData = null) + { + var missing = BuildMissingCompletionPrerequisitesCore( + accountSetupProvisioned: builder.AccountSetupProvisioningSucceededUtc != null, + skipUserEsp: builder.ScenarioObservations.SkipUserEsp?.Value == true, + helloResolved: builder.HelloResolvedUtc != null, + helloPolicyDisabled: builder.HelloPolicyEnabled?.Value == false, + desktopArrived: builder.DesktopArrivedUtc != null, + realmJoinGateOpen: RealmJoinGateOpen(builder.RealmJoinFacts)); + if (missing.Count == 0) return null; + + var fingerprint = string.Join(",", missing); + if (before.CompletionWaitingFingerprint?.Value == fingerprint) return null; + + builder.CompletionWaitingFingerprint = + new SignalFact(fingerprint, signal.SessionSignalOrdinal); + + var parameters = new Dictionary(StringComparer.Ordinal) + { + ["eventType"] = SharedConstants.EventTypes.CompletionWaiting, + ["source"] = "DecisionEngine", + ["severity"] = "Info", + ["immediateUpload"] = "false", + ["message"] = $"Completion is waiting on: {fingerprint}", + ["missingPrerequisites"] = fingerprint, + ["trigger"] = trigger, + ["stage"] = builder.Stage.ToString(), + }; + + if (builder.Deadlines.Count > 0) + { + var names = new string[builder.Deadlines.Count]; + for (var i = 0; i < builder.Deadlines.Count; i++) + { + var d = builder.Deadlines[i]; + names[i] = $"{d.Name}={d.DueAtUtc:o}"; + } + parameters["armedDeadlines"] = string.Join(",", names); + } + + if (extraData != null) + { + foreach (var kv in extraData) + { + if (!parameters.ContainsKey(kv.Key)) parameters[kv.Key] = kv.Value; + } + } + + return new DecisionEffect(DecisionEffectKind.EmitEventTimelineEntry, parameters: parameters); + } + + /// Append an optional effect to a materialized effect array (no-op when null). + private static DecisionEffect[] AppendEffect(DecisionEffect[] effects, DecisionEffect? extra) + { + if (extra == null) return effects; + var combined = new DecisionEffect[effects.Length + 1]; + Array.Copy(effects, combined, effects.Length); + combined[effects.Length] = extra; + return combined; } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index 92bb70aa..8204e48b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -46,9 +46,17 @@ namespace AutopilotMonitor.DecisionCore.Engine /// terminal transition. /// internal static bool RealmJoinGateOpen(DecisionState state) => - state.RealmJoinFacts.DetectedUtc == null - || state.RealmJoinFacts.ResolvedUtc != null - || state.RealmJoinFacts.Outcome != null; + RealmJoinGateOpen(state.RealmJoinFacts); + + /// + /// Facts-level overload — lets the completion_waiting helper (liveness plan PR2) + /// evaluate the gate against a 's in-flight facts + /// before the new state is materialized. + /// + internal static bool RealmJoinGateOpen(RealmJoinFacts facts) => + facts.DetectedUtc == null + || facts.ResolvedUtc != null + || facts.Outcome != null; /// /// Build the 60-min RJ hard-timeout deadline. Floored at diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index aea55ebd..185b3e72 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -425,6 +425,12 @@ namespace AutopilotMonitor.DecisionCore.Engine } builder.WithStage(SessionStage.AwaitingDesktop); + + // Liveness plan PR2: Hello just resolved synthetically (Timeout) but Desktop is + // still missing — a blocked completion attempt; surface what we are waiting on. + var waitingEffect = BuildCompletionWaitingEffect( + state, builder, signal, trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}"); + var newState = builder.Build(); var transition = BuildTakenTransition( @@ -436,7 +442,10 @@ namespace AutopilotMonitor.DecisionCore.Engine // AwaitingDesktop path: terminal effect is deferred to the later DesktopArrived // handler (which will itself route through Finalizing per Fix 6). - return new DecisionStep(newState, transition, Array.Empty()); + return new DecisionStep( + newState, + transition, + waitingEffect != null ? new[] { waitingEffect } : Array.Empty()); } // ============================================================== diagnostic signals @@ -788,6 +797,14 @@ namespace AutopilotMonitor.DecisionCore.Engine if (!shouldPromote) { + // Liveness plan PR2: the strong gate fact is recorded but no promotion happens + // here — say what completion still waits on. In the typical ordering (fact + // before esp_exiting) that is hello/desktop; the fingerprint dedupes repeats + // and duplicate signals on an already-satisfied state emit nothing. + var waitingEffect = BuildCompletionWaitingEffect( + state, builder, signal, + trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete) + ":NoPromote"); + var newState = builder.Build(); var transition = BuildTakenTransition( before: state, @@ -795,7 +812,10 @@ namespace AutopilotMonitor.DecisionCore.Engine toStage: state.Stage, nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete)); - return new DecisionStep(newState, transition, Array.Empty()); + return new DecisionStep( + newState, + transition, + waitingEffect != null ? new[] { waitingEffect } : Array.Empty()); } // Deferred-completion parity (session caa6cf50 fix, 2026-06-11): when the strong gate diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs index ad48fc3a..b6278ea5 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionStateSnapshotBuilder.cs @@ -64,6 +64,7 @@ namespace AutopilotMonitor.DecisionCore.Engine ["lastFailureTrigger"] = SerializeFact(state.LastFailureTrigger, v => v), ["espAdvisoryFailureRecordedUtc"] = SerializeFact(state.EspAdvisoryFailureRecordedUtc, FormatUtc), ["imeUserSessionCompletedUtc"] = SerializeFact(state.ImeUserSessionCompletedUtc, FormatUtc), + ["completionWaitingFingerprint"] = SerializeFact(state.CompletionWaitingFingerprint, v => v), }; var scenario = new Dictionary(StringComparer.Ordinal) diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs index 52195121..87ba7fed 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionState.cs @@ -62,7 +62,8 @@ namespace AutopilotMonitor.DecisionCore.State SignalFact? deviceSetupResolvedUtc = null, string? schemaVersion = null, SignalFact? espAdvisoryFailureRecordedUtc = null, - SignalFact? imeUserSessionCompletedUtc = null) + SignalFact? imeUserSessionCompletedUtc = null, + SignalFact? completionWaitingFingerprint = null) { if (string.IsNullOrEmpty(sessionId)) { @@ -104,6 +105,7 @@ namespace AutopilotMonitor.DecisionCore.State SchemaVersion = schemaVersion ?? CurrentSchemaVersion; EspAdvisoryFailureRecordedUtc = espAdvisoryFailureRecordedUtc; ImeUserSessionCompletedUtc = imeUserSessionCompletedUtc; + CompletionWaitingFingerprint = completionWaitingFingerprint; } public string SessionId { get; } @@ -286,6 +288,17 @@ namespace AutopilotMonitor.DecisionCore.State /// public SignalFact? ImeUserSessionCompletedUtc { get; } + /// + /// Dedupe anchor for the completion_waiting observability event (liveness plan + /// PR2). Holds the comma-joined list of missing completion prerequisites that was last + /// surfaced on the timeline (e.g. "hello_resolution,desktop_arrival"). The + /// engine's blocked/deferred completion sites only emit a new completion_waiting + /// event when the freshly computed set differs from this fingerprint — making the event + /// state-change-only by construction (no heartbeats). Null until the first blocked + /// completion attempt; additive-nullable, no snapshot-schema bump. + /// + public SignalFact? CompletionWaitingFingerprint { get; } + public string SchemaVersion { get; } /// diff --git a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs index 4f1eee69..24041330 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/State/DecisionStateBuilder.cs @@ -49,6 +49,7 @@ namespace AutopilotMonitor.DecisionCore.State SchemaVersion = source.SchemaVersion; EspAdvisoryFailureRecordedUtc = source.EspAdvisoryFailureRecordedUtc; ImeUserSessionCompletedUtc = source.ImeUserSessionCompletedUtc; + CompletionWaitingFingerprint = source.CompletionWaitingFingerprint; } public string SessionId { get; set; } @@ -80,6 +81,7 @@ namespace AutopilotMonitor.DecisionCore.State public SignalFact? DeviceSetupResolvedUtc { get; set; } public SignalFact? EspAdvisoryFailureRecordedUtc { get; set; } public SignalFact? ImeUserSessionCompletedUtc { get; set; } + public SignalFact? CompletionWaitingFingerprint { get; set; } public string SchemaVersion { get; set; } // ---------- fluent helpers for the most common reducer operations ---------- @@ -230,6 +232,7 @@ namespace AutopilotMonitor.DecisionCore.State deviceSetupResolvedUtc: DeviceSetupResolvedUtc, schemaVersion: SchemaVersion, espAdvisoryFailureRecordedUtc: EspAdvisoryFailureRecordedUtc, - imeUserSessionCompletedUtc: ImeUserSessionCompletedUtc); + imeUserSessionCompletedUtc: ImeUserSessionCompletedUtc, + completionWaitingFingerprint: CompletionWaitingFingerprint); } } diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 7bbd6d85..5c071af3 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -274,6 +274,12 @@ namespace AutopilotMonitor.Shared public const string DesktopDetectorFirstPoll = "desktop_detector_first_poll"; // 1x after first PollForDesktop() completes public const string DesktopDetectorNoCandidate = "desktop_detector_no_candidate"; // 1x after threshold polls without resolution (configurable) public const string CompletionCheck = "completion_check"; + // Liveness plan PR2 — emitted (state-change-only, deduped via the + // CompletionWaitingFingerprint state fact) whenever the DecisionEngine blocks or + // defers a completion attempt. Data.missingPrerequisites names the stable literals + // the engine is still waiting on (account_setup_provisioning_complete, + // hello_resolution, desktop_arrival, realmjoin_resolution). + public const string CompletionWaiting = "completion_waiting"; public const string ScriptStarted = "script_started"; public const string ScriptCompleted = "script_completed"; public const string ScriptFailed = "script_failed"; diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts index dc93d8b3..fd0ed995 100644 --- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts +++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts @@ -34,6 +34,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [ description: "User desktop detected (explorer.exe under real user)." }, { value: "completion_check", label: "completion_check", category: "enrollment", description: "Throttled state-machine snapshot during completion evaluation." }, + { value: "completion_waiting", label: "completion_waiting", category: "enrollment", + description: "Engine blocked or deferred a completion attempt; missingPrerequisites lists what it is still waiting on (deduped, emitted only when the set changes)." }, { value: "agent_started", label: "agent_started", category: "enrollment", description: "Agent process started." }, { value: "agent_shutdown", label: "agent_shutdown (V1)", category: "enrollment",