mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e20476559 |
@@ -143,11 +143,29 @@ advisory backstop. All routes converge on `CompleteThroughFinalizingOrDefer`:
|
||||
first-deployment window 100/101 for 200/210 without 110 — the RJ ESP was aborted,
|
||||
typically by an interactive logon reclassifying the run as secondary-user; emits
|
||||
the `realmjoin_first_deployment_incomplete` Warning, session 224b2087), and
|
||||
`Timeout` (hard 60-min deadline). The abort rule reads the *persisted*
|
||||
`Timeout` (60-min deadline from detection). The abort rule reads the *persisted*
|
||||
`RealmJoinFacts.LastDeploymentPhase` (updated by the typed `RealmJoinPhaseChanged`
|
||||
signal), so it survives agent restarts — the `RealmJoinDetected` replay path
|
||||
evaluates it too. A first observation of 210 does **not** release (RJ may have
|
||||
completed before agent boot and deploy afterwards).
|
||||
|
||||
**Activity-based timeout extension** (report 55e6afd61c9d — a large RJ package
|
||||
catalog outlived the 60-min window mid-install): when the timeout deadline fires
|
||||
while the first deployment is demonstrably active — `LastDeploymentPhase` 100/101
|
||||
*and* deployment activity (`RealmJoinPhaseChanged` or a package observation, tracked
|
||||
in `RealmJoinFacts.LastActivityUtc`; the phase captured at detection deliberately
|
||||
does not count) within the last 60 min — the handler re-arms the deadline to
|
||||
`lastActivity + 60 min` instead of timing out, emitting an Info
|
||||
`realmjoin_timeout_extended` event. Re-arms clamp to an absolute cap of
|
||||
**detection + 4 h**. Package activity is only observable at package *completion*
|
||||
(RJ writes the registry subkey when the install finishes), so a single install
|
||||
that is registry-silent for over 60 min still times out — the eventual
|
||||
`realmjoin_timeout` carries a `reason` payload (`hard_timeout` / `inactivity` /
|
||||
`absolute_cap`) telling the three shapes apart. A stale fire of an older deadline
|
||||
incarnation (armed due later than the fire time; `OccurredAtUtc = DueAtUtc` per
|
||||
scheduler contract) dead-ends as `realmjoin_timeout_stale_superseded_by_rearm`.
|
||||
The idle case (detected, no deployment activity ever) times out at 60 min
|
||||
unchanged, and phase 200/210 activity never extends.
|
||||
2. `Finalizing` stage + ~5 s `FinalizingGrace` deadline.
|
||||
3. Grace fires → `Completed`, `EnrollmentComplete` outcome, `enrollment_complete`
|
||||
timeline event with full audit payload.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## 2026-08-21
|
||||
|
||||
* **Update**: `agent/decision-engine.md` — activity-based RealmJoin timeout extension (report 55e6afd61c9d: a large RJ package catalog outlived the 60-min window mid-install — the timer arms at RJ-agent-MSI detection, so the first deployment had effectively ~44 min). When the `RealmJoinTimeout` deadline fires with `LastDeploymentPhase` 100/101 and deployment activity (phase change or package observation, persisted in `RealmJoinFacts.LastActivityUtc`; the detection-time phase capture deliberately excluded) inside the last 60 min, the deadline re-arms to `lastActivity + 60 min` (Info event `realmjoin_timeout_extended`), clamped at detection + 4 h. `realmjoin_timeout` now carries `reason` = `hard_timeout` / `inactivity` / `absolute_cap`; a fire of a superseded deadline incarnation dead-ends. Idle detection and phase-200/210 sessions keep the original 60-min behavior.
|
||||
|
||||
* **New**: `backend/send-time-frame-separation.md` — P14: the agent stamps one device-clock send timestamp per upload attempt (`X-Send-Time-Utc`), the ingest function server-stamps it as `SentAt` on every event of the request. `SentAt − OccurredUtc` is pure spool delay, `ReceivedAt − SentAt` is network + device-vs-server clock offset — the two were indistinguishable before and had to be separated statistically. The `clock_skew` condition measures SentAt-carrying batches directly (spool-immune, no spread cap, IME-only sessions become measurable; evidence carries `sentAtBatchCount`), legacy batches keep the median path; the CMTrace tripwire deliberately stays on its differential math (common-mode clock offset cancels there).
|
||||
|
||||
## 2026-08-20
|
||||
|
||||
@@ -372,6 +372,9 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
// Before RealmJoinPhaseChanged became a typed signal, LastDeploymentPhase was only
|
||||
// written at detection time — every realmjoin_timeout claimed "last phase: 0" no
|
||||
// matter how far RJ actually got, hiding this whole failure class from ops.
|
||||
// Since the activity-based extension, the PhaseChanged at T0+6 counts as activity,
|
||||
// so the first fire (59 min later) extends once; the timeout lands on the re-armed
|
||||
// fire a full inactivity window after the last activity.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
@@ -384,7 +387,13 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DesktopArrived, T0.AddMinutes(7))).NewState;
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65),
|
||||
// First fire at the original due (T0+65): activity 59 min ago → extends to T0+66.
|
||||
state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, T0.AddMinutes(65),
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })).NewState;
|
||||
var rearmed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(T0.AddMinutes(66), rearmed.DueAtUtc);
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.DeadlineFired, rearmed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
@@ -395,6 +404,248 @@ namespace AutopilotMonitor.DecisionCore.Tests
|
||||
Assert.Equal("101", timeout.Parameters["lastSeenPhase"]);
|
||||
}
|
||||
|
||||
// ============================================================== Activity-based extension (report 55e6afd61c9d)
|
||||
|
||||
[Fact]
|
||||
public void Timeout_fire_with_recent_first_deployment_activity_extends_instead_of_timing_out()
|
||||
{
|
||||
// Report 55e6afd61c9d (Douglas): timer armed at detection (phase 0, RJ agent MSI
|
||||
// install during DeviceSetup), first deployment only started 16 min later, Office
|
||||
// completed 3 s before the deadline — the hard cut truncated an actively working
|
||||
// deployment. The fire must now re-arm to lastActivity + 60 min instead.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(21),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "0",
|
||||
})).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.RealmJoinPackageCompleted, T0.AddMinutes(62),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-microsoft-office-2016-proplus",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Success] = "true",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.LastExitCode] = "0",
|
||||
})).NewState;
|
||||
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
// No completion, no outcome — the gate stays closed and the window is re-armed.
|
||||
Assert.NotEqual(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Equal($"DeadlineFired:{DeadlineNames.RealmJoinTimeout}:Extended", step.Transition.Trigger);
|
||||
|
||||
var rearmed = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(T0.AddMinutes(62 + 60), rearmed.DueAtUtc); // lastActivity + inactivity window
|
||||
|
||||
Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.ScheduleDeadline &&
|
||||
e.Deadline != null &&
|
||||
e.Deadline.Name == DeadlineNames.RealmJoinTimeout &&
|
||||
e.Deadline.DueAtUtc == rearmed.DueAtUtc);
|
||||
|
||||
var extended = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout_extended");
|
||||
Assert.Equal("Info", extended.Parameters!["severity"]);
|
||||
Assert.Equal("101", extended.Parameters["deploymentPhase"]);
|
||||
Assert.DoesNotContain(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Resolved_after_extension_completes_via_classic_path()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(40),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "100",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "101",
|
||||
})).NewState;
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })).NewState;
|
||||
Assert.Null(state.RealmJoinFacts.Outcome); // extended, not timed out
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.RealmJoinResolved, T0.AddMinutes(80),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "110" }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Resolved", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
Assert.DoesNotContain(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Rearmed_fire_after_quiet_window_times_out_with_inactivity_reason()
|
||||
{
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(30),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "0",
|
||||
})).NewState;
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })).NewState;
|
||||
var rearmed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(T0.AddMinutes(90), rearmed.DueAtUtc); // lastActivity (T0+30) + 60 min
|
||||
|
||||
// No further activity — the re-armed fire must now time out for real.
|
||||
var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.DeadlineFired, rearmed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
Assert.Equal("inactivity", timeout.Parameters!["reason"]);
|
||||
Assert.Contains("no deployment activity", timeout.Parameters["message"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Extension_is_capped_at_absolute_ceiling_and_cap_fire_times_out_despite_activity()
|
||||
{
|
||||
// Late activity pushes the sliding window beyond detection + 4 h — the re-arm must
|
||||
// clamp to the cap, and the cap fire must time out even though activity is recent.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
// Package completion whose sliding window (T0+230+60) would exceed the cap (T0+245).
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPackageCompleted, T0.AddMinutes(230),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PackageId] = "generic-big-suite",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Scope] = RealmJoinPackageFact.ScopeMachine,
|
||||
[DecisionEngine.RealmJoinPayloadKeys.Success] = "true",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.LastExitCode] = "0",
|
||||
})).NewState;
|
||||
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
state = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })).NewState;
|
||||
|
||||
var rearmed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(T0.AddMinutes(5).AddHours(4), rearmed.DueAtUtc); // clamped to detection + cap
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(9, DecisionSignalKind.DeadlineFired, rearmed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
Assert.Equal("absolute_cap", timeout.Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Stale_fire_of_old_incarnation_after_rearm_is_dead_end()
|
||||
{
|
||||
// Race: the OLD deadline incarnation's fire was already queued when the extension
|
||||
// re-armed. The armed deadline is due LATER than the stale fire — dead-end, no
|
||||
// second extension evaluation, no timeout.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "0" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(30),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "0",
|
||||
})).NewState;
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout })).NewState;
|
||||
Assert.Null(state.RealmJoinFacts.Outcome); // extended
|
||||
|
||||
var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
Assert.False(step.Transition.Taken);
|
||||
Assert.Null(step.NewState.RealmJoinFacts.Outcome);
|
||||
Assert.Empty(step.Effects);
|
||||
var stillArmed = Assert.Single(step.NewState.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
Assert.Equal(T0.AddMinutes(90), stillArmed.DueAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Detection_phase_alone_is_not_activity_idle_case_times_out_at_60_min_unchanged()
|
||||
{
|
||||
// The phase captured AT detection must not count as activity — RJ detected in a
|
||||
// first-deployment phase that then never moves still times out after the original
|
||||
// 60 min (the extension exists for demonstrable progress, not for standing still).
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "101" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
Assert.Null(state.RealmJoinFacts.LastActivityUtc);
|
||||
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
var step = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
var timeout = Assert.Single(step.Effects, e =>
|
||||
e.Kind == DecisionEffectKind.EmitEventTimelineEntry &&
|
||||
e.Parameters != null &&
|
||||
e.Parameters.TryGetValue("eventType", out var et) && et == "realmjoin_timeout");
|
||||
Assert.Equal("hard_timeout", timeout.Parameters!["reason"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Regular_deployment_phase_with_activity_does_not_extend()
|
||||
{
|
||||
// Session 6f1959c0 shape: RJ stood at 210 when the agent booted and deploys
|
||||
// regular (non-first) packages afterwards. Phase 200/210 is outside the
|
||||
// first-deployment window — activity there must NOT hold the session hostage.
|
||||
var engine = new DecisionEngine();
|
||||
var state = PrimeClassicAwaitingDesktop(engine);
|
||||
state = engine.Reduce(state, MakeSignal(5, DecisionSignalKind.RealmJoinDetected, T0.AddMinutes(5),
|
||||
new Dictionary<string, string> { [DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "210" })).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(6, DecisionSignalKind.DesktopArrived, T0.AddMinutes(6))).NewState;
|
||||
state = engine.Reduce(state, MakeSignal(7, DecisionSignalKind.RealmJoinPhaseChanged, T0.AddMinutes(40),
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
[DecisionEngine.RealmJoinPayloadKeys.DeploymentPhase] = "200",
|
||||
[DecisionEngine.RealmJoinPayloadKeys.PreviousPhase] = "210",
|
||||
})).NewState;
|
||||
|
||||
var armed = Assert.Single(state.Deadlines, d => d.Name == DeadlineNames.RealmJoinTimeout);
|
||||
var step = engine.Reduce(state, MakeSignal(8, DecisionSignalKind.DeadlineFired, armed.DueAtUtc,
|
||||
new Dictionary<string, string> { [SignalPayloadKeys.Deadline] = DeadlineNames.RealmJoinTimeout }));
|
||||
|
||||
Assert.Equal(SessionStage.Finalizing, step.NewState.Stage);
|
||||
Assert.Equal("Timeout", step.NewState.RealmJoinFacts.Outcome!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelfDeploying_deferred_terminal_released_by_first_deployment_incomplete()
|
||||
{
|
||||
|
||||
@@ -121,6 +121,7 @@ export const EVENT_TYPES_CATALOG = {
|
||||
'realmjoin_phase_changed',
|
||||
'realmjoin_resolved',
|
||||
'realmjoin_timeout',
|
||||
'realmjoin_timeout_extended',
|
||||
'realmjoin_first_deployment_incomplete',
|
||||
'realmjoin_package_started',
|
||||
'realmjoin_package_completed',
|
||||
|
||||
@@ -151,6 +151,10 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
{
|
||||
data["realmjoinReleaseChannel"] = postState.RealmJoinFacts.ReleaseChannel.Value;
|
||||
}
|
||||
if (postState.RealmJoinFacts.LastActivityUtc != null)
|
||||
{
|
||||
data["realmjoinLastActivityUtc"] = FormatUtc(postState.RealmJoinFacts.LastActivityUtc.Value);
|
||||
}
|
||||
data["realmjoinPackages"] = BuildRealmJoinPackageSummary(postState.RealmJoinFacts.Packages);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,16 +11,30 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
// RealmJoin (RJ) deployment-tracking handlers. Plan: tasks/zany-gathering-oasis plan.
|
||||
// The agent posts six DecisionSignalKinds when it observes the
|
||||
// HKLM\SYSTEM\CurrentControlSet\Services\realmjoin\Parameters key + HKLM\SOFTWARE\RealmJoin\Packages
|
||||
// (and the HKU\<sid>\... user-scope counterpart). Detection arms a 60-min hard timeout
|
||||
// (and the HKU\<sid>\... user-scope counterpart). Detection arms a 60-min timeout
|
||||
// and gates the enrollment-completion AND-gate. Resolution (phase 110), the
|
||||
// aborted-first-deployment release (phase left 100/101 for 200/210 without 110 —
|
||||
// session 224b2087) or the timeout releases the gate so the session can complete.
|
||||
// When the timeout fires while the first deployment is demonstrably still active
|
||||
// (phase 100/101 with deployment activity inside the last 60 min), the deadline is
|
||||
// re-armed instead — up to an absolute 4-h cap from detection (report 55e6afd61c9d:
|
||||
// a large package catalog outlived the 60-min window mid-install).
|
||||
public sealed partial class DecisionEngine
|
||||
{
|
||||
// Hard 60-minute deadline from RJ-detected. Not configurable (per design choice —
|
||||
// the trigger is intentionally aggressive, the timeout has to bound it).
|
||||
// 60-minute deadline from RJ-detected. Not configurable (per design choice —
|
||||
// the trigger is intentionally aggressive, the timeout has to bound it). Doubles
|
||||
// as the inactivity window for the activity-based extension: RJ package writes are
|
||||
// only observable at package COMPLETION (RJ creates the registry subkey when the
|
||||
// install finishes), so a single long install is registry-silent for its whole
|
||||
// duration and the window must not be tightened below the original 60 min.
|
||||
private static readonly TimeSpan s_realmJoinHardTimeout = TimeSpan.FromMinutes(60);
|
||||
|
||||
// Absolute ceiling for activity-based extensions, measured from RJ detection.
|
||||
// Preserves the original design intent (the aggressive detection trigger stays
|
||||
// bounded) and sits safely inside AgentMaxLifetimeMinutes (360) — the agent's own
|
||||
// lifetime watchdog would otherwise be the only backstop.
|
||||
private static readonly TimeSpan s_realmJoinAbsoluteTimeout = TimeSpan.FromHours(4);
|
||||
|
||||
// Payload keys consumed by the new handlers (set by the agent's RealmJoinWatcherAdapter).
|
||||
// Public because the V2.Core agent adapter assembles signal payloads with these keys.
|
||||
public static class RealmJoinPayloadKeys
|
||||
@@ -64,14 +78,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|| facts.Outcome != null;
|
||||
|
||||
/// <summary>
|
||||
/// Build the 60-min RJ hard-timeout deadline. Floored at <see cref="DecisionState.AgentBootUtc"/>
|
||||
/// via <see cref="EffectiveDeadlineBase"/> so a replayed RealmJoinDetected signal cannot
|
||||
/// collapse the timer into immediate-fire at boot.
|
||||
/// Build the RJ timeout deadline for an explicit due time. Initial arm: detection
|
||||
/// (floored at <see cref="DecisionState.AgentBootUtc"/> via <see cref="EffectiveDeadlineBase"/>
|
||||
/// so a replayed RealmJoinDetected signal cannot collapse the timer into immediate-fire
|
||||
/// at boot) + 60 min. Re-arm (activity-based extension): last activity + 60 min,
|
||||
/// capped at detection + 4 h.
|
||||
/// </summary>
|
||||
private static ActiveDeadline BuildRealmJoinTimeoutDeadline(DateTime fromUtc) =>
|
||||
private static ActiveDeadline BuildRealmJoinTimeoutDeadline(DateTime dueAtUtc) =>
|
||||
new ActiveDeadline(
|
||||
name: DeadlineNames.RealmJoinTimeout,
|
||||
dueAtUtc: fromUtc.Add(s_realmJoinHardTimeout),
|
||||
dueAtUtc: dueAtUtc,
|
||||
firesSignalKind: DecisionSignalKind.DeadlineFired,
|
||||
firesPayload: new Dictionary<string, string>
|
||||
{
|
||||
@@ -134,7 +150,8 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var effects = Array.Empty<DecisionEffect>();
|
||||
if (!alreadyDetected)
|
||||
{
|
||||
var deadline = BuildRealmJoinTimeoutDeadline(EffectiveDeadlineBase(state, signal));
|
||||
var deadline = BuildRealmJoinTimeoutDeadline(
|
||||
EffectiveDeadlineBase(state, signal).Add(s_realmJoinHardTimeout));
|
||||
builder.AddDeadline(deadline);
|
||||
effects = new[]
|
||||
{
|
||||
@@ -224,13 +241,20 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
private DecisionStep HandleRealmJoinTimeoutDeadlineFired(DecisionState state, DecisionSignal signal)
|
||||
{
|
||||
var alreadyResolvedOrTimedOut = state.RealmJoinFacts.Outcome != null;
|
||||
var deadlineStillArmed = false;
|
||||
ActiveDeadline? armedDeadline = null;
|
||||
foreach (var d in state.Deadlines)
|
||||
{
|
||||
if (d.Name == DeadlineNames.RealmJoinTimeout) { deadlineStillArmed = true; break; }
|
||||
if (d.Name == DeadlineNames.RealmJoinTimeout) { armedDeadline = d; break; }
|
||||
}
|
||||
|
||||
if (alreadyResolvedOrTimedOut || !deadlineStillArmed)
|
||||
// Third staleness shape (introduced with the activity-based extension): the fire
|
||||
// belongs to an OLDER deadline incarnation that a re-arm has since replaced —
|
||||
// recognizable because the armed deadline is due LATER than this fire
|
||||
// (OccurredAtUtc = DueAtUtc per the scheduler contract). Without this guard the
|
||||
// stale fire would evaluate the timeout/extension decision ahead of schedule.
|
||||
var supersededByRearm = armedDeadline != null && armedDeadline.DueAtUtc > signal.OccurredAtUtc;
|
||||
|
||||
if (alreadyResolvedOrTimedOut || armedDeadline == null || supersededByRearm)
|
||||
{
|
||||
var bookkept = BumpStepBookkeeping(state, signal);
|
||||
var staleTransition = BuildDeadEndTransition(
|
||||
@@ -240,12 +264,60 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}",
|
||||
deadEndReason: alreadyResolvedOrTimedOut
|
||||
? "realmjoin_timeout_stale_outcome_already_set"
|
||||
: "realmjoin_timeout_stale_deadline_not_armed");
|
||||
: armedDeadline == null
|
||||
? "realmjoin_timeout_stale_deadline_not_armed"
|
||||
: "realmjoin_timeout_stale_superseded_by_rearm");
|
||||
return new DecisionStep(bookkept, staleTransition, Array.Empty<DecisionEffect>());
|
||||
}
|
||||
|
||||
var nextStep = state.StepIndex + 1;
|
||||
|
||||
// Activity-based extension (report 55e6afd61c9d): when the first deployment is
|
||||
// demonstrably still active — phase 100/101 with deployment activity (phase change
|
||||
// or package observation) inside the last 60 min — re-arm instead of cutting off
|
||||
// mid-install. Bounded by an absolute 4-h cap from detection so the original
|
||||
// "aggressive trigger stays bounded" design intent survives. The idle case
|
||||
// (detected, but the first deployment never produced any activity) is unchanged:
|
||||
// LastActivityUtc stays null and the 60-min timeout fires exactly as before.
|
||||
var facts = state.RealmJoinFacts;
|
||||
var now = signal.OccurredAtUtc;
|
||||
var lastActivity = facts.LastActivityUtc?.Value;
|
||||
var detectedUtc = facts.DetectedUtc?.Value;
|
||||
var inFirstDeployment = facts.LastDeploymentPhase != null
|
||||
&& IsRealmJoinFirstDeploymentPhase(facts.LastDeploymentPhase.Value);
|
||||
var recentActivity = lastActivity != null && now - lastActivity.Value < s_realmJoinHardTimeout;
|
||||
var withinCap = detectedUtc != null && now - detectedUtc.Value < s_realmJoinAbsoluteTimeout;
|
||||
|
||||
if (inFirstDeployment && recentActivity && withinCap)
|
||||
{
|
||||
var windowDue = lastActivity!.Value.Add(s_realmJoinHardTimeout);
|
||||
var capDue = detectedUtc!.Value.Add(s_realmJoinAbsoluteTimeout);
|
||||
var newDue = windowDue < capDue ? windowDue : capDue;
|
||||
|
||||
var rearm = BuildRealmJoinTimeoutDeadline(newDue);
|
||||
var rearmBuilder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
.CancelDeadline(DeadlineNames.RealmJoinTimeout)
|
||||
.AddDeadline(rearm);
|
||||
|
||||
var rearmState = rearmBuilder.Build();
|
||||
var rearmTransition = BuildTakenTransition(
|
||||
before: state,
|
||||
signal: signal,
|
||||
toStage: state.Stage,
|
||||
nextStepIndex: nextStep,
|
||||
trigger: $"DeadlineFired:{DeadlineNames.RealmJoinTimeout}:Extended");
|
||||
|
||||
var rearmEffects = new[]
|
||||
{
|
||||
new DecisionEffect(DecisionEffectKind.ScheduleDeadline, deadline: rearm),
|
||||
BuildRealmJoinTimeoutExtendedEvent(facts, lastActivity.Value, newDue),
|
||||
};
|
||||
|
||||
return new DecisionStep(rearmState, rearmTransition, rearmEffects);
|
||||
}
|
||||
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal)
|
||||
@@ -253,7 +325,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithTimeoutOutcome(signal.SessionSignalOrdinal);
|
||||
|
||||
var timeoutEffect = BuildRealmJoinTimeoutEvent(state);
|
||||
// Message differentiates WHY the gate gave up: absolute cap exhausted while still
|
||||
// active, activity went quiet, or the original hard timeout (no activity ever seen).
|
||||
var reason = inFirstDeployment && recentActivity && !withinCap
|
||||
? RealmJoinTimeoutReasonAbsoluteCap
|
||||
: inFirstDeployment && lastActivity != null
|
||||
? RealmJoinTimeoutReasonInactivity
|
||||
: RealmJoinTimeoutReasonHardTimeout;
|
||||
|
||||
var timeoutEffect = BuildRealmJoinTimeoutEvent(state, reason);
|
||||
|
||||
return CompleteIfDeferredOrBookkeep(
|
||||
state: state,
|
||||
@@ -331,7 +411,9 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithLastPhase(currentPhase.Value, signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts
|
||||
.WithLastPhase(currentPhase.Value, signal.SessionSignalOrdinal)
|
||||
.WithActivity(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
|
||||
if (!IsFirstDeploymentAbortTransition(state.RealmJoinFacts, previousPhase, currentPhase.Value))
|
||||
{
|
||||
@@ -442,12 +524,14 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageStarted(
|
||||
packageId: packageId!,
|
||||
displayName: displayName,
|
||||
version: version,
|
||||
scope: scope,
|
||||
startedUtc: signal.OccurredAtUtc);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts
|
||||
.WithPackageStarted(
|
||||
packageId: packageId!,
|
||||
displayName: displayName,
|
||||
version: version,
|
||||
scope: scope,
|
||||
startedUtc: signal.OccurredAtUtc)
|
||||
.WithActivity(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
@@ -490,14 +574,16 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
var builder = state.ToBuilder()
|
||||
.WithStepIndex(nextStep)
|
||||
.WithLastAppliedSignalOrdinal(signal.SessionSignalOrdinal);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts.WithPackageCompleted(
|
||||
packageId: packageId!,
|
||||
displayName: displayName,
|
||||
version: version,
|
||||
scope: scope,
|
||||
completedUtc: signal.OccurredAtUtc,
|
||||
success: success,
|
||||
lastExitCode: lastExitCode);
|
||||
builder.RealmJoinFacts = state.RealmJoinFacts
|
||||
.WithPackageCompleted(
|
||||
packageId: packageId!,
|
||||
displayName: displayName,
|
||||
version: version,
|
||||
scope: scope,
|
||||
completedUtc: signal.OccurredAtUtc,
|
||||
success: success,
|
||||
lastExitCode: lastExitCode)
|
||||
.WithActivity(signal.OccurredAtUtc, signal.SessionSignalOrdinal);
|
||||
|
||||
var newState = builder.Build();
|
||||
var transition = BuildTakenTransition(
|
||||
@@ -654,6 +740,15 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reason strings carried in the realmjoin_timeout event payload ("reason") and
|
||||
// reflected in its message. HardTimeout = original semantics (no deployment activity
|
||||
// ever observed, 60 min from detection); Inactivity = the first deployment produced
|
||||
// activity at some point but went quiet for a full 60-min window; AbsoluteCap = still
|
||||
// active but the 4-h extension ceiling from detection is exhausted.
|
||||
internal const string RealmJoinTimeoutReasonHardTimeout = "hard_timeout";
|
||||
internal const string RealmJoinTimeoutReasonInactivity = "inactivity";
|
||||
internal const string RealmJoinTimeoutReasonAbsoluteCap = "absolute_cap";
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>realmjoin_timeout</c> timeline-entry effect emitted by the deadline-
|
||||
/// fired handler. Unlike <see cref="DecisionSignalKind.RealmJoinDetected"/> /
|
||||
@@ -661,7 +756,7 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
/// InformationalEvent), the timeout is a synthetic deadline so the reducer owns its
|
||||
/// timeline visibility.
|
||||
/// </summary>
|
||||
private static DecisionEffect BuildRealmJoinTimeoutEvent(DecisionState state)
|
||||
private static DecisionEffect BuildRealmJoinTimeoutEvent(DecisionState state, string reason)
|
||||
{
|
||||
var facts = state.RealmJoinFacts;
|
||||
var lastPhase = facts.LastDeploymentPhase?.Value ?? 0;
|
||||
@@ -672,17 +767,60 @@ namespace AutopilotMonitor.DecisionCore.Engine
|
||||
if (facts.Packages[i].CompletedUtc != null) completed++;
|
||||
}
|
||||
|
||||
var message = reason == RealmJoinTimeoutReasonAbsoluteCap
|
||||
? $"RealmJoin did not reach phase 110 within {(int)s_realmJoinAbsoluteTimeout.TotalHours} h of detection (last phase: {lastPhase}) — monitoring window exhausted despite recent deployment activity."
|
||||
: reason == RealmJoinTimeoutReasonInactivity
|
||||
? $"RealmJoin did not reach phase 110 — no deployment activity for {(int)s_realmJoinHardTimeout.TotalMinutes} min (last phase: {lastPhase})."
|
||||
: $"RealmJoin did not reach phase 110 within {(int)s_realmJoinHardTimeout.TotalMinutes} min (last phase: {lastPhase}).";
|
||||
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinTimeout,
|
||||
[SignalPayloadKeys.Source] = "DecisionEngine",
|
||||
[SignalPayloadKeys.Severity] = "Warning",
|
||||
[SignalPayloadKeys.Message] = message,
|
||||
["lastSeenPhase"] = lastPhase.ToString(CultureInfo.InvariantCulture),
|
||||
["packagesTracked"] = tracked.ToString(CultureInfo.InvariantCulture),
|
||||
["packagesCompleted"] = completed.ToString(CultureInfo.InvariantCulture),
|
||||
["reason"] = reason,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build the <c>realmjoin_timeout_extended</c> timeline-entry effect emitted when the
|
||||
/// timeout deadline fires but the first deployment is demonstrably still active and
|
||||
/// the monitoring window is re-armed instead. Reducer-owned like
|
||||
/// <see cref="BuildRealmJoinTimeoutEvent"/> (synthetic deadline, no agent dual-emit).
|
||||
/// </summary>
|
||||
private static DecisionEffect BuildRealmJoinTimeoutExtendedEvent(
|
||||
RealmJoinFacts facts,
|
||||
DateTime lastActivityUtc,
|
||||
DateTime newDueUtc)
|
||||
{
|
||||
var lastPhase = facts.LastDeploymentPhase?.Value ?? 0;
|
||||
var tracked = facts.Packages.Count;
|
||||
var completed = 0;
|
||||
for (var i = 0; i < facts.Packages.Count; i++)
|
||||
{
|
||||
if (facts.Packages[i].CompletedUtc != null) completed++;
|
||||
}
|
||||
|
||||
return new DecisionEffect(
|
||||
kind: DecisionEffectKind.EmitEventTimelineEntry,
|
||||
parameters: new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
[SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinTimeout,
|
||||
[SignalPayloadKeys.EventType] = SharedConstants.EventTypes.RealmJoinTimeoutExtended,
|
||||
[SignalPayloadKeys.Source] = "DecisionEngine",
|
||||
[SignalPayloadKeys.Severity] = "Warning",
|
||||
[SignalPayloadKeys.Message] = $"RealmJoin did not reach phase 110 within 60 min (last phase: {lastPhase}).",
|
||||
["lastSeenPhase"] = lastPhase.ToString(CultureInfo.InvariantCulture),
|
||||
[SignalPayloadKeys.Severity] = "Info",
|
||||
[SignalPayloadKeys.Message] =
|
||||
$"RealmJoin first deployment still active (phase {lastPhase}, {completed}/{tracked} packages completed, last activity {lastActivityUtc:HH:mm:ss} UTC) — extending monitoring window to {newDueUtc:HH:mm:ss} UTC (cap {(int)s_realmJoinAbsoluteTimeout.TotalHours} h after detection).",
|
||||
["deploymentPhase"] = lastPhase.ToString(CultureInfo.InvariantCulture),
|
||||
["packagesTracked"] = tracked.ToString(CultureInfo.InvariantCulture),
|
||||
["packagesCompleted"] = completed.ToString(CultureInfo.InvariantCulture),
|
||||
["lastActivityUtc"] = lastActivityUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
["extendedUntilUtc"] = newDueUtc.ToString("O", CultureInfo.InvariantCulture),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: null,
|
||||
productVersion: null,
|
||||
releaseChannel: null,
|
||||
packages: Array.Empty<RealmJoinPackageFact>());
|
||||
packages: Array.Empty<RealmJoinPackageFact>(),
|
||||
lastActivityUtc: null);
|
||||
|
||||
public RealmJoinFacts(
|
||||
SignalFact<DateTime>? detectedUtc,
|
||||
@@ -49,7 +50,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
SignalFact<bool>? selfDeployingDeferredCompletion,
|
||||
SignalFact<string>? productVersion,
|
||||
SignalFact<string>? releaseChannel,
|
||||
IReadOnlyList<RealmJoinPackageFact> packages)
|
||||
IReadOnlyList<RealmJoinPackageFact> packages,
|
||||
SignalFact<DateTime>? lastActivityUtc = null)
|
||||
{
|
||||
DetectedUtc = detectedUtc;
|
||||
ResolvedUtc = resolvedUtc;
|
||||
@@ -59,6 +61,7 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
ProductVersion = productVersion;
|
||||
ReleaseChannel = releaseChannel;
|
||||
Packages = packages ?? Array.Empty<RealmJoinPackageFact>();
|
||||
LastActivityUtc = lastActivityUtc;
|
||||
}
|
||||
|
||||
/// <summary>First-observation timestamp of the <c>HKLM\SYSTEM\...\realmjoin\Parameters</c> key.</summary>
|
||||
@@ -98,6 +101,15 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
/// <summary>Tracked per-package install rows (machine + user scope combined).</summary>
|
||||
public IReadOnlyList<RealmJoinPackageFact> Packages { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Most recent observed RJ deployment activity: a <c>RealmJoinPhaseChanged</c> or a
|
||||
/// per-package started/completed observation. Deliberately NOT set at detection time —
|
||||
/// the RJ-detected-but-never-deploying idle case must still hit the 60-min hard
|
||||
/// timeout unchanged. Drives the activity-based timeout extension (report 55e6afd61c9d:
|
||||
/// the hard timeout cut off a demonstrably active first deployment mid-install).
|
||||
/// </summary>
|
||||
public SignalFact<DateTime>? LastActivityUtc { get; }
|
||||
|
||||
public RealmJoinFacts WithDetected(DateTime utc, long sourceSignalOrdinal)
|
||||
{
|
||||
if (DetectedUtc != null) return this; // set-once
|
||||
@@ -109,7 +121,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,7 +142,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: new SignalFact<string>(productVersion, sourceSignalOrdinal),
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -149,7 +163,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: new SignalFact<string>(releaseChannel, sourceSignalOrdinal),
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithResolved(DateTime utc, int phase, long sourceSignalOrdinal)
|
||||
@@ -163,7 +178,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -184,7 +200,28 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Record an RJ deployment-activity observation (phase change or package event).
|
||||
/// Monotonic — an earlier or equal timestamp (replayed signal) is a no-op, so the
|
||||
/// activity fact can never move backwards.
|
||||
/// </summary>
|
||||
public RealmJoinFacts WithActivity(DateTime utc, long sourceSignalOrdinal)
|
||||
{
|
||||
if (LastActivityUtc != null && LastActivityUtc.Value >= utc) return this;
|
||||
return new RealmJoinFacts(
|
||||
detectedUtc: DetectedUtc,
|
||||
resolvedUtc: ResolvedUtc,
|
||||
lastDeploymentPhase: LastDeploymentPhase,
|
||||
outcome: Outcome,
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages,
|
||||
lastActivityUtc: new SignalFact<DateTime>(utc, sourceSignalOrdinal));
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithLastPhase(int phase, long sourceSignalOrdinal)
|
||||
@@ -198,7 +235,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithTimeoutOutcome(long sourceSignalOrdinal)
|
||||
@@ -212,7 +250,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
public RealmJoinFacts WithSelfDeployingDeferred(long sourceSignalOrdinal)
|
||||
@@ -226,7 +265,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: new SignalFact<bool>(true, sourceSignalOrdinal),
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -246,7 +286,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: null,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: Packages);
|
||||
packages: Packages,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -301,7 +342,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: copy);
|
||||
packages: copy,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -370,7 +412,8 @@ namespace AutopilotMonitor.DecisionCore.State
|
||||
selfDeployingDeferredCompletion: SelfDeployingDeferredCompletion,
|
||||
productVersion: ProductVersion,
|
||||
releaseChannel: ReleaseChannel,
|
||||
packages: copy);
|
||||
packages: copy,
|
||||
lastActivityUtc: LastActivityUtc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,12 +512,17 @@ namespace AutopilotMonitor.Shared
|
||||
// RealmJoin (RJ) deployment tracking — driven by HKLM\SYSTEM\...\realmjoin\Parameters
|
||||
// + HKLM/HKU \SOFTWARE\RealmJoin\Packages\<id>. Detection extends the V2 enrollment
|
||||
// session lifetime until DeploymentPhase reaches CompletedFirstDeployment (110) or
|
||||
// the 60-min hard timeout fires. Per-package events surface install lifecycle in
|
||||
// the 60-min timeout fires. Per-package events surface install lifecycle in
|
||||
// the same shape as IME app_install_started / app_install_completed.
|
||||
public const string RealmJoinDetected = "realmjoin_detected";
|
||||
public const string RealmJoinPhaseChanged = "realmjoin_phase_changed";
|
||||
public const string RealmJoinResolved = "realmjoin_resolved";
|
||||
public const string RealmJoinTimeout = "realmjoin_timeout";
|
||||
// Info: the timeout deadline fired while the first deployment was demonstrably
|
||||
// still active (phase 100/101, deployment activity inside the last 60 min) —
|
||||
// the monitoring window was re-armed instead of timing out (capped at 4 h from
|
||||
// detection). Report 55e6afd61c9d.
|
||||
public const string RealmJoinTimeoutExtended = "realmjoin_timeout_extended";
|
||||
// Warning: DeploymentPhase left the first-deployment window (100/101) for 200/210
|
||||
// without CompletedFirstDeployment (110) — the RJ ESP was aborted (typically an
|
||||
// interactive logon during the first deployment reclassified the run as
|
||||
|
||||
@@ -608,6 +608,7 @@ export const SHARED_MANIFEST = {
|
||||
"realmjoin_phase_changed",
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"realmjoin_timeout_extended",
|
||||
"reboot_triggered",
|
||||
"registry_app_baseline",
|
||||
"registry_app_state",
|
||||
|
||||
@@ -604,6 +604,7 @@
|
||||
"realmjoin_phase_changed",
|
||||
"realmjoin_resolved",
|
||||
"realmjoin_timeout",
|
||||
"realmjoin_timeout_extended",
|
||||
"reboot_triggered",
|
||||
"registry_app_baseline",
|
||||
"registry_app_state",
|
||||
|
||||
Reference in New Issue
Block a user