@@ -353,6 +370,7 @@ function PhaseSection({
event={event}
showScriptOutput={showScriptOutput}
prevEvent={i > 0 ? events[i - 1] : null}
+ clockDeltas={clockDeltas}
/>
))}
@@ -413,11 +431,13 @@ function GapBadge({ prevTime, eventTime }: { prevTime: Date | null; eventTime: D
// tier; an unexplained backwards step gets amber.
const TIME_JUMP_ORDER_NOTE = "Events are shown in true write order (sequence).";
-function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | null; event: EnrollmentEvent }) {
+function TimeJumpBadge({ prevEvent, event, clockDeltas }: { prevEvent?: EnrollmentEvent | null; event: EnrollmentEvent; clockDeltas?: number[] }) {
if (!prevEvent) return null;
const jump = classifyTimeJump(
{ displayTime: getDisplayTime(prevEvent), provenance: readTimeProvenance(prevEvent.data) },
{ displayTime: getDisplayTime(event), provenance: readTimeProvenance(event.data) },
+ undefined,
+ clockDeltas,
);
if (!jump) return null;
@@ -430,7 +450,9 @@ function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | nul
const times = `(${getDisplayTime(prevEvent).toLocaleTimeString()} → ${getDisplayTime(event).toLocaleTimeString()})`;
const title =
- jump.cause === "era-offset"
+ jump.cause === "clock-set"
+ ? `Time moved backwards by ${human} vs the previous event ${times}. The OS clock was actually set back — a system_clock_changed event in this session records a matching step (ground truth, not an ordering artifact). ${TIME_JUMP_ORDER_NOTE}`
+ : jump.cause === "era-offset"
? `Time moved backwards by ${human} vs the previous event ${times}. The two log lines were corrected with different UTC offsets (era-mixed log) — the jump reflects the offset change, not reordering. ${TIME_JUMP_ORDER_NOTE}`
: jump.cause === "derived-timestamp"
? `Backdated log line: written ${human} before the previous event's displayed time ${times}. Its own timestamp was unusable, so the agent's clock time is shown. ${TIME_JUMP_ORDER_NOTE}`
@@ -449,7 +471,7 @@ function TimeJumpBadge({ prevEvent, event }: { prevEvent?: EnrollmentEvent | nul
const CLAMPED_TOOLTIP_PREFIX =
"The device-reported timestamp was outside the accepted range and was clamped to server receive time on ingest.";
-function EventRow({ event, showScriptOutput, prevEvent }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevEvent?: EnrollmentEvent | null }) {
+function EventRow({ event, showScriptOutput, prevEvent, clockDeltas }: { event: EnrollmentEvent; showScriptOutput?: boolean; prevEvent?: EnrollmentEvent | null; clockDeltas?: number[] }) {
const prevDisplayTime = prevEvent ? getDisplayTime(prevEvent) : null;
const [showDetails, setShowDetails] = useState(false);
const [showRaw, setShowRaw] = useState(false);
@@ -541,7 +563,7 @@ function EventRow({ event, showScriptOutput, prevEvent }: { event: EnrollmentEve
)}
);
})()}
+ {/* Ground-truth clock step (system timeline watcher): the OS clock was set from
+ oldTime to newTime — payload times are authoritative. Amber at ≥5 min (can
+ invalidate token/cert windows and visibly reorders the timeline), sky below. */}
+ {event.eventType === "system_clock_changed" && (() => {
+ const deltaMs = readClockChangeDeltaMs(event.data);
+ if (deltaMs === null) return null;
+ const forward = deltaMs >= 0;
+ const isLarge = Math.abs(deltaMs) >= 5 * 60 * 1000;
+ const reasonText = event.data?.reasonText as string | undefined;
+ const processName = event.data?.processName as string | undefined;
+ const processLeaf = processName ? processName.split("\\").pop() : null;
+ return (
+
+
+ ⏱ {forward ? "+" : "−"}{formatDuration(Math.abs(deltaMs) / 1000)}
+
+ {reasonText && {reasonText === "application_set" ? "set by a process" : reasonText === "hardware_clock_sync" ? "hardware clock sync" : reasonText}}
+ {processLeaf && {processLeaf}}
+
+ );
+ })()}
+ {/* Completed sleep episode (emitted retroactively at wake): enteredAt/exitedAt in the
+ payload are the authoritative bounds — the gap in the surrounding timeline is this
+ episode, not missing telemetry. */}
+ {event.eventType === "system_sleep_episode" && (() => {
+ const kind = event.data?.kind as string | undefined;
+ const kindLabel = kind === "modern_standby" ? "Modern Standby" : kind === "hibernate" ? "Hibernate" : "Sleep";
+ const durationSeconds = Number(event.data?.durationSeconds);
+ const wakeText = event.data?.wakeSourceText as string | undefined;
+ const onAc = event.data?.onAcPower;
+ const onAcLabel = onAc === true || onAc === "true" ? "on AC" : onAc === false || onAc === "false" ? "on battery" : null;
+ return (
+
+
+ 🌙 {kindLabel}
+
+ {Number.isFinite(durationSeconds) && durationSeconds > 0 && (
+ {formatDuration(durationSeconds)}
+ )}
+ {wakeText && wake: {wakeText}}
+ {onAcLabel && {onAcLabel}}
+
+ );
+ })()}
+ {/* Live power-state change (power watcher, DEV-009/010): make the transition readable
+ without expanding the details. */}
+ {event.eventType === "power_state_change" && (() => {
+ const transition = event.data?.transition as string | undefined;
+ const batteryPercent = event.data?.batteryPercent;
+ const threshold = event.data?.thresholdPercent;
+ const chip = transition === "ac_to_battery"
+ ? { label: "AC → battery", cls: "bg-amber-100 text-amber-800" }
+ : transition === "battery_to_ac"
+ ? { label: "Battery → AC", cls: "bg-emerald-100 text-emerald-800" }
+ : transition === "threshold_crossed"
+ ? { label: `Battery below ${String(threshold ?? "?")}%`, cls: String(threshold) === "15" ? "bg-red-100 text-red-800" : "bg-amber-100 text-amber-800" }
+ : null;
+ if (!chip) return null;
+ return (
+
+ 🔋 {chip.label}
+ {batteryPercent !== undefined && batteryPercent !== null && String(batteryPercent) !== "unknown" && (
+ {String(batteryPercent)}% charge
+ )}
+
+ );
+ })()}
Source: {event.source}
Seq: {event.sequence}
@@ -989,7 +1084,7 @@ function TimeProvenanceRows({ event, provenance }: { event: EnrollmentEvent; pro
);
}
-function RawEventRow({ event, prevEvent }: { event: EnrollmentEvent; prevEvent?: EnrollmentEvent | null }) {
+function RawEventRow({ event, prevEvent, clockDeltas }: { event: EnrollmentEvent; prevEvent?: EnrollmentEvent | null; clockDeltas?: number[] }) {
const [expanded, setExpanded] = useState(false);
const detailData = useMemo(() => normalizeEventDataForDisplay(event.data), [event.data]);
const hasDetails = detailData && Object.keys(detailData).length > 0;
@@ -1011,7 +1106,7 @@ function RawEventRow({ event, prevEvent }: { event: EnrollmentEvent; prevEvent?:
{event.sequence}
{(recordedAt ?? new Date(event.timestamp)).toLocaleTimeString()}
-
+
{event.severity}
{isBackfilled && (
⟲
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
index 2a3187d2..afda8084 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/SessionInfoCard.tsx
@@ -12,9 +12,17 @@ interface SessionInfoCardProps {
isGatherRulesSession: boolean;
ntpOffset?: { offsetSeconds: number; ntpServer?: string } | null;
configMgrDetected?: { ccmVersion?: string; ccmServiceState?: string; siteCode?: string; confidenceScore?: number } | null;
+ /** Total observed sleep/standby seconds (system_sleep_episode ground truth) — explains an inflated wall-clock duration. */
+ standbySeconds?: number | null;
}
-export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected }: SessionInfoCardProps) {
+// Compact duration for the standby note: "56m", "1h 2m".
+function formatStandby(seconds: number): string {
+ if (seconds >= 3600) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+ return `${Math.max(1, Math.round(seconds / 60))}m`;
+}
+
+export default function SessionInfoCard({ session, enrollmentDuration, displayStatus, isGatherRulesSession, ntpOffset, configMgrDetected, standbySeconds }: SessionInfoCardProps) {
const lastContactTooltip = session.lastEventAt
? `Last contact: ${new Date(session.lastEventAt).toLocaleString([], { dateStyle: "short", timeStyle: "medium" })}`
: undefined;
@@ -56,7 +64,25 @@ export default function SessionInfoCard({ session, enrollmentDuration, displaySt
copyText={session.sessionId}
/>
-
+ = 60 ? (
+
+ {enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`}
+
+ {" "}· 🌙 {formatStandby(standbySeconds)} standby
+
+
+ ) : (
+ enrollmentDuration ?? `${Math.round(session.durationSeconds / 60)} min`
+ )
+ }
+ tooltip={lastContactTooltip}
+ />
} />
diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx
index 22beeaf2..8dab7a05 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/components/TimeAttributionLane.tsx
@@ -33,6 +33,15 @@ export interface RebootSpanDto {
segmentKey: string;
}
+export interface SleepSpanDto {
+ startUtc: string;
+ endUtc: string;
+ seconds: number;
+ segmentKey: string;
+ /** "sleep" | "hibernate" | "modern_standby" (from the system_sleep_episode payload). */
+ kind: string;
+}
+
export interface SessionTimeBreakdownDto {
tenantId: string;
sessionId: string;
@@ -42,6 +51,9 @@ export interface SessionTimeBreakdownDto {
unattributedSeconds: number;
rebootSeconds: number;
rebootSpans: RebootSpanDto[];
+ /** Optional: rows computed before AttributionVersion 3 lack these. */
+ sleepSeconds?: number;
+ sleepSpans?: SleepSpanDto[];
blockingApps: BlockingAppIntervalDto[];
blockingAppCount: number;
espAppsOccupancySeconds: number | null;
@@ -108,14 +120,24 @@ export default function TimeAttributionLane({ breakdown }: { breakdown: SessionT
{formatDuration(wallClock)} enrollment time
- {breakdown.rebootSeconds > 0 && (
-
- ⟳ {breakdown.rebootSpans.length} reboot{breakdown.rebootSpans.length !== 1 ? "s" : ""} · {formatDuration(breakdown.rebootSeconds)}
-
- )}
+
+ {(breakdown.sleepSeconds ?? 0) > 0 && (
+
+ 🌙 {(breakdown.sleepSpans ?? []).length} standby · {formatDuration(breakdown.sleepSeconds ?? 0)}
+
+ )}
+ {breakdown.rebootSeconds > 0 && (
+
+ ⟳ {breakdown.rebootSpans.length} reboot{breakdown.rebootSpans.length !== 1 ? "s" : ""} · {formatDuration(breakdown.rebootSeconds)}
+
+ )}
+
{/* Proportional stacked bar over the authoritative wall clock */}
diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
index 7c27f36b..2e3322b4 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
+++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDerivedData.ts
@@ -25,6 +25,7 @@ export interface UseSessionDerivedDataReturn {
gatherRulesSucceeded: boolean;
displayStatus: string;
enrollmentDurationFromEvents: string | null;
+ standbySeconds: number | null;
phaseNamesMap: Record;
phaseOrder: string[];
isSkipUserStatusPage: boolean;
@@ -138,6 +139,25 @@ export function useSessionDerivedData(
return `${Math.floor(durationSec / 3600)}h ${Math.floor((durationSec % 3600) / 60)}m`;
}, [events]);
+ // Total observed sleep/standby seconds (system_sleep_episode ground truth). The wall-clock
+ // duration deliberately keeps the pause — this number tells the story next to it ("1h 20m,
+ // 56m of it standby"). Dedup on enteredAt: the same episode can be observed twice (agent
+ // restart re-backfills it from a different event-log record).
+ const standbySeconds = useMemo(() => {
+ const seenEpisodes = new Set();
+ let total = 0;
+ for (const e of events) {
+ if (e.eventType !== "system_sleep_episode" || !e.data) continue;
+ const duration = Number(e.data.durationSeconds);
+ if (!Number.isFinite(duration) || duration <= 0) continue;
+ const enteredAt = typeof e.data.enteredAt === "string" ? e.data.enteredAt : `seq-${e.sequence}`;
+ if (seenEpisodes.has(enteredAt)) continue;
+ seenEpisodes.add(enteredAt);
+ total += duration;
+ }
+ return total > 0 ? Math.round(total) : null;
+ }, [events]);
+
const phaseNamesMap = session?.enrollmentType === "v2" ? V2_PHASE_NAMES : V1_PHASE_NAMES;
const phaseOrder = session?.enrollmentType === "v2" ? V2_PHASE_ORDER : V1_PHASE_ORDER;
@@ -213,6 +233,7 @@ export function useSessionDerivedData(
gatherRulesSucceeded: !!gatherRulesSucceeded,
displayStatus,
enrollmentDurationFromEvents,
+ standbySeconds,
phaseNamesMap,
phaseOrder,
isSkipUserStatusPage,
diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx
index 23bad706..e042956f 100644
--- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx
+++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx
@@ -191,6 +191,7 @@ function SessionDetailContent() {
isGatherRulesSession,
displayStatus,
enrollmentDurationFromEvents,
+ standbySeconds,
isSkipUserStatusPage,
isWhiteGloveSession,
whiteGloveSplitSequence,
@@ -565,6 +566,7 @@ function SessionDetailContent() {
isGatherRulesSession={isGatherRulesSession}
ntpOffset={ntpOffset}
configMgrDetected={configMgrDetected}
+ standbySeconds={standbySeconds}
/>
)}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/timeProvenance.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/timeProvenance.test.ts
index 09ae8d59..fd605bf4 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/timeProvenance.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/timeProvenance.test.ts
@@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
readTimeProvenance,
classifyTimeJump,
+ readClockChangeDeltaMs,
+ findExplainingClockChange,
BACKWARD_JUMP_THRESHOLD_MS,
type TimeJumpInput,
} from "../timeProvenance";
@@ -115,4 +117,45 @@ describe("classifyTimeJump", () => {
it("respects a caller-supplied threshold", () => {
expect(classifyTimeJump(input(T0), input(minutes(-3)), 120_000)).toEqual({ deltaMs: 180_000, cause: null });
});
+
+ it("names a recorded OS clock set (ground truth) matching the jump magnitude", () => {
+ // A −60 min system_clock_changed step explains a ~60 min backwards display step.
+ const jump = classifyTimeJump(input(T0), input(minutes(-60)), undefined, [-60 * 60 * 1000]);
+ expect(jump!.cause).toBe("clock-set");
+ });
+
+ it("clock-set (ground truth) wins over derived-timestamp inference (precedence)", () => {
+ const curr = input(minutes(-60), { derivedTimestamp: "true" });
+ const jump = classifyTimeJump(input(T0), curr, undefined, [-60 * 60 * 1000]);
+ expect(jump!.cause).toBe("clock-set");
+ });
+
+ it("stays unexplained when the recorded clock deltas do not match the jump", () => {
+ // Forward set, and a backward set of a very different magnitude — neither explains −60 min.
+ const jump = classifyTimeJump(input(T0), input(minutes(-60)), undefined, [60 * 60 * 1000, -5 * 60 * 1000]);
+ expect(jump!.cause).toBeNull();
+ });
+});
+
+describe("clock-change ground truth helpers", () => {
+ it("reads the signed payload delta (number or numeric string)", () => {
+ expect(readClockChangeDeltaMs({ timeDeltaMs: -3_600_000 })).toBe(-3_600_000);
+ expect(readClockChangeDeltaMs({ timeDeltaMs: "7200000" })).toBe(7_200_000);
+ expect(readClockChangeDeltaMs({ time_delta_ms: "-1000" })).toBe(-1000);
+ expect(readClockChangeDeltaMs({})).toBeNull();
+ expect(readClockChangeDeltaMs(undefined)).toBeNull();
+ });
+
+ it("matches within max(60 s, 20 %) tolerance — the display step includes elapsed real time", () => {
+ const hour = 60 * 60 * 1000;
+ expect(findExplainingClockChange(hour, [-hour])).toBe(-hour);
+ // 10 min drift on a 60 min jump is inside the 20 % band …
+ expect(findExplainingClockChange(hour, [-(hour - 10 * 60 * 1000)])).toBe(-(hour - 10 * 60 * 1000));
+ // … 20 min is outside.
+ expect(findExplainingClockChange(hour, [-(hour - 20 * 60 * 1000)])).toBeNull();
+ // Small jumps use the absolute 60 s floor.
+ expect(findExplainingClockChange(6 * 60 * 1000, [-(5 * 60 * 1000)])).toBe(-(5 * 60 * 1000));
+ // A forward set never explains a backward step.
+ expect(findExplainingClockChange(hour, [hour])).toBeNull();
+ });
});
diff --git a/src/Web/autopilot-monitor-web/lib/timeProvenance.ts b/src/Web/autopilot-monitor-web/lib/timeProvenance.ts
index 401b1f74..5fba8f41 100644
--- a/src/Web/autopilot-monitor-web/lib/timeProvenance.ts
+++ b/src/Web/autopilot-monitor-web/lib/timeProvenance.ts
@@ -80,7 +80,7 @@ export function readTimeProvenance(data: Record | undefined | n
*/
export const BACKWARD_JUMP_THRESHOLD_MS = 5 * 60 * 1000;
-export type TimeJumpCause = "era-offset" | "derived-timestamp" | "rejected-source" | null;
+export type TimeJumpCause = "clock-set" | "era-offset" | "derived-timestamp" | "rejected-source" | null;
export interface TimeJump {
/** Positive magnitude of the backwards step in milliseconds. */
@@ -93,24 +93,55 @@ export interface TimeJumpInput {
provenance: TimeProvenance | null;
}
+/**
+ * Reads the signed step of a `system_clock_changed` event's payload — the OS-level ground
+ * truth for "the clock was set from X to Y" (Kernel-General EventID 1, forwarded by the
+ * agent's system timeline watcher). Returns null for any other payload.
+ */
+export function readClockChangeDeltaMs(data: Record | undefined | null): number | null {
+ if (!data || typeof data !== "object") return null;
+ return readNumber(data, "timeDeltaMs", "time_delta_ms");
+}
+
+/**
+ * Matches an observed backwards display step against the session's recorded clock sets.
+ * A backwards step of J ms is explained by a clock set of −D ms when the magnitudes agree
+ * within max(60 s, 20 % of the jump) — the display step includes whatever real time
+ * elapsed between the two rows, so exact equality never holds. Returns the matching
+ * (negative) clock delta, or null.
+ */
+export function findExplainingClockChange(backwardJumpMs: number, clockDeltasMs: readonly number[]): number | null {
+ for (const delta of clockDeltasMs) {
+ if (delta >= 0) continue; // only a backward set explains a backward display step
+ const tolerance = Math.max(60_000, backwardJumpMs * 0.2);
+ if (Math.abs(-delta - backwardJumpMs) <= tolerance) return delta;
+ }
+ return null;
+}
+
/**
* Detects a backwards step of the displayed time versus the previous RENDERED row and
- * names its cause when the provenance identifies one. Cause precedence: a clock-fallback
- * (derived) timestamp explains the row by itself; a rejected source timestamp likewise;
- * only then is an offset difference between the two rows read as an era-mixed log.
+ * names its cause when the provenance identifies one. Cause precedence: a recorded OS
+ * clock set (ground truth, when the session's `system_clock_changed` deltas are passed)
+ * beats every inference; then a clock-fallback (derived) timestamp explains the row by
+ * itself; a rejected source timestamp likewise; only then is an offset difference between
+ * the two rows read as an era-mixed log.
* Callers pass prev = null at phase-section starts (jump detection never spans sections).
*/
export function classifyTimeJump(
prev: TimeJumpInput | null,
curr: TimeJumpInput,
thresholdMs: number = BACKWARD_JUMP_THRESHOLD_MS,
+ clockDeltasMs?: readonly number[],
): TimeJump | null {
if (!prev) return null;
const deltaMs = prev.displayTime.getTime() - curr.displayTime.getTime();
if (!Number.isFinite(deltaMs) || deltaMs < thresholdMs) return null;
let cause: TimeJumpCause = null;
- if (curr.provenance?.derivedTimestamp) {
+ if (clockDeltasMs && findExplainingClockChange(deltaMs, clockDeltasMs) !== null) {
+ cause = "clock-set";
+ } else if (curr.provenance?.derivedTimestamp) {
cause = "derived-timestamp";
} else if (curr.provenance?.rejectedSourceTimestamp) {
cause = "rejected-source";
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
index f883e33a..21520fc8 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts
@@ -633,7 +633,9 @@ export const SHARED_MANIFEST = {
"stall_probe_check",
"stall_probe_result",
"state_quarantine_recovered",
+ "system_clock_changed",
"system_reboot_detected",
+ "system_sleep_episode",
"telemetry_upload_blocked",
"telemetry_upload_poisoned",
"timezone_auto_set",
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
index 7be85b28..f296934e 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
@@ -629,7 +629,9 @@
"stall_probe_check",
"stall_probe_result",
"state_quarantine_recovered",
+ "system_clock_changed",
"system_reboot_detected",
+ "system_sleep_episode",
"telemetry_upload_blocked",
"telemetry_upload_poisoned",
"timezone_auto_set",