mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user