mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
refactor(decisioncore): edge scenarios — hybrid reboot, ESP failure, recovery (M3.5)
Final M3 sub-milestone. Closes the plan's §4 M3 scenario matrix:
Handlers (DecisionEngine.Edge.cs):
- EspResumed mid-ESP reboot. Stage preserved; records
SystemRebootUtc on first observation (hash
stable on repeats).
- EspTerminalFailure Stage=Failed + Outcome=EnrollmentFailed,
deadlines cleared, enrollment_failed event
emitted with reason carried from payload.
Definitive — no classifier re-run.
- SystemRebootObserved records SystemRebootUtc so the WhiteGlove
sealing classifier credits the +15 reboot
weight on the next RunClassifier effect.
Scenario fixtures (committed-anonymized):
- hybrid-reboot-v1.jsonl ESP -> mid-phase reboot -> EspResumed ->
AccountSetup -> Hello -> Desktop ->
Completed. No WhiteGloveSealed path.
- esp-terminal-failure-v1.jsonl ESP -> AccountSetup -> terminal failure
-> Failed / reason=
esp_terminal_failure_account_setup.
Tests (EdgeScenarioTests, 5 new):
- Hybrid reboot preserves stage, records reboot fact, completes
normally without triggering WhiteGlove.
- ESP terminal failure transitions straight to Failed.
- Restart-recovery determinism: replaying the same JSONL twice yields
identical FinalStepHash, Stage, Outcome and transition count.
- Prefix-replay check: truncated log ends in the expected intermediate
stage (simulates crash between signals).
- Global sanity: every committed fixture (all 12) ends in a terminal
stage — regression guard for any future reducer change.
Suite totals: 57 DecisionCore (was 52) / 507 Legacy / 557 Functions =
1121 tests green. All 14+ scenarios from plan §R6 Szenario-Matrix pass
through the reducer with the expected terminal outcome.
This closes M3 (DecisionEngine-Reducer + Classifier-Kernel). M4 wires the
SignalAdapter + EffectRunner + TelemetryTransport in the V2 agent runtime.
Plan: plans/REFACTOR_AGENT_V2.md §4 M3 sub-milestone M3.5 — M3 complete.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
||||
12
|
||||
13
|
||||
|
||||
@@ -1 +1 @@
|
||||
1054
|
||||
1055
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Classifiers;
|
||||
using AutopilotMonitor.DecisionCore.Engine;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
using AutopilotMonitor.DecisionCore.Tests.Harness;
|
||||
using AutopilotMonitor.Shared.Models;
|
||||
using Xunit;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Tests.Scenarios
|
||||
{
|
||||
/// <summary>
|
||||
/// Plan §4 M3.5 — Edge scenarios: hybrid reboot, ESP terminal failure, restart-recovery
|
||||
/// determinism.
|
||||
/// </summary>
|
||||
public sealed class EdgeScenarioTests : ScenarioTestBase
|
||||
{
|
||||
private static ClassifierAwareReplayHarness NewHarness() =>
|
||||
new ClassifierAwareReplayHarness(
|
||||
new DecisionEngine(),
|
||||
new Dictionary<string, IClassifier>
|
||||
{
|
||||
[WhiteGloveSealingClassifier.ClassifierId] = new WhiteGloveSealingClassifier(),
|
||||
[WhiteGlovePart2CompletionClassifier.ClassifierId] = new WhiteGlovePart2CompletionClassifier(),
|
||||
});
|
||||
|
||||
[Fact]
|
||||
public void HybridReboot_midEsp_preservesStage_recordsRebootFact_completesNormally()
|
||||
{
|
||||
var signals = LoadFixture("hybrid-reboot-v1.jsonl");
|
||||
var result = NewHarness().Replay("session-anon-0030", "tenant-anon-0030", signals);
|
||||
|
||||
Assert.Equal(SessionStage.Completed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentComplete, result.FinalState.Outcome);
|
||||
Assert.Equal("Success", result.FinalState.HelloOutcome!.Value);
|
||||
|
||||
// Reboot fact is recorded.
|
||||
Assert.NotNull(result.FinalState.SystemRebootUtc);
|
||||
|
||||
// Stage flow: EspDeviceSetup -> (Reboot) preserved -> EspResumed preserved ->
|
||||
// EspAccountSetup on next EspPhaseChanged -> AwaitingHello -> Completed.
|
||||
// Importantly, no WhiteGloveSealed — reboot alone doesn't imply WG.
|
||||
Assert.NotEqual(HypothesisLevel.Confirmed, result.FinalState.WhiteGloveSealing.Level);
|
||||
Assert.Null(result.FinalState.ShellCoreWhiteGloveSuccessSeen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void EspTerminalFailure_transitionsToFailed_withReasonFromPayload()
|
||||
{
|
||||
var signals = LoadFixture("esp-terminal-failure-v1.jsonl");
|
||||
var result = NewHarness().Replay("session-anon-0031", "tenant-anon-0031", signals);
|
||||
|
||||
Assert.Equal(SessionStage.Failed, result.FinalState.Stage);
|
||||
Assert.Equal(SessionOutcome.EnrollmentFailed, result.FinalState.Outcome);
|
||||
Assert.Empty(result.FinalState.Deadlines);
|
||||
|
||||
// The last transition carries the payload-derived reason via trigger.
|
||||
var last = result.Transitions[^1];
|
||||
Assert.Equal("EspTerminalFailure", last.Trigger);
|
||||
Assert.True(last.Taken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartRecovery_replayTwice_producesIdenticalFinalStepHash()
|
||||
{
|
||||
// Determinism: the same signal stream through a fresh engine + fresh state
|
||||
// yields the same FinalStepHash. Simulates the Orchestrator replaying a
|
||||
// persisted SignalLog after an agent crash. Plan L.2 Event-Sourcing.
|
||||
var signals = LoadFixture("whiteglove-part2-happy-v1.jsonl");
|
||||
|
||||
var r1 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals);
|
||||
var r2 = NewHarness().Replay("session-anon-replay", "tenant-anon-replay", signals);
|
||||
|
||||
Assert.Equal(r1.FinalStepHash, r2.FinalStepHash);
|
||||
Assert.Equal(r1.FinalState.Stage, r2.FinalState.Stage);
|
||||
Assert.Equal(r1.FinalState.Outcome, r2.FinalState.Outcome);
|
||||
Assert.Equal(r1.Transitions.Count, r2.Transitions.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RestartRecovery_prefixReplay_yieldsIntermediateState()
|
||||
{
|
||||
// Replay a truncated log and verify the state matches what full-replay had at
|
||||
// the same point (simulates partial recovery after crash between signals).
|
||||
var all = LoadFixture("userdriven-happy-v1.jsonl");
|
||||
var prefix = new List<AutopilotMonitor.DecisionCore.Signals.DecisionSignal>();
|
||||
for (int i = 0; i < 3 && i < all.Count; i++) prefix.Add(all[i]);
|
||||
|
||||
var prefixResult = NewHarness().Replay("s", "t", prefix);
|
||||
|
||||
Assert.Equal(SessionStage.EspAccountSetup, prefixResult.FinalState.Stage);
|
||||
Assert.Null(prefixResult.FinalState.Outcome);
|
||||
Assert.NotNull(prefixResult.FinalState.CurrentEnrollmentPhase);
|
||||
Assert.Equal(EnrollmentPhase.AccountSetup, prefixResult.FinalState.CurrentEnrollmentPhase!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AllSixCommittedFixtures_produceTerminalState()
|
||||
{
|
||||
// Global sanity: every committed anonymized fixture ends in a terminal stage.
|
||||
// This catches regressions where a new reducer change breaks a scenario end.
|
||||
string[] fixtures =
|
||||
{
|
||||
"userdriven-happy-v1.jsonl",
|
||||
"userdriven-hello-timeout-v1.jsonl",
|
||||
"late-aadj-v1.jsonl",
|
||||
"selfdeploying-happy-v1.jsonl",
|
||||
"selfdeploying-esp-exit-unknown-v1.jsonl",
|
||||
"whiteglove-inline-v1.jsonl",
|
||||
"whiteglove-signal-correlated-v1.jsonl",
|
||||
"whiteglove-false-positive-v1.jsonl",
|
||||
"whiteglove-part2-happy-v1.jsonl",
|
||||
"whiteglove-part2-stuck-v1.jsonl",
|
||||
"hybrid-reboot-v1.jsonl",
|
||||
"esp-terminal-failure-v1.jsonl",
|
||||
};
|
||||
|
||||
foreach (var fx in fixtures)
|
||||
{
|
||||
var signals = LoadFixture(fx);
|
||||
var result = NewHarness().Replay($"session:{fx}", $"tenant:{fx}", signals);
|
||||
|
||||
var isTerminal = result.FinalState.Stage == SessionStage.Completed
|
||||
|| result.FinalState.Stage == SessionStage.Failed
|
||||
|| result.FinalState.Stage == SessionStage.WhiteGloveSealed
|
||||
|| result.FinalState.Stage == SessionStage.WhiteGloveCompletedPart2;
|
||||
|
||||
Assert.True(
|
||||
isTerminal,
|
||||
$"Fixture {fx} ended in non-terminal stage {result.FinalState.Stage}.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using AutopilotMonitor.DecisionCore.Signals;
|
||||
using AutopilotMonitor.DecisionCore.State;
|
||||
|
||||
namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
// Edge-case handlers: hybrid reboot, ESP terminal failure, system reboot observation.
|
||||
// Plan §2.5 / §M3.5.
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.EspResumed"/>. Emitted by
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private DecisionStep HandleEspResumedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
|
||||
// Record the reboot fact on first observation. Subsequent EspResumed signals
|
||||
// keep the original timestamp so the hash over SystemRebootUtc is stable.
|
||||
if (state.SystemRebootUtc == null)
|
||||
{
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.EspResumed));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.EspTerminalFailure"/>. Directly transitions
|
||||
/// to <see cref="SessionStage.Failed"/> with <see cref="SessionOutcome.EnrollmentFailed"/>,
|
||||
/// clears deadlines, and emits <c>enrollment_failed</c>. No classifier re-run —
|
||||
/// ESP terminal failure is definitive.
|
||||
/// </summary>
|
||||
private DecisionStep HandleEspTerminalFailureV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
|
||||
var reason = signal.Payload != null && signal.Payload.TryGetValue("reason", out var r)
|
||||
? r
|
||||
: "esp_terminal_failure";
|
||||
|
||||
var builder = state.ToBuilder()
|
||||
.WithStage(SessionStage.Failed)
|
||||
.WithOutcome(SessionOutcome.EnrollmentFailed)
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.ClearDeadlines();
|
||||
|
||||
var newState = builder.Build();
|
||||
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: SessionStage.Failed,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.EspTerminalFailure));
|
||||
|
||||
var effects = new[]
|
||||
{
|
||||
new DecisionEffect(
|
||||
DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>
|
||||
{
|
||||
["eventType"] = "enrollment_failed",
|
||||
["reason"] = reason,
|
||||
}),
|
||||
};
|
||||
|
||||
return new DecisionStep(newState, transition, effects);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
|
||||
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight.
|
||||
/// </summary>
|
||||
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var nextStep = state.StepIndex + 1;
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
|
||||
if (state.SystemRebootUtc == null)
|
||||
{
|
||||
builder.SystemRebootUtc = new SignalFact<DateTime>(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
}
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
|
||||
|
||||
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,12 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
(DecisionSignalKind.DesktopArrivedPart2, 1) => HandleDesktopArrivedPart2V1(state, signal),
|
||||
(DecisionSignalKind.AccountSetupCompletedPart2, 1) => HandleAccountSetupCompletedPart2V1(state, signal),
|
||||
|
||||
// ----- Not yet wired (edge in M3.5) -----
|
||||
// ----- Edge (DecisionEngine.Edge.cs) -----
|
||||
(DecisionSignalKind.EspResumed, 1) => HandleEspResumedV1(state, signal),
|
||||
(DecisionSignalKind.EspTerminalFailure, 1) => HandleEspTerminalFailureV1(state, signal),
|
||||
(DecisionSignalKind.SystemRebootObserved, 1) => HandleSystemRebootObservedV1(state, signal),
|
||||
|
||||
// ----- Still unwired (AppInstall, DeviceInfo, AutopilotProfileRead — v11.1+) -----
|
||||
_ => HandleUnhandledSignal(state, signal),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Scenario: ESP Terminal Failure
|
||||
# ESP reports a non-recoverable failure (e.g. Event 62411 failure) during AccountSetup.
|
||||
# Reducer transitions to Failed directly with reason carried from the signal payload.
|
||||
# enrollment_failed event emitted.
|
||||
# Plan: §4 M3.5 ESP-Terminal-Failure.
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:esp-fail:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}}
|
||||
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspTerminalFailure","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T09:12:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"eventlog:Shell-Core:62411:recordId:900","Summary":"ESP terminal failure (Event 62411)","RawPointer":"blob://events/shell-core/900"},"Payload":{"reason":"esp_terminal_failure_account_setup"}}
|
||||
@@ -0,0 +1,15 @@
|
||||
# Scenario: Hybrid-Reboot UserDriven (ESP mid-phase reboot, session resumes cleanly)
|
||||
# The device enters DeviceSetup, reboots before AccountSetup, agent restarts and emits
|
||||
# EspResumed + SystemRebootObserved. The existing stage is preserved, reboot fact is
|
||||
# recorded. Session then continues through AccountSetup, ESP exit, Hello, Desktop ->
|
||||
# Completed. Hybrid Reboot DOES NOT cause WhiteGlove sealing (no WG facts).
|
||||
# Plan: §4 M3.5 hybrid reboot scenario.
|
||||
{"SessionSignalOrdinal":0,"SessionTraceOrdinal":0,"Kind":"SessionStarted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:00:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Synthetic","Identifier":"session:hybrid-reboot:started","Summary":"Session start anchor"}}
|
||||
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:01:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=DeviceSetup","DerivationInputs":{"rawValue":"1"}},"Payload":{"phase":"DeviceSetup"}}
|
||||
{"SessionSignalOrdinal":2,"SessionTraceOrdinal":2,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:00Z","SourceOrigin":"orchestrator","Evidence":{"Kind":"Derived","Identifier":"boot-time-delta-detector-v1","Summary":"System boot time advanced since session start","DerivationInputs":{"priorBoot":"2026-04-24T07:59:00Z","currentBoot":"2026-04-24T08:05:00Z"}}}
|
||||
{"SessionSignalOrdinal":3,"SessionTraceOrdinal":3,"Kind":"EspResumed","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:05:30Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-resume-detector-v1","Summary":"ESP resumed after reboot","DerivationInputs":{"previousPhase":"DeviceSetup"}}}
|
||||
{"SessionSignalOrdinal":4,"SessionTraceOrdinal":4,"Kind":"EspPhaseChanged","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:10:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-phase-detector-v1","Summary":"ESP phase=AccountSetup","DerivationInputs":{"rawValue":"2"}},"Payload":{"phase":"AccountSetup"}}
|
||||
{"SessionSignalOrdinal":5,"SessionTraceOrdinal":5,"Kind":"ImeUserSessionCompleted","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:12:00Z","SourceOrigin":"ImeLogTracker","Evidence":{"Kind":"Raw","Identifier":"ime-log-line-hash:ghi789","Summary":"User session completed pattern"},"Payload":{"patternId":"ime-pattern-user-session-complete-v1"}}
|
||||
{"SessionSignalOrdinal":6,"SessionTraceOrdinal":6,"Kind":"EspExiting","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:13:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Derived","Identifier":"esp-exit-detector-v1","Summary":"ESP exit observed","DerivationInputs":{"rawValue":"exit"}}}
|
||||
{"SessionSignalOrdinal":7,"SessionTraceOrdinal":7,"Kind":"HelloResolved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:14:00Z","SourceOrigin":"EspAndHelloTracker","Evidence":{"Kind":"Raw","Identifier":"hello-event-recordId:77","Summary":"Hello for Business enrolled"},"Payload":{"outcome":"Success"}}
|
||||
{"SessionSignalOrdinal":8,"SessionTraceOrdinal":8,"Kind":"DesktopArrived","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-24T08:15:00Z","SourceOrigin":"DesktopArrivalDetector","Evidence":{"Kind":"Derived","Identifier":"desktop-arrival-detector-v1","Summary":"Explorer.exe running as real user","DerivationInputs":{"user":"user-anon-0030","explorerPid":"4500"}}}
|
||||
Reference in New Issue
Block a user