feat(decision-core): completion_waiting event with state-change-only fingerprint dedupe (PR2)

When the engine blocks or defers a completion attempt it now says what it
is still waiting on instead of going silent. New Info event
completion_waiting with data.missingPrerequisites built from stable
literals (account_setup_provisioning_complete, hello_resolution,
desktop_arrival, realmjoin_resolution), plus trigger, stage,
armedDeadlines and — on the guard-blocked esp_exiting site — the
AdvisoryCompletion window due-time.

Emission sites: CompleteThroughFinalizingOrDefer gate-closed defer,
HandleDesktopArrivedV1 desktop-first / blocked fast-path,
HandleEspExitingV1 post-AccountSetup guard-blocked exit,
HandleHelloResolvedV1 + HandleHelloSafetyDeadlineFired AwaitingDesktop
branches, HandleAccountSetupProvisioningCompleteV1 no-promote branch.
Pre-AccountSetup handoff exits stay silent (normal flow).

Dedupe via new additive-nullable state fact CompletionWaitingFingerprint
(comma-joined missing-set; ctor-end param, no schema bump — same pattern
as ImeUserSessionCompletedUtc in a77ad21e). A repeat with the same set
emits nothing; a set change emits one new event. SnapshotBuilder facts
16 -> 17; serializer roundtrip + legacy-snapshot compat covered.

New event type synced to MCP resource catalog (esp/enrollment group) and
Web gather-rules eventTypes.

