refactor(agent-v2): route whiteglove_resumed / system_reboot_detected / admin preemption through reducer effects

V2 parity cleanup - the three lifecycle events were emitted from Program.cs
directly while the corresponding reducer handlers (HandleWhiteGlovePart1To2Bridge,
HandleSystemRebootObservedV1) returned Array.Empty<DecisionEffect>(). Admin
preemption synthesised an EnrollmentTerminatedEventArgs and invoked
terminationHandler.Handle directly, bypassing the reducer entirely.

This commit unifies all three flows: the reducer is the single state owner, the
telemetry event appears as an EmitEventTimelineEntry effect of the state
transition, and the orchestrator's Terminated event fires automatically when
stage goes terminal - no more synthetic bypass.

- B1: HandleWhiteGlovePart1To2Bridge emits whiteglove_resumed timeline entry.
- B2: HandleSystemRebootObservedV1 emits system_reboot_detected with payload
  keys previousExitType/lastBootUtc.
- B3: new DecisionSignalKind.AdminPreemptionDetected + new SessionOutcome.AdminPreempted.
  Reducer HandleAdminPreemptionDetectedV1 transitions to Completed/Failed based on
  adminOutcome payload, emits enrollment_complete/_failed timeline entry with
  adminAction+source data keys. Program.cs posts the signal; terminationHandler
  still subscribes to orchestrator.Terminated (no direct Handle calls on the
  startup-preemption path).

