mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 20:53:06 +00:00
refactor(decisioncore): centralize completion-gate weaving (ARCH-F1)
Wave 4 / ARCH-F1. The cross-cutting "may the session complete now?" AND-gate
was hand-inlined at every terminal site across the Classic / Shared engine
partials — each with a near-identical RealmJoinGateOpen check plus its own
":RealmJoinGateClosed" defer block. That duplication was the origin of the
premature/duplicate enrollment_complete regressions (8b8d611d, 08c99638,
330f73f3) and meant a second completion gate (WDP v2 device-association
settle) would have to be woven into every site.
New DecisionEngine.Completion.cs introduces:
* CompletionGate (name + IsOpen predicate) + an append-only s_completionGates
collection (RealmJoin is the sole gate today) + FirstClosedCompletionGate.
* CompleteThroughFinalizingOrDefer — the single router every "both
prerequisites in -> finish via Finalizing" site now calls. All gates open
-> TransitionToFinalizing; a gate closed -> the canonical defer step with
the trigger tagged ":<GateName>Closed" (derived from the gate, not
hardcoded).
The four identical Classic/Shared sites (HelloResolved, DesktopArrived normal
+ Hello-disabled fast-path, Hello-safety timeout) now route through the helper.
The SelfDeploying deadline path keeps its gate-specific deferral (it owns the
SelfDeployingDeferredCompletion flag, released by the RealmJoin handlers); the
RealmJoin release path completes directly because the completion gates read the
pre-resolution state where RJ is still closed.
No behavior change — proven by the replay-deterministic scenario tests passing
bit-identically (same terminal stages, outcomes, hashes). Adding a gate is now
one entry in s_completionGates. New forward-proof test locks the gate-name ->
trigger-suffix derivation.
DecisionCore.Tests: 359/359 (was 358 + 1 new). Full solution builds; V2.Core
engine integration green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
1041
|
||||
1043
|
||||
|
||||
@@ -1 +1 @@
|
||||
1439
|
||||
1440
|
||||
|
||||
@@ -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 (":<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<string, string> { [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()
|
||||
{
|
||||
|
||||
@@ -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<DecisionEffect>();
|
||||
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<DecisionEffect>();
|
||||
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<DecisionEffect>();
|
||||
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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// A cross-cutting completion precondition. Completion is deferred while any gate is
|
||||
/// closed; the deferring transition is tagged <c>:<Name>Closed</c> until a later
|
||||
/// signal re-opens the gate and a completion-release handler re-attempts.
|
||||
/// </summary>
|
||||
private readonly struct CompletionGate
|
||||
{
|
||||
public CompletionGate(string name, Func<DecisionState, bool> isOpen)
|
||||
{
|
||||
Name = name;
|
||||
IsOpen = isOpen;
|
||||
}
|
||||
|
||||
/// <summary>Stable gate identifier; forms the <c>:<Name>Closed</c> trigger suffix.</summary>
|
||||
public string Name { get; }
|
||||
|
||||
/// <summary>Returns <c>true</c> when this precondition no longer blocks completion.</summary>
|
||||
public Func<DecisionState, bool> IsOpen { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The ordered set of completion preconditions. <b>Append-only</b>: a new enrollment
|
||||
/// scenario registers one gate here and every completion site that routes through
|
||||
/// <see cref="CompleteThroughFinalizingOrDefer"/> inherits it — no per-site edits.
|
||||
/// RealmJoin is the only gate today (the SelfDeploying deadline path additionally owns a
|
||||
/// gate-specific deferral; see <c>HandleDeviceOnlyEspDetectionDeadlineFired</c>).
|
||||
/// </summary>
|
||||
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),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Returns the <see cref="CompletionGate.Name"/> of the first closed completion gate, or
|
||||
/// <c>null</c> when every gate is open (completion may proceed). Centralizes the
|
||||
/// cross-cutting AND-gate (ARCH-F1).
|
||||
/// </summary>
|
||||
private static string? FirstClosedCompletionGate(DecisionState state)
|
||||
{
|
||||
foreach (var gate in s_completionGates)
|
||||
{
|
||||
if (!gate.IsOpen(state)) return gate.Name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="TransitionToFinalizing"/>; when a gate is closed it emits
|
||||
/// the canonical defer step — current stage unchanged, trigger tagged
|
||||
/// <c>:<Gate>Closed</c>, the same <paramref name="leadingEffects"/> preserved, no
|
||||
/// deadline armed — so a later gate-release handler re-attempts completion.
|
||||
/// <para>
|
||||
/// The caller has already populated <paramref name="preparedBuilder"/> with its
|
||||
/// signal-specific facts (HelloResolvedUtc / DesktopArrivedUtc / Hello-safety cancel).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep CompleteThroughFinalizingOrDefer(
|
||||
DecisionState state,
|
||||
DecisionSignal signal,
|
||||
DecisionStateBuilder preparedBuilder,
|
||||
int nextStepIndex,
|
||||
string trigger,
|
||||
IReadOnlyList<DecisionEffect>? 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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materialize an optional effect list into the array shape the <see cref="DecisionStep"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static DecisionEffect[] MaterializeEffects(IReadOnlyList<DecisionEffect>? effects)
|
||||
{
|
||||
if (effects == null || effects.Count == 0) return Array.Empty<DecisionEffect>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<DecisionEffect>());
|
||||
}
|
||||
|
||||
builder.WithStage(SessionStage.AwaitingDesktop);
|
||||
var newState = builder.Build();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user