mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
fix(agent-v2): complete Classic sessions when a skipped user-ESP app starves the AccountSetup gate
Session caa6cf50: a policy-skipped user-ESP app leaves the ESP registry's Apps subcategory permanently inProgress, so Windows never writes AccountSetupCategory.Status.categorySucceeded before tearing down the ESP page. Both registry-driven AccountSetupProvisioningComplete paths (normal + all-subcategories fallback) starve, ShouldTransitionToAwaitingHello never opens, and the session stalls after 60 min despite a successful enrollment (esp_exiting + desktop_arrived + helloPolicy=false all recorded). Fix, in three parts: - ImeLogTracker.AreUserEspAppsSettled(): conservative probe — true only in AccountSetup phase with >=1 required app, all required apps terminal (Installed/Skipped/Postponed) and zero errors. Exposed via ImeLogHost. - EspAndHelloTracker: on every Shell-Core exit forward, fire-once synthesis of AccountSetupProvisioningComplete when the probe reports settled, plus an esp_provisioning_status observability event (fallbackReason=esp_exited_user_apps_settled_category_unresolved). Wired through a new optional userEspAppsSettledProbe ctor param; DefaultComponentFactory connects it to ImeLogHost via closure. - HandleAccountSetupProvisioningCompleteV1: deferred-completion parity. When the strong gate arrives last (Win11 ordering: explorer.exe runs under the User-ESP page, so Desktop arrives before the final exit) and Hello is resolved or policy-disabled, complete through Finalizing with HelloOutcome="Skipped" instead of parking in AwaitingHello until HelloSafety stamps a misleading "Timeout" 300 s later. Sessions with failed or in-flight user apps keep the existing conservative stall behaviour. DecisionCore 366/366, V2.Core 1321/1321 green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+163
@@ -0,0 +1,163 @@
|
|||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.SignalAdapters;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
|
||||||
|
using AutopilotMonitor.DecisionCore.Engine;
|
||||||
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11) — coordinator-side synthesis tests.
|
||||||
|
/// When a Shell-Core normal exit (62407) is forwarded while the injected IME probe reports
|
||||||
|
/// "all tracked user-ESP apps terminal (0 failed)", <see cref="EspAndHelloTracker"/> must
|
||||||
|
/// raise <c>AccountSetupProvisioningComplete</c> as alternative gate evidence — because a
|
||||||
|
/// policy-skipped user-ESP app leaves the registry's Apps subcategory permanently
|
||||||
|
/// <c>inProgress</c> and both registry-driven paths (normal + fallback) starve.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EspAndHelloTrackerUserAppsSettledSynthesisTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Fixed = new DateTime(2026, 6, 11, 8, 0, 2, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
private sealed class Fixture : IDisposable
|
||||||
|
{
|
||||||
|
public TempDirectory Tmp { get; } = new TempDirectory();
|
||||||
|
public AgentLogger Logger { get; }
|
||||||
|
public FakeSignalIngressSink Ingress { get; } = new FakeSignalIngressSink();
|
||||||
|
public FakeSignalIngressSink TrackerPostSink { get; } = new FakeSignalIngressSink();
|
||||||
|
public VirtualClock Clock { get; } = new VirtualClock(Fixed);
|
||||||
|
|
||||||
|
public Fixture() { Logger = new AgentLogger(Tmp.Path, AgentLogLevel.Debug); }
|
||||||
|
|
||||||
|
public EspAndHelloTracker BuildCoordinator(Func<bool> settledProbe)
|
||||||
|
{
|
||||||
|
return new EspAndHelloTracker(
|
||||||
|
sessionId: "S1",
|
||||||
|
tenantId: "T1",
|
||||||
|
post: new InformationalEventPost(TrackerPostSink, Clock),
|
||||||
|
logger: Logger,
|
||||||
|
skipConfigProbe: () => ((bool?)false, (bool?)false),
|
||||||
|
accountSetupActivityProbe: () => true,
|
||||||
|
userEspAppsSettledProbe: settledProbe);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { Tmp.Dispose(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspExited_withSettledUserApps_raises_AccountSetupProvisioningComplete_after_EspExiting()
|
||||||
|
{
|
||||||
|
using var f = new Fixture();
|
||||||
|
using var coordinator = f.BuildCoordinator(() => true);
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
|
||||||
|
// Signal order matters: EspExiting first (records EspFinalExitUtc), then the
|
||||||
|
// synthesized gate signal (deferred-promote path reads the recorded fact).
|
||||||
|
Assert.Equal(2, f.Ingress.Posted.Count);
|
||||||
|
Assert.Equal(DecisionSignalKind.EspExiting, f.Ingress.Posted[0].Kind);
|
||||||
|
Assert.Equal(DecisionSignalKind.AccountSetupProvisioningComplete, f.Ingress.Posted[1].Kind);
|
||||||
|
Assert.True(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||||
|
|
||||||
|
// Observability: the coordinator emits an esp_provisioning_status informational
|
||||||
|
// event so session-debug can see WHY the gate opened without registry confirmation.
|
||||||
|
var info = Assert.Single(f.TrackerPostSink.Posted);
|
||||||
|
Assert.Equal(DecisionSignalKind.InformationalEvent, info.Kind);
|
||||||
|
Assert.Equal("esp_provisioning_status", info.Payload![SignalPayloadKeys.EventType]);
|
||||||
|
// EnrollmentEvent.Data flows through the typed sidecar (plan §1.3), not the string payload.
|
||||||
|
var data = Assert.IsAssignableFrom<System.Collections.Generic.IReadOnlyDictionary<string, object>>(info.TypedPayload);
|
||||||
|
Assert.Equal("esp_exited_user_apps_settled_category_unresolved", data["fallbackReason"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EspExited_withUnsettledUserApps_forwards_EspExiting_only()
|
||||||
|
{
|
||||||
|
using var f = new Fixture();
|
||||||
|
using var coordinator = f.BuildCoordinator(() => false);
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
|
||||||
|
var posted = Assert.Single(f.Ingress.Posted);
|
||||||
|
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||||
|
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||||
|
Assert.Empty(f.TrackerPostSink.Posted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Synthesis_fires_once_across_multiple_exits()
|
||||||
|
{
|
||||||
|
using var f = new Fixture();
|
||||||
|
using var coordinator = f.BuildCoordinator(() => true);
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed.AddSeconds(30));
|
||||||
|
|
||||||
|
// Two EspExiting forwards (no dedup by design), exactly one synthesized gate signal.
|
||||||
|
Assert.Equal(2, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.EspExiting));
|
||||||
|
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||||
|
Assert.Single(f.TrackerPostSink.Posted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Synthesis_becomes_eligible_on_a_later_exit_when_apps_settle_in_between()
|
||||||
|
{
|
||||||
|
// Exit #1 fires while user apps are still in flight (probe false) — no synthesis.
|
||||||
|
// Apps settle, exit #2 fires — synthesis must trigger on the later exit.
|
||||||
|
using var f = new Fixture();
|
||||||
|
var settled = false;
|
||||||
|
using var coordinator = f.BuildCoordinator(() => settled);
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
Assert.DoesNotContain(f.Ingress.Posted, p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete);
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed.AddMinutes(10));
|
||||||
|
|
||||||
|
Assert.Equal(1, f.Ingress.Posted.Count(p => p.Kind == DecisionSignalKind.AccountSetupProvisioningComplete));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ThrowingProbe_is_swallowed_and_EspExiting_still_forwards()
|
||||||
|
{
|
||||||
|
using var f = new Fixture();
|
||||||
|
using var coordinator = f.BuildCoordinator(() => throw new InvalidOperationException("probe boom"));
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
|
||||||
|
var posted = Assert.Single(f.Ingress.Posted);
|
||||||
|
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||||
|
Assert.False(coordinator.UserAppsSettledSynthesisFiredForTest);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultProbe_never_synthesizes()
|
||||||
|
{
|
||||||
|
// Single-tracker wiring scenarios construct the coordinator without the probe —
|
||||||
|
// the default must preserve prior behaviour (no synthesis, ever).
|
||||||
|
using var f = new Fixture();
|
||||||
|
using var coordinator = new EspAndHelloTracker(
|
||||||
|
sessionId: "S1",
|
||||||
|
tenantId: "T1",
|
||||||
|
post: new InformationalEventPost(f.TrackerPostSink, f.Clock),
|
||||||
|
logger: f.Logger,
|
||||||
|
skipConfigProbe: () => ((bool?)false, (bool?)false),
|
||||||
|
accountSetupActivityProbe: () => true);
|
||||||
|
using var adapter = new EspAndHelloTrackerAdapter(coordinator, f.Ingress, f.Clock);
|
||||||
|
|
||||||
|
coordinator.TriggerEspExitedForTest(Fixed);
|
||||||
|
|
||||||
|
var posted = Assert.Single(f.Ingress.Posted);
|
||||||
|
Assert.Equal(DecisionSignalKind.EspExiting, posted.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime;
|
||||||
|
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Ime
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11) — IME-side probe semantics.
|
||||||
|
/// <see cref="ImeLogTracker.AreUserEspAppsSettled"/> must be conservative: it only returns
|
||||||
|
/// <c>true</c> when the tracker is in the AccountSetup phase AND the live (current-phase)
|
||||||
|
/// package list contains at least one required app, all required apps are terminal
|
||||||
|
/// (Installed / Skipped / Postponed), and no tracked app is in Error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ImeLogTrackerUserEspAppsSettledTests
|
||||||
|
{
|
||||||
|
private static ImeLogTracker BuildTracker(TempDirectory tmp) =>
|
||||||
|
new ImeLogTracker(
|
||||||
|
logFolder: tmp.Path,
|
||||||
|
patterns: new List<ImeLogPattern>(),
|
||||||
|
logger: new AgentLogger(tmp.Path, AgentLogLevel.Info));
|
||||||
|
|
||||||
|
private static AppPackageState AddApp(
|
||||||
|
ImeLogTracker tracker,
|
||||||
|
string id,
|
||||||
|
AppIntent intent,
|
||||||
|
AppInstallationState state)
|
||||||
|
{
|
||||||
|
var pkg = tracker.PackageStates.GetPackage(id, createIfNotFound: true);
|
||||||
|
pkg.UpdateIntent(intent);
|
||||||
|
if (state == AppInstallationState.Installed)
|
||||||
|
{
|
||||||
|
// Installed requires DownloadingOrInstallingSeen, else UpdateState auto-downgrades
|
||||||
|
// to Skipped (inverse-detection handling). Walk the real lifecycle.
|
||||||
|
pkg.UpdateState(AppInstallationState.Installing);
|
||||||
|
}
|
||||||
|
if (state != AppInstallationState.Unknown)
|
||||||
|
{
|
||||||
|
pkg.UpdateState(state);
|
||||||
|
}
|
||||||
|
return pkg;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_outside_AccountSetup_phase()
|
||||||
|
{
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("DeviceSetup");
|
||||||
|
AddApp(tracker, "app-1", AppIntent.Install, AppInstallationState.Installed);
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_when_phase_never_detected()
|
||||||
|
{
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
AddApp(tracker, "app-1", AppIntent.Install, AppInstallationState.Installed);
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_when_no_apps_tracked_in_current_phase()
|
||||||
|
{
|
||||||
|
// The live list is cleared on the DeviceSetup→AccountSetup transition; an empty list
|
||||||
|
// (user apps not yet surfaced) must read as "not settled", never vacuously true —
|
||||||
|
// otherwise the first intermediate esp_exiting would open the gate prematurely.
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_while_a_required_app_is_still_in_flight()
|
||||||
|
{
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
AddApp(tracker, "app-skipped", AppIntent.Install, AppInstallationState.Skipped);
|
||||||
|
AddApp(tracker, "app-installing", AppIntent.Install, AppInstallationState.Installing);
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_when_any_app_failed()
|
||||||
|
{
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
AddApp(tracker, "app-ok", AppIntent.Install, AppInstallationState.Installed);
|
||||||
|
AddApp(tracker, "app-failed", AppIntent.Install, AppInstallationState.Error);
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void False_when_only_intent_unknown_apps_are_tracked()
|
||||||
|
{
|
||||||
|
// Intent never parsed → too weak as completion evidence; stay conservative.
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
AddApp(tracker, "app-unknown-intent", AppIntent.Unknown, AppInstallationState.Skipped);
|
||||||
|
|
||||||
|
Assert.False(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void True_when_all_required_user_apps_are_terminal_with_zero_failures()
|
||||||
|
{
|
||||||
|
// The caa6cf50 shape: one app policy-skipped (the registry's Apps subcategory
|
||||||
|
// therefore never reaches "succeeded"), one app installed.
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
AddApp(tracker, "gsa-test", AppIntent.Install, AppInstallationState.Skipped);
|
||||||
|
AddApp(tracker, "teamviewer", AppIntent.Install, AppInstallationState.Installed);
|
||||||
|
|
||||||
|
Assert.True(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void True_with_postponed_apps_counts_as_terminal()
|
||||||
|
{
|
||||||
|
// Postponed = IME deferred the install past ESP; the ESP page will not wait for it.
|
||||||
|
using var tmp = new TempDirectory();
|
||||||
|
var tracker = BuildTracker(tmp);
|
||||||
|
tracker.SeedCurrentPhaseForTesting("AccountSetup");
|
||||||
|
AddApp(tracker, "app-postponed", AppIntent.Install, AppInstallationState.Postponed);
|
||||||
|
|
||||||
|
Assert.True(tracker.AreUserEspAppsSettled());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -201,6 +201,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
|||||||
_phasePackageSnapshots[phaseName] = new List<AppPackageState>(apps);
|
_phasePackageSnapshots[phaseName] = new List<AppPackageState>(apps);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Test-only seam: sets the tracker's current detected ESP phase without driving the
|
||||||
|
/// IME-ESP-PHASE regex pattern through a real log file. Production code never calls
|
||||||
|
/// this — the phase is set by <c>HandleEspPhaseDetected</c> from live pattern matches
|
||||||
|
/// (or restored from persisted state). Used by <c>AreUserEspAppsSettled</c> tests.
|
||||||
|
/// </summary>
|
||||||
|
internal void SeedCurrentPhaseForTesting(string phaseName)
|
||||||
|
{
|
||||||
|
_lastEspPhaseDetected = phaseName;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a deduped union of phase-snapshotted apps plus the live
|
/// Returns a deduped union of phase-snapshotted apps plus the live
|
||||||
/// <see cref="PackageStates"/> (current phase). Live entries win on Id collision.
|
/// <see cref="PackageStates"/> (current phase). Live entries win on Id collision.
|
||||||
@@ -229,6 +240,56 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11): returns <c>true</c> when the
|
||||||
|
/// tracker's current ESP phase is AccountSetup AND every tracked user-phase app has
|
||||||
|
/// reached a terminal state (Installed / Skipped / Postponed) with zero errors.
|
||||||
|
/// <para>
|
||||||
|
/// Used by <c>EspAndHelloTracker</c> as alternative evidence for the strong
|
||||||
|
/// post-AccountSetup completion gate: when a user-ESP app is policy-skipped, Windows
|
||||||
|
/// never flips the registry's Apps subcategory to <c>succeeded</c> and never writes
|
||||||
|
/// <c>AccountSetupCategory.Status.categorySucceeded</c> before tearing down the ESP
|
||||||
|
/// page — the registry-driven <c>AccountSetupProvisioningComplete</c> (and its
|
||||||
|
/// all-subcategories fallback) can then never fire. IME's own app tracking is the
|
||||||
|
/// independent evidence that AccountSetup app enforcement actually finished.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Conservative by construction: requires at least one required (Install/Uninstall
|
||||||
|
/// intent) app in the live current-phase list — an empty list (phase just cleared, or
|
||||||
|
/// apps not yet surfaced) or intent-unknown-only tracking returns <c>false</c>. Any
|
||||||
|
/// app in <see cref="AppInstallationState.Error"/> returns <c>false</c>. May be called
|
||||||
|
/// from the Shell-Core event-log watcher thread while the polling thread mutates the
|
||||||
|
/// list — any race-induced exception is swallowed and reported as "not settled".
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public bool AreUserEspAppsSettled()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (!string.Equals(_lastEspPhaseDetected, "AccountSetup", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var apps = new List<AppPackageState>(_packageStates);
|
||||||
|
if (apps.Count == 0) return false;
|
||||||
|
|
||||||
|
var sawRequired = false;
|
||||||
|
foreach (var pkg in apps)
|
||||||
|
{
|
||||||
|
if (pkg == null) continue;
|
||||||
|
if (pkg.IsError) return false;
|
||||||
|
if (!pkg.IsRequired) continue;
|
||||||
|
sawRequired = true;
|
||||||
|
if (!pkg.IsCompleted) return false;
|
||||||
|
}
|
||||||
|
return sawRequired;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Debug($"ImeLogTracker: AreUserEspAppsSettled probe threw: {ex.Message}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Promotes every app currently in <see cref="AppInstallationState.Installing"/> to
|
/// Promotes every app currently in <see cref="AppInstallationState.Installing"/> to
|
||||||
/// <see cref="AppInstallationState.Error"/>, stamping <paramref name="failureType"/>
|
/// <see cref="AppInstallationState.Error"/>, stamping <paramref name="failureType"/>
|
||||||
|
|||||||
+91
-3
@@ -1,6 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
using AutopilotMonitor.Agent.V2.Core.Logging;
|
using AutopilotMonitor.Agent.V2.Core.Logging;
|
||||||
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
using AutopilotMonitor.Agent.V2.Core.Orchestration;
|
||||||
|
using AutopilotMonitor.Shared;
|
||||||
using AutopilotMonitor.Shared.Models;
|
using AutopilotMonitor.Shared.Models;
|
||||||
|
|
||||||
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
||||||
@@ -37,6 +39,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
|||||||
private readonly int[] _modernDeploymentHarmlessEventIds;
|
private readonly int[] _modernDeploymentHarmlessEventIds;
|
||||||
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
|
private readonly Func<(bool? skipUser, bool? skipDevice)> _skipConfigProbe;
|
||||||
private readonly Func<bool> _accountSetupActivityProbe;
|
private readonly Func<bool> _accountSetupActivityProbe;
|
||||||
|
private readonly Func<bool> _userEspAppsSettledProbe;
|
||||||
|
|
||||||
|
// Session caa6cf50 gate-starvation fix (2026-06-11) — fire-once guard for the
|
||||||
|
// user-ESP-apps-settled AccountSetup synthesis (see MaybeSynthesizeAccountSetupComplete).
|
||||||
|
private bool _userAppsSettledSynthesisFired;
|
||||||
|
|
||||||
private HelloTracker _helloTracker;
|
private HelloTracker _helloTracker;
|
||||||
private ShellCoreTracker _shellCoreTracker;
|
private ShellCoreTracker _shellCoreTracker;
|
||||||
@@ -187,7 +194,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
|||||||
string stateDirectory = null,
|
string stateDirectory = null,
|
||||||
int[] modernDeploymentHarmlessEventIds = null,
|
int[] modernDeploymentHarmlessEventIds = null,
|
||||||
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
|
Func<(bool? skipUser, bool? skipDevice)> skipConfigProbe = null,
|
||||||
Func<bool> accountSetupActivityProbe = null)
|
Func<bool> accountSetupActivityProbe = null,
|
||||||
|
Func<bool> userEspAppsSettledProbe = null)
|
||||||
{
|
{
|
||||||
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
_sessionId = sessionId ?? throw new ArgumentNullException(nameof(sessionId));
|
||||||
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
_tenantId = tenantId ?? throw new ArgumentNullException(nameof(tenantId));
|
||||||
@@ -206,6 +214,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
|||||||
// Test seam: allow injection of a fake AccountSetup-activity probe. Production uses
|
// Test seam: allow injection of a fake AccountSetup-activity probe. Production uses
|
||||||
// the provisioning tracker's live registry-derived flag.
|
// the provisioning tracker's live registry-derived flag.
|
||||||
_accountSetupActivityProbe = accountSetupActivityProbe ?? (() => _provisioningTracker?.HasAccountSetupActivity == true);
|
_accountSetupActivityProbe = accountSetupActivityProbe ?? (() => _provisioningTracker?.HasAccountSetupActivity == true);
|
||||||
|
// Session caa6cf50 gate-starvation fix: IME-side "all tracked user-ESP apps terminal"
|
||||||
|
// probe (wired to ImeLogHost.AreUserEspAppsSettled by DefaultComponentFactory).
|
||||||
|
// Defaults to "not settled" so single-tracker wiring scenarios keep prior behaviour.
|
||||||
|
_userEspAppsSettledProbe = userEspAppsSettledProbe ?? (() => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -561,9 +573,85 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
|
|||||||
// this path is NOT filtered by IsIntermediateDeviceEspExit — the reducer-side
|
// this path is NOT filtered by IsIntermediateDeviceEspExit — the reducer-side
|
||||||
// ShouldTransitionToAwaitingHello guard decides whether HelloSafety arms.
|
// ShouldTransitionToAwaitingHello guard decides whether HelloSafety arms.
|
||||||
LastEventOccurredAtUtc = args?.OccurredAtUtc;
|
LastEventOccurredAtUtc = args?.OccurredAtUtc;
|
||||||
try { EspExited?.Invoke(this, args); }
|
try
|
||||||
catch (Exception ex) { _logger.Error("Error forwarding EspExited", ex); }
|
{
|
||||||
|
try { EspExited?.Invoke(this, args); }
|
||||||
|
catch (Exception ex) { _logger.Error("Error forwarding EspExited", ex); }
|
||||||
|
|
||||||
|
// Session caa6cf50 gate-starvation fix: a Shell-Core normal exit while IME's
|
||||||
|
// user-ESP app tracking is fully settled is alternative evidence that
|
||||||
|
// AccountSetup completed. Raised AFTER the EspExited forward so the reducer
|
||||||
|
// records EspFinalExitUtc first and the deferred-promote path in
|
||||||
|
// HandleAccountSetupProvisioningCompleteV1 takes over.
|
||||||
|
MaybeSynthesizeAccountSetupCompleteFromSettledUserApps();
|
||||||
|
}
|
||||||
finally { LastEventOccurredAtUtc = null; }
|
finally { LastEventOccurredAtUtc = null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11). When a user-ESP app is
|
||||||
|
/// policy-skipped by IME, Windows leaves the ESP registry's Apps subcategory stuck at
|
||||||
|
/// <c>inProgress</c> ("Apps (N of M installed)") and never writes
|
||||||
|
/// <c>AccountSetupCategory.Status.categorySucceeded</c> before tearing down the ESP page
|
||||||
|
/// — after the page exits those values are never updated again. Both registry-driven
|
||||||
|
/// <see cref="ProvisioningStatusTracker.AccountSetupProvisioningComplete"/> paths
|
||||||
|
/// (normal + all-subcategories fallback) are then permanently starved, the reducer's
|
||||||
|
/// strong post-AccountSetup gate never opens, and the session stalls despite a fully
|
||||||
|
/// successful enrollment.
|
||||||
|
/// <para>
|
||||||
|
/// This synthesis accepts the equivalent evidence pair instead: Shell-Core 62407 normal
|
||||||
|
/// exit (the caller) + all tracked user-ESP apps terminal with zero failures (the
|
||||||
|
/// injected IME probe). Fire-once; the registry path's tracker-level dedup and the
|
||||||
|
/// adapter's fire-once flag make a duplicate raise benign either way. Never fires while
|
||||||
|
/// any user-phase app is failed or still in flight — those sessions keep today's
|
||||||
|
/// conservative stall behaviour.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
private void MaybeSynthesizeAccountSetupCompleteFromSettledUserApps()
|
||||||
|
{
|
||||||
|
if (_userAppsSettledSynthesisFired) return;
|
||||||
|
|
||||||
|
bool settled;
|
||||||
|
try { settled = _userEspAppsSettledProbe(); }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Debug($"EspAndHelloTracker: user-ESP-apps-settled probe threw: {ex.Message}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!settled) return;
|
||||||
|
|
||||||
|
_userAppsSettledSynthesisFired = true;
|
||||||
|
_logger.Warning(
|
||||||
|
"EspAndHelloTracker: ESP exited normally with all tracked user-ESP apps terminal (0 failed) " +
|
||||||
|
"but AccountSetupCategory.Status never confirmed categorySucceeded — treating AccountSetup as complete " +
|
||||||
|
"(user-apps-settled synthesis)");
|
||||||
|
|
||||||
|
_post.Emit(new EnrollmentEvent
|
||||||
|
{
|
||||||
|
SessionId = _sessionId,
|
||||||
|
TenantId = _tenantId,
|
||||||
|
EventType = Constants.EventTypes.EspProvisioningStatus,
|
||||||
|
Severity = EventSeverity.Warning,
|
||||||
|
Source = "EspAndHelloTracker",
|
||||||
|
Phase = EnrollmentPhase.Unknown,
|
||||||
|
Message = "ESP provisioning status: AccountSetup — ESP page exited normally and all tracked " +
|
||||||
|
"user-ESP apps reached a terminal state (0 failed) but categorySucceeded was never " +
|
||||||
|
"confirmed by Windows — treating as complete (user-apps-settled synthesis)",
|
||||||
|
Data = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
{ "category", "AccountSetup" },
|
||||||
|
{ "categorySucceeded", "in_progress" },
|
||||||
|
{ "fallbackApplied", true },
|
||||||
|
{ "fallbackReason", "esp_exited_user_apps_settled_category_unresolved" },
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try { AccountSetupProvisioningComplete?.Invoke(this, EventArgs.Empty); }
|
||||||
|
catch (Exception ex) { _logger.Error("AccountSetupProvisioningComplete handler failed (user-apps-settled synthesis)", ex); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam for the user-apps-settled synthesis — drives OnEspExited with the live
|
||||||
|
// handler signature, mirroring TriggerEspExitedForTest's contract.
|
||||||
|
internal bool UserAppsSettledSynthesisFiredForTest => _userAppsSettledSynthesisFired;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
|
|
||||||
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
// ----- Kernel hosts (always-on; they produce decision signals) --------------------
|
||||||
|
|
||||||
|
// Session caa6cf50 gate-starvation fix: the EspAndHelloTracker's user-ESP-apps-settled
|
||||||
|
// probe reads the IME log host, which is constructed further down. Same closure-over-
|
||||||
|
// local pattern as realmJoinHost below — null until assigned, and the probe only fires
|
||||||
|
// on esp_exiting events long after Start.
|
||||||
|
ImeLogHost? imeLogHostRef = null;
|
||||||
|
|
||||||
var espAndHelloHost = new EspAndHelloHost(
|
var espAndHelloHost = new EspAndHelloHost(
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
tenantId: tenantId,
|
tenantId: tenantId,
|
||||||
@@ -114,7 +120,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled,
|
modernDeploymentBackfillEnabled: collectors.ModernDeploymentBackfillEnabled,
|
||||||
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
modernDeploymentBackfillLookbackMinutes: collectors.ModernDeploymentBackfillLookbackMinutes,
|
||||||
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
modernDeploymentHarmlessEventIds: collectors.ModernDeploymentHarmlessEventIds,
|
||||||
stateDirectory: _stateDirectory);
|
stateDirectory: _stateDirectory,
|
||||||
|
userEspAppsSettledProbe: () => imeLogHostRef?.AreUserEspAppsSettled() == true);
|
||||||
hosts.Add(espAndHelloHost);
|
hosts.Add(espAndHelloHost);
|
||||||
|
|
||||||
// Hybrid User-Driven completion-gap fix (2026-05-01): the AadJoinHost notifies
|
// Hybrid User-Driven completion-gap fix (2026-05-01): the AadJoinHost notifies
|
||||||
@@ -202,6 +209,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
simulationMode: simulationMode,
|
simulationMode: simulationMode,
|
||||||
simulationSpeedFactor: _agentConfig.ReplaySpeedFactor);
|
simulationSpeedFactor: _agentConfig.ReplaySpeedFactor);
|
||||||
hosts.Add(imeLogHost);
|
hosts.Add(imeLogHost);
|
||||||
|
imeLogHostRef = imeLogHost;
|
||||||
|
|
||||||
if (collectors.StallProbeEnabled)
|
if (collectors.StallProbeEnabled)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -60,7 +60,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
bool modernDeploymentBackfillEnabled,
|
bool modernDeploymentBackfillEnabled,
|
||||||
int modernDeploymentBackfillLookbackMinutes,
|
int modernDeploymentBackfillLookbackMinutes,
|
||||||
int[]? modernDeploymentHarmlessEventIds,
|
int[]? modernDeploymentHarmlessEventIds,
|
||||||
string stateDirectory)
|
string stateDirectory,
|
||||||
|
Func<bool>? userEspAppsSettledProbe = null)
|
||||||
{
|
{
|
||||||
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
if (ingress == null) throw new ArgumentNullException(nameof(ingress));
|
||||||
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
if (clock == null) throw new ArgumentNullException(nameof(clock));
|
||||||
@@ -76,7 +77,8 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
|
modernDeploymentBackfillEnabled: modernDeploymentBackfillEnabled,
|
||||||
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
modernDeploymentBackfillLookbackMinutes: modernDeploymentBackfillLookbackMinutes,
|
||||||
stateDirectory: stateDirectory,
|
stateDirectory: stateDirectory,
|
||||||
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds);
|
modernDeploymentHarmlessEventIds: modernDeploymentHarmlessEventIds,
|
||||||
|
userEspAppsSettledProbe: userEspAppsSettledProbe);
|
||||||
|
|
||||||
_tracker.WhiteGloveCompleted += OnTrackerWhiteGloveCompleted;
|
_tracker.WhiteGloveCompleted += OnTrackerWhiteGloveCompleted;
|
||||||
_tracker.DeviceSetupProvisioningComplete += OnTrackerDeviceSetupProvisioningComplete;
|
_tracker.DeviceSetupProvisioningComplete += OnTrackerDeviceSetupProvisioningComplete;
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal ImeLogTracker Tracker => _tracker;
|
internal ImeLogTracker Tracker => _tracker;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11) — bridge for the
|
||||||
|
/// <c>EspAndHelloTracker</c> user-ESP-apps-settled probe. True when the tracker's
|
||||||
|
/// current ESP phase is AccountSetup and every tracked user-phase app is terminal
|
||||||
|
/// (installed/skipped/postponed, zero errors). See
|
||||||
|
/// <see cref="ImeLogTracker.AreUserEspAppsSettled"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool AreUserEspAppsSettled() => _tracker.AreUserEspAppsSettled();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// c117946b debrief (2026-05-12) — bridge for the <c>EnrollmentTerminationHandler</c>
|
/// c117946b debrief (2026-05-12) — bridge for the <c>EnrollmentTerminationHandler</c>
|
||||||
/// pre-hook to promote apps still in <see cref="AppInstallationState.Installing"/>
|
/// pre-hook to promote apps still in <see cref="AppInstallationState.Installing"/>
|
||||||
|
|||||||
+236
@@ -0,0 +1,236 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using AutopilotMonitor.DecisionCore.Engine;
|
||||||
|
using AutopilotMonitor.DecisionCore.Signals;
|
||||||
|
using AutopilotMonitor.DecisionCore.State;
|
||||||
|
using AutopilotMonitor.Shared.Models;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AutopilotMonitor.DecisionCore.Tests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Session caa6cf50 gate-starvation fix (2026-06-11). Production sequence: Classic-v1 +
|
||||||
|
/// Hello-disabled on Win11, where explorer.exe runs underneath the User-ESP page so
|
||||||
|
/// DesktopArrived lands BEFORE the final EspExiting. A policy-skipped user-ESP app left
|
||||||
|
/// the registry's Apps subcategory permanently <c>inProgress</c>, so the registry-driven
|
||||||
|
/// <c>AccountSetupProvisioningComplete</c> never fired and the session stalled with all
|
||||||
|
/// completion facts in place (espFinalExit + desktop + helloPolicy=false) but the strong
|
||||||
|
/// gate closed.
|
||||||
|
/// <para>
|
||||||
|
/// The agent-side half of the fix synthesises <c>AccountSetupProvisioningComplete</c> from
|
||||||
|
/// "Shell-Core normal exit + all tracked user-ESP apps terminal (0 failed)". These tests
|
||||||
|
/// cover the reducer-side half: <c>HandleAccountSetupProvisioningCompleteV1</c>'s deferred
|
||||||
|
/// path must mirror the live-ordering completion checks when the strong gate arrives LAST —
|
||||||
|
/// completing through Finalizing instead of parking in AwaitingHello until HelloSafety
|
||||||
|
/// stamps a misleading <c>HelloOutcome="Timeout"</c> 300 s later.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ClassicGateStarvationDeferredCompletionTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime T0 = new DateTime(2026, 6, 11, 7, 36, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GateArrivesLast_HelloDisabled_DesktopAlreadyArrived_completes_through_Finalizing()
|
||||||
|
{
|
||||||
|
// Replay of session caa6cf50's signal ordering:
|
||||||
|
// DeviceSetup → Hello policy disabled → AccountSetup → EspExiting (gate closed, fact
|
||||||
|
// only) → DesktopArrived (fast-path blocked by gate) → EspExiting (final, still
|
||||||
|
// blocked) → synthesized AccountSetupProvisioningComplete → MUST complete.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine);
|
||||||
|
|
||||||
|
// Sanity: all completion facts in place, but the session is parked (the bug shape).
|
||||||
|
Assert.NotNull(state.EspFinalExitUtc);
|
||||||
|
Assert.NotNull(state.DesktopArrivedUtc);
|
||||||
|
Assert.False(state.HelloPolicyEnabled!.Value);
|
||||||
|
Assert.Null(state.HelloResolvedUtc);
|
||||||
|
Assert.Null(state.AccountSetupProvisioningSucceededUtc);
|
||||||
|
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||||
|
Assert.Empty(state.Deadlines);
|
||||||
|
|
||||||
|
var step = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24).AddSeconds(10), null));
|
||||||
|
|
||||||
|
// Deferred completion: Finalizing in one step, synthetic HelloOutcome="Skipped".
|
||||||
|
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||||
|
Assert.Null(step.NewState.Outcome); // EnrollmentComplete deferred to FinalizingGrace
|
||||||
|
Assert.Equal("Skipped", step.NewState.HelloOutcome!.Value);
|
||||||
|
Assert.NotNull(step.NewState.HelloResolvedUtc);
|
||||||
|
Assert.NotNull(step.NewState.AccountSetupProvisioningSucceededUtc);
|
||||||
|
|
||||||
|
// No HelloSafety armed — we went straight to Finalizing, not AwaitingHello.
|
||||||
|
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||||
|
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||||
|
Assert.Contains(step.Effects,
|
||||||
|
e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||||
|
&& e.Parameters != null
|
||||||
|
&& e.Parameters.TryGetValue("eventType", out var et) && et == "phase_transition"
|
||||||
|
&& e.Parameters.TryGetValue("phase", out var ph) && ph == nameof(EnrollmentPhase.FinalizingSetup));
|
||||||
|
|
||||||
|
// FinalizingGrace fires → terminal enrollment_complete.
|
||||||
|
var afterGrace = engine.Reduce(
|
||||||
|
step.NewState,
|
||||||
|
MakeSignal(21, DecisionSignalKind.DeadlineFired, T0.AddMinutes(24).AddSeconds(15),
|
||||||
|
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace }));
|
||||||
|
|
||||||
|
Assert.Equal(SessionStage.Completed, afterGrace.NewState.Stage);
|
||||||
|
Assert.Equal(SessionOutcome.EnrollmentComplete, afterGrace.NewState.Outcome);
|
||||||
|
Assert.Contains(afterGrace.Effects,
|
||||||
|
e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry
|
||||||
|
&& e.Parameters != null
|
||||||
|
&& e.Parameters.TryGetValue("eventType", out var et) && et == "enrollment_complete");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GateArrivesLast_HelloAlreadyResolved_DesktopAlreadyArrived_completes_without_overwriting_outcome()
|
||||||
|
{
|
||||||
|
// Hello resolved earlier (e.g. wizard observed) but the strong gate arrived last.
|
||||||
|
// The deferred path must complete and keep the real HelloOutcome.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine, includeHelloPolicy: false);
|
||||||
|
state = engine.Reduce(state, MakeSignal(15, DecisionSignalKind.HelloResolved, T0.AddMinutes(23),
|
||||||
|
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "completed" })).NewState;
|
||||||
|
|
||||||
|
// HelloResolved with Desktop already in routes to Finalizing only when the gate is
|
||||||
|
// open — which it is not yet, so HandleHelloResolvedV1's both-prerequisites branch
|
||||||
|
// already fired Finalizing... verify the actual stage first.
|
||||||
|
// NOTE: HandleHelloResolvedV1 does not consult ShouldTransitionToAwaitingHello — with
|
||||||
|
// desktop already arrived it completes directly. That ordering therefore never needed
|
||||||
|
// this fix; assert it as documentation of the boundary.
|
||||||
|
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GateArrivesLast_DesktopNotYetArrived_keeps_existing_DeferredPromote_to_AwaitingHello()
|
||||||
|
{
|
||||||
|
// Regression guard: without DesktopArrived the deferred path must keep promoting to
|
||||||
|
// AwaitingHello + arm HelloSafety exactly as before this fix.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine, includeDesktop: false);
|
||||||
|
Assert.Null(state.DesktopArrivedUtc);
|
||||||
|
|
||||||
|
var step = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24), null));
|
||||||
|
|
||||||
|
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||||
|
Assert.Null(step.NewState.HelloResolvedUtc);
|
||||||
|
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||||
|
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GateArrivesLast_HelloPolicyUnknown_DesktopArrived_stays_pessimistic_AwaitingHello()
|
||||||
|
{
|
||||||
|
// HelloPolicyEnabled == null → the deferred path must NOT synthesise "Skipped";
|
||||||
|
// promote to AwaitingHello and let the Hello wizard / HelloSafety resolve it.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine, includeHelloPolicy: false);
|
||||||
|
Assert.Null(state.HelloPolicyEnabled);
|
||||||
|
Assert.NotNull(state.DesktopArrivedUtc);
|
||||||
|
|
||||||
|
var step = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24), null));
|
||||||
|
|
||||||
|
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||||
|
Assert.Null(step.NewState.HelloResolvedUtc);
|
||||||
|
Assert.Null(step.NewState.HelloOutcome);
|
||||||
|
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GateArrivesLast_HelloPolicyEnabled_DesktopArrived_stays_pessimistic_AwaitingHello()
|
||||||
|
{
|
||||||
|
// HelloPolicyEnabled == true → the real Hello wizard is expected; no synthesis.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine, helloEnabled: true);
|
||||||
|
Assert.True(state.HelloPolicyEnabled!.Value);
|
||||||
|
|
||||||
|
var step = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24), null));
|
||||||
|
|
||||||
|
Assert.Equal(SessionStage.AwaitingHello, step.NewState.Stage);
|
||||||
|
Assert.Null(step.NewState.HelloResolvedUtc);
|
||||||
|
Assert.Contains(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DuplicateGateSignal_after_completion_path_is_a_noop_bookkeeping_step()
|
||||||
|
{
|
||||||
|
// The adapter dedupes, but a replayed duplicate must not double-complete: the fact is
|
||||||
|
// already recorded, so shouldPromote (first-record guard) rejects and the signal
|
||||||
|
// becomes bookkeeping only.
|
||||||
|
var engine = new DecisionEngine();
|
||||||
|
var state = ProgressToBlockedPostExitState(engine);
|
||||||
|
state = engine.Reduce(state, MakeSignal(20, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24), null)).NewState;
|
||||||
|
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||||
|
|
||||||
|
var dup = engine.Reduce(state, MakeSignal(21, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||||
|
T0.AddMinutes(24).AddSeconds(2), null));
|
||||||
|
|
||||||
|
Assert.Equal(SessionStage.Finalizing, dup.NewState.Stage);
|
||||||
|
// Exactly one FinalizingGrace deadline; no second phase_transition effect.
|
||||||
|
Assert.Single(dup.NewState.Deadlines, d => d.Name == DeadlineNames.FinalizingGrace);
|
||||||
|
Assert.DoesNotContain(dup.Effects,
|
||||||
|
e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================================================== test helpers
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drives the engine into session caa6cf50's blocked shape: Classic AccountSetup entered,
|
||||||
|
/// Hello policy fact (configurable), EspExiting recorded but guard-rejected (gate closed),
|
||||||
|
/// DesktopArrived recorded but fast-path-rejected. Stage stays EspAccountSetup with no
|
||||||
|
/// active deadlines.
|
||||||
|
/// </summary>
|
||||||
|
private static DecisionState ProgressToBlockedPostExitState(
|
||||||
|
DecisionEngine engine,
|
||||||
|
bool includeHelloPolicy = true,
|
||||||
|
bool helloEnabled = false,
|
||||||
|
bool includeDesktop = true)
|
||||||
|
{
|
||||||
|
var state = DecisionState.CreateInitial("sess-caa6cf50", "tenant-e46bc88e", T0);
|
||||||
|
state = engine.Reduce(state, MakeSignal(0, DecisionSignalKind.SessionStarted, T0, null)).NewState;
|
||||||
|
state = engine.Reduce(state, MakeSignal(1, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(1),
|
||||||
|
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" })).NewState;
|
||||||
|
if (includeHelloPolicy)
|
||||||
|
{
|
||||||
|
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.HelloPolicyDetected, T0.AddMinutes(1).AddSeconds(5),
|
||||||
|
new Dictionary<string, string> { [SignalPayloadKeys.HelloEnabled] = helloEnabled ? "true" : "false" })).NewState;
|
||||||
|
}
|
||||||
|
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.EspPhaseChanged, T0.AddMinutes(12),
|
||||||
|
new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" })).NewState;
|
||||||
|
// First esp_exiting (Device→Account handoff per Shell-Core 62407) — gate closed, fact only.
|
||||||
|
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.EspExiting, T0.AddMinutes(13), null)).NewState;
|
||||||
|
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||||
|
if (includeDesktop)
|
||||||
|
{
|
||||||
|
// Desktop arrives during User-ESP (Win11: explorer.exe under the ESP page) —
|
||||||
|
// Hello-disabled fast-path blocked by the closed strong gate.
|
||||||
|
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(16), null)).NewState;
|
||||||
|
Assert.NotEqual(SessionStage.Finalizing, state.Stage);
|
||||||
|
}
|
||||||
|
// Final esp_exiting — still blocked (gate evidence never came from the registry).
|
||||||
|
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.EspExiting, T0.AddMinutes(24), null)).NewState;
|
||||||
|
Assert.Equal(SessionStage.EspAccountSetup, state.Stage);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DecisionSignal MakeSignal(
|
||||||
|
long ordinal,
|
||||||
|
DecisionSignalKind kind,
|
||||||
|
DateTime occurredAtUtc,
|
||||||
|
IReadOnlyDictionary<string, string>? payload)
|
||||||
|
{
|
||||||
|
return new DecisionSignal(
|
||||||
|
sessionSignalOrdinal: ordinal,
|
||||||
|
sessionTraceOrdinal: ordinal,
|
||||||
|
kind: kind,
|
||||||
|
kindSchemaVersion: 1,
|
||||||
|
occurredAtUtc: occurredAtUtc,
|
||||||
|
sourceOrigin: "test",
|
||||||
|
evidence: new Evidence(EvidenceKind.Synthetic, $"test-{kind}-{ordinal}", $"synthetic {kind}"),
|
||||||
|
payload: payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -796,6 +796,45 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
|||||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deferred-completion parity (session caa6cf50 fix, 2026-06-11): when the strong gate
|
||||||
|
// arrives LAST — after both EspExiting and DesktopArrived already landed (the Win11
|
||||||
|
// Classic ordering where explorer.exe runs underneath the User-ESP page, so Desktop
|
||||||
|
// "arrives" before the final exit) — promoting to AwaitingHello would park the session
|
||||||
|
// until HelloSafety stamps a misleading HelloOutcome="Timeout" 300 s later. Mirror the
|
||||||
|
// completion checks the live-ordering handlers perform instead:
|
||||||
|
// * Hello already resolved → both prerequisites in → Finalizing (HandleDesktopArrivedV1
|
||||||
|
// helloAlreadyResolved branch).
|
||||||
|
// * Hello policy explicitly disabled → synthesise HelloOutcome="Skipped" → Finalizing
|
||||||
|
// (HandleDesktopArrivedV1 Hello-disabled fast-path; its strong-gate arm is satisfied
|
||||||
|
// by the very fact this signal records).
|
||||||
|
// HelloPolicyEnabled == null keeps the pessimistic AwaitingHello promotion below.
|
||||||
|
var desktopAlreadyArrived = state.DesktopArrivedUtc != null;
|
||||||
|
if (desktopAlreadyArrived
|
||||||
|
&& (state.HelloResolvedUtc != null || state.HelloPolicyEnabled?.Value == false))
|
||||||
|
{
|
||||||
|
if (state.HelloResolvedUtc == null)
|
||||||
|
{
|
||||||
|
builder.HelloResolvedUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||||
|
builder.HelloOutcome = new SignalFact<string>("Skipped", signal.SessionSignalOrdinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
var helloSafetyCancelEffect = BuildHelloSafetyCancelEffectIfArmed(state);
|
||||||
|
if (helloSafetyCancelEffect != null)
|
||||||
|
{
|
||||||
|
builder.CancelDeadline(DeadlineNames.HelloSafety);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CompleteThroughFinalizingOrDefer(
|
||||||
|
state: state,
|
||||||
|
signal: signal,
|
||||||
|
preparedBuilder: builder,
|
||||||
|
nextStepIndex: nextStep,
|
||||||
|
trigger: nameof(DecisionSignalKind.AccountSetupProvisioningComplete) + ":DeferredCompletion",
|
||||||
|
leadingEffects: helloSafetyCancelEffect != null
|
||||||
|
? new[] { helloSafetyCancelEffect }
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
|
// Mirror of HandleEspExitingV1's promote branch, using this signal's instant as
|
||||||
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
|
// the deadline base. EffectiveDeadlineBase still floors at AgentBootUtc to keep
|
||||||
// the replay-safety guarantee.
|
// the replay-safety guarantee.
|
||||||
|
|||||||
Reference in New Issue
Block a user