/// True for an enrollment_failed event whose Data.failureType is
/// agent_timeout — the V1-parity companion of the max-lifetime watchdog shutdown
diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
index 2bfc1038..9fcc130a 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts
@@ -80,7 +80,7 @@ export const EVENT_TYPES_CATALOG = {
'office_install_failed',
'office_preinstalled_detected',
],
- script_events: ['script_started', 'script_completed', 'script_failed', 'script_timeout_suspected'],
+ script_events: ['script_started', 'script_completed', 'script_failed', 'script_timeout_suspected', 'historic_script_replay_detected'],
network_events: [
'network_state_change',
'network_connectivity_check',
diff --git a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts
index 18817f84..ee7a7be7 100644
--- a/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts
+++ b/src/McpServer/autopilot-monitor-mcp/src/tools/sessions.ts
@@ -28,7 +28,8 @@ export const KEY_EVENT_TYPES = new Set([
'enrollment_complete', 'enrollment_failed', 'completion_check',
'desktop_arrived', 'hello_policy_detected', 'waiting_for_hello', 'hello_completion_timeout',
'agent_started', 'agent_shutdown', 'agent_shutting_down', 'agent_trace',
- 'script_started', 'script_completed', 'script_failed', 'vulnerability_report',
+ 'script_started', 'script_completed', 'script_failed', 'historic_script_replay_detected',
+ 'vulnerability_report',
]);
// Phase-defining events promoted to the top of the triage timeline. Module-level
// (not handler-local) so the same drift test can validate it.
diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs
index 4f0f0f76..5ac0c54c 100644
--- a/src/Shared/AutopilotMonitor.Shared/Constants.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs
@@ -340,6 +340,12 @@ namespace AutopilotMonitor.Shared
// start→completion duration (works on replay since start carries the source log
// timestamp). Warning, advisory ("suspected"); no state mutation.
public const string ScriptTimeoutSuspected = "script_timeout_suspected";
+ // One-shot per agent run: the IME adapter detected replayed log content from a
+ // previous enrollment (script source lines > 24 h stale, e.g. IME logs surviving a
+ // re-enrollment) and suppressed the historic script_started/script_completed/
+ // script_failed events. Data.earliestRejectedSourceTimestamp dates the replayed
+ // content so operators know why script events are missing (session eaf3d8c4).
+ public const string HistoricScriptReplayDetected = "historic_script_replay_detected";
public const string EspProvisioningStatus = "esp_provisioning_status";
public const string SoftwareInventoryAnalysis = "software_inventory_analysis";
public const string VulnerabilityReport = "vulnerability_report";
diff --git a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
index 7414e1b0..3f6dbfea 100644
--- a/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
+++ b/src/Web/autopilot-monitor-web/app/gather-rules/eventTypes.ts
@@ -142,6 +142,8 @@ export const KNOWN_EVENT_TYPES: EventTypeEntry[] = [
description: "A platform script crashed (exit code != 0), or the remediation phase of a health script crashed. Health-script detection / post-detection phases use exit code as a compliance verdict (non-zero = non-compliant) and emit script_completed even on non-zero exit; only a true script crash in those phases would surface here." },
{ value: "script_timeout_suspected", label: "script_timeout_suspected", category: "script",
description: "A platform script ran to the IME script-execution timeout (~30 min; threshold 25 min) and was marked Failed while the enrollment was in progress. IME runs platform scripts serially, so a hung script starves app installs and the Autopilot-Monitor bootstrap — the prime suspect behind a late agent start / pre-failed ESP. Advisory (one per policyId); carries durationSeconds, exitCode and espPhase." },
+ { value: "historic_script_replay_detected", label: "historic_script_replay_detected", category: "script",
+ description: "One-shot per agent run: the IME log contained replayed script activity from a previous enrollment (source lines more than 24 h stale, e.g. IME logs surviving a re-enrollment). The historic script_started/script_completed/script_failed events were suppressed for this session; carries earliestRejectedSourceTimestamp so the replayed window is datable." },
// -------- Diagnostics / misc --------
{ value: "error_detected", label: "error_detected", category: "diagnostics",
diff --git a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
index c5dc5afb..51a39608 100644
--- a/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
+++ b/src/Web/autopilot-monitor-web/components/ScriptExecutions.tsx
@@ -10,6 +10,7 @@ import {
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
+ partitionHistoricScriptEvents,
reduceScriptEvents,
scriptCardKey,
scriptItemKey,
@@ -38,11 +39,15 @@ interface ScriptExecutionsProps {
}
export default function ScriptExecutions({ events, showScriptOutput, latestBootstrapVersion, displayNamesByRefKey }: ScriptExecutionsProps) {
- const cards = useMemo(() => groupScriptItems(reduceScriptEvents(events)), [events]);
+ // Legacy-agent guard: split off script events replayed from a previous enrollment's IME
+ // log (newer agents suppress them at the source) so week-old runs never render as current
+ // executions. The muted note below keeps the gap explainable.
+ const { current, historicCount } = useMemo(() => partitionHistoricScriptEvents(events), [events]);
+ const cards = useMemo(() => groupScriptItems(reduceScriptEvents(current)), [current]);
const [expanded, setExpanded] = useState(true);
- if (cards.length === 0) return null;
+ if (cards.length === 0 && historicCount === 0) return null;
// Header counters work on cards (one per policy), so a remediated cycle reads as
// "1 succeeded" rather than "1 succeeded + 2 non-compliant" when its detection /
@@ -114,6 +119,14 @@ export default function ScriptExecutions({ events, showScriptOutput, latestBoots
displayNamesByRefKey={displayNamesByRefKey}
/>
))}
+ {historicCount > 0 && (
+
+ {historicCount} historic script execution{historicCount === 1 ? "" : "s"} from a previous enrollment hidden
+
+ )}
)}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
index 04fb7521..ab79d59b 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/scriptExecutions.test.ts
@@ -7,6 +7,7 @@ import {
isDetectOnlyRow,
isNonCompliantReport,
mapRemediationStatus,
+ partitionHistoricScriptEvents,
reduceScriptEvents,
scriptItemKey,
toNumber,
@@ -905,3 +906,82 @@ describe("groupScriptItems — card durationSeconds is the max phase run time",
expect(cards[0].durationSeconds).toBeUndefined();
});
});
+
+describe("partitionHistoricScriptEvents", () => {
+ // Agent-side clamp evidence: rejectedSourceTimestamp = the source log line's own time,
+ // timestamp = the clock-clamped event stamp. > 24 h older ⇒ replayed history from a
+ // previous enrollment (session eaf3d8c4).
+ const DAY_MS = 24 * 60 * 60 * 1000;
+
+ it("drops events whose rejectedSourceTimestamp is > 24 h older than the event stamp and counts finals", () => {
+ const historicFinal = finalEvent({
+ ts: 0,
+ data: { policyId: "p1", scriptType: "remediation", rejectedSourceTimestamp: new Date(1700000000000 - 7 * DAY_MS).toISOString() },
+ });
+ const historicStart = finalEvent({
+ eventType: "script_started",
+ ts: 1,
+ data: { policyId: "p1", scriptType: "remediation", rejectedSourceTimestamp: new Date(1700000000000 - 7 * DAY_MS).toISOString() },
+ });
+ const fresh = finalEvent({ ts: 2, data: { policyId: "p2", scriptType: "platform" } });
+
+ const { current, historicCount } = partitionHistoricScriptEvents([historicFinal, historicStart, fresh]);
+ expect(current).toEqual([fresh]);
+ // Only finals count toward the "hidden executions" note — dropped starts would
+ // double-count the same run.
+ expect(historicCount).toBe(1);
+ });
+
+ it("keeps future-skew rejections (source timestamp ahead of the event stamp)", () => {
+ const skewed = finalEvent({
+ ts: 0,
+ data: { policyId: "p1", scriptType: "platform", rejectedSourceTimestamp: new Date(1700000000000 + 2 * 60 * 60 * 1000).toISOString() },
+ });
+ const { current, historicCount } = partitionHistoricScriptEvents([skewed]);
+ expect(current).toEqual([skewed]);
+ expect(historicCount).toBe(0);
+ });
+
+ it("keeps within-24h rejections and events without or with malformed rejectedSourceTimestamp", () => {
+ const within = finalEvent({
+ ts: 0,
+ data: { policyId: "p1", scriptType: "platform", rejectedSourceTimestamp: new Date(1700000000000 - 23 * 60 * 60 * 1000).toISOString() },
+ });
+ const none = finalEvent({ ts: 1, data: { policyId: "p2", scriptType: "platform" } });
+ const malformed = finalEvent({ ts: 2, data: { policyId: "p3", scriptType: "platform", rejectedSourceTimestamp: "not-a-date" } });
+
+ const { current, historicCount } = partitionHistoricScriptEvents([within, none, malformed]);
+ expect(current).toHaveLength(3);
+ expect(historicCount).toBe(0);
+ });
+
+ it("reads the snake_case wire variant too", () => {
+ const historic = finalEvent({
+ ts: 0,
+ data: { policyId: "p1", scriptType: "platform", rejected_source_timestamp: new Date(1700000000000 - 7 * DAY_MS).toISOString() },
+ });
+ const { current, historicCount } = partitionHistoricScriptEvents([historic]);
+ expect(current).toEqual([]);
+ expect(historicCount).toBe(1);
+ });
+});
+
+describe("reduceScriptEvents — implausible duration blanking", () => {
+ it("blanks durations above 24 h (mixed-timeline artifacts from legacy agents)", () => {
+ // Session eaf3d8c4: clock-clamped completion minus raw week-old start = 612225 s ≈ 170 h.
+ const items = reduceScriptEvents([
+ finalEvent({ data: { policyId: "p1", scriptType: "remediation", scriptPart: "detection", exitCode: "0", complianceResult: "True", durationSeconds: "612225.00", durationBasis: "script_runtime" } }),
+ ]);
+ expect(items[0].durationSeconds).toBeUndefined();
+ expect(items[0].reportedAfterSeconds).toBeUndefined();
+ });
+
+ it("keeps the 24 h boundary value and blanks cycle-basis values the same way", () => {
+ const items = reduceScriptEvents([
+ finalEvent({ data: { policyId: "p1", scriptType: "platform", exitCode: "0", result: "Success", durationSeconds: "86400", durationBasis: "script_runtime" } }),
+ finalEvent({ ts: 1, data: { policyId: "p2", scriptType: "remediation", scriptPart: "detection", exitCode: "0", complianceResult: "True", durationSeconds: "90000", durationBasis: "cycle_including_reporting_latency" } }),
+ ]);
+ expect(items[0].durationSeconds).toBe(86400);
+ expect(items[1].reportedAfterSeconds).toBeUndefined();
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
index 531c6cd9..e254d54f 100644
--- a/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
+++ b/src/Web/autopilot-monitor-web/lib/scriptExecutions.ts
@@ -119,6 +119,51 @@ export function scriptCardKey(card: Pick 0) {
+ const rej = Date.parse(rejected);
+ const ts = Date.parse(evt.timestamp);
+ if (Number.isFinite(rej) && Number.isFinite(ts) && ts - rej > HISTORIC_REPLAY_THRESHOLD_MS) {
+ if (evt.eventType === "script_completed" || evt.eventType === "script_failed") historicCount++;
+ continue;
+ }
+ }
+ current.push(evt);
+ }
+ return { current, historicCount };
+}
+
/**
* Score how complete a script item's data is (higher = better). Used by the reducer to
* pick the best entry when re-emissions of the same script collapse into one row.
@@ -261,7 +306,13 @@ export function reduceScriptEvents(events: ScriptInputEvent[]): ScriptItem[] {
// the script's own result line (runtime), remediation durations to HS-NEW-RESULT
// (cycle) — infer accordingly so old data is never displayed as run time when it
// isn't one.
- const rawDuration = toNumber(d.durationSeconds ?? d.duration_seconds);
+ // Scripts cannot run > 24 h inside an enrollment (IME's execution timeout is ~30 min);
+ // larger values are mixed-timeline artifacts from legacy agents (raw stale start paired
+ // with a clock-clamped completion — session eaf3d8c4 showed 170 h). Hide, don't lie.
+ const rawDurationUnchecked = toNumber(d.durationSeconds ?? d.duration_seconds);
+ const rawDuration = rawDurationUnchecked != null && rawDurationUnchecked <= HISTORIC_REPLAY_THRESHOLD_MS / 1000
+ ? rawDurationUnchecked
+ : undefined;
const rawBasis = d.durationBasis ?? d.duration_basis;
const isCycleBasis = typeof rawBasis === "string"
? rawBasis === "cycle_including_reporting_latency"