DecisionCore 400/400, V2.Core 1347/1347, MCP vitest 220, Web 458, tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-12 15:32:55 +02:00
parent 7f314b1507
commit eb3af41a96
12 changed files with 647 additions and 14 deletions
@@ -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
{
/// <summary>
/// Liveness plan PR2 — <c>completion_waiting</c> observability. Whenever the engine blocks
/// or defers a completion attempt it says what it is still waiting on; the
/// <c>CompletionWaitingFingerprint</c> state fact makes the event state-change-only (no
/// repeats while the missing-set is unchanged, a new event when the set changes).
/// Coverage:
/// <list type="bullet">
/// <item>1ec8f4c6 replay: blocked completion attempts list exactly the missing
/// prerequisites (satisfied facts are not listed).</item>
/// <item>Fingerprint dedupe: same missing-set → no second event; set change → new event.</item>
/// <item>RealmJoin defer → <c>realmjoin_resolution</c> listed.</item>
/// <item>HelloSafety-timeout / AwaitingDesktop / no-promote emission sites.</item>
/// <item><c>CompletionWaitingFingerprint</c> serialization compatibility (roundtrip +
/// legacy snapshot without the field).</item>
/// </list>
/// </summary>
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<string, string>? 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<string, string> { [SignalPayloadKeys.Deadline] = deadlineName });
private static List<DecisionEffect> 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();
/// <summary>
/// Classic session driven to "AccountSetup entered, Hello policy known" — the common
/// prefix of the blocked-completion scenarios.
/// </summary>
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<string, string>
{
[SignalPayloadKeys.SkipUserEsp] = "false",
[SignalPayloadKeys.SkipDeviceEsp] = "false",
})).NewState;
if (helloPolicyDisabled)
{
state = engine.Reduce(state, MakeSignal(
8, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1),
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = "false" })).NewState;
}
state = engine.Reduce(state, MakeSignal(
10, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(2),
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [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<string, string> { [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<string, string>
{
[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<string, string> { [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<string, string> { [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<string, string>
{
[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<string, string>
{
[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<string, string> { [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<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
state = engine.Reduce(state, MakeSignal(
20, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(17),
new Dictionary<string, string> { [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<DateTime>(T0.AddMinutes(20), 10);
builder.HelloResolvedUtc = new SignalFact<DateTime>(T0.AddMinutes(21), 11);
builder.DesktopArrivedUtc = new SignalFact<DateTime>(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<string>(
"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);
}
}
}
@@ -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<string, object?>)snapshot["facts"]!;
@@ -24,6 +24,7 @@ export const EVENT_TYPES_CATALOG = {
'esp_exiting',
'esp_config_detected',
'completion_check',
'completion_waiting',
'enrollment_complete',
'enrollment_failed',
'desktop_arrived',
@@ -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<string, string>? waitingExtra = null;
foreach (var d in builder.Deadlines)
{
if (d.Name == DeadlineNames.AdvisoryCompletion)
{
waitingExtra = new Dictionary<string, string>(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<DecisionEffect>();
return new DecisionStep(newState, transition, effects);
return new DecisionStep(newState, transition, AppendEffect(effects, waitingEffect));
}
/// <summary>
@@ -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<DecisionEffect>());
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
}
/// <summary>
@@ -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)
/// <summary>Stable literals for <c>completion_waiting</c>'s <c>missingPrerequisites</c> data field.</summary>
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";
}
/// <summary>
/// Compute the ordered list of completion prerequisites the engine is still waiting on
/// for <paramref name="state"/>. Liveness plan PR2 — feeds the <c>completion_waiting</c>
/// event's <c>missingPrerequisites</c> field and the dedupe fingerprint.
/// </summary>
internal static List<string> 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<string> BuildMissingCompletionPrerequisitesCore(
bool accountSetupProvisioned,
bool skipUserEsp,
bool helloResolved,
bool helloPolicyDisabled,
bool desktopArrived,
bool realmJoinGateOpen)
{
var missing = new List<string>(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;
}
/// <summary>
/// Build the <c>completion_waiting</c> timeline effect for a blocked / deferred
/// completion attempt, or <c>null</c> when nothing should be emitted. Liveness plan PR2.
/// <para>
/// The missing-prerequisites set is computed from the <paramref name="builder"/>'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 <see cref="DecisionState.CompletionWaitingFingerprint"/> on
/// the <paramref name="before"/> 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.
/// </para>
/// </summary>
private static DecisionEffect? BuildCompletionWaitingEffect(
DecisionState before,
DecisionStateBuilder builder,
DecisionSignal signal,
string trigger,
IReadOnlyDictionary<string, string>? 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<string>(fingerprint, signal.SessionSignalOrdinal);
var parameters = new Dictionary<string, string>(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);
}
/// <summary>Append an optional effect to a materialized effect array (no-op when null).</summary>
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;
}
/// <summary>
@@ -46,9 +46,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// terminal transition.
/// </summary>
internal static bool RealmJoinGateOpen(DecisionState state) =>
state.RealmJoinFacts.DetectedUtc == null
|| state.RealmJoinFacts.ResolvedUtc != null
|| state.RealmJoinFacts.Outcome != null;
RealmJoinGateOpen(state.RealmJoinFacts);
/// <summary>
/// Facts-level overload — lets the <c>completion_waiting</c> helper (liveness plan PR2)
/// evaluate the gate against a <see cref="DecisionStateBuilder"/>'s in-flight facts
/// before the new state is materialized.
/// </summary>
internal static bool RealmJoinGateOpen(RealmJoinFacts facts) =>
facts.DetectedUtc == null
|| facts.ResolvedUtc != null
|| facts.Outcome != null;
/// <summary>
/// Build the 60-min RJ hard-timeout deadline. Floored at <see cref="DecisionState.AgentBootUtc"/>
@@ -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<DecisionEffect>());
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
}
// ============================================================== 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<DecisionEffect>());
return new DecisionStep(
newState,
transition,
waitingEffect != null ? new[] { waitingEffect } : Array.Empty<DecisionEffect>());
}
// Deferred-completion parity (session caa6cf50 fix, 2026-06-11): when the strong gate
@@ -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<string, object?>(StringComparer.Ordinal)
@@ -62,7 +62,8 @@ namespace AutopilotMonitor.DecisionCore.State
SignalFact<DateTime>? deviceSetupResolvedUtc = null,
string? schemaVersion = null,
SignalFact<DateTime>? espAdvisoryFailureRecordedUtc = null,
SignalFact<DateTime>? imeUserSessionCompletedUtc = null)
SignalFact<DateTime>? imeUserSessionCompletedUtc = null,
SignalFact<string>? 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
/// </summary>
public SignalFact<DateTime>? ImeUserSessionCompletedUtc { get; }
/// <summary>
/// Dedupe anchor for the <c>completion_waiting</c> observability event (liveness plan
/// PR2). Holds the comma-joined list of missing completion prerequisites that was last
/// surfaced on the timeline (e.g. <c>"hello_resolution,desktop_arrival"</c>). The
/// engine's blocked/deferred completion sites only emit a new <c>completion_waiting</c>
/// 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.
/// </summary>
public SignalFact<string>? CompletionWaitingFingerprint { get; }
public string SchemaVersion { get; }
/// <summary>
@@ -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<DateTime>? DeviceSetupResolvedUtc { get; set; }
public SignalFact<DateTime>? EspAdvisoryFailureRecordedUtc { get; set; }
public SignalFact<DateTime>? ImeUserSessionCompletedUtc { get; set; }
public SignalFact<string>? 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);
}
}
@@ -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";
@@ -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",