Fixtures: tests/fixtures/signal-kinds/{system-reboot-observed,admin-preemption-detected}-v1.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-22 16:00:25 +02:00
parent a63512d139
commit 5b7f424d19
13 changed files with 382 additions and 123 deletions
+67 -115
View File
@@ -697,20 +697,26 @@ namespace AutopilotMonitor.Agent.V2
// alerting. Fires AFTER orchestrator.Start so the EventEmitter is alive.
EmitUnrestrictedModeAuditIfChanged(orchestrator, agentConfig, configMergeResult, logger);
// V1 parity — agent_started / whiteglove_resumed / system_reboot_detected
// lifecycle events. These are purely informational (Phase=Unknown) but the
// session timeline and downstream alerting depend on them to classify the
// boot.
// V1 parity — agent_started lifecycle event. Purely informational
// (Phase=Unknown); the session timeline and downstream alerting rely on
// it to classify the boot.
EmitAgentStartedEvent(orchestrator, agentConfig, previousExit, logger);
// WhiteGlove Part-2 resume: EnrollmentOrchestrator.Start already posts
// SessionRecovered, which triggers HandleWhiteGlovePart1To2Bridge — that
// reducer effect emits whiteglove_resumed on the timeline. We only need
// to clear the persisted marker here.
if (isWhiteGloveResume)
{
EmitWhiteGloveResumedEvent(orchestrator, agentConfig, logger);
try { sessionPersistence.ClearWhiteGloveComplete(logger); }
catch (Exception ex) { logger.Debug($"ClearWhiteGloveComplete threw: {ex.Message}"); }
}
// Reboot mid-session: post SystemRebootObserved so the reducer records
// the fact and emits the system_reboot_detected timeline entry.
if (string.Equals(previousExit?.ExitType, "reboot_kill", StringComparison.OrdinalIgnoreCase))
{
EmitSystemRebootDetectedEvent(orchestrator, agentConfig, previousExit, logger);
PostSystemRebootObservedSignal(orchestrator.IngressSink, previousExit, logger);
}
// V2 parity — post SessionStarted so the reducer establishes the session anchor
@@ -724,20 +730,18 @@ namespace AutopilotMonitor.Agent.V2
PostSessionStartedSignal(orchestrator.IngressSink, registrationResult, agentConfig, logger);
}
// V1 parity (MonitoringService.cs:388-413 — the "ADMIN OVERRIDE on startup"
// block) — if the register-session response carried an AdminAction the
// session was already marked terminal by an operator via the portal. Emit
// the matching lifecycle event and synthesise a Terminated so the normal
// termination pipeline (cleanup + summary + self-destruct) runs. The
// orchestrator has only just started so the event ordering is clean.
// V1 parity (MonitoringService.cs:388-413 "ADMIN OVERRIDE on startup") —
// if the register-session response carried an AdminAction the operator
// has already marked the session terminal via the portal. Post
// AdminPreemptionDetected so the reducer transitions Stage to Completed /
// Failed and emits the enrollment_complete/_failed timeline event as a
// side effect. The orchestrator's DecisionStepProcessor picks up the
// terminal stage and raises Terminated, which runs the termination
// pipeline (cleanup + summary + self-destruct) through the subscribed
// handler — no direct synthesis needed.
if (!string.IsNullOrEmpty(registrationResult.AdminAction))
{
HandleStartupAdminOverride(
orchestrator,
terminationHandler,
agentConfig,
registrationResult.AdminAction,
logger);
PostAdminPreemptionSignal(orchestrator.IngressSink, registrationResult, logger);
}
// M4.6.δ — fire-and-forget startup analyzers (LocalAdmin / SoftwareInventory /
@@ -906,59 +910,40 @@ namespace AutopilotMonitor.Agent.V2
}
/// <summary>
/// V1 parity (MonitoringService.cs:388-413) — when the register-session response
/// carried an <c>AdminAction</c> ("Succeeded" / "Failed"), emit the matching
/// <c>enrollment_complete</c> / <c>enrollment_failed</c> event and synthesise a
/// <see cref="EnrollmentTerminatedEventArgs"/> so the termination pipeline (cleanup,
/// summary dialog, self-destruct) runs without waiting for an external signal.
/// V2 parity — post <see cref="DecisionSignalKind.AdminPreemptionDetected"/> when the
/// register-session response carried an <c>AdminAction</c>. The reducer handler at
/// DecisionEngine.Shared.cs#HandleAdminPreemptionDetectedV1 transitions Stage to the
/// terminal state and emits the enrollment_complete/_failed telemetry event; the
/// orchestrator's DecisionStepProcessor then raises the Terminated event, which the
/// subscribed <c>EnrollmentTerminationHandler</c> picks up — no direct synthesis needed.
/// </summary>
private static void HandleStartupAdminOverride(
EnrollmentOrchestrator orchestrator,
EnrollmentTerminationHandler terminationHandler,
AgentConfiguration agentConfig,
string adminAction,
private static void PostAdminPreemptionSignal(
ISignalIngressSink ingressSink,
SessionRegistrationResult registrationResult,
AgentLogger logger)
{
try
{
var succeeded = string.Equals(adminAction, "Succeeded", StringComparison.OrdinalIgnoreCase);
var adminOutcome = registrationResult.AdminAction; // "Succeeded" | "Failed"
logger.Warning(
$"=== ADMIN OVERRIDE on startup: session already marked as {adminAction} by administrator — running cleanup only ===");
$"=== ADMIN OVERRIDE on startup: session already marked as {adminOutcome} by administrator — posting AdminPreemptionDetected signal ===");
orchestrator.EventEmitter.Emit(new EnrollmentEvent
{
SessionId = agentConfig.SessionId,
TenantId = agentConfig.TenantId,
EventType = succeeded ? "enrollment_complete" : "enrollment_failed",
Severity = succeeded ? EventSeverity.Info : EventSeverity.Warning,
Source = "AdminOverride",
Phase = EnrollmentPhase.Complete,
Message = $"Session {adminAction.ToLowerInvariant()} by administrator (detected on restart) — cleanup initiated.",
Data = new System.Collections.Generic.Dictionary<string, object>
ingressSink.Post(
kind: DecisionSignalKind.AdminPreemptionDetected,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "register_session_response",
evidence: new Evidence(
kind: EvidenceKind.Synthetic,
identifier: $"admin_preemption:{adminOutcome}",
summary: $"Operator marked session as {adminOutcome} via portal before agent startup."),
payload: new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
{
{ "adminAction", adminAction },
{ "source", "register_session_response" },
},
ImmediateUpload = true,
});
var outcome = succeeded
? EnrollmentTerminationOutcome.Succeeded
: EnrollmentTerminationOutcome.Failed;
var stage = succeeded ? SessionStage.Completed : SessionStage.Failed;
terminationHandler.Handle(
sender: null,
args: new EnrollmentTerminatedEventArgs(
reason: EnrollmentTerminationReason.DecisionTerminalStage,
outcome: outcome,
stageName: stage.ToString(),
terminatedAtUtc: DateTime.UtcNow,
details: $"Admin marked session as {adminAction} via portal (detected on register-session)."));
["adminOutcome"] = adminOutcome,
});
}
catch (Exception ex)
{
logger.Warning($"HandleStartupAdminOverride failed: {ex.Message}");
logger.Warning($"AdminPreemptionDetected post failed: {ex.Message}");
}
}
@@ -1060,71 +1045,38 @@ namespace AutopilotMonitor.Agent.V2
}
/// <summary>
/// Emits <c>whiteglove_resumed</c> when the whiteglove.complete marker was present at
/// boot — signals that the agent has resumed Part 2 after the device reseal.
/// V2 parity — post <see cref="DecisionSignalKind.SystemRebootObserved"/> when the prior
/// agent process was terminated by an OS reboot. The reducer handler at
/// DecisionEngine.Edge.cs#HandleSystemRebootObservedV1 records the reboot fact on the
/// state (used by the WhiteGlove reboot-observed scoring weight, plan §2.4) and emits
/// the <c>system_reboot_detected</c> telemetry event as a side effect.
/// </summary>
private static void EmitWhiteGloveResumedEvent(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
AgentLogger logger)
{
try
{
orchestrator.EventEmitter.Emit(new EnrollmentEvent
{
SessionId = agentConfig.SessionId,
TenantId = agentConfig.TenantId,
EventType = "whiteglove_resumed",
Severity = EventSeverity.Info,
Source = "AutopilotMonitor.Agent.V2",
Phase = EnrollmentPhase.Unknown,
Message = "WhiteGlove Part 2 resume detected — continuing user-phase monitoring.",
Data = new System.Collections.Generic.Dictionary<string, object>
{
{ "resumedAtUtc", DateTime.UtcNow.ToString("o") },
},
ImmediateUpload = true,
});
}
catch (Exception ex)
{
logger.Warning($"whiteglove_resumed emission failed: {ex.Message}");
}
}
/// <summary>
/// Emits <c>system_reboot_detected</c> when the previous agent exit was classified as
/// <c>reboot_kill</c> — the OS rebooted mid-session. Gives the backend a hook to split
/// the session into pre-reboot and post-reboot segments.
/// </summary>
private static void EmitSystemRebootDetectedEvent(
EnrollmentOrchestrator orchestrator,
AgentConfiguration agentConfig,
private static void PostSystemRebootObservedSignal(
ISignalIngressSink ingressSink,
PreviousExitSummary previousExit,
AgentLogger logger)
{
try
{
orchestrator.EventEmitter.Emit(new EnrollmentEvent
var payload = new System.Collections.Generic.Dictionary<string, string>(StringComparer.Ordinal)
{
SessionId = agentConfig.SessionId,
TenantId = agentConfig.TenantId,
EventType = "system_reboot_detected",
Severity = EventSeverity.Info,
Source = "AutopilotMonitor.Agent.V2",
Phase = EnrollmentPhase.Unknown,
Message = "Session resumed after OS reboot — prior agent process was terminated by the reboot.",
Data = new System.Collections.Generic.Dictionary<string, object>
{
{ "lastBootUtc", previousExit?.LastBootUtc?.ToString("o") ?? string.Empty },
{ "previousExitType", previousExit?.ExitType ?? string.Empty },
},
ImmediateUpload = true,
});
["previousExitType"] = previousExit?.ExitType ?? string.Empty,
["lastBootUtc"] = previousExit?.LastBootUtc?.ToString("o") ?? string.Empty,
};
ingressSink.Post(
kind: DecisionSignalKind.SystemRebootObserved,
occurredAtUtc: DateTime.UtcNow,
sourceOrigin: "Program.DetectPreviousExit",
evidence: new Evidence(
kind: EvidenceKind.Synthetic,
identifier: "previous_exit_reboot_kill",
summary: $"Prior agent process terminated by OS reboot (exitType={previousExit?.ExitType})."),
payload: payload);
}
catch (Exception ex)
{
logger.Warning($"system_reboot_detected emission failed: {ex.Message}");
logger.Warning($"SystemRebootObserved post failed: {ex.Message}");
}
}
@@ -1 +1 @@
102
104
@@ -1 +1 @@
1072
1073
@@ -0,0 +1,196 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
using AutopilotMonitor.DecisionCore.State;
using Xunit;
namespace AutopilotMonitor.DecisionCore.Tests
{
/// <summary>
/// Plan V2-parity PR-B — the reducer is the single state owner, and telemetry events
/// for the three lifecycle signals (SystemRebootObserved, SessionRecovered→WG bridge,
/// AdminPreemptionDetected) appear as <see cref="DecisionEffectKind.EmitEventTimelineEntry"/>
/// effects rather than being synthesised by the V2 host.
/// </summary>
public sealed class ReducerTimelineEffectTests
{
private static DecisionSignal BuildSignal(
DecisionSignalKind kind,
DateTime occurredAtUtc,
IReadOnlyDictionary<string, string>? payload = null,
string sourceOrigin = "test",
int ordinal = 1)
{
return new DecisionSignal(
sessionSignalOrdinal: ordinal,
sessionTraceOrdinal: ordinal,
kind: kind,
kindSchemaVersion: 1,
occurredAtUtc: occurredAtUtc,
sourceOrigin: sourceOrigin,
evidence: new Evidence(EvidenceKind.Synthetic, $"{kind}-{ordinal}", "test"),
payload: payload);
}
// ---------------------------------------------------------------- SystemRebootObserved
[Fact]
public void SystemRebootObservedV1_emits_system_reboot_detected_timeline_entry()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var payload = new Dictionary<string, string>
{
["previousExitType"] = "reboot_kill",
["lastBootUtc"] = "2026-04-22T08:00:00.0000000Z",
};
var signal = BuildSignal(
DecisionSignalKind.SystemRebootObserved,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
payload);
var step = engine.Reduce(state, signal);
Assert.NotNull(step.NewState.SystemRebootUtc);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.NotNull(timelineEffect.Parameters);
Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
Assert.Equal("reboot_kill", timelineEffect.Parameters["previousExitType"]);
Assert.Equal("2026-04-22T08:00:00.0000000Z", timelineEffect.Parameters["lastBootUtc"]);
}
[Fact]
public void SystemRebootObservedV1_without_payload_still_emits_timeline_entry()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.SystemRebootObserved,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc));
var step = engine.Reduce(state, signal);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.Equal("system_reboot_detected", timelineEffect.Parameters!["eventType"]);
Assert.False(timelineEffect.Parameters.ContainsKey("previousExitType"));
}
// ------------------------------------------------------ WhiteGlovePart1To2 bridge
[Fact]
public void WhiteGlovePart1To2Bridge_emits_whiteglove_resumed_timeline_entry()
{
var engine = new DecisionEngine();
var sealedState = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.WithStage(SessionStage.WhiteGloveSealed)
.Build();
var recoverSignal = BuildSignal(
DecisionSignalKind.SessionRecovered,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
sourceOrigin: "EnrollmentOrchestrator");
var step = engine.Reduce(sealedState, recoverSignal);
Assert.Equal(SessionStage.WhiteGloveAwaitingUserSignIn, step.NewState.Stage);
var timelineEffect = step.Effects.Single(e => e.Kind == DecisionEffectKind.EmitEventTimelineEntry);
Assert.Equal("whiteglove_resumed", timelineEffect.Parameters!["eventType"]);
Assert.Equal("2026-04-22T10:00:00.0000000Z", timelineEffect.Parameters["resumedAtUtc"]);
Assert.Equal("EnrollmentOrchestrator", timelineEffect.Parameters["sourceOrigin"]);
// ScheduleDeadline remains.
Assert.Contains(step.Effects, e => e.Kind == DecisionEffectKind.ScheduleDeadline);
}
// ---------------------------------------------------- AdminPreemptionDetected
[Fact]
public void AdminPreemptionDetectedV1_succeeded_transitions_to_completed_and_emits_complete_event()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Succeeded" },
sourceOrigin: "register_session_response");
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Completed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
var effect = Assert.Single(step.Effects);
Assert.Equal(DecisionEffectKind.EmitEventTimelineEntry, effect.Kind);
Assert.Equal("enrollment_complete", effect.Parameters!["eventType"]);
Assert.Equal("Succeeded", effect.Parameters["adminAction"]);
Assert.Equal("register_session_response", effect.Parameters["source"]);
}
[Fact]
public void AdminPreemptionDetectedV1_failed_transitions_to_failed_and_emits_failed_event()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Failed" });
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
var effect = Assert.Single(step.Effects);
Assert.Equal("enrollment_failed", effect.Parameters!["eventType"]);
Assert.Equal("Failed", effect.Parameters["adminAction"]);
}
[Fact]
public void AdminPreemptionDetectedV1_missing_outcome_defaults_to_failed()
{
var engine = new DecisionEngine();
var state = DecisionState.CreateInitial("s", "t");
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
payload: null);
var step = engine.Reduce(state, signal);
Assert.Equal(SessionStage.Failed, step.NewState.Stage);
Assert.Equal(SessionOutcome.AdminPreempted, step.NewState.Outcome);
}
[Fact]
public void AdminPreemptionDetectedV1_clears_active_deadlines()
{
var engine = new DecisionEngine();
var stateWithDeadline = DecisionState.CreateInitial("s", "t")
.ToBuilder()
.AddDeadline(new ActiveDeadline(
name: DeadlineNames.ClassifierTick,
dueAtUtc: new DateTime(2026, 4, 22, 11, 0, 0, DateTimeKind.Utc),
firesSignalKind: DecisionSignalKind.DeadlineFired,
firesPayload: new Dictionary<string, string>
{
[SignalPayloadKeys.Deadline] = DeadlineNames.ClassifierTick,
}))
.Build();
var signal = BuildSignal(
DecisionSignalKind.AdminPreemptionDetected,
new DateTime(2026, 4, 22, 10, 0, 0, DateTimeKind.Utc),
new Dictionary<string, string> { ["adminOutcome"] = "Succeeded" });
var step = engine.Reduce(stateWithDeadline, signal);
Assert.Empty(step.NewState.Deadlines);
}
}
}
@@ -86,7 +86,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
/// <summary>
/// Handle <see cref="DecisionSignalKind.SystemRebootObserved"/>. Records the fact so
/// WhiteGlove scoring (plan §2.4) can credit the +15 reboot-observed weight.
/// 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.
/// </summary>
private DecisionStep HandleSystemRebootObservedV1(DecisionState state, DecisionSignal signal)
{
@@ -108,7 +110,27 @@ namespace AutopilotMonitor.DecisionCore.Engine
nextStepIndex: nextStep,
trigger: nameof(DecisionSignalKind.SystemRebootObserved));
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
var timelineParams = new Dictionary<string, string>
{
["eventType"] = "system_reboot_detected",
["reason"] = "prior agent process was terminated by the reboot",
};
if (signal.Payload != null)
{
if (signal.Payload.TryGetValue("previousExitType", out var previousExitType))
timelineParams["previousExitType"] = previousExitType ?? string.Empty;
if (signal.Payload.TryGetValue("lastBootUtc", out var lastBootUtc))
timelineParams["lastBootUtc"] = lastBootUtc ?? string.Empty;
}
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: timelineParams),
};
return new DecisionStep(newState, transition, effects);
}
}
}
@@ -139,6 +139,64 @@ namespace AutopilotMonitor.DecisionCore.Engine
return new DecisionStep(newState, transition, Array.Empty<DecisionEffect>());
}
/// <summary>
/// Handle <see cref="DecisionSignalKind.AdminPreemptionDetected"/>. Plan §2.7 admin-
/// action audit; V2 parity PR-B3.
/// <para>
/// Emitted by <c>Program.RunAgent</c> when the register-session response carries an
/// <c>AdminAction</c> value (operator marked the session terminal via the portal before
/// the agent even started). The signal payload carries
/// <c>adminOutcome=Succeeded|Failed</c>.
/// </para>
/// <para>
/// Stage transitions to <see cref="SessionStage.Completed"/> (Succeeded) or
/// <see cref="SessionStage.Failed"/> (anything else); <see cref="SessionOutcome.AdminPreempted"/>
/// captures the non-enrollment nature of the transition so dashboards + KQL can tell
/// an admin-override apart from a genuine enrollment_complete/_failed.
/// </para>
/// </summary>
private DecisionStep HandleAdminPreemptionDetectedV1(DecisionState state, DecisionSignal signal)
{
var adminOutcome = signal.Payload != null && signal.Payload.TryGetValue("adminOutcome", out var v)
? v
: "Failed"; // defensive default: preemption without outcome is treated as failure.
var succeeded = string.Equals(adminOutcome, "Succeeded", StringComparison.OrdinalIgnoreCase);
var toStage = succeeded ? SessionStage.Completed : SessionStage.Failed;
var eventType = succeeded ? "enrollment_complete" : "enrollment_failed";
var nextStep = state.StepIndex + 1;
var newState = state.ToBuilder()
.WithStage(toStage)
.WithOutcome(SessionOutcome.AdminPreempted)
.WithStepIndex(nextStep)
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
.ClearDeadlines()
.Build();
var transition = BuildTakenTransition(
before: state,
signal: signal,
toStage: toStage,
nextStepIndex: nextStep,
trigger: $"AdminPreemption:{adminOutcome}");
var effects = new[]
{
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = eventType,
["adminAction"] = adminOutcome,
["source"] = signal.SourceOrigin ?? "register_session_response",
["reason"] = $"Session {adminOutcome.ToLowerInvariant()} by administrator (detected on register-session).",
}),
};
return new DecisionStep(newState, transition, effects);
}
// ============================================================== shared helpers
// Partial-class shared helpers used by Classic / SelfDeploying / WhiteGlove handlers
// as they come online in M3.1+ live below. M3.0 establishes the skeleton; the bodies
@@ -51,6 +51,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
var effects = new[]
{
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: safety),
new DecisionEffect(
DecisionEffectKind.EmitEventTimelineEntry,
parameters: new Dictionary<string, string>
{
["eventType"] = "whiteglove_resumed",
["resumedAtUtc"] = signal.OccurredAtUtc.ToString("o"),
["sourceOrigin"] = signal.SourceOrigin ?? string.Empty,
}),
};
return new DecisionStep(newState, transition, effects);
@@ -56,10 +56,11 @@ namespace AutopilotMonitor.DecisionCore.Engine
return (signal.Kind, signal.KindSchemaVersion) switch
{
// ----- Lifecycle (DecisionEngine.Shared.cs) -----
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
(DecisionSignalKind.SessionStarted, 1) => HandleSessionStartedV1(state, signal),
(DecisionSignalKind.SessionRecovered, 1) => HandleSessionRecoveredV1(state, signal),
(DecisionSignalKind.SessionAborted, 1) => HandleSessionAbortedV1(state, signal),
(DecisionSignalKind.AdminPreemptionDetected, 1) => HandleAdminPreemptionDetectedV1(state, signal),
(DecisionSignalKind.DeadlineFired, 1) => HandleDeadlineFiredV1(state, signal),
// ----- Classic UserDriven-v1 (DecisionEngine.Classic.cs) -----
(DecisionSignalKind.EspPhaseChanged, 1) => HandleEspPhaseChangedV1(state, signal),
@@ -41,5 +41,8 @@ namespace AutopilotMonitor.DecisionCore.Signals
SessionStarted,
SessionAborted,
SessionRecovered,
// --- Admin-driven preemption (Plan §2.7 admin-action audit, V2 parity PR-B3) ---
AdminPreemptionDetected,
}
}
@@ -13,5 +13,12 @@ namespace AutopilotMonitor.DecisionCore.State
WhiteGlovePart1Sealed,
WhiteGlovePart2Complete,
Aborted,
// V2 parity PR-B3 / plan §2.7 admin-action audit:
// Set when the register-session response carries an AdminAction
// (operator marked the session terminal via the portal before the
// agent even started). Stage transitions to Completed or Failed
// depending on the adminOutcome payload.
AdminPreempted,
}
}
@@ -0,0 +1,3 @@
# AdminPreemptionDetected v1 — adminOutcome=Failed → Stage.Failed,
# Outcome=AdminPreempted, effect emits enrollment_failed timeline entry.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Failed","Summary":"Operator marked session as Failed via portal before agent startup."},"Payload":{"adminOutcome":"Failed"}}
@@ -0,0 +1,4 @@
# AdminPreemptionDetected v1 — adminOutcome=Succeeded → Stage.Completed,
# Outcome=AdminPreempted, effect emits enrollment_complete timeline entry with
# adminAction=Succeeded + source=register_session_response.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"AdminPreemptionDetected","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"register_session_response","Evidence":{"Kind":"Synthetic","Identifier":"admin_preemption:Succeeded","Summary":"Operator marked session as Succeeded via portal before agent startup."},"Payload":{"adminOutcome":"Succeeded"}}
@@ -0,0 +1,5 @@
# SystemRebootObserved v1 — V2 parity PR-B2.
# Reducer handler HandleSystemRebootObservedV1 records SystemRebootUtc fact and emits
# system_reboot_detected timeline entry. Payload carries previousExitType + lastBootUtc
# so the backend can split the session into pre-/post-reboot segments.
{"SessionSignalOrdinal":1,"SessionTraceOrdinal":1,"Kind":"SystemRebootObserved","KindSchemaVersion":1,"OccurredAtUtc":"2026-04-22T10:00:00.0000000Z","SourceOrigin":"Program.DetectPreviousExit","Evidence":{"Kind":"Synthetic","Identifier":"previous_exit_reboot_kill","Summary":"Prior agent process terminated by OS reboot."},"Payload":{"previousExitType":"reboot_kill","lastBootUtc":"2026-04-22T08:00:00.0000000Z"}}