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",