+ {item.isIncomplete && item.observedMs != null && (
+ // Lower bound: watched this long, then the agent's reports stopped.
+
+ {`≥ ${formatDuration(item.observedMs)}`}
+
+ )}
{elapsedMs != null && elapsedMs > 0 && (
{formatDuration(elapsedMs)}
)}
diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/installProgress.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/installProgress.test.ts
index 8a4077bf..6b8c8436 100644
--- a/src/Web/autopilot-monitor-web/lib/__tests__/installProgress.test.ts
+++ b/src/Web/autopilot-monitor-web/lib/__tests__/installProgress.test.ts
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
-import { buildInstallItems, isRebootOrRetryClass, type InstallEvent } from "@/lib/installProgress";
+import { applyObservationEnd, buildInstallItems, isRebootOrRetryClass, type InstallEvent } from "@/lib/installProgress";
function evt(eventType: string, timestamp: string, data: Record): InstallEvent {
return { eventType, timestamp, data };
@@ -226,3 +226,41 @@ describe("isRebootOrRetryClass", () => {
expect(isRebootOrRetryClass(undefined)).toBe(false);
});
});
+
+describe("applyObservationEnd", () => {
+ // Shape of session ac5660b8: the app started 143 s after enrollment_complete and was still
+ // installing when the agent sent its last event 30 s later — no terminal event ever came.
+ const stillInstalling = evt("app_install_started", "2026-09-09T06:45:18Z", { appName: "Suite", appId: "a1" });
+ const finished = [
+ evt("app_install_started", "2026-09-09T06:40:31Z", { appName: "Teams", appId: "t1" }),
+ evt("app_install_completed", "2026-09-09T06:41:05Z", { appName: "Teams", appId: "t1", state: "Installed" }),
+ ];
+ const lastReport = Date.parse("2026-09-09T06:45:48Z");
+
+ it("leaves every row untouched while the session is still live (null observation end)", () => {
+ const items = buildInstallItems([stillInstalling, ...finished]);
+ expect(applyObservationEnd(items, null)).toBe(items);
+ expect(items.every(i => !i.isIncomplete)).toBe(true);
+ });
+
+ it("marks a row still installing at the observation end as incomplete with the watched span", () => {
+ const [suite] = applyObservationEnd(buildInstallItems([stillInstalling]), lastReport);
+ expect(suite.state).toBe("Installing");
+ expect(suite.isIncomplete).toBe(true);
+ expect(suite.observedMs).toBe(30 * 1000);
+ });
+
+ it("never touches rows that reached a terminal state", () => {
+ const items = applyObservationEnd(buildInstallItems([...finished, stillInstalling]), lastReport);
+ const teams = items.find(i => i.appName === "Teams")!;
+ expect(teams.isIncomplete).toBe(false);
+ expect(teams.observedMs).toBeUndefined();
+ expect(teams.durationMs).toBe(34 * 1000);
+ });
+
+ it("clamps the watched span to zero when the observation end precedes the start (clock skew)", () => {
+ const [suite] = applyObservationEnd(buildInstallItems([stillInstalling]), Date.parse("2026-09-09T06:45:00Z"));
+ expect(suite.isIncomplete).toBe(true);
+ expect(suite.observedMs).toBe(0);
+ });
+});
diff --git a/src/Web/autopilot-monitor-web/lib/installProgress.ts b/src/Web/autopilot-monitor-web/lib/installProgress.ts
index e4c9445c..b8a1d0d4 100644
--- a/src/Web/autopilot-monitor-web/lib/installProgress.ts
+++ b/src/Web/autopilot-monitor-web/lib/installProgress.ts
@@ -94,6 +94,11 @@ export interface InstallItem {
isLikelyStuck: boolean;
isDetectionFailure: boolean;
isInstallFailure: boolean;
+ // Set by applyObservationEnd, never by the fold: the row was still installing when the
+ // session's observation ended (agent gone, session terminal). Outcome unknown — neither a
+ // failure nor a success — and `observedMs` is the span actually watched, a lower bound.
+ isIncomplete: boolean;
+ observedMs?: number;
firstSeenIndex: number;
eventData?: Record;
}
@@ -185,7 +190,7 @@ export function buildInstallItems(events: InstallEvent[]): InstallItem[] {
const isCompleted = (type === "app_install_completed" && !isSkippedCompletion) || type === "office_install_completed" || (type === "realmjoin_package_completed" && !rjFailed);
const isFailed = type === "app_install_failed" || type === "office_install_failed" || rjFailed;
- const base = { key, source, appName, appId, intent: intent ?? existing?.intent };
+ const base = { key, source, appName, appId, intent: intent ?? existing?.intent, isIncomplete: false };
if (isStarted) {
// Don't reset an app that already completed — later batch re-scans
@@ -334,3 +339,19 @@ export function buildInstallItems(events: InstallEvent[]): InstallItem[] {
return Array.from(installMap.values()).sort((a, b) => a.firstSeenIndex - b.firstSeenIndex);
}
+
+/**
+ * Marks rows that were still installing when observation ended. `observedUntilMs` is the last
+ * moment the agent reported anything (session `lastEventAt`) and must be passed only once the
+ * session is terminal — `null` means "still live", and every row comes back untouched so the
+ * panel keeps its live timer. A start after the observation end (clock skew) clamps to 0.
+ */
+export function applyObservationEnd(items: InstallItem[], observedUntilMs: number | null): InstallItem[] {
+ if (observedUntilMs == null) return items;
+ return items.map(item => {
+ if (item.state !== "Installing" || !item.startedAt) return item;
+ const startedMs = new Date(item.startedAt).getTime();
+ const observedMs = Number.isFinite(startedMs) ? Math.max(0, observedUntilMs - startedMs) : 0;
+ return { ...item, isIncomplete: true, observedMs };
+ });
+}
diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
index 312c1f5d..c80bb83d 100644
--- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json
+++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json
@@ -2845,6 +2845,14 @@
"name": "number"
}
},
+ {
+ "name": "incomplete",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Still installing when the session's observation ended — outcome unknown, outside the rate."
+ },
{
"name": "failureRate",
"type": {
@@ -3232,6 +3240,14 @@
"name": "number"
}
},
+ {
+ "name": "incomplete",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Still installing when the session's observation ended — outcome unknown, outside the rate."
+ },
{
"name": "failureRate",
"type": {
@@ -3398,6 +3414,14 @@
"name": "number"
}
},
+ {
+ "name": "totalIncomplete",
+ "type": {
+ "kind": "primitive",
+ "name": "number"
+ },
+ "doc": "Rows still installing when the session's observation ended — outcome unknown, outside the failure rate."
+ },
{
"name": "totalCollisionExcluded",
"type": {
diff --git a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
index 5edfd9d9..25ec90ca 100644
--- a/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
+++ b/src/Web/autopilot-monitor-web/utils/wire-types.generated.ts
@@ -470,6 +470,8 @@ export interface AppAnalyticsSummary {
skipped: number;
unmeasured: number;
failed: number;
+ /** Still installing when the session's observation ended — outcome unknown, outside the rate. */
+ incomplete: number;
failureRate: number;
avgDurationSeconds: number;
p95DurationSeconds: number;
@@ -558,6 +560,8 @@ export interface AppMetricsAppGroup {
skipped: number;
unmeasured: number;
failed: number;
+ /** Still installing when the session's observation ended — outcome unknown, outside the rate. */
+ incomplete: number;
/** Failed / (failed + succeeded) as a percentage; skips never count as attempts. */
failureRate: number;
/** Average measured FINAL-attempt duration (whole seconds); 0 with no measured installs. */
@@ -591,6 +595,8 @@ export interface AppMetricsResponse {
totalInstalls: number;
totalSkipped: number;
totalUnmeasured: number;
+ /** Rows still installing when the session's observation ended — outcome unknown, outside the failure rate. */
+ totalIncomplete: number;
/** Rows excluded from per-app groups because their name-keyed row merged distinct appIds. */
totalCollisionExcluded: number;
slowestApps: AppMetricsAppGroup[];