mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix decisionEngine missing cancel deadlines in FinalizingStage
This commit is contained in:
@@ -1 +1 @@
|
||||
823
|
||||
826
|
||||
|
||||
@@ -1 +1 @@
|
||||
1387
|
||||
1388
|
||||
|
||||
@@ -217,5 +217,169 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.False(SessionStage.Finalizing.IsTerminal());
|
||||
Assert.True(SessionStage.Completed.IsTerminal());
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
// Session 8b8d611d regression coverage (2026-05-20):
|
||||
//
|
||||
// Bug: HandleHelloResolvedV1 / HandleDesktopArrivedV1 cancelled HelloSafety in the
|
||||
// reducer state but did NOT emit a CancelDeadline EFFECT — the live
|
||||
// DeadlineScheduler timer kept ticking and later fired DeadlineFired:HelloSafety
|
||||
// AFTER the session had already reached Completed via the FinalizingGrace path.
|
||||
// The dispatcher then re-entered HandleHelloSafetyDeadlineFired, which routes
|
||||
// through TransitionToFinalizing → second phase_transition + second
|
||||
// enrollment_complete on the wire.
|
||||
//
|
||||
// Fix: a) Emit CancelDeadline(HelloSafety) effect from both signal handlers when the
|
||||
// deadline is actually armed in state.Deadlines.
|
||||
// b) Defense-in-depth dispatch guard: any signal arriving with state.Stage in
|
||||
// {Completed, Failed} is short-circuited to a bookkept dead-end before
|
||||
// reaching the handlers.
|
||||
// ===================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Build a state where the deferred-promote path of
|
||||
/// <c>HandleAccountSetupProvisioningCompleteV1</c> has just armed HelloSafety. Mirrors
|
||||
/// the 8b8d611d production sequence: ESP intermediate exit → AccountSetup entered →
|
||||
/// AccountSetup provisioning completed (deferred-promote → AwaitingHello, HelloSafety
|
||||
/// armed for +300 s).
|
||||
/// </summary>
|
||||
private static DecisionState InitialAwaitingHello_WithHelloSafetyArmed(DecisionEngine engine, out DateTime accountSetupProvisioningCompleteUtc)
|
||||
{
|
||||
var state = DecisionState.CreateInitial("sess-hs", "tenant-hs", 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] = "AccountSetup" })).NewState;
|
||||
// EspExiting fires before AccountSetup provisioning completes — guard blocks the
|
||||
// promote, but EspFinalExitUtc is recorded so the next AccountSetupProvisioningComplete
|
||||
// takes the deferred-promote path.
|
||||
state = engine.Reduce(state, MakeSignal(2, DecisionSignalKind.EspExiting, T0.AddMinutes(2), null)).NewState;
|
||||
Assert.NotNull(state.EspFinalExitUtc);
|
||||
|
||||
accountSetupProvisioningCompleteUtc = T0.AddMinutes(3);
|
||||
state = engine.Reduce(state, MakeSignal(3, DecisionSignalKind.AccountSetupProvisioningComplete,
|
||||
accountSetupProvisioningCompleteUtc, null)).NewState;
|
||||
|
||||
// Sanity: deferred-promote landed us in AwaitingHello with HelloSafety armed.
|
||||
Assert.Equal(SessionStage.AwaitingHello, state.Stage);
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
return state;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_when_HelloSafety_armed_emits_CancelDeadline_effect_to_scheduler()
|
||||
{
|
||||
// Session 8b8d611d primary fix #1. AwaitingDesktop branch: Hello resolves before
|
||||
// Desktop arrives. The reducer cancels HelloSafety in state — we now require the
|
||||
// matching CancelDeadline effect so the live DeadlineScheduler timer is disposed.
|
||||
var engine = new DecisionEngine();
|
||||
var state = InitialAwaitingHello_WithHelloSafetyArmed(engine, out _);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" }));
|
||||
|
||||
Assert.Equal(SessionStage.AwaitingDesktop, step.NewState.Stage);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
var cancelEffect = Assert.Single(
|
||||
step.Effects,
|
||||
e => e.Kind == DecisionEffectKind.CancelDeadline
|
||||
&& e.CancelDeadlineName == DeadlineNames.HelloSafety);
|
||||
Assert.NotNull(cancelEffect);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloResolved_when_HelloSafety_armed_and_Desktop_already_arrived_cancels_then_arms_Finalizing()
|
||||
{
|
||||
// Both-prereqs path: HelloSafety must be cancelled BEFORE FinalizingGrace is armed
|
||||
// so the scheduler observes the cancel before any subsequent state read could see
|
||||
// both timers active. TransitionToFinalizing's extraLeadingEffects parameter orders
|
||||
// the cancel before ScheduleDeadline + phase_transition.
|
||||
var engine = new DecisionEngine();
|
||||
var state = InitialAwaitingHello_WithHelloSafetyArmed(engine, out _);
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.DesktopArrived, T0.AddMinutes(4), null)).NewState;
|
||||
Assert.NotNull(state.DesktopArrivedUtc);
|
||||
// Desktop without HelloResolved keeps us in AwaitingHello with HelloSafety still armed.
|
||||
Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.HelloResolved, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.HelloSafety);
|
||||
|
||||
// Effect ordering: cancel HelloSafety → schedule FinalizingGrace → emit phase_transition.
|
||||
Assert.Equal(3, step.Effects.Count);
|
||||
Assert.Equal(DecisionEffectKind.CancelDeadline, step.Effects[0].Kind);
|
||||
Assert.Equal(DeadlineNames.HelloSafety, step.Effects[0].CancelDeadlineName);
|
||||
Assert.Equal(DecisionEffectKind.ScheduleDeadline, step.Effects[1].Kind);
|
||||
Assert.Equal(DeadlineNames.FinalizingGrace, step.Effects[1].Deadline!.Name);
|
||||
Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, step.Effects[2].Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HelloSafetyDeadline_firing_after_Completed_is_dead_end_with_no_duplicate_enrollment_complete()
|
||||
{
|
||||
// Session 8b8d611d defense-in-depth. Simulate the production race: HelloSafety
|
||||
// armed by the deferred-promote path, HelloResolved + DesktopArrived drive the
|
||||
// session through Finalizing → Completed, THEN the stale HelloSafety timer fires
|
||||
// its synthetic DeadlineFired signal. Without the dispatch guard the signal would
|
||||
// hit HandleHelloSafetyDeadlineFired → TransitionToFinalizing → duplicate
|
||||
// enrollment_complete. With the guard it produces a bookkept dead-end and
|
||||
// emits no effects.
|
||||
var engine = new DecisionEngine();
|
||||
var state = InitialAwaitingHello_WithHelloSafetyArmed(engine, out _);
|
||||
state = engine.Reduce(state, MakeSignal(4, DecisionSignalKind.HelloResolved, T0.AddMinutes(4),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.HelloOutcome] = "Success" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5), null)).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
|
||||
// Drive Finalizing → Completed via the FinalizingGrace deadline (+5s).
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DeadlineFired,
|
||||
T0.AddMinutes(5).AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace })).NewState;
|
||||
Assert.Equal(SessionStage.Completed, state.Stage);
|
||||
|
||||
// Stale HelloSafety timer fires AFTER Completed.
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DeadlineFired,
|
||||
T0.AddMinutes(8),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.HelloSafety }));
|
||||
|
||||
// State must NOT regress out of Completed.
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, step.NewState.Outcome);
|
||||
|
||||
// No effects — in particular, no second ScheduleDeadline(FinalizingGrace) and no
|
||||
// second enrollment_complete emission.
|
||||
Assert.Empty(step.Effects);
|
||||
|
||||
// Recorded as a dead-end with a descriptive reason.
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.StartsWith("signal_after_terminal:", step.Transition.DeadEndReason!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispatch_guard_short_circuits_arbitrary_signals_after_Completed()
|
||||
{
|
||||
// Defense-in-depth coverage beyond the HelloSafety-specific scenario: any signal
|
||||
// arriving in Completed must be bookkept-only.
|
||||
var engine = new DecisionEngine();
|
||||
var state = InitialAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.DesktopArrived, T0.AddMinutes(5), null)).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DeadlineFired,
|
||||
T0.AddMinutes(5).AddSeconds(5),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.FinalizingGrace })).NewState;
|
||||
Assert.Equal(SessionStage.Completed, state.Stage);
|
||||
|
||||
// A late ImeUserSessionCompleted (replayed CMTrace tail after Completed) must not
|
||||
// mutate state or emit effects.
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.ImeUserSessionCompleted,
|
||||
T0.AddMinutes(7),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.ImePatternId] = "IME-USER-COMPLETE" }));
|
||||
|
||||
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
|
||||
Assert.Empty(step.Effects);
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Equal("signal_after_terminal:Completed", step.Transition.DeadEndReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +195,17 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// Handle <see cref="DecisionSignalKind.HelloResolved"/>. Records the resolution fact
|
||||
/// and cancels the Hello-safety deadline. If Desktop has already arrived the session
|
||||
/// completes here; otherwise stage transitions to <see cref="SessionStage.AwaitingDesktop"/>.
|
||||
/// <para>
|
||||
/// Session 8b8d611d fix (2026-05-20): when <see cref="DeadlineNames.HelloSafety"/> is
|
||||
/// armed in the live <see cref="DecisionState.Deadlines"/> set we also emit a
|
||||
/// <see cref="DecisionEffectKind.CancelDeadline"/> effect so the wall-clock
|
||||
/// <see cref="DeadlineScheduler"/> timer is disposed. Without that effect the builder
|
||||
/// cancel only cleared the reducer-state view; the OS timer kept ticking and fired
|
||||
/// later (~300 s after arm), re-entering <see cref="HandleHelloSafetyDeadlineFired"/>
|
||||
/// from a Completed stage and emitting a duplicate <c>enrollment_complete</c>.
|
||||
/// Mirrors the pattern already used by the Hello-disabled fast-path in
|
||||
/// <see cref="HandleDesktopArrivedV1"/>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep HandleHelloResolvedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
@@ -211,6 +222,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.HelloOutcome = new SignalFact<string>(outcome, signal.SessionSignalOrdinal);
|
||||
|
||||
var desktopAlreadyArrived = state.DesktopArrivedUtc != null;
|
||||
var helloSafetyCancelEffect = BuildHelloSafetyCancelEffectIfArmed(state);
|
||||
|
||||
// Plan §5 Fix 6: when both prerequisites have resolved, go through the
|
||||
// non-terminal Finalizing stage with a short grace period before Completed.
|
||||
@@ -223,7 +235,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.HelloResolved));
|
||||
trigger: nameof(DecisionSignalKind.HelloResolved),
|
||||
extraLeadingEffects: helloSafetyCancelEffect != null
|
||||
? new[] { helloSafetyCancelEffect }
|
||||
: null);
|
||||
}
|
||||
|
||||
builder.WithStage(SessionStage.AwaitingDesktop);
|
||||
@@ -236,7 +251,32 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.HelloResolved));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
var effects = helloSafetyCancelEffect != null
|
||||
? new[] { helloSafetyCancelEffect }
|
||||
: Array.Empty<DecisionEffect>();
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Session 8b8d611d fix (2026-05-20): emit a <see cref="DecisionEffectKind.CancelDeadline"/>
|
||||
/// effect for <see cref="DeadlineNames.HelloSafety"/> when it is actually armed in
|
||||
/// <paramref name="state"/>. Returns <c>null</c> when the deadline is not present —
|
||||
/// avoids spurious cancel-noise on the scheduler. Callers chain this in front of any
|
||||
/// reducer-side <c>CancelDeadline(HelloSafety)</c> so the live timer is disposed
|
||||
/// before it can fire after a Completed transition.
|
||||
/// </summary>
|
||||
private static DecisionEffect? BuildHelloSafetyCancelEffectIfArmed(DecisionState state)
|
||||
{
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if (d.Name == DeadlineNames.HelloSafety)
|
||||
{
|
||||
return new DecisionEffect(
|
||||
DecisionEffectKind.CancelDeadline,
|
||||
cancelDeadlineName: DeadlineNames.HelloSafety);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -311,14 +351,29 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
// Plan §5 Fix 6: mirror of HandleHelloResolvedV1. When both prerequisites are in,
|
||||
// go through Finalizing (non-terminal) instead of jumping straight to Completed.
|
||||
// Session 8b8d611d fix (2026-05-20): when HelloSafety is still armed in the live
|
||||
// state (deferred-promote path from HandleAccountSetupProvisioningCompleteV1 — the
|
||||
// HelloResolved branch did not clear it because Hello came in via the Hello-timeout
|
||||
// synthesis after this Desktop signal in some orderings), cancel the scheduler
|
||||
// timer too. Without the effect the timer fires after Completed and re-enters
|
||||
// HandleHelloSafetyDeadlineFired → TransitionToFinalizing → duplicate
|
||||
// enrollment_complete.
|
||||
if (helloAlreadyResolved)
|
||||
{
|
||||
var helloSafetyCancelEffect = BuildHelloSafetyCancelEffectIfArmed(state);
|
||||
if (helloSafetyCancelEffect != null)
|
||||
{
|
||||
builder.CancelDeadline(DeadlineNames.HelloSafety);
|
||||
}
|
||||
return TransitionToFinalizing(
|
||||
state: state,
|
||||
signal: signal,
|
||||
preparedBuilder: builder,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.DesktopArrived));
|
||||
trigger: nameof(DecisionSignalKind.DesktopArrived),
|
||||
extraLeadingEffects: helloSafetyCancelEffect != null
|
||||
? new[] { helloSafetyCancelEffect }
|
||||
: null);
|
||||
}
|
||||
|
||||
// Desktop came first: keep current stage (AwaitingHello or EspAccountSetup) until
|
||||
|
||||
@@ -49,9 +49,30 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// Pure dispatch on <c>(Kind, SchemaVersion)</c> — no mutation here, no try/catch
|
||||
/// (outer <see cref="Reduce"/> provides the fail-safe wrapper). Handlers return fully
|
||||
/// constructed <see cref="DecisionStep"/> instances.
|
||||
/// <para>
|
||||
/// Session 8b8d611d defense-in-depth (2026-05-20): when the state already reached a
|
||||
/// classic terminal stage (<see cref="SessionStage.Completed"/> /
|
||||
/// <see cref="SessionStage.Failed"/>), reject any further signal as a bookkept dead
|
||||
/// end before it can re-enter a handler that mutates state or emits effects. The
|
||||
/// orchestrator tear-down after <c>OnDecisionTerminalStage</c> runs on a background
|
||||
/// task (off-worker dispatch in <c>EnrollmentOrchestrator.OnDecisionTerminalStage</c>),
|
||||
/// so the SignalIngress worker can still pump queued or late-fired signals through
|
||||
/// <see cref="Reduce"/> for seconds afterwards. A live timer that escaped a missing
|
||||
/// <see cref="DecisionEffectKind.CancelDeadline"/> effect (e.g. a stale HelloSafety
|
||||
/// firing post-Completed) would otherwise re-enter <c>TransitionToFinalizing</c> and
|
||||
/// emit a duplicate <c>enrollment_complete</c>. <see cref="SessionStage.WhiteGloveSealed"/>
|
||||
/// is intentionally NOT guarded here — Part-2 resume runs as a fresh decision state
|
||||
/// after archive-and-reset, so no signal ever reaches the engine with that stage in
|
||||
/// a position that would warrant a dead-end.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private DecisionStep Dispatch(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
if (state.Stage == SessionStage.Completed || state.Stage == SessionStage.Failed)
|
||||
{
|
||||
return BuildPostTerminalDeadEnd(state, signal);
|
||||
}
|
||||
|
||||
return (signal.Kind, signal.KindSchemaVersion) switch
|
||||
{
|
||||
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
|
||||
@@ -220,6 +241,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bookkeeping-only step for signals that arrive after the session reached a classic
|
||||
/// terminal stage. State (Stage / Outcome / facts / deadlines) is unchanged; only
|
||||
/// StepIndex and LastAppliedSignalOrdinal advance so the signal is recorded as
|
||||
/// processed in the SignalLog. <c>DeadEndReason="signal_after_terminal:<stage>"</c>
|
||||
/// lets the Inspector pinpoint which late-arriving signal would have re-entered the
|
||||
/// terminal handlers without this guard.
|
||||
/// </summary>
|
||||
private DecisionStep BuildPostTerminalDeadEnd(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
var transition = BuildDeadEndTransition(
|
||||
state: state,
|
||||
signal: signal,
|
||||
nextStepIndex: bookkept.StepIndex,
|
||||
trigger: signal.Kind.ToString(),
|
||||
deadEndReason: $"signal_after_terminal:{state.Stage}");
|
||||
|
||||
return new DecisionStep(bookkept, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
private static string HashStackTrace(Exception exception)
|
||||
{
|
||||
var raw = exception.StackTrace ?? exception.GetType().FullName ?? "unknown";
|
||||
|
||||
Reference in New Issue
Block a user