Compare commits

...

1 Commits

Author SHA1 Message Date
Oliver Kieselbach 2e20476559 feat(agent): extend RealmJoin timeout while first deployment is demonstrably active
The 60-min RealmJoinTimeout deadline arms at RJ detection (the RJ agent MSI
install during DeviceSetup) and used to cut off monitoring mid-install: report
55e6afd61c9d (session 46a96078) lost the tail of a large package catalog whose
last observed package completed 3 seconds before the deadline, with the first
deployment only starting 16 minutes into the window.

When the deadline now fires with LastDeploymentPhase 100/101 and deployment
activity (phase change or package observation, persisted restart-safe in
RealmJoinFacts.LastActivityUtc; the detection-time phase capture deliberately
does not count) inside the last 60 min, the handler re-arms to
lastActivity + 60 min instead of timing out — Info event
realmjoin_timeout_extended — clamped at an absolute cap of detection + 4 h.
realmjoin_timeout carries a reason payload (hard_timeout / inactivity /
absolute_cap); a fire of a deadline incarnation superseded by a re-arm
dead-ends via the OccurredAtUtc == DueAtUtc scheduler contract. The idle case
(no activity ever) still times out at 60 min, and phase-200/210 activity never
extends. Packages are only registry-visible at completion, so the inactivity
window keeps the full original 60 min.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 01:11:48 +02:00
10 changed files with 514 additions and 50 deletions
+19 -1
View File
@@ -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
View File
@@ -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",