diff --git a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt index 6aaa2493..34d9aaeb 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent.V2/buildcounter.txt @@ -1 +1 @@ -1041 +1043 diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 2a63ccf6..68abbd9a 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1439 +1440 diff --git a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs index b61bac79..5d107afb 100644 --- a/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs +++ b/src/Agent/AutopilotMonitor.DecisionCore.Tests/RealmJoinGateTests.cs @@ -112,6 +112,25 @@ namespace AutopilotMonitor.DecisionCore.Tests Assert.NotNull(step.NewState.DesktopArrivedUtc); // fact still recorded } + [Fact] + public void Closed_gate_tags_the_deferred_transition_with_the_gate_name_suffix() + { + // ARCH-F1 forward-proof: the defer trigger suffix is derived from the closed gate's + // CompletionGate.Name (":Closed") inside CompleteThroughFinalizingOrDefer, not + // hardcoded at the call site. A new gate registered in s_completionGates inherits the + // same deferral shape with its own suffix — this test locks the derivation so a rename + // or a regression back to per-site hardcoding is caught. + var engine = new DecisionEngine(); + var state = PrimeClassicAwaitingDesktop(engine); + state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5), + new Dictionary { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100" })).NewState; + + var step = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))); + + Assert.True(step.Transition.Taken); + Assert.Equal(nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed", step.Transition.Trigger); + } + [Fact] public void RealmJoinResolved_after_hello_and_desktop_triggers_finalizing_via_classic_path() { diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs index 2cb57b52..4b6bc61e 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Classic.cs @@ -227,40 +227,23 @@ namespace AutopilotMonitor.DecisionCore.Engine // This lets the backend see phase_transition(FinalizingSetup) + enrollment_complete // before IsTerminal() fires and EnrollmentTerminationHandler tears down the spool. // - // RealmJoin gate: while RJ is detected and unresolved, the Finalizing transition - // is deferred. Hello+Desktop facts are still recorded; the next RealmJoinResolved - // or RealmJoinTimeout signal releases the deferred completion via + // Completion gates (ARCH-F1): while a gate (e.g. an active RealmJoin deployment) is + // closed the Finalizing transition is deferred. Hello+Desktop facts are still + // recorded; the gate's release handler re-attempts completion via // CompleteIfDeferredOrBookkeep(). - if (desktopAlreadyArrived && RealmJoinGateOpen(state)) + if (desktopAlreadyArrived) { - return TransitionToFinalizing( + return CompleteThroughFinalizingOrDefer( state: state, signal: signal, preparedBuilder: builder, nextStepIndex: nextStep, trigger: nameof(DecisionSignalKind.HelloResolved), - extraLeadingEffects: helloSafetyCancelEffect != null + leadingEffects: helloSafetyCancelEffect != null ? new[] { helloSafetyCancelEffect } : null); } - if (desktopAlreadyArrived) - { - // RJ gate closed: defer Finalizing. Stay in the current stage; the RJ - // resolved/timeout handler will route through CompleteIfDeferredOrBookkeep(). - var deferredState = builder.Build(); - var deferredTransition = BuildTakenTransition( - before: state, - signal: signal, - toStage: state.Stage, - nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.HelloResolved) + ":RealmJoinGateClosed"); - var deferredEffects = helloSafetyCancelEffect != null - ? new[] { helloSafetyCancelEffect } - : Array.Empty(); - return new DecisionStep(deferredState, deferredTransition, deferredEffects); - } - builder.WithStage(SessionStage.AwaitingDesktop); var newState = builder.Build(); @@ -377,30 +360,15 @@ namespace AutopilotMonitor.DecisionCore.Engine }; } - if (RealmJoinGateOpen(state)) - { - return TransitionToFinalizing( - state: state, - signal: signal, - preparedBuilder: builder, - nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath", - extraLeadingEffects: extraEffects); - } - - // RJ gate closed: synthetic Hello + Desktop are recorded; defer Finalizing - // until the RJ resolved/timeout handler releases the gate. - var fastPathDeferredState = builder.Build(); - var fastPathDeferredTransition = BuildTakenTransition( - before: state, + // Completion gates (ARCH-F1): synthetic Hello + Desktop are recorded; complete + // through Finalizing when all gates are open, else defer until a gate releases. + return CompleteThroughFinalizingOrDefer( + state: state, signal: signal, - toStage: state.Stage, + preparedBuilder: builder, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath:RealmJoinGateClosed"); - var fastPathDeferredEffects = extraEffects != null - ? extraEffects.ToArray() - : Array.Empty(); - return new DecisionStep(fastPathDeferredState, fastPathDeferredTransition, fastPathDeferredEffects); + trigger: nameof(DecisionSignalKind.DesktopArrived) + ":HelloDisabledFastPath", + leadingEffects: extraEffects); } // Plan §5 Fix 6: mirror of HandleHelloResolvedV1. When both prerequisites are in, @@ -420,31 +388,17 @@ namespace AutopilotMonitor.DecisionCore.Engine builder.CancelDeadline(DeadlineNames.HelloSafety); } - if (RealmJoinGateOpen(state)) - { - return TransitionToFinalizing( - state: state, - signal: signal, - preparedBuilder: builder, - nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived), - extraLeadingEffects: helloSafetyCancelEffect != null - ? new[] { helloSafetyCancelEffect } - : null); - } - - // RJ gate closed: Desktop fact is recorded; defer Finalizing. - var deferredState = builder.Build(); - var deferredTransition = BuildTakenTransition( - before: state, + // Completion gates (ARCH-F1): Desktop fact recorded; complete through Finalizing + // when all gates are open, else defer. + return CompleteThroughFinalizingOrDefer( + state: state, signal: signal, - toStage: state.Stage, + preparedBuilder: builder, nextStepIndex: nextStep, - trigger: nameof(DecisionSignalKind.DesktopArrived) + ":RealmJoinGateClosed"); - var deferredEffects = helloSafetyCancelEffect != null - ? new[] { helloSafetyCancelEffect } - : Array.Empty(); - return new DecisionStep(deferredState, deferredTransition, deferredEffects); + trigger: nameof(DecisionSignalKind.DesktopArrived), + leadingEffects: helloSafetyCancelEffect != null + ? new[] { helloSafetyCancelEffect } + : null); } // Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs new file mode 100644 index 00000000..6948d6af --- /dev/null +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Completion.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using AutopilotMonitor.DecisionCore.Signals; +using AutopilotMonitor.DecisionCore.State; + +namespace AutopilotMonitor.DecisionCore.Engine +{ + // Completion-gate weaving (ARCH-F1). The cross-cutting "may the session complete now?" + // AND-gate used to be hand-inlined at every terminal site across the Classic / Shared / + // SelfDeploying / RealmJoin engine partials, each with its own near-identical + // RealmJoinGateOpen check + ":RealmJoinGateClosed" defer block. That duplication was the + // origin of the premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638, + // 330f73f3). This partial centralizes the gate SET and the deferral shape so adding a new + // completion precondition (e.g. WDP v2 device-association settle) is one entry in + // s_completionGates rather than an edit at every completion site. + public sealed partial class DecisionEngine + { + /// + /// A cross-cutting completion precondition. Completion is deferred while any gate is + /// closed; the deferring transition is tagged :<Name>Closed until a later + /// signal re-opens the gate and a completion-release handler re-attempts. + /// + private readonly struct CompletionGate + { + public CompletionGate(string name, Func isOpen) + { + Name = name; + IsOpen = isOpen; + } + + /// Stable gate identifier; forms the :<Name>Closed trigger suffix. + public string Name { get; } + + /// Returns true when this precondition no longer blocks completion. + public Func IsOpen { get; } + } + + /// + /// The ordered set of completion preconditions. Append-only: a new enrollment + /// scenario registers one gate here and every completion site that routes through + /// inherits it — no per-site edits. + /// RealmJoin is the only gate today (the SelfDeploying deadline path additionally owns a + /// gate-specific deferral; see HandleDeviceOnlyEspDetectionDeadlineFired). + /// + private static readonly CompletionGate[] s_completionGates = + { + // An active RealmJoin deployment (detected, not yet resolved / timed-out) blocks + // completion until RealmJoinResolved (phase 110) or the 60-min hard timeout fires. + new CompletionGate("RealmJoinGate", RealmJoinGateOpen), + }; + + /// + /// Returns the of the first closed completion gate, or + /// null when every gate is open (completion may proceed). Centralizes the + /// cross-cutting AND-gate (ARCH-F1). + /// + private static string? FirstClosedCompletionGate(DecisionState state) + { + foreach (var gate in s_completionGates) + { + if (!gate.IsOpen(state)) return gate.Name; + } + return null; + } + + /// + /// Shared completion router for every "both prerequisites in → finish via Finalizing" + /// site (Classic Hello/Desktop handlers, the Hello-disabled fast-path, the Hello-safety + /// timeout, and the RealmJoin release path). When all completion gates are open this + /// routes through ; when a gate is closed it emits + /// the canonical defer step — current stage unchanged, trigger tagged + /// :<Gate>Closed, the same preserved, no + /// deadline armed — so a later gate-release handler re-attempts completion. + /// + /// The caller has already populated with its + /// signal-specific facts (HelloResolvedUtc / DesktopArrivedUtc / Hello-safety cancel). + /// + /// + private DecisionStep CompleteThroughFinalizingOrDefer( + DecisionState state, + DecisionSignal signal, + DecisionStateBuilder preparedBuilder, + int nextStepIndex, + string trigger, + IReadOnlyList? leadingEffects = null) + { + var closedGate = FirstClosedCompletionGate(state); + if (closedGate == null) + { + return TransitionToFinalizing( + state: state, + signal: signal, + preparedBuilder: preparedBuilder, + nextStepIndex: nextStepIndex, + trigger: trigger, + extraLeadingEffects: leadingEffects); + } + + // Gate closed: defer Finalizing. Stay in the current stage; the gate's resolved/ + // timeout handler routes back through completion once it re-opens. + 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)); + } + + /// + /// Materialize an optional effect list into the array shape the + /// defer path expects. Returns the shared empty array when there is nothing to emit, and + /// passes a backing array through untouched to avoid a copy. + /// + private static DecisionEffect[] MaterializeEffects(IReadOnlyList? effects) + { + if (effects == null || effects.Count == 0) return Array.Empty(); + if (effects is DecisionEffect[] arr) return arr; + var copy = new DecisionEffect[effects.Count]; + for (var i = 0; i < effects.Count; i++) copy[i] = effects[i]; + return copy; + } + } +} diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs index d7b53712..dc32a255 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.RealmJoin.cs @@ -412,6 +412,12 @@ namespace AutopilotMonitor.DecisionCore.Engine if (classicReady) { + // This IS the gate-release path: the RealmJoin gate has just opened (the caller + // wrote WithResolved / WithTimeoutOutcome into preparedBuilder), so complete + // directly. Note the completion gates read `state` (pre-resolution), where RJ is + // still closed — routing this through CompleteThroughFinalizingOrDefer would + // re-defer on the very gate we just released. A future second gate that must + // re-block here would re-check the *post* state (WDP-v2 follow-up, ARCH-F1). var extra = leadingEffect != null ? new[] { leadingEffect } : null; return TransitionToFinalizing( state: state, diff --git a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs index 15ac0e49..5b36099b 100644 --- a/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs +++ b/src/Shared/AutopilotMonitor.DecisionCore/Engine/DecisionEngine.Shared.cs @@ -409,12 +409,12 @@ namespace AutopilotMonitor.DecisionCore.Engine // synthetic-timeout terminal event shares the same grace window + phase-declaration // pathway as the happy-path handlers. AwaitingDesktop path is unchanged. // - // RealmJoin gate: while RJ is detected and unresolved, defer Finalizing. The - // synthetic Hello-timeout fact stays recorded; the RJ resolved/timeout handler - // routes through CompleteIfDeferredOrBookkeep when both gates are ready. - if (desktopAlreadyArrived && RealmJoinGateOpen(state)) + // Completion gates (ARCH-F1): while a gate (e.g. an active RealmJoin deployment) is + // closed, defer Finalizing. The synthetic Hello-timeout fact stays recorded; the + // gate's release handler routes through CompleteIfDeferredOrBookkeep. + if (desktopAlreadyArrived) { - return TransitionToFinalizing( + return CompleteThroughFinalizingOrDefer( state: state, signal: signal, preparedBuilder: builder, @@ -422,18 +422,6 @@ namespace AutopilotMonitor.DecisionCore.Engine trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}"); } - if (desktopAlreadyArrived) - { - var deferredState = builder.Build(); - var deferredTransition = BuildTakenTransition( - before: state, - signal: signal, - toStage: state.Stage, - nextStepIndex: nextStep, - trigger: $"DeadlineFired:{DeadlineNames.HelloSafety}:RealmJoinGateClosed"); - return new DecisionStep(deferredState, deferredTransition, Array.Empty()); - } - builder.WithStage(SessionStage.AwaitingDesktop); var newState = builder.Build();