mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(decision-core): rebase esp-exit advisory window on reboot instead of cancelling (session 7443317c)
The9a107a19reboot-cancel guard correctly stopped false esp_exit_without_completion_evidence failures, but a pure cancel left the post-AccountSetup dead-end zone with no resolution-capable deadline: session 7443317c (same tenant/shape as 1924092e, plus a mid-OOBE Windows-Update reboot chain) tripped the session_parked_without_deadline liveness tripwire on a live enrollment that later completed normally. RebaseEspExitVariantAdvisoryWindowOnReboot now replaces the stale window with a fresh 30-min one whose enforcement baselines (armSignalOrdinal / armAppTerminalCount) anchor at the reboot signal, from both HandleSystemRebootObservedV1 and HandleEspResumedV1: - A live enrollment keeps disproving the dead-end at each fire via HasEnforcementProgressSinceArming (re-arm, never fail). - A true dead-end where the user rebooted inside the window now resolves one window later (30-60 min) instead of idling to the 6-h max-lifetime watchdog - the9a107a19trade-off gap is closed. - The parked tripwire needs no change: a resolution-capable deadline is armed throughout, so IsParkedWithoutDeadline stays false and the tripwire keeps its "every fire is a bug" charter. - The rebase emits a completion_waiting with the new resolutionDeadlineDueAtUtc (trigger <Signal>:AdvisoryRebase), deliberately bypassing the fingerprint dedupe like the EnforcementActive re-arm site - bounded to one per reboot. Tests: rebase with reboot-anchored baselines (both call sites), advisory variant exempt, no-window no-op, true-dead-end-after-reboot fails at the rebased due-time, 7443317c double-reboot live-install replay, fire-once keeps the rebased window, 1924092e full replay updated (stray fire re-arms on progress), engine-driven parked-tripwire regression. DecisionCore 445/445, Agent.V2.Core 1754/1754. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+37
@@ -374,6 +374,43 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Orchestration
|
||||
AssertNoTripwireAfterDwell();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Reboot_after_guard_blocked_exit_rebases_window_and_never_parks()
|
||||
{
|
||||
// Session 7443317c (2026-07-10): the pre-rebase reboot CANCEL left the dead-end
|
||||
// zone without a resolution-capable deadline and tripped this tripwire on a live
|
||||
// enrollment. Regression: drive the REAL engine through the field shape (premature
|
||||
// AccountSetup entry → guard-blocked exit arms the window → reboot) and prove the
|
||||
// resulting state carries the rebased AdvisoryCompletion deadline — not parked.
|
||||
var state = DecisionState.CreateInitial("S1", "T1", At);
|
||||
foreach (var signal in new[]
|
||||
{
|
||||
TestSignals.Raw(1, DecisionSignalKind.SessionStarted, At),
|
||||
TestSignals.Raw(2, DecisionSignalKind.EspConfigDetected, At.AddMinutes(1),
|
||||
payload: new Dictionary<string, string>
|
||||
{
|
||||
[SignalPayloadKeys.SkipUserEsp] = "false",
|
||||
[SignalPayloadKeys.SkipDeviceEsp] = "false",
|
||||
}),
|
||||
TestSignals.Raw(3, DecisionSignalKind.EspPhaseChanged, At.AddMinutes(2),
|
||||
payload: new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "DeviceSetup" }),
|
||||
TestSignals.Raw(4, DecisionSignalKind.EspPhaseChanged, At.AddMinutes(5),
|
||||
payload: new Dictionary<string, string> { [SignalPayloadKeys.EspPhase] = "AccountSetup" }),
|
||||
TestSignals.Raw(5, DecisionSignalKind.EspExiting, At.AddMinutes(6)),
|
||||
TestSignals.Raw(6, DecisionSignalKind.SystemRebootObserved, At.AddMinutes(20)),
|
||||
})
|
||||
{
|
||||
state = _engine.Reduce(state, signal).NewState;
|
||||
}
|
||||
Assert.Contains(state.Deadlines, d => d.Name == DeadlineNames.AdvisoryCompletion);
|
||||
|
||||
var sut = Build(state);
|
||||
var (step, signal2) = ReduceInformational(sut, 10);
|
||||
sut.ApplyStep(step, signal2);
|
||||
|
||||
AssertNoTripwireAfterDwell();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Without_informational_post_wired_parked_state_is_tolerated()
|
||||
{
|
||||
|
||||
+117
-31
@@ -19,9 +19,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
/// esp_exit_without_completion_evidence</c> on a live enrollment. 65 sessions platform-wide
|
||||
/// hit this between 2026-06-15 and 2026-07-10. Coverage:
|
||||
/// <list type="bullet">
|
||||
/// <item>Reboot cancel — <c>SystemRebootObserved</c> / <c>EspResumed</c> cancel the
|
||||
/// esp-exit-variant window (the arming exit predates a reboot ⇒ pre-reboot page
|
||||
/// close, not the 1ec8f4c6 dead-end) but never the advisory variant.</item>
|
||||
/// <item>Reboot rebase — <c>SystemRebootObserved</c> / <c>EspResumed</c> replace the
|
||||
/// esp-exit-variant window with a fresh one whose baselines anchor at the reboot
|
||||
/// (the arming exit predates a reboot ⇒ pre-reboot page close, not the 1ec8f4c6
|
||||
/// dead-end; session 7443317c: a pure cancel parked the session without a
|
||||
/// resolution deadline) but never touch the advisory variant.</item>
|
||||
/// <item>Enforcement-progress re-arm — a fire while apps kept reaching terminal states
|
||||
/// or the ESP re-asserted a user phase re-arms instead of failing; convergent
|
||||
/// (a second fire without NEW progress fails).</item>
|
||||
@@ -113,39 +115,80 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
return armed;
|
||||
}
|
||||
|
||||
// ============================================================ reboot cancel ====
|
||||
// ============================================================ reboot rebase ====
|
||||
|
||||
[Fact]
|
||||
public void SystemReboot_CancelsEspExitVariantWindow_AndEmitsCancelEffect()
|
||||
public void SystemReboot_RebasesEspExitVariantWindow_WithRebootAnchoredBaselines()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state);
|
||||
|
||||
var rebootAt = T0.AddMinutes(19);
|
||||
var step = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19),
|
||||
60, DecisionSignalKind.SystemRebootObserved, rebootAt,
|
||||
new Dictionary<string, string> { ["previousExitType"] = "reboot_kill" }));
|
||||
|
||||
Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
|
||||
// The stale window is replaced by a fresh one anchored at the reboot — the session
|
||||
// is never parked without a resolution-capable deadline (7443317c tripwire).
|
||||
var rebased = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rebased);
|
||||
Assert.Equal(rebootAt.AddMinutes(30), rebased!.DueAtUtc);
|
||||
Assert.Equal("60", rebased.FiresPayload!["armSignalOrdinal"]);
|
||||
Assert.Equal("0", rebased.FiresPayload!["armAppTerminalCount"]);
|
||||
|
||||
var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
|
||||
var schedule = step.Effects.Single(e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
Assert.Equal(rebased.DueAtUtc, schedule.Deadline!.DueAtUtc);
|
||||
|
||||
// The re-based due-time is announced on the timeline (fingerprint bypass).
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Equal("SystemRebootObserved:AdvisoryRebase", waiting.Parameters!["trigger"]);
|
||||
Assert.Equal(rebased.DueAtUtc.ToString("o"), waiting.Parameters!["resolutionDeadlineDueAtUtc"]);
|
||||
|
||||
// The reboot's own observability is untouched.
|
||||
SingleTimelineEffect(step, "system_reboot_detected");
|
||||
Assert.Null(step.NewState.Outcome);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspResumed_CancelsEspExitVariantWindow()
|
||||
public void EspResumed_RebasesEspExitVariantWindow()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.EspResumed, T0.AddMinutes(19)));
|
||||
var resumeAt = T0.AddMinutes(19);
|
||||
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.EspResumed, resumeAt));
|
||||
|
||||
Assert.Null(FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion));
|
||||
var rebased = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rebased);
|
||||
Assert.Equal(resumeAt.AddMinutes(30), rebased!.DueAtUtc);
|
||||
var cancel = step.Effects.Single(e => e.Kind == DecisionEffectKind.CancelDeadline);
|
||||
Assert.Equal(DeadlineNames.AdvisoryCompletion, cancel.CancelDeadlineName);
|
||||
var waiting = SingleTimelineEffect(step, "completion_waiting");
|
||||
Assert.Equal("EspResumed:AdvisoryRebase", waiting.Parameters!["trigger"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebasedWindow_TrueDeadEnd_NoProgressAfterReboot_FailsAtRebasedDue()
|
||||
{
|
||||
// The rebase must not swallow the genuine dead-end verdict: user reboots inside
|
||||
// the window, ESP never comes back, nothing installs — the rebased window fires
|
||||
// 30 min after the reboot and fails the session (instead of the pre-rebase
|
||||
// behavior of idling to the max-lifetime watchdog after a pure cancel).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state, ordinal: 50);
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19))).NewState;
|
||||
|
||||
var step = engine.Reduce(state, DeadlineFired(70, T0.AddMinutes(49), DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, step.NewState.Outcome);
|
||||
var failed = SingleTimelineEffect(step, "enrollment_failed");
|
||||
Assert.Equal("esp_exit_without_completion_evidence", failed.Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -173,10 +216,11 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.NotNull(after);
|
||||
Assert.Equal(armed!.DueAtUtc, after!.DueAtUtc);
|
||||
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
|
||||
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SystemReboot_WithoutArmedWindow_EmitsNoCancelEffect()
|
||||
public void SystemReboot_WithoutArmedWindow_EmitsNoRebaseEffects()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
@@ -184,6 +228,7 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
var step = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(10)));
|
||||
|
||||
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.CancelDeadline);
|
||||
Assert.DoesNotContain(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
|
||||
SingleTimelineEffect(step, "system_reboot_detected");
|
||||
}
|
||||
|
||||
@@ -321,19 +366,22 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
public void Session1924092e_HandoffExitThenReboot_ActiveInstalls_CompletesInsteadOfFailing()
|
||||
{
|
||||
// The field session end-to-end: premature IME AccountSetup line → handoff exit
|
||||
// arms the window → reboot cancels it → real user desktop → apps actively
|
||||
// installing → a stray recovered-timer fire dead-ends → AccountSetup provisioning
|
||||
// completes → the session finishes Completed. Pre-fix, the recovered deadline
|
||||
// fired at 12:35:08 and failed the session mid-install.
|
||||
// arms the window → reboot re-bases it → real user desktop → apps actively
|
||||
// installing → a recovered-timer fire re-arms on demonstrated progress →
|
||||
// AccountSetup provisioning completes → the session finishes Completed. Pre-fix,
|
||||
// the recovered deadline fired at 12:35:08 and failed the session mid-install.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state, ordinal: 50);
|
||||
|
||||
// AutoLogon restart chain (12:20 / 12:23 in the field session).
|
||||
// AutoLogon restart chain (12:20 / 12:23 in the field session). The window is
|
||||
// re-based at the reboot, never cancelled outright (7443317c parked tripwire).
|
||||
state = engine.Reduce(state, MakeSignal(
|
||||
60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19),
|
||||
new Dictionary<string, string> { ["previousExitType"] = "reboot_kill" })).NewState;
|
||||
Assert.Null(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
|
||||
var rebased = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rebased);
|
||||
Assert.Equal(T0.AddMinutes(49), rebased!.DueAtUtc);
|
||||
|
||||
// Real user signs in, Account-Setup ESP resumes visibly.
|
||||
state = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.DesktopArrived, T0.AddMinutes(23))).NewState;
|
||||
@@ -344,13 +392,12 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
state = engine.Reduce(state, AppInstallCompleted(95, T0.AddMinutes(28), "app-lansweeper")).NewState;
|
||||
Assert.Null(state.Outcome);
|
||||
|
||||
// The pre-reboot wall-clock timer can still fire after recovery (state says
|
||||
// cancelled, but the scheduler re-armed it from the recovered snapshot before the
|
||||
// reboot signal was processed). The stale guard must dead-end it — NOT fail.
|
||||
// A stray pre-reboot wall-clock timer fires early: the rebased window IS armed,
|
||||
// and enforcement progressed since the reboot baseline — re-arm, never fail.
|
||||
var strayFire = engine.Reduce(state, DeadlineFired(100, T0.AddMinutes(33), DeadlineNames.AdvisoryCompletion));
|
||||
Assert.False(strayFire.Transition.Taken);
|
||||
Assert.Equal("advisory_completion_stale_deadline_not_armed", strayFire.Transition.DeadEndReason);
|
||||
Assert.Null(strayFire.NewState.Outcome);
|
||||
Assert.Equal(state.Stage, strayFire.NewState.Stage);
|
||||
Assert.NotNull(FindDeadline(strayFire.NewState, DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
// Account-Setup ESP finishes for real → normal completion path.
|
||||
state = engine.Reduce(strayFire.NewState, MakeSignal(
|
||||
@@ -363,23 +410,62 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootCancel_LaterGenuineGuardBlockedExit_RearmsFreshWindow()
|
||||
public void Session7443317c_DoubleRebootRebase_LiveInstalls_RearmsAtFire_NeverParksNorFails()
|
||||
{
|
||||
// After the reboot cancel, the arming site's fire-once guard must pass again: a
|
||||
// later genuine guard-blocked post-AccountSetup exit re-arms a fresh window so
|
||||
// the 1ec8f4c6 dead-end closure is not lost for the rest of the session.
|
||||
// Field session 7443317c (2026-07-10, same tenant as 1924092e): handoff exit arms
|
||||
// the window, a Windows-Update reboot chain (14:06 + 14:09) re-bases it twice,
|
||||
// 32 apps install over ~65 min — each window fire sees new terminal states and
|
||||
// re-arms — until the genuine ESP exit resolves the session. The session must
|
||||
// never be parked (a resolution-capable deadline stays armed throughout) and
|
||||
// never fail.
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state, ordinal: 50);
|
||||
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(19))).NewState;
|
||||
Assert.Null(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
|
||||
state = engine.Reduce(state, MakeSignal(65, DecisionSignalKind.SystemRebootObserved, T0.AddMinutes(22))).NewState;
|
||||
var rebased = FindDeadline(state, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rebased);
|
||||
Assert.Equal(T0.AddMinutes(52), rebased!.DueAtUtc);
|
||||
Assert.Equal("65", rebased.FiresPayload!["armSignalOrdinal"]);
|
||||
|
||||
var exitAt = T0.AddMinutes(30);
|
||||
var step = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.EspExiting, exitAt));
|
||||
state = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.DesktopArrived, T0.AddMinutes(26))).NewState;
|
||||
state = engine.Reduce(state, AppInstallCompleted(80, T0.AddMinutes(30), "app-lansweeper")).NewState;
|
||||
|
||||
var rearmed = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
// Window fires at the rebased due-time mid-install → progress since the second
|
||||
// reboot (one app terminal) → re-arm, session stays live.
|
||||
var fire = engine.Reduce(state, DeadlineFired(90, T0.AddMinutes(52), DeadlineNames.AdvisoryCompletion));
|
||||
Assert.Null(fire.NewState.Outcome);
|
||||
var rearmed = FindDeadline(fire.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(rearmed);
|
||||
Assert.Equal(exitAt.AddMinutes(30), rearmed!.DueAtUtc);
|
||||
Assert.Equal(T0.AddMinutes(82), rearmed!.DueAtUtc);
|
||||
|
||||
// At no point between arming and completion was the session without a
|
||||
// resolution-capable deadline (the 7443317c parked-tripwire condition).
|
||||
state = engine.Reduce(fire.NewState, MakeSignal(
|
||||
100, DecisionSignalKind.AccountSetupProvisioningComplete, T0.AddMinutes(70))).NewState;
|
||||
Assert.Equal(SessionStage.Finalizing, state.Stage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RebootRebase_LaterGuardBlockedExit_KeepsRebasedWindow_FireOnce()
|
||||
{
|
||||
// After the reboot rebase a window is still armed, so the arming site's fire-once
|
||||
// guard holds: a later guard-blocked post-AccountSetup exit must NOT re-base the
|
||||
// window again (the rebased baselines stay authoritative until it fires).
|
||||
var engine = new DecisionEngine();
|
||||
var state = SetupClassicAccountSetupSession(engine);
|
||||
state = ArmEspExitWindow(engine, state, ordinal: 50);
|
||||
var rebootAt = T0.AddMinutes(19);
|
||||
state = engine.Reduce(state, MakeSignal(60, DecisionSignalKind.SystemRebootObserved, rebootAt)).NewState;
|
||||
Assert.NotNull(FindDeadline(state, DeadlineNames.AdvisoryCompletion));
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(70, DecisionSignalKind.EspExiting, T0.AddMinutes(30)));
|
||||
|
||||
var kept = FindDeadline(step.NewState, DeadlineNames.AdvisoryCompletion);
|
||||
Assert.NotNull(kept);
|
||||
Assert.Equal(rebootAt.AddMinutes(30), kept!.DueAtUtc);
|
||||
Assert.Equal("60", kept.FiresPayload!["armSignalOrdinal"]);
|
||||
}
|
||||
|
||||
// ================================================== serialization round-trip ====
|
||||
|
||||
@@ -181,10 +181,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// deliberately replaces it — the failure is the fresher dead-end signal).
|
||||
// Caveat (session 1924092e, 2026-07-10): AccountSetupEnteredUtc can be set by
|
||||
// a pre-sign-in IME AccountSetup line, letting the Device→Account handoff
|
||||
// exit arm this window falsely. A reboot cancels the window again
|
||||
// (CancelEspExitVariantAdvisoryWindowOnReboot) — after which a later genuine
|
||||
// guard-blocked exit re-arms here — and the fire handler re-arms instead of
|
||||
// failing while enforcement is demonstrably progressing.
|
||||
// exit arm this window falsely. A reboot re-bases the window with fresh
|
||||
// enforcement baselines (RebaseEspExitVariantAdvisoryWindowOnReboot; session
|
||||
// 7443317c — a pure cancel parked the session without a resolution deadline)
|
||||
// and the fire handler re-arms instead of failing while enforcement is
|
||||
// demonstrably progressing.
|
||||
var exitEffects = Array.Empty<DecisionEffect>();
|
||||
if (state.AccountSetupEnteredUtc != null && !HasAdvisoryCompletionDeadline(state))
|
||||
{
|
||||
|
||||
@@ -17,8 +17,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// <c>EspAndHelloTracker</c> after a reboot mid-ESP. The session's stage is preserved
|
||||
/// (we were already somewhere in the ESP phase sequence), but the reboot fact is
|
||||
/// recorded so downstream classifiers (WhiteGloveSealingClassifier) can factor it in.
|
||||
/// A reboot also cancels an esp-exit-variant AdvisoryCompletion window — see
|
||||
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
|
||||
/// A reboot also re-bases an esp-exit-variant AdvisoryCompletion window — see
|
||||
/// <see cref="RebaseEspExitVariantAdvisoryWindowOnReboot"/>.
|
||||
/// </summary>
|
||||
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
@@ -34,7 +34,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
|
||||
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
@@ -44,10 +44,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.EspResumed));
|
||||
|
||||
return new DecisionStep(
|
||||
newState,
|
||||
transition,
|
||||
cancelEffect != null ? new[] { cancelEffect } : Array.Empty<DecisionEffect>());
|
||||
return new DecisionStep(newState, transition, rebaseEffects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -60,35 +57,68 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// IME logged an AccountSetup phase line pre-sign-in (defaultuser0/AutoLogon frame),
|
||||
/// the handoff exit armed the window, and the deadline survived two reboots via state
|
||||
/// recovery — then failed the session mid-AccountSetup while apps were actively
|
||||
/// installing. Cancel the window; a later genuine guard-blocked post-AccountSetup
|
||||
/// exit re-arms a fresh one (the arming site is fire-once only while armed).
|
||||
/// installing.
|
||||
/// <para>
|
||||
/// Session 7443317c (2026-07-10): a pure cancel left the post-AccountSetup dead-end
|
||||
/// zone with no resolution-capable deadline and tripped the
|
||||
/// <c>session_parked_without_deadline</c> liveness tripwire. So instead of cancelling,
|
||||
/// RE-BASE: replace the stale window with a fresh one whose enforcement baselines
|
||||
/// (<see cref="AdvisoryArmOrdinalKey"/> / <see cref="AdvisoryArmAppTerminalCountKey"/>)
|
||||
/// anchor at the reboot signal. A live enrollment keeps disproving the dead-end at
|
||||
/// each fire via <see cref="HasEnforcementProgressSinceArming"/>; a true dead-end
|
||||
/// where the user rebooted inside the window now resolves one window later (30–60 min)
|
||||
/// instead of idling to the max-lifetime watchdog.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The advisory variant (<see cref="DecisionState.EspAdvisoryFailureRecordedUtc"/>
|
||||
/// set) is deliberately NOT cancelled: its anchor is a real ESP terminal failure and
|
||||
/// set) is deliberately NOT touched: its anchor is a real ESP terminal failure and
|
||||
/// a reboot does not un-happen that failure — the window must still un-defang it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Trade-off: in a true dead-end where the user manually reboots inside the window,
|
||||
/// the verdict falls back to the max-lifetime watchdog (plus the backend timeout
|
||||
/// reconcile) instead of the 30-min resolution — acceptable against failing live
|
||||
/// enrollments, which is a trust-destroying false positive.
|
||||
/// </para>
|
||||
/// Returns the CancelDeadline effect (and cancels on <paramref name="builder"/>),
|
||||
/// or null when no esp-exit-variant window is armed.
|
||||
/// Returns the effects (cancel + schedule + a fingerprint-bypassing
|
||||
/// <c>completion_waiting</c> carrying the re-based due-time, mirroring the
|
||||
/// enforcement-progress re-arm site), or an empty array when no esp-exit-variant
|
||||
/// window is armed. Mutates <paramref name="builder"/> accordingly.
|
||||
/// </summary>
|
||||
private static DecisionEffect? CancelEspExitVariantAdvisoryWindowOnReboot(
|
||||
private DecisionEffect[] RebaseEspExitVariantAdvisoryWindowOnReboot(
|
||||
DecisionState state,
|
||||
DecisionStateBuilder builder)
|
||||
DecisionStateBuilder builder,
|
||||
DecisionSignal signal)
|
||||
{
|
||||
if (state.EspAdvisoryFailureRecordedUtc != null || !HasAdvisoryCompletionDeadline(state))
|
||||
{
|
||||
return null;
|
||||
return Array.Empty<DecisionEffect>();
|
||||
}
|
||||
|
||||
builder.CancelDeadline(DeadlineNames.AdvisoryCompletion);
|
||||
return new DecisionEffect(
|
||||
DecisionEffectKind.CancelDeadline,
|
||||
cancelDeadlineName: DeadlineNames.AdvisoryCompletion);
|
||||
var rebasedDeadline = BuildAdvisoryCompletionDeadline(state, signal);
|
||||
builder.AddDeadline(rebasedDeadline);
|
||||
|
||||
return new[]
|
||||
{
|
||||
new DecisionEffect(
|
||||
DecisionEffectKind.CancelDeadline,
|
||||
cancelDeadlineName: DeadlineNames.AdvisoryCompletion),
|
||||
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rebasedDeadline),
|
||||
// Deliberately bypasses BuildCompletionWaitingEffect's fingerprint dedupe:
|
||||
// the missing-prerequisites set is unchanged by a reboot, but the re-based
|
||||
// resolution due-time is what an operator debugging the session needs on the
|
||||
// timeline. Bounded by construction — at most one event per observed reboot
|
||||
// signal.
|
||||
new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = SharedConstants.EventTypes.CompletionWaiting,
|
||||
["source"] = "DecisionEngine",
|
||||
["severity"] = "Info",
|
||||
["immediateUpload"] = "false",
|
||||
["message"] = "Completion resolution window re-based across reboot: the arming ESP exit predates the reboot",
|
||||
["missingPrerequisites"] = string.Join(",", BuildMissingCompletionPrerequisites(state)),
|
||||
["trigger"] = $"{signal.Kind}:AdvisoryRebase",
|
||||
["stage"] = state.Stage.ToString(),
|
||||
["resolutionDeadlineDueAtUtc"] = rebasedDeadline.DueAtUtc.ToString("o"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -492,7 +522,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// `advisory_completion_stale_deadline_not_armed`. Session 1924092e (2026-07-10)
|
||||
// showed it CAN slip in when the IME logs an AccountSetup phase line pre-sign-in
|
||||
// (defaultuser0/AutoLogon frame) — that misfire is defused downstream by the
|
||||
// reboot cancel (CancelEspExitVariantAdvisoryWindowOnReboot) and the
|
||||
// reboot rebase (RebaseEspExitVariantAdvisoryWindowOnReboot) and the
|
||||
// enforcement-progress re-arm below, not by the anchor.
|
||||
var hasEspExitAnchor = state.EspFinalExitUtc != null
|
||||
&& state.AccountSetupEnteredUtc != null;
|
||||
@@ -812,9 +842,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
|
||||
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight, and emits
|
||||
/// the <c>system_reboot_detected</c> timeline entry so the session timeline shows the
|
||||
/// split between pre-reboot and post-reboot collection. A reboot also cancels an
|
||||
/// split between pre-reboot and post-reboot collection. A reboot also re-bases an
|
||||
/// esp-exit-variant AdvisoryCompletion window — see
|
||||
/// <see cref="CancelEspExitVariantAdvisoryWindowOnReboot"/>.
|
||||
/// <see cref="RebaseEspExitVariantAdvisoryWindowOnReboot"/>.
|
||||
/// </summary>
|
||||
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
@@ -828,7 +858,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
var cancelEffect = CancelEspExitVariantAdvisoryWindowOnReboot(state, builder);
|
||||
var rebaseEffects = RebaseEspExitVariantAdvisoryWindowOnReboot(state, builder, signal);
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
@@ -854,9 +884,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var timelineEffect = new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: timelineParams);
|
||||
var effects = cancelEffect != null
|
||||
? new[] { cancelEffect, timelineEffect }
|
||||
: new[] { timelineEffect };
|
||||
var effects = AppendEffect(rebaseEffects, timelineEffect);
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user