diff --git a/apps/web/e2e/agents-lifecycle.spec.ts b/apps/web/e2e/agents-lifecycle.spec.ts
index 1db2e89e9..545ff34c6 100644
--- a/apps/web/e2e/agents-lifecycle.spec.ts
+++ b/apps/web/e2e/agents-lifecycle.spec.ts
@@ -2462,8 +2462,9 @@ test("presents Dashboard page-chain results and System boundaries without extra
await page.getByRole("button", { name: "Dashboard", exact: true }).click();
await dashboard.getByRole("table", { name: "Recent Sessions" }).getByRole("button", { name: "Lifecycle Agent" }).click();
- await expect(page.locator(".session-page")).toBeVisible();
- await expect(page.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible();
+ const sessionPage = page.locator(".session-page");
+ await expect(sessionPage).toBeVisible();
+ await expect(sessionPage.getByText("Lifecycle Agent", { exact: true }).first()).toBeVisible();
await page.getByRole("button", { name: "System", exact: true }).click();
const system = page.locator(".system-page");
@@ -2656,6 +2657,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand
device_id: null,
connection_generation: null,
},
+ lifecycle_state: "active",
status: "observed",
reason: null,
allocation_created_at: baseline - 8_500,
@@ -2680,7 +2682,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand
await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("1 sample ·");
await expect(dashboard.getByRole("heading", { name: "CPU usage" })).toBeVisible();
await expect(dashboard.getByRole("heading", { name: "Memory usage" })).toBeVisible();
- await expect(dashboard.getByRole("heading", { name: "Compute uptime" })).toBeVisible();
+ await expect(dashboard.getByRole("heading", { name: "Active sandboxes" })).toBeVisible();
await expect(dashboard.getByRole("heading", { name: "Token throughput" })).toBeVisible();
await expect(dashboard.getByLabel("Live Runtime sampling every 30 seconds")).toBeVisible();
const liveRange = dashboard.getByRole("group", { name: "Runtime live range" });
@@ -2692,7 +2694,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand
await refresh.click();
await expect(dashboard.getByLabel("CPU usage: 3 live samples")).toBeVisible();
await expect(dashboard.getByLabel("Memory usage: 3 live samples")).toBeVisible();
- await expect(dashboard.getByLabel("Compute uptime: 3 live samples")).toBeVisible();
+ await expect(dashboard.getByLabel("Active sandboxes: 3 live samples")).toBeVisible();
await expect(dashboard.getByLabel("Token throughput: 3 live samples")).toBeVisible();
await expect(dashboard.locator(".dashboard-runtime-sample-count")).toContainText("3 samples");
await expect(dashboard.getByText("CPU usage live trend available")).toBeAttached();
@@ -2728,7 +2730,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand
await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toBeVisible();
await cpuChart.click({ position: { x: 260, y: 90 } });
await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned");
- await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable");
+ await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("10%");
await cpuChart.focus();
await cpuChart.press("ArrowRight");
await expect(cpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Pinned");
@@ -2760,7 +2762,7 @@ test("renders Runtime telemetry as visual snapshot panels with details on demand
const keyboardSelectedAt = Number(await cpuChart.getAttribute("data-selected-at"));
expect(keyboardSelectedAt).toBeGreaterThanOrEqual(zoomedViewStart);
expect(keyboardSelectedAt).toBeLessThanOrEqual(zoomedViewEnd);
- for (const chartName of ["Memory usage", "Compute uptime", "Token throughput"]) {
+ for (const chartName of ["Memory usage", "Active sandboxes", "Token throughput"]) {
await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-start", String(initialViewStart));
await expect(dashboard.getByLabel(`${chartName}: 3 live samples`)).toHaveAttribute("data-view-end", String(initialViewEnd));
}
@@ -2838,6 +2840,7 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page
id: sessionId, object: "agent.runtime_observation", session_id: sessionId, environment_id: environmentId,
mode: "openai_hosted", provider_type: "docker",
instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null },
+ lifecycle_state: "active",
status: "observed", reason: null, allocation_created_at: now - 600, resolved_at: now,
observed_at: now - 1, started_at: now - 600,
cpu: { usage_seconds_total: 120, capacity_cores: 2, usage_cores: null, utilization_ratio: null },
@@ -2910,19 +2913,21 @@ test("restores retained Runtime history after a Dashboard reload", async ({ page
await expect(dashboard.getByLabel(/Durable · 30s; 1 Runtime targets/)).toBeVisible();
await expect(dashboard.getByLabel("Runtime durable-history charts")).toBeVisible();
await expect(dashboard.getByRole("heading", { name: "Compute uptime", exact: true })).toHaveCount(0);
- await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(3);
+ await expect(dashboard.getByRole("heading", { name: "Active sandboxes", exact: true })).toBeVisible();
+ await expect(dashboard.locator('[data-chart-engine="uplot"]')).toHaveCount(4);
await expect(dashboard.getByText("CPU usage durable trend available")).toBeAttached();
await expect(dashboard).toContainText("120 buckets");
await expect(dashboard).toContainText("119/120 observations");
+ await expect(dashboard.getByText("Active sandboxes durable trend available")).toBeAttached();
await expect(dashboard.getByText("Token throughput durable trend available")).toBeAttached();
const durableCpuChart = dashboard.getByLabel("CPU usage: 120 retained buckets");
await expect(dashboard.getByRole("region", { name: "CPU usage durable history chart" })).toBeVisible();
const durableCpuCard = durableCpuChart.locator("xpath=ancestor::section[contains(@class, 'dashboard-runtime-trend-card')]");
await durableCpuChart.focus();
await durableCpuChart.press("ArrowLeft");
- await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable");
+ await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%");
await durableCpuChart.press("ArrowLeft");
- await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("Unavailable");
+ await expect(durableCpuCard.locator(".dashboard-runtime-trend-tooltip")).toContainText("0%");
const durableMemoryCard = dashboard.getByRole("region", { name: "Memory usage durable history chart" });
const durableMemorySpan = await durableMemoryCard.locator("canvas").evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
@@ -3037,18 +3042,16 @@ test("publishes Dashboard counts only after every top-level Agent and Session pa
await expect(dashboard.locator(".dashboard-source-badge").filter({ hasText: "Runtime" })).toContainText("Unavailable");
expect(sessionAfters).toEqual([null, "session_snapshot", null, "session_snapshot"]);
await page.getByRole("button", { name: "Sessions", exact: true }).click();
- await expect.poll(() => sessionAfters.length).toBe(6);
+ await expect.poll(() => sessionAfters.length).toBe(4);
await page.getByRole("button", { name: "Dashboard", exact: true }).click();
await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Agents" })).toContainText("3");
await expect(dashboard.locator(".dashboard-summary > div").filter({ hasText: "Sessions" })).toContainText("2");
expect(agentAfters).toEqual([null, "agent_b"]);
- // Session collection loads once; the unavailable Runtime snapshot is retried
- // on entry to Sessions and again on return to Dashboard. Each reads both pages.
+ // The cached Dashboard and shared Session collection stay mounted across
+ // navigation, so no extra page-chain read occurs on either transition.
await expect.poll(() => sessionAfters).toEqual([
null, "session_snapshot",
null, "session_snapshot",
- null, "session_snapshot",
- null, "session_snapshot",
]);
});
diff --git a/apps/web/src/features/dashboard/DashboardView.test.tsx b/apps/web/src/features/dashboard/DashboardView.test.tsx
index 448b016c0..39f3a23e0 100644
--- a/apps/web/src/features/dashboard/DashboardView.test.tsx
+++ b/apps/web/src/features/dashboard/DashboardView.test.tsx
@@ -228,6 +228,7 @@ describe("Dashboard loaded-result presentation", () => {
device_id: null,
connection_generation: null,
},
+ lifecycle_state: "active",
status: "observed",
reason: null,
allocation_created_at: 1_700_000_000,
@@ -256,7 +257,8 @@ describe("Dashboard loaded-result presentation", () => {
expect(html).toContain('aria-pressed="true">1h');
expect(html).toContain("CPU usage");
expect(html).toContain("Memory usage");
- expect(html).toContain("Compute uptime");
+ expect(html).not.toContain("Compute uptime");
+ expect(html).toContain("Active sandboxes");
expect(html).toContain("Token throughput");
expect(html).not.toContain("No retained CPU samples");
expect(html).toContain("Latest value");
@@ -342,6 +344,7 @@ describe("Dashboard loaded-result presentation", () => {
mode: "openai_hosted",
provider_type: "docker",
instance: { kind: "managed_allocation", allocation_id: "33333333-3333-4333-8333-333333333333", device_id: null, connection_generation: null },
+ lifecycle_state: "active",
status: "observed",
reason: null,
allocation_created_at: 1_700_000_000,
diff --git a/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx
index 00188b746..2c39d9d37 100644
--- a/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx
+++ b/apps/web/src/features/dashboard/RuntimeObservabilityContent.tsx
@@ -315,7 +315,7 @@ export function RuntimeObservabilityContent({
return (
<>
-
} label={t("runtime.metrics.active")} value={summary.observedRuntimeCount.toLocaleString(locale)} detail={t("runtime.metrics.activeDetail", { managed: summary.managedRuntimeCount, unavailable: summary.unavailableRuntimeCount })} />
+
} label={t("runtime.metrics.sandboxState")} value={t("runtime.metrics.sandboxStateValue", { active: summary.activeSandboxCount.toLocaleString(locale), sleeping: summary.sleepingSandboxCount.toLocaleString(locale) })} detail={t("runtime.metrics.sandboxStateDetail", { total: summary.sandboxTotalCount.toLocaleString(locale), transitioning: (summary.transitioningSandboxCount + summary.pendingSandboxCount).toLocaleString(locale) })} />
} label={t("runtime.metrics.cpu")} value={summary.cpuUsageSecondsTotal === null && summary.cpuCapacityCores === null ? t("runtime.metrics.noSample") : `${summary.cpuUsageSecondsTotal === null ? t("runtime.filters.unavailable") : formatDashboardDuration(summary.cpuUsageSecondsTotal)} / ${summary.cpuCapacityCores === null ? "—" : t("runtime.cores", { value: summary.cpuCapacityCores.toLocaleString(locale) })}`} detail={t("runtime.metrics.cpuDetail", { covered: summary.cpuCoverageCount, total: summary.observedRuntimeCount })} />
} label={t("runtime.metrics.memory")} value={summary.memoryUsageBytes === null && summary.memoryLimitBytes === null ? t("runtime.metrics.noSample") : `${summary.memoryUsageBytes === null ? t("runtime.filters.unavailable") : formatDashboardBytes(summary.memoryUsageBytes)} / ${summary.memoryLimitBytes === null ? t("runtime.filters.unavailable") : formatDashboardBytes(summary.memoryLimitBytes)}`} detail={t("runtime.metrics.memoryDetail", { covered: summary.memoryCoverageCount, total: summary.observedRuntimeCount })} />
} label={t("runtime.metrics.tokens")} value={summary.totalTokens === null ? t("runtime.filters.unavailable") : formatDashboardTokens(summary.totalTokens, locale)} detail={t("runtime.metrics.tokenDetail", { covered: summary.tokenCoverageCount, total: summary.sessionCount })} />
diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
index 5fb94734f..eeba94ed4 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.test.tsx
@@ -1,17 +1,17 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
-import { RuntimeTrendCharts, runtimeChartCaption, runtimeChartShowsSparsePoints } from "./RuntimeTrendCharts";
+import { integerTickRatios, RuntimeTrendCharts, runtimeChartCaption, runtimeChartShowsSparsePoints } from "./RuntimeTrendCharts";
import type { RuntimeTrendSample } from "./runtime-trends";
function sample(sampledAt: number, cpuRatio: number | null): RuntimeTrendSample {
return {
sampledAt,
+ activeSandboxCount: 1,
targets: [{
seriesId: "session-1:allocation-1",
label: "Runtime worker",
cpuRatio,
- uptimeSeconds: 120,
}],
cpuCandidates: [],
memoryUsageBytes: 512,
@@ -23,6 +23,11 @@ function sample(sampledAt: number, cpuRatio: number | null): RuntimeTrendSample
}
describe("Runtime live-window chart accessibility", () => {
+ it("uses exact integer y-axis positions for Sandbox counts", () => {
+ expect(integerTickRatios(5).map((ratio) => ratio * 5)).toEqual([5, 3, 2, 0]);
+ expect(integerTickRatios(17).map((ratio) => ratio * 17)).toEqual([17, 11, 6, 0]);
+ });
+
it("shows isolated or sparse values as points without inventing continuity", () => {
expect(runtimeChartShowsSparsePoints([null, 512, null])).toBe(true);
expect(runtimeChartShowsSparsePoints([512, 768])).toBe(true);
@@ -38,23 +43,32 @@ describe("Runtime live-window chart accessibility", () => {
allSeriesHidden: true,
validPoints: 0,
sampleCount: 24,
- emptyMessage: "No complete retained memory samples",
+ emptyMessage: "No retained observed memory samples",
})).toBe("Memory usage all series hidden; use the legend to show a series");
});
it("renders an unavailable current value as zero without retaining a stale value", () => {
+ const unavailable = {
+ ...sample(120_000, null),
+ activeSandboxCount: 0,
+ memoryUsageBytes: null,
+ memoryLimitBytes: null,
+ } satisfies RuntimeTrendSample;
const html = renderToStaticMarkup(
-
,
+
,
);
expect(html).toContain("Runtime worker
0% | 0 | ");
expect(html).not.toContain("Runtime worker
50% | 1 | ");
+ expect(html).toContain("used
0 B | 0 | ");
+ expect(html).toContain("active
0 | 0 | ");
});
it("renders empty retained buckets as continuous zero-value chart series", () => {
const empty = (sampledAt: number): RuntimeTrendSample => ({
...sample(sampledAt, null),
targets: [],
+ activeSandboxCount: 0,
memoryUsageBytes: null,
memoryLimitBytes: null,
inputTokensPerMinute: null,
@@ -66,6 +80,7 @@ describe("Runtime live-window chart accessibility", () => {
expect(html).toContain("usage
0% | 0 | ");
expect(html).toContain("used
0 B | 0 | ");
+ expect(html).toContain("active
0 | 0 | ");
expect(html).toContain("input
0/min | 0 | ");
expect(html).not.toContain("No retained CPU samples");
expect(html).not.toContain("No complete retained memory samples");
@@ -79,7 +94,7 @@ describe("Runtime live-window chart accessibility", () => {
expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4);
expect(html).not.toContain("Collecting live samples");
- expect(html).toContain("Compute uptime");
+ expect(html).toContain("Active sandboxes");
});
it("exposes interactive series, point selection, and Grafana-style in-plot range selection", () => {
@@ -103,29 +118,42 @@ describe("Runtime live-window chart accessibility", () => {
);
expect(html).toContain('aria-label="CPU usage durable history chart"');
- expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(3);
+ expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4);
expect(html).not.toContain("Compute uptime");
expect(html).toContain('aria-label="CPU usage: 2 retained buckets"');
+ expect(html).toContain('aria-label="Active sandboxes durable history chart"');
+ expect(html).toContain("active
1 | 0 | ");
expect(html).not.toContain('aria-label="CPU usage: 2 live samples"');
});
- it("can retain an honest uptime card when a consumer requires four metric panels", () => {
+ it("renders one summed active-Sandbox series instead of one series per Session", () => {
+ const latest = { ...sample(120_000, .5), activeSandboxCount: 3 };
const html = renderToStaticMarkup(
-
,
+
,
);
expect(html.match(/data-chart-engine="uplot"/g)).toHaveLength(4);
- expect(html).toContain("Compute uptime");
- expect(html).toContain("Live-only metric");
- expect(html).toContain("Select Live to inspect current Runtime uptime");
+ expect(html).toContain("active
3 | 0 | ");
+ expect(html.match(/aria-label="Hide active series"/g)).toHaveLength(1);
});
+ it("renders the Session view as one binary Runtime-active series", () => {
+ const latest = { ...sample(120_000, .5), activeSandboxCount: 3 };
+ const html = renderToStaticMarkup(
+
,
+ );
+
+ expect(html).toContain("Runtime active");
+ expect(html).toContain("1 active / 0 inactive");
+ expect(html).toContain("Runtime
Active | 0 | ");
+ expect(html).not.toContain("Active sandboxes");
+ });
it("announces an isolated durable value as sparse rather than empty", () => {
const html = renderToStaticMarkup(
,
);
expect(html).toContain("Memory usage durable trend has 1 sparse valid point; a line requires consecutive buckets");
- expect(html).not.toContain("Memory usage No complete retained memory samples");
+ expect(html).not.toContain("Memory usage No retained observed memory samples");
});
});
diff --git a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
index 74d1b6b13..c85f06857 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendCharts.tsx
@@ -10,7 +10,7 @@ import { useTranslation } from "react-i18next";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
-import { formatDashboardBytes, formatDashboardDuration, formatDashboardTokens } from "./dashboard-model";
+import { formatDashboardBytes, formatDashboardTokens } from "./dashboard-model";
import { tokenThroughput, type RuntimeTrendSample } from "./runtime-trends";
interface TrendPoint {
@@ -23,6 +23,7 @@ interface TrendSeries {
label: string;
tone: "orange" | "green" | "blue" | "purple";
points: TrendPoint[];
+ stepped?: boolean;
}
interface TrendBand {
@@ -257,6 +258,7 @@ function TrendChart({
stroke: toneColors[entry.tone],
width: 2,
spanGaps: false,
+ paths: entry.stepped ? uPlot.paths.stepped!({ align: 1 }) : undefined,
points: {
show: (plot, seriesIndex, first, last) => runtimeChartShowsSparsePoints(
Array.from(plot.data[seriesIndex] ?? []).slice(first, last + 1),
@@ -485,12 +487,12 @@ function TrendChart({
);
}
-function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio" | "uptimeSeconds"): string[] {
+function targetIds(samples: readonly RuntimeTrendSample[], field: "cpuRatio"): string[] {
const latest = new Map
();
for (const sample of samples) {
for (const target of sample.targets) {
const value = target[field];
- latest.set(target.seriesId, value ?? latest.get(target.seriesId) ?? 0);
+ if (value !== null) latest.set(target.seriesId, value);
}
}
return [...latest.entries()].sort((left, right) => right[1] - left[1]).slice(0, 3).map(([id]) => id);
@@ -506,24 +508,31 @@ function targetLabel(samples: readonly RuntimeTrendSample[], id: string): string
const tones: TrendSeries["tone"][] = ["orange", "green", "blue"];
+export function integerTickRatios(maximum: number): number[] {
+ const integerMaximum = Math.max(1, Math.ceil(maximum));
+ const values = integerMaximum <= 4
+ ? Array.from({ length: integerMaximum + 1 }, (_, index) => integerMaximum - index)
+ : [integerMaximum, Math.round(integerMaximum * 2 / 3), Math.round(integerMaximum / 3), 0];
+ return [...new Set(values)].map((value) => value / integerMaximum);
+}
+
export function RuntimeTrendCharts({
samples,
source = "live",
rangeStart,
rangeEnd,
- showDurableUptimePlaceholder = false,
+ activeDisplay = "sum",
}: {
samples: readonly RuntimeTrendSample[];
source?: RuntimeTrendSource;
rangeStart?: number;
rangeEnd?: number;
- showDurableUptimePlaceholder?: boolean;
+ activeDisplay?: "sum" | "binary";
}) {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.resolvedLanguage ?? "en";
const charts = useMemo(() => {
const cpuIds = targetIds(samples, "cpuRatio");
- const uptimeIds = targetIds(samples, "uptimeSeconds");
const cpu = cpuIds.map((id, index): TrendSeries => ({
id,
label: targetLabel(samples, id),
@@ -535,15 +544,18 @@ export function RuntimeTrendCharts({
}
const memoryUsed = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryUsageBytes ?? 0 }));
const memoryLimit = samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.memoryLimitBytes ?? 0 }));
- const uptime = uptimeIds.map((id, index): TrendSeries => ({
- id,
- label: targetLabel(samples, id),
- tone: tones[(index + 2) % tones.length] ?? "blue",
- points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.targets.find((target) => target.seriesId === id)?.uptimeSeconds ?? 0 })),
- }));
- if (uptime.length === 0 && samples.length > 0) {
- uptime.push({ id: "uptime", label: t("charts.runtime"), tone: "blue", points: samples.map((sample) => ({ sampledAt: sample.sampledAt, value: 0 })) });
- }
+ const active = [{
+ id: "active",
+ label: t(activeDisplay === "binary" ? "charts.runtime" : "charts.active.series"),
+ tone: "green",
+ stepped: true,
+ points: samples.map((sample) => ({
+ sampledAt: sample.sampledAt,
+ value: activeDisplay === "binary"
+ ? (sample.activeSandboxCount ?? 0) > 0 ? 1 : 0
+ : sample.activeSandboxCount ?? 0,
+ })),
+ }] satisfies TrendSeries[];
const throughput = tokenThroughput(samples);
return {
cpu,
@@ -551,26 +563,35 @@ export function RuntimeTrendCharts({
{ id: "used", label: t("charts.used"), tone: "purple", points: memoryUsed },
{ id: "limit", label: t("charts.configuredLimit"), tone: "green", points: memoryLimit },
] satisfies TrendSeries[],
- uptime,
+ active,
tokens: [
{ id: "input", label: t("charts.input"), tone: "orange", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.inputPerMinute ?? 0 })) },
{ id: "output", label: t("charts.output"), tone: "green", points: throughput.map((sample) => ({ sampledAt: sample.sampledAt, value: sample.outputPerMinute ?? 0 })) },
] satisfies TrendSeries[],
};
- }, [samples, t]);
+ }, [activeDisplay, samples, t]);
const cpuMaximum = Math.max(100, ...finite(charts.cpu.flatMap((series) => series.points.map((point) => point.value))));
const memoryMaximum = Math.max(1, ...finite(charts.memory.flatMap((series) => series.points.map((point) => point.value))));
- const uptimeMaximum = Math.max(1, ...finite(charts.uptime.flatMap((series) => series.points.map((point) => point.value))));
+ const activeMaximum = Math.max(1, ...finite(charts.active.flatMap((series) => series.points.map((point) => point.value))));
+ const activeTicks = integerTickRatios(activeMaximum);
const tokenMaximum = Math.max(1, ...finite(charts.tokens.flatMap((series) => series.points.map((point) => point.value))));
const newest = rangeEnd ?? samples.at(-1)?.sampledAt ?? Date.now();
const oldest = rangeStart ?? samples[0]?.sampledAt ?? newest - 60 * 60 * 1_000;
const durable = source === "durable";
+ const binaryActive = activeDisplay === "binary";
+ const activeTitle = t(binaryActive ? "charts.active.runtimeTitle" : "charts.active.sandboxTitle");
+ const activeSubtitle = t(binaryActive
+ ? durable ? "charts.active.binaryDurable" : "charts.active.binaryLive"
+ : durable ? "charts.active.sumDurable" : "charts.active.sumLive");
+ const formatActive = binaryActive
+ ? (value: number) => t(value >= .5 ? "charts.active.active" : "charts.active.inactive")
+ : (value: number) => `${Math.round(value)}`;
return (
`${Math.round(value)}%`} rangeStart={oldest} rangeEnd={newest} source={source} bands={[{ from: 0, to: 30, tone: "safe" }, { from: 30, to: 70, tone: "warning" }, { from: 70, to: 100, tone: "danger" }]} ticks={[1, .7, .3, 0]} emptyMessage={durable ? t("charts.cpu.empty") : undefined} />
formatDashboardBytes(Math.round(value))} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.memory.empty") : undefined} />
- {!durable || showDurableUptimePlaceholder ? formatDashboardDuration(value)} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.uptime.empty") : undefined} emptyDetail={durable ? t("charts.uptime.detail") : undefined} /> : null}
+
t("charts.tokens.perMinute", { value: formatDashboardTokens(Math.round(value), locale) })} rangeStart={oldest} rangeEnd={newest} source={source} emptyMessage={durable ? t("charts.tokens.empty") : undefined} />
);
diff --git a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
index 91930af2a..270a4c9b3 100644
--- a/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
+++ b/apps/web/src/features/dashboard/RuntimeTrendPanel.tsx
@@ -30,16 +30,16 @@ export function RuntimeTrendPanel({
loadRuntimeHistory,
headingId = "dashboard-runtime-live-heading",
title,
- showDurableUptimePlaceholder = false,
allowSourceSelection = false,
+ activeDisplay = "sum",
}: {
snapshot: RuntimeDashboardSnapshot;
stale: boolean;
loadRuntimeHistory: RuntimeHistoryLoader;
headingId?: string;
title?: string;
- showDurableUptimePlaceholder?: boolean;
allowSourceSelection?: boolean;
+ activeDisplay?: "sum" | "binary";
}) {
const { t, i18n } = useTranslation("dashboard");
const locale = i18n.resolvedLanguage;
@@ -178,7 +178,7 @@ export function RuntimeTrendPanel({
{durableState === "failed" && durableError ? {t("trends.refreshFailed", { error: durableError })}
: null}
{durableState === "unavailable" ? {t("trends.notConfigured")}
: null}
-
+
);
}
diff --git a/apps/web/src/features/dashboard/dashboard-model.test.ts b/apps/web/src/features/dashboard/dashboard-model.test.ts
index 384d8619b..349e6b3ae 100644
--- a/apps/web/src/features/dashboard/dashboard-model.test.ts
+++ b/apps/web/src/features/dashboard/dashboard-model.test.ts
@@ -246,6 +246,7 @@ describe("Dashboard loaded-snapshot model", () => {
mode: "openai_hosted",
provider_type: "docker",
instance: { kind: "managed_allocation", allocation_id: "44444444-4444-4444-8444-444444444444", device_id: null, connection_generation: null },
+ lifecycle_state: "active",
status: "observed",
reason: null,
allocation_created_at: 100,
@@ -262,6 +263,7 @@ describe("Dashboard loaded-snapshot model", () => {
mode: "none",
provider_type: null,
instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null },
+ lifecycle_state: null,
status: "unsupported",
reason: "runtime_mode_not_observable",
allocation_created_at: null,
@@ -276,6 +278,11 @@ describe("Dashboard loaded-snapshot model", () => {
expect(model.summary).toMatchObject({
sessionCount: 2,
managedRuntimeCount: 1,
+ sandboxTotalCount: 1,
+ activeSandboxCount: 1,
+ sleepingSandboxCount: 0,
+ transitioningSandboxCount: 0,
+ pendingSandboxCount: 0,
observedRuntimeCount: 1,
unavailableRuntimeCount: 0,
unsupportedRuntimeCount: 1,
@@ -297,6 +304,59 @@ describe("Dashboard loaded-snapshot model", () => {
expect(formatDashboardDuration(90)).toBe("1m 30s");
});
+ it("counts a shared managed allocation once while retaining both Session rows", () => {
+ const first = session("11111111-1111-4111-8111-111111111111", { usage: usage(21) });
+ const second = session("22222222-2222-4222-8222-222222222222", { usage: usage(5) });
+ const allocationId = "44444444-4444-4444-8444-444444444444";
+ const firstObservation: RuntimeObservation = {
+ id: first.id,
+ object: "agent.runtime_observation",
+ session_id: first.id,
+ environment_id: "33333333-3333-4333-8333-333333333333",
+ mode: "openai_hosted",
+ provider_type: "docker",
+ instance: { kind: "managed_allocation", allocation_id: allocationId, device_id: null, connection_generation: null },
+ lifecycle_state: "active",
+ status: "observed",
+ reason: null,
+ allocation_created_at: 100,
+ resolved_at: 220,
+ observed_at: 210,
+ started_at: 150,
+ cpu: { usage_seconds_total: 3.5, capacity_cores: 2, usage_cores: null, utilization_ratio: null },
+ memory: { usage_bytes: 512, limit_bytes: 2048 },
+ };
+ const secondObservation: RuntimeObservation = {
+ ...firstObservation,
+ id: second.id,
+ session_id: second.id,
+ environment_id: "55555555-5555-4555-8555-555555555555",
+ resolved_at: 221,
+ observed_at: 211,
+ cpu: { usage_seconds_total: 4.5, capacity_cores: 2, usage_cores: null, utilization_ratio: null },
+ memory: { usage_bytes: 768, limit_bytes: 2048 },
+ };
+
+ const model = buildRuntimeDashboardModel([first, second], [firstObservation, secondObservation]);
+
+ expect(model.rows).toHaveLength(2);
+ expect(model.summary).toMatchObject({
+ sessionCount: 2,
+ managedRuntimeCount: 1,
+ sandboxTotalCount: 1,
+ activeSandboxCount: 1,
+ observedRuntimeCount: 1,
+ cpuUsageSecondsTotal: 4.5,
+ cpuCapacityCores: 2,
+ cpuCoverageCount: 1,
+ memoryUsageBytes: 768,
+ memoryLimitBytes: 2048,
+ memoryCoverageCount: 1,
+ totalTokens: 26,
+ tokenCoverageCount: 2,
+ });
+ });
+
it("holds each Session's last reported tokens in the summary while public usage is null", () => {
const running = session("11111111-1111-4111-8111-111111111111", { usage: usage(21) });
const other = session("22222222-2222-4222-8222-222222222222", { usage: usage(5) });
@@ -308,6 +368,7 @@ describe("Dashboard loaded-snapshot model", () => {
mode: "none",
provider_type: null,
instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null },
+ lifecycle_state: null,
status: "unsupported",
reason: "runtime_mode_not_observable",
allocation_created_at: null,
@@ -350,6 +411,7 @@ describe("Dashboard loaded-snapshot model", () => {
device_id: null,
connection_generation: null,
},
+ lifecycle_state: "stopped",
status: "unavailable",
reason: "runtime_not_running",
allocation_created_at: 100,
@@ -360,7 +422,9 @@ describe("Dashboard loaded-snapshot model", () => {
memory: null,
};
- expect(buildRuntimeDashboardModel([stopped], [observation]).rows[0]?.allocationAgeSeconds).toBeNull();
+ const model = buildRuntimeDashboardModel([stopped], [observation]);
+ expect(model.rows[0]?.allocationAgeSeconds).toBeNull();
+ expect(model.summary).toMatchObject({ sandboxTotalCount: 0, activeSandboxCount: 0, sleepingSandboxCount: 0 });
});
it("does not count capacity-only or limit-only samples as usage coverage", () => {
@@ -389,6 +453,7 @@ describe("Dashboard loaded-snapshot model", () => {
device_id: null,
connection_generation: null,
},
+ lifecycle_state: "active",
status: "observed",
reason: null,
allocation_created_at: null,
diff --git a/apps/web/src/features/dashboard/dashboard-model.ts b/apps/web/src/features/dashboard/dashboard-model.ts
index 2fd6a7a14..9f506aef7 100644
--- a/apps/web/src/features/dashboard/dashboard-model.ts
+++ b/apps/web/src/features/dashboard/dashboard-model.ts
@@ -56,6 +56,11 @@ export interface RuntimeDashboardRow {
export interface RuntimeDashboardSummary {
sessionCount: number;
managedRuntimeCount: number;
+ sandboxTotalCount: number;
+ activeSandboxCount: number;
+ sleepingSandboxCount: number;
+ transitioningSandboxCount: number;
+ pendingSandboxCount: number;
observedRuntimeCount: number;
unavailableRuntimeCount: number;
unsupportedRuntimeCount: number;
@@ -299,6 +304,11 @@ export function buildRuntimeDashboardModel(
const sessionsById = new Map(sessions.map((session) => [session.id, session]));
const rows: RuntimeDashboardRow[] = [];
let managedRuntimeCount = 0;
+ let sandboxTotalCount = 0;
+ let activeSandboxCount = 0;
+ let sleepingSandboxCount = 0;
+ let transitioningSandboxCount = 0;
+ let pendingSandboxCount = 0;
let observedRuntimeCount = 0;
let unavailableRuntimeCount = 0;
let unsupportedRuntimeCount = 0;
@@ -322,6 +332,9 @@ export function buildRuntimeDashboardModel(
let tokenCoverageCount = 0;
let oldestResolvedAt: number | null = null;
let newestResolvedAt: number | null = null;
+ const latestManagedByAllocation = new Map();
+ const managedWithoutAllocation: RuntimeObservation[] = [];
+ const latestObservedByAllocation = new Map();
for (const observation of observations) {
const session = sessionsById.get(observation.session_id);
@@ -332,9 +345,67 @@ export function buildRuntimeDashboardModel(
oldestResolvedAt = oldestResolvedAt === null ? resolvedAt : Math.min(oldestResolvedAt, resolvedAt);
newestResolvedAt = newestResolvedAt === null ? resolvedAt : Math.max(newestResolvedAt, resolvedAt);
}
- if (observation.mode === "openai_hosted") managedRuntimeCount += 1;
- if (observation.status === "observed") {
- observedRuntimeCount += 1;
+ if (observation.mode === "openai_hosted") {
+ const allocationId = observation.instance.allocation_id;
+ if (allocationId === null || allocationId.length === 0) {
+ managedWithoutAllocation.push(observation);
+ } else {
+ const previous = latestManagedByAllocation.get(allocationId);
+ if (!previous || observation.resolved_at >= previous.resolved_at) {
+ latestManagedByAllocation.set(allocationId, observation);
+ }
+ if (observation.status === "observed") {
+ const previousObserved = latestObservedByAllocation.get(allocationId);
+ if (!previousObserved || (
+ observation.observed_at ?? -1
+ ) >= (previousObserved.observed_at ?? -1)) {
+ latestObservedByAllocation.set(allocationId, observation);
+ }
+ }
+ }
+ }
+ if (observation.status === "unavailable") {
+ unavailableRuntimeCount += 1;
+ } else if (observation.status === "unsupported") {
+ unsupportedRuntimeCount += 1;
+ }
+
+ const sessionTokens = sessionRow.totalTokens ?? heldTokens.get(session.id) ?? null;
+ if (sessionTokens !== null) {
+ const next = safeAdd(totalTokens, sessionTokens);
+ if (next !== null) {
+ totalTokens = next;
+ tokensKnown = true;
+ } else tokensSafe = false;
+ tokenCoverageCount += 1;
+ }
+ rows.push({
+ session: sessionRow,
+ observation,
+ computeUptimeSeconds: observation.status === "observed"
+ ? elapsedSeconds(canonicalTimestamp(observation.started_at), canonicalTimestamp(observation.observed_at))
+ : null,
+ allocationAgeSeconds: observation.mode === "openai_hosted" && observation.reason !== "runtime_not_running"
+ ? elapsedSeconds(canonicalTimestamp(observation.allocation_created_at), resolvedAt)
+ : null,
+ });
+ }
+
+ const managedRuntimes = [...latestManagedByAllocation.values(), ...managedWithoutAllocation];
+ managedRuntimeCount = managedRuntimes.length;
+ for (const observation of managedRuntimes) {
+ switch (observation.lifecycle_state) {
+ case "active": activeSandboxCount += 1; sandboxTotalCount += 1; break;
+ case "sleeping": sleepingSandboxCount += 1; sandboxTotalCount += 1; break;
+ case "transitioning": transitioningSandboxCount += 1; sandboxTotalCount += 1; break;
+ case "pending": pendingSandboxCount += 1; sandboxTotalCount += 1; break;
+ case "stopped": break;
+ }
+ }
+
+ const observedRuntimes = [...latestObservedByAllocation.values()];
+ observedRuntimeCount = observedRuntimes.length;
+ for (const observation of observedRuntimes) {
const cpuUsage = safeFiniteNonNegative(observation.cpu?.usage_seconds_total);
const cpuCapacity = safeFiniteNonNegative(observation.cpu?.capacity_cores);
if (cpuUsage !== null) {
@@ -370,31 +441,6 @@ export function buildRuntimeDashboardModel(
} else memoryLimitSafe = false;
}
if (memoryUsage !== null) memoryCoverageCount += 1;
- } else if (observation.status === "unavailable") {
- unavailableRuntimeCount += 1;
- } else {
- unsupportedRuntimeCount += 1;
- }
-
- const sessionTokens = sessionRow.totalTokens ?? heldTokens.get(session.id) ?? null;
- if (sessionTokens !== null) {
- const next = safeAdd(totalTokens, sessionTokens);
- if (next !== null) {
- totalTokens = next;
- tokensKnown = true;
- } else tokensSafe = false;
- tokenCoverageCount += 1;
- }
- rows.push({
- session: sessionRow,
- observation,
- computeUptimeSeconds: observation.status === "observed"
- ? elapsedSeconds(canonicalTimestamp(observation.started_at), canonicalTimestamp(observation.observed_at))
- : null,
- allocationAgeSeconds: observation.mode === "openai_hosted" && observation.reason !== "runtime_not_running"
- ? elapsedSeconds(canonicalTimestamp(observation.allocation_created_at), resolvedAt)
- : null,
- });
}
const statusOrder = { observed: 0, unavailable: 1, unsupported: 2 } as const;
@@ -406,6 +452,11 @@ export function buildRuntimeDashboardModel(
summary: {
sessionCount: rows.length,
managedRuntimeCount,
+ sandboxTotalCount,
+ activeSandboxCount,
+ sleepingSandboxCount,
+ transitioningSandboxCount,
+ pendingSandboxCount,
observedRuntimeCount,
unavailableRuntimeCount,
unsupportedRuntimeCount,
diff --git a/apps/web/src/features/dashboard/runtime-history.test.ts b/apps/web/src/features/dashboard/runtime-history.test.ts
index 58cbfcd3c..32bf7e8ca 100644
--- a/apps/web/src/features/dashboard/runtime-history.test.ts
+++ b/apps/web/src/features/dashboard/runtime-history.test.ts
@@ -125,26 +125,74 @@ describe("Runtime Durable Dashboard history", () => {
expect(samples).toHaveLength(2);
expect(samples[0]).toMatchObject({
sampledAt: 130_000,
+ activeSandboxCount: 1,
memoryUsageBytes: 512,
memoryLimitBytes: 1_024,
inputTokensPerMinute: null,
outputTokensPerMinute: null,
- targets: [{ label: "Durable worker", cpuRatio: .25, uptimeSeconds: null }],
});
- expect(samples[1]?.targets[0]?.uptimeSeconds).toBeNull();
+ expect(samples[0]?.targets).toEqual([
+ expect.objectContaining({ label: "Durable worker", cpuRatio: .25 }),
+ ]);
expect(samples[1]).toMatchObject({ inputTokensPerMinute: 60, outputTokensPerMinute: 20 });
});
- it("does not derive compute uptime from retained allocation starts or unavailable observations", () => {
+ it("counts and aggregates distinct Runtime allocations within one Session", () => {
+ const source = history();
+ const secondSeries = {
+ ...source.series[0]!,
+ allocation_id: "55555555-5555-4555-8555-555555555555",
+ points: source.series[0]!.points.map((point) => ({
+ ...point,
+ memory: point.memory ? { ...point.memory, usage_bytes: 128, limit_bytes: 256 } : null,
+ })),
+ };
+ const samples = runtimeDurableTrendSamples([session], [{
+ ...source,
+ series: [source.series[0]!, secondSeries],
+ }]);
+
+ expect(samples[0]).toMatchObject({
+ activeSandboxCount: 2,
+ memoryUsageBytes: 640,
+ memoryLimitBytes: 1_280,
+ });
+ });
+
+ it("deduplicates one Runtime allocation repeated across Session histories", () => {
+ const second = { ...session, id: "44444444-4444-4444-8444-444444444444" } as AgentSession;
+ const repeated = history({
+ session_id: second.id,
+ series: [{
+ ...history().series[0]!,
+ points: history().series[0]!.points.map((point) => ({
+ ...point,
+ memory: point.memory ? { ...point.memory, usage_bytes: 128, limit_bytes: 256 } : null,
+ })),
+ }],
+ });
+ const samples = runtimeDurableTrendSamples([session, second], [history(), repeated]);
+
+ expect(samples[0]).toMatchObject({
+ activeSandboxCount: 1,
+ memoryUsageBytes: 128,
+ memoryLimitBytes: 256,
+ });
+ });
+
+ it("projects an unavailable retained observation as zero active Sandboxes", () => {
const source = history();
source.series[0]!.points[1] = {
...source.series[0]!.points[1]!, observed_count: 0, unavailable_count: 1, cpu: null, memory: null,
};
+ source.coverage.buckets[1] = {
+ ...source.coverage.buckets[1]!, observed_count: 0, unavailable_count: 1,
+ };
const samples = runtimeDurableTrendSamples([session], [source]);
- expect(samples.flatMap((sample) => sample.targets.map((target) => target.uptimeSeconds))).toEqual([null, null]);
+ expect(samples[1]?.activeSandboxCount).toBe(0);
});
- it("keeps aggregate memory absent when any queried target has no memory value", () => {
+ it("aggregates observed memory without letting an unavailable target erase it", () => {
const second = { ...session, id: "44444444-4444-4444-8444-444444444444" } as AgentSession;
const secondHistory = history({
session_id: second.id,
@@ -152,7 +200,8 @@ describe("Runtime Durable Dashboard history", () => {
series: [],
});
const samples = runtimeDurableTrendSamples([session, second], [history(), secondHistory]);
- expect(samples.every((sample) => sample.memoryUsageBytes === null && sample.memoryLimitBytes === null)).toBe(true);
+ expect(samples.every((sample) => sample.memoryUsageBytes !== null && sample.memoryLimitBytes !== null)).toBe(true);
+ expect(samples[0]).toMatchObject({ memoryUsageBytes: 512, memoryLimitBytes: 1_024, activeSandboxCount: 1 });
});
it("keeps omitted buckets between distant observations as gaps", () => {
@@ -183,13 +232,13 @@ describe("Runtime Durable Dashboard history", () => {
expect(samples.map((sample) => sample.sampledAt)).toEqual(
Array.from({ length: 11 }, (_, index) => (130 + index * 30) * 1_000),
);
- expect(samples[0]?.targets[0]?.cpuRatio).toBe(.25);
- expect(samples[10]?.targets[0]?.cpuRatio).toBe(.5);
+ expect(samples[0]?.targets.find((target) => target.cpuRatio !== null)?.cpuRatio).toBe(.25);
+ expect(samples[10]?.targets.find((target) => target.cpuRatio !== null)?.cpuRatio).toBe(.5);
expect(samples[10]?.inputTokensPerMinute).toBeNull();
expect(samples[10]?.outputTokensPerMinute).toBeNull();
for (const sample of samples.slice(1, -1)) {
expect(sample).toMatchObject({
- targets: [], memoryUsageBytes: null, memoryLimitBytes: null,
+ activeSandboxCount: null, targets: [], memoryUsageBytes: null, memoryLimitBytes: null,
inputTokensPerMinute: null, outputTokensPerMinute: null,
});
}
@@ -203,6 +252,7 @@ describe("Runtime Durable Dashboard history", () => {
expect(samples.map((sample) => sample.sampledAt)).toEqual([100_000, 130_000, 160_000, 190_000, 205_000]);
expect(samples.map((sample) => sample.memoryUsageBytes)).toEqual([null, 512, 768, null, null]);
expect(samples.map((sample) => sample.targets.length)).toEqual([0, 1, 1, 0, 0]);
+ expect(samples.map((sample) => sample.activeSandboxCount)).toEqual([null, 1, 1, null, null]);
});
it("represents an entirely missing range without fabricating zero measurements", () => {
@@ -219,7 +269,7 @@ describe("Runtime Durable Dashboard history", () => {
expect(samples.map((sample) => sample.sampledAt)).toEqual([130_000, 160_000, 175_000]);
for (const sample of samples) {
expect(sample).toMatchObject({
- targets: [], memoryUsageBytes: null, memoryLimitBytes: null,
+ activeSandboxCount: null, targets: [], memoryUsageBytes: null, memoryLimitBytes: null,
inputTokensPerMinute: null, outputTokensPerMinute: null,
});
}
diff --git a/apps/web/src/features/dashboard/runtime-history.ts b/apps/web/src/features/dashboard/runtime-history.ts
index 9200bf92a..91098f53d 100644
--- a/apps/web/src/features/dashboard/runtime-history.ts
+++ b/apps/web/src/features/dashboard/runtime-history.ts
@@ -80,7 +80,9 @@ async function mapBounded(
interface MutableBucket {
sampledAt: number;
+ hasObservationCoverage: boolean;
targets: Map;
+ activeSandboxes: Map;
memory: Map;
tokens: Map;
}
@@ -94,7 +96,7 @@ export function runtimeDurableTrendSamples(
const bucket = (sampledAt: number): MutableBucket => {
let value = buckets.get(sampledAt);
if (!value) {
- value = { sampledAt, targets: new Map(), memory: new Map(), tokens: new Map() };
+ value = { sampledAt, hasObservationCoverage: false, targets: new Map(), activeSandboxes: new Map(), memory: new Map(), tokens: new Map() };
buckets.set(sampledAt, value);
}
return value;
@@ -103,9 +105,13 @@ export function runtimeDurableTrendSamples(
for (const history of histories) {
const { start, end } = history.requested_range;
for (let bucketStart = start; bucketStart < end; bucketStart += history.resolution_seconds) {
- bucket(Math.min(bucketStart + history.resolution_seconds, end) * 1_000);
+ const bucketEnd = Math.min(bucketStart + history.resolution_seconds, end);
+ bucket(bucketEnd * 1_000);
+ }
+ for (const coverage of history.coverage.buckets) {
+ const value = bucket(coverage.end * 1_000);
+ value.hasObservationCoverage ||= coverage.observation_count > 0;
}
- for (const coverage of history.coverage.buckets) bucket(coverage.end * 1_000);
for (const usage of history.token_usage) {
bucket(usage.end * 1_000).tokens.set(history.session_id, {
sampledAt: usage.sampled_at * 1_000,
@@ -118,19 +124,25 @@ export function runtimeDurableTrendSamples(
const label = titles.get(history.session_id) ?? "Runtime";
for (const point of series.points) {
const value = bucket(point.end * 1_000);
+ value.hasObservationCoverage ||= point.observation_count > 0;
const observedAt = point.last_observed_at;
value.targets.set(targetID, {
seriesId: targetID,
label,
cpuRatio: point.cpu?.utilization_ratio ?? null,
- uptimeSeconds: null,
});
+ if (observedAt !== null && point.observed_count > 0) {
+ const previous = value.activeSandboxes.get(series.allocation_id);
+ if (!previous || observedAt >= previous.observedAt) {
+ value.activeSandboxes.set(series.allocation_id, { observedAt });
+ }
+ }
const usage = point.memory?.usage_bytes;
const limit = point.memory?.limit_bytes;
if (observedAt !== null && usage != null && limit != null) {
- const previous = value.memory.get(history.session_id);
+ const previous = value.memory.get(series.allocation_id);
if (!previous || observedAt >= previous.observedAt) {
- value.memory.set(history.session_id, { observedAt, usage, limit });
+ value.memory.set(series.allocation_id, { observedAt, usage, limit });
}
}
}
@@ -138,16 +150,17 @@ export function runtimeDurableTrendSamples(
}
const samples = [...buckets.values()].sort((left, right) => left.sampledAt - right.sampledAt).map((value) => {
- const completeMemory = sessions.length > 0 && value.memory.size === sessions.length;
+ const observedMemory = [...value.memory.values()];
return {
sampledAt: value.sampledAt,
+ activeSandboxCount: value.hasObservationCoverage ? value.activeSandboxes.size : null,
targets: [...value.targets.values()],
cpuCandidates: [],
- memoryUsageBytes: completeMemory
- ? [...value.memory.values()].reduce((total, current) => total + current.usage, 0)
+ memoryUsageBytes: observedMemory.length > 0
+ ? observedMemory.reduce((total, current) => total + current.usage, 0)
: null,
- memoryLimitBytes: completeMemory
- ? [...value.memory.values()].reduce((total, current) => total + current.limit, 0)
+ memoryLimitBytes: observedMemory.length > 0
+ ? observedMemory.reduce((total, current) => total + current.limit, 0)
: null,
tokenTotals: value.tokens.size === sessions.length
? [...value.tokens.entries()].map(([sessionId, usage]) => ({ sessionId, ...usage }))
diff --git a/apps/web/src/features/dashboard/runtime-trends.test.ts b/apps/web/src/features/dashboard/runtime-trends.test.ts
index 40af9e83a..ab6e339d8 100644
--- a/apps/web/src/features/dashboard/runtime-trends.test.ts
+++ b/apps/web/src/features/dashboard/runtime-trends.test.ts
@@ -9,6 +9,7 @@ import {
runtimeTrendRange,
runtimeTrendSample,
tokenThroughput,
+ type RuntimeTrendSample,
} from "./runtime-trends";
function snapshot(at: number, options: {
@@ -72,6 +73,7 @@ function snapshot(at: number, options: {
device_id: null,
connection_generation: null,
},
+ lifecycle_state: "active",
allocation_created_at: observedAt - 180,
resolved_at: observedAt,
observed_at: observedAt,
@@ -88,10 +90,13 @@ function snapshot(at: number, options: {
}
describe("Runtime live-window trends", () => {
+ const cpuTarget = (sample: RuntimeTrendSample | undefined) => sample?.targets[0];
+
it("projects only honest point-in-time and cumulative Session values", () => {
const sample = runtimeTrendSample(snapshot(120_000));
expect(sample).toMatchObject({
sampledAt: 120_000,
+ activeSandboxCount: 1,
tokenTotals: [{
sessionId: "11111111-1111-4111-8111-111111111111",
inputTokens: 100,
@@ -103,10 +108,70 @@ describe("Runtime live-window trends", () => {
expect(sample.targets).toEqual([expect.objectContaining({
label: "Runtime worker",
cpuRatio: .25,
- uptimeSeconds: 120,
})]);
});
+ it("projects a managed Session without an allocation as inactive", () => {
+ const pending = snapshot(120_000);
+ pending.observations = [{
+ ...pending.observations[0]!,
+ instance: { kind: "managed_allocation", allocation_id: null, device_id: null, connection_generation: null },
+ lifecycle_state: "pending",
+ status: "unavailable",
+ reason: "allocation_pending",
+ allocation_created_at: null,
+ observed_at: null,
+ started_at: null,
+ cpu: null,
+ memory: null,
+ } as RuntimeObservation];
+
+ expect(runtimeTrendSample(pending)).toMatchObject({ activeSandboxCount: 0, targets: [] });
+ });
+
+ it("deduplicates live aggregate count and memory by Runtime allocation identity", () => {
+ const duplicate = snapshot(120_000);
+ const secondSession = {
+ ...duplicate.sessions[0]!,
+ id: "44444444-4444-4444-8444-444444444444",
+ } as AgentSession;
+ const secondObservation = {
+ ...duplicate.observations[0]!,
+ id: secondSession.id,
+ session_id: secondSession.id,
+ observed_at: (duplicate.observations[0]!.observed_at ?? 0) + 1,
+ memory: { usage_bytes: 128, limit_bytes: 256 },
+ } as RuntimeObservation;
+ duplicate.sessions.push(secondSession);
+ duplicate.observations.push(secondObservation);
+
+ expect(runtimeTrendSample(duplicate)).toMatchObject({
+ activeSandboxCount: 1,
+ memoryUsageBytes: 128,
+ memoryLimitBytes: 256,
+ });
+ });
+
+ it("keeps lifecycle-active allocation count when resource metrics are unavailable", () => {
+ const unavailable = snapshot(180_000);
+ unavailable.observations[0] = {
+ ...unavailable.observations[0]!,
+ status: "unavailable",
+ reason: "runtime_not_running",
+ observed_at: null,
+ started_at: null,
+ cpu: null,
+ memory: null,
+ } as RuntimeObservation;
+
+ expect(runtimeTrendSample(unavailable)).toMatchObject({
+ activeSandboxCount: 1,
+ memoryUsageBytes: null,
+ memoryLimitBytes: null,
+ targets: [],
+ });
+ });
+
it("deduplicates refreshes and bounds the rolling window", () => {
let samples = appendRuntimeTrendSample([], snapshot(60_000), 120_000, 2);
samples = appendRuntimeTrendSample(samples, snapshot(120_000));
@@ -151,7 +216,7 @@ describe("Runtime live-window trends", () => {
let samples = appendRuntimeTrendSample([], cumulative(60_000, 10));
samples = appendRuntimeTrendSample(samples, cumulative(120_000, 70));
samples = appendRuntimeTrendSample(samples, cumulative(180_000, 130));
- expect(samples.map((sample) => sample.targets[0]?.cpuRatio ?? null)).toEqual([null, .5, .5]);
+ expect(samples.map((sample) => cpuTarget(sample)?.cpuRatio ?? null)).toEqual([null, .5, .5]);
});
it("uses allocation identity for Live chart series while ignoring start-time jitter", () => {
@@ -162,8 +227,8 @@ describe("Runtime live-window trends", () => {
startedAt: 1,
allocationId: "44444444-4444-4444-8444-444444444444",
}));
- expect(first.targets[0]?.seriesId).toBe(jittered.targets[0]?.seriesId);
- expect(replaced.targets[0]?.seriesId).not.toBe(first.targets[0]?.seriesId);
+ expect(cpuTarget(first)?.seriesId).toBe(cpuTarget(jittered)?.seriesId);
+ expect(cpuTarget(replaced)?.seriesId).not.toBe(cpuTarget(first)?.seriesId);
});
it("resets cumulative CPU on start changes, allocation changes or counter regressions", () => {
@@ -173,14 +238,13 @@ describe("Runtime live-window trends", () => {
const continued = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), snapshot(120_000, {
cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 160, startedAt: 1,
}));
- expect(continued.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull();
- expect(continued[1]?.targets[0]?.seriesId).toBe(continued[0]?.targets[0]?.seriesId);
+ expect(cpuTarget(continued.at(-1))?.cpuRatio ?? null).toBeNull();
for (const next of [
snapshot(120_000, { cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 160, startedAt: 0, allocationId: "44444444-4444-4444-8444-444444444444" }),
snapshot(120_000, { cpuRatio: null, cpuUsageCores: null, cpuUsageSecondsTotal: 10, startedAt: 0 }),
]) {
const samples = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), next);
- expect(samples.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull();
+ expect(cpuTarget(samples.at(-1))?.cpuRatio ?? null).toBeNull();
}
});
@@ -191,26 +255,25 @@ describe("Runtime live-window trends", () => {
let samples = appendRuntimeTrendSample([], cumulative(60_000, 1, 0));
samples = appendRuntimeTrendSample(samples, cumulative(90_000, 20, 65));
samples = appendRuntimeTrendSample(samples, cumulative(120_000, 50, 65));
- expect(samples.map((sample) => sample.targets[0]?.cpuRatio ?? null)).toEqual([null, null, .5]);
- expect(new Set(samples.map((sample) => sample.targets[0]?.seriesId)).size).toBe(1);
+ expect(samples.map((sample) => cpuTarget(sample)?.cpuRatio ?? null)).toEqual([null, null, .5]);
for (const [priorStart, nextStart] of [[null, 0], [0, null], [null, null]] as const) {
const missingFence = appendRuntimeTrendSample(
appendRuntimeTrendSample([], cumulative(60_000, 1, priorStart)),
cumulative(90_000, 20, nextStart),
);
- expect(missingFence.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull();
+ expect(cpuTarget(missingFence.at(-1))?.cpuRatio ?? null).toBeNull();
}
});
it("keeps directly reported CPU continuous across start changes but not stale observations", () => {
const base = snapshot(60_000, { cpuRatio: .25, startedAt: 0 });
const continued = appendRuntimeTrendSample(appendRuntimeTrendSample([], base), snapshot(120_000, { cpuRatio: .5, startedAt: 1 }));
- expect(continued.at(-1)?.targets[0]?.cpuRatio ?? null).toBe(.5);
+ expect(cpuTarget(continued.at(-1))?.cpuRatio ?? null).toBe(.5);
const stale = appendRuntimeTrendSample(
appendRuntimeTrendSample([], base),
snapshot(120_000, { cpuRatio: .5, startedAt: 0, observedAt: 60 }),
);
- expect(stale.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull();
+ expect(cpuTarget(stale.at(-1))?.cpuRatio ?? null).toBeNull();
});
it("rejects non-finite CPU ratios produced by finite provider inputs", () => {
@@ -219,7 +282,7 @@ describe("Runtime live-window trends", () => {
cpuUsageCores: Number.MAX_VALUE,
cpuCapacity: Number.MIN_VALUE,
}));
- expect(direct.targets[0]?.cpuRatio ?? null).toBeNull();
+ expect(cpuTarget(direct)?.cpuRatio ?? null).toBeNull();
const cumulative = (at: number, usage: number) => snapshot(at, {
cpuRatio: null,
@@ -232,7 +295,7 @@ describe("Runtime live-window trends", () => {
appendRuntimeTrendSample([], cumulative(60_000, 0)),
cumulative(120_000, Number.MAX_VALUE),
);
- expect(samples.at(-1)?.targets[0]?.cpuRatio ?? null).toBeNull();
+ expect(cpuTarget(samples.at(-1))?.cpuRatio ?? null).toBeNull();
});
it("leaves a gap while a Session's public usage is null and spreads the next report", () => {
@@ -290,6 +353,7 @@ describe("Runtime live-window trends", () => {
...many.observations[0]!,
id: session.id,
session_id: session.id,
+ instance: { ...many.observations[0]!.instance, allocation_id: `allocation-${index}` },
cpu: { ...many.observations[0]!.cpu!, utilization_ratio: index / 10 },
started_at: 60 - index,
} as RuntimeObservation));
diff --git a/apps/web/src/features/dashboard/runtime-trends.ts b/apps/web/src/features/dashboard/runtime-trends.ts
index 433151439..342c90d10 100644
--- a/apps/web/src/features/dashboard/runtime-trends.ts
+++ b/apps/web/src/features/dashboard/runtime-trends.ts
@@ -17,7 +17,6 @@ export interface RuntimeTrendTarget {
seriesId: string;
label: string;
cpuRatio: number | null;
- uptimeSeconds: number | null;
}
export interface RuntimeTrendCPUCandidate extends RuntimeTrendTarget {
@@ -31,6 +30,7 @@ export interface RuntimeTrendCPUCandidate extends RuntimeTrendTarget {
export interface RuntimeTrendSample {
sampledAt: number;
+ activeSandboxCount: number | null;
targets: RuntimeTrendTarget[];
cpuCandidates: RuntimeTrendCPUCandidate[];
memoryUsageBytes: number | null;
@@ -81,22 +81,13 @@ function reportedCpuRatio(observation: RuntimeObservation): number | null {
}
function allocationKey(observation: RuntimeObservation): string | null {
- if (observation.status !== "observed") return null;
+ if (observation.mode !== "openai_hosted") return null;
const allocationId = observation.instance.allocation_id;
return typeof allocationId === "string" && allocationId.length > 0
? `${observation.instance.kind}:${allocationId}`
: null;
}
-function uptimeSeconds(observation: RuntimeObservation): number | null {
- if (observation.status !== "observed") return null;
- const startedAt = safeInteger(observation.started_at);
- const observedAt = safeInteger(observation.observed_at);
- return startedAt !== null && observedAt !== null && observedAt >= startedAt
- ? observedAt - startedAt
- : null;
-}
-
function tokenTotals(sessions: readonly AgentSession[], sampledAt: number): RuntimeTrendTokenTotal[] {
return sessions.flatMap((session): RuntimeTrendTokenTotal[] => {
const inputTokens = safeInteger(session.usage?.input_tokens);
@@ -120,15 +111,18 @@ function carryTokenTotals(previous: RuntimeTrendSample, next: RuntimeTrendSample
export function runtimeTrendSample(snapshot: RuntimeDashboardSnapshot): RuntimeTrendSample {
const sessions = new Map(snapshot.sessions.map((session) => [session.id, session]));
- const observed = snapshot.observations.flatMap((observation) => {
+ const managed = snapshot.observations.flatMap((observation) => {
const session = sessions.get(observation.session_id);
- if (!session || observation.status !== "observed") return [];
+ if (!session || observation.mode !== "openai_hosted") return [];
const key = allocationKey(observation);
- if (key === null) return [];
+ const allocationId = observation.instance.allocation_id;
+ if (key === null || typeof allocationId !== "string" || allocationId.length === 0) return [];
return [{
seriesId: `${observation.session_id}:${key}`,
+ allocationId,
label: sessionTitle(session),
cpuRatio: reportedCpuRatio(observation),
+ resolvedAt: safeInteger(observation.resolved_at),
observedAt: safeInteger(observation.observed_at),
startedAt: safeInteger(observation.started_at),
allocationKey: allocationKey(observation),
@@ -136,41 +130,51 @@ export function runtimeTrendSample(snapshot: RuntimeDashboardSnapshot): RuntimeT
capacityCores: finiteNonNegative(observation.cpu?.capacity_cores),
memoryUsageBytes: finiteNonNegative(observation.memory?.usage_bytes),
memoryLimitBytes: finiteNonNegative(observation.memory?.limit_bytes),
- uptimeSeconds: uptimeSeconds(observation),
+ lifecycleState: observation.lifecycle_state,
+ observed: observation.status === "observed",
}];
});
- const targetIds = new Set([
- ...observed.filter((target) => target.cpuRatio !== null)
- .sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0))
- .slice(0, RUNTIME_TREND_SERIES_LIMIT)
- .map((target) => target.seriesId),
- ...observed.filter((target) => target.uptimeSeconds !== null)
- .sort((left, right) => (right.uptimeSeconds ?? 0) - (left.uptimeSeconds ?? 0))
- .slice(0, RUNTIME_TREND_SERIES_LIMIT)
- .map((target) => target.seriesId),
- ]);
- const targets = observed.filter((target) => targetIds.has(target.seriesId)).map((target) => ({
- seriesId: target.seriesId,
- label: target.label,
- cpuRatio: target.cpuRatio,
- uptimeSeconds: target.uptimeSeconds,
- }));
+ const latestByAllocation = new Map();
+ for (const target of managed) {
+ const previous = latestByAllocation.get(target.allocationId);
+ if (!previous || (target.resolvedAt ?? -1) >= (previous.resolvedAt ?? -1)) {
+ latestByAllocation.set(target.allocationId, target);
+ }
+ }
+ const allocations = [...latestByAllocation.values()];
+ const latestObservedByAllocation = new Map();
+ for (const target of managed) {
+ if (!target.observed) continue;
+ const previous = latestObservedByAllocation.get(target.allocationId);
+ if (!previous || (target.observedAt ?? -1) >= (previous.observedAt ?? -1)) {
+ latestObservedByAllocation.set(target.allocationId, target);
+ }
+ }
+ const observed = [...latestObservedByAllocation.values()];
+ const targets: RuntimeTrendTarget[] = observed.filter((target) => target.cpuRatio !== null)
+ .sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0))
+ .slice(0, RUNTIME_TREND_SERIES_LIMIT)
+ .map((target) => ({
+ seriesId: target.seriesId,
+ label: target.label,
+ cpuRatio: target.cpuRatio,
+ }));
const pairedMemory = observed.filter((target) => (
target.memoryUsageBytes !== null && target.memoryLimitBytes !== null
));
return {
sampledAt: snapshot.loadedAt,
+ activeSandboxCount: allocations.filter((target) => target.lifecycleState === "active").length,
targets,
cpuCandidates: observed.flatMap((target): RuntimeTrendCPUCandidate[] => (
- target.cpuRatio !== null || (
+ target.seriesId !== null && (target.cpuRatio !== null || (
target.observedAt !== null && target.allocationKey !== null &&
target.usageSecondsTotal !== null && target.capacityCores !== null && target.capacityCores > 0
- )
+ ))
? [{
- seriesId: target.seriesId,
+ seriesId: target.seriesId!,
label: target.label,
cpuRatio: target.cpuRatio,
- uptimeSeconds: target.uptimeSeconds,
observedAt: target.observedAt,
startedAt: target.startedAt,
allocationKey: target.allocationKey,
@@ -224,25 +228,16 @@ function cpuRatios(previous: RuntimeTrendSample, next: RuntimeTrendSample): Map<
}
function applyCPURatios(sample: RuntimeTrendSample, ratios: ReadonlyMap): void {
- const uptime = sample.targets.filter((target) => target.uptimeSeconds !== null)
- .sort((left, right) => (right.uptimeSeconds ?? 0) - (left.uptimeSeconds ?? 0))
- .slice(0, RUNTIME_TREND_SERIES_LIMIT)
- .map((target) => ({ ...target, cpuRatio: null }));
const cpu = sample.cpuCandidates.flatMap((candidate): RuntimeTrendTarget[] => {
const ratio = ratios.get(candidate.seriesId);
return ratio === undefined ? [] : [{
seriesId: candidate.seriesId,
label: candidate.label,
cpuRatio: ratio,
- uptimeSeconds: candidate.uptimeSeconds,
}];
}).sort((left, right) => (right.cpuRatio ?? 0) - (left.cpuRatio ?? 0))
.slice(0, RUNTIME_TREND_SERIES_LIMIT);
- const selected = new Map(
- uptime.map((target) => [target.seriesId, target]),
- );
- for (const target of cpu) selected.set(target.seriesId, target);
- sample.targets = [...selected.values()];
+ sample.targets = cpu;
}
function tokenRate(
diff --git a/apps/web/src/features/sessions/SessionsView.tsx b/apps/web/src/features/sessions/SessionsView.tsx
index 53b003169..fe13712e0 100644
--- a/apps/web/src/features/sessions/SessionsView.tsx
+++ b/apps/web/src/features/sessions/SessionsView.tsx
@@ -956,8 +956,8 @@ export function SessionsView({
loadRuntimeHistory={loadRuntimeHistory}
headingId={`session-runtime-trends-heading-${sessionId}`}
title={t("metrics.resourceTrends")}
- showDurableUptimePlaceholder
allowSourceSelection
+ activeDisplay="binary"
/>
) : null;
diff --git a/apps/web/src/i18n/locales/en/dashboard.ts b/apps/web/src/i18n/locales/en/dashboard.ts
index 01ea7643b..449e5a735 100644
--- a/apps/web/src/i18n/locales/en/dashboard.ts
+++ b/apps/web/src/i18n/locales/en/dashboard.ts
@@ -10,7 +10,7 @@ export const dashboard = {
status: { observed: "Observed", unsupported: "Unsupported", allocation_pending: "Allocation pending", runtime_not_running: "Not running", source_not_configured: "Source unavailable", sample_timeout: "Sample timeout", sample_unavailable: "Sample unavailable", idle: "Idle", in_progress: "In progress", requires_action: "Requires action", failed: "Failed", unknown: "Unavailable" },
managedProvider: "Managed {{provider}}", managed: "Managed", cores: "{{value}} cores", capacityUnknown: "Capacity unknown",
limitUnknown: "Limit unknown", ofLimit: "of {{limit}}", memoryUsed: "{{percent}}% memory used", allocationUnknown: "Allocation age unknown", allocated: "{{duration}} allocated", notReported: "Not reported", sessionReported: "Session reported",
- metrics: { active: "Active Runtimes", activeDetail: "{{managed}} managed · {{unavailable}} unavailable", cpu: "Cumulative CPU / capacity", cpuDetail: "{{covered}}/{{total}} observed Runtimes report CPU time", memory: "Memory now", memoryDetail: "{{covered}}/{{total}} observed Runtimes report usage", tokens: "Reported tokens", tokenDetail: "{{covered}}/{{total}} Sessions report usage", noSample: "No current sample" },
+ metrics: { sandboxState: "Sandbox state", sandboxStateValue: "{{active}} active · {{sleeping}} sleeping", sandboxStateDetail: "{{total}} total · {{transitioning}} transitioning or pending", cpu: "Cumulative CPU / capacity", cpuDetail: "{{covered}}/{{total}} observed Runtimes report CPU time", memory: "Memory now", memoryDetail: "{{covered}}/{{total}} observed Runtimes report usage", tokens: "Reported tokens", tokenDetail: "{{covered}}/{{total}} Sessions report usage", noSample: "No current sample" },
explorerHint: "Search and inspect exact observations · unknown remains unknown, never zero", targetCount: "{{value}} targets · {{snapshot}}", retainedSnapshot: "retained snapshot", currentSnapshot: "current snapshot",
},
trends: {
@@ -27,8 +27,8 @@ export const dashboard = {
pinned: "Pinned", hover: "Hover", unavailable: "Unavailable", allHidden: "All series hidden", sparse: "Sparse samples", showLegend: "Use the legend to show a series", sparseDetail: "{{count}} valid points · a line requires consecutive buckets", emptyDetail: "{{valid}}/2 valid points · {{count}} snapshots · no history is synthesized",
table: { series: "Series", latest: "Latest value", missing: "Missing samples" }, runtime: "Runtime", usage: "usage", used: "used", configuredLimit: "configured limit", input: "input", output: "output", gridDurable: "Runtime durable-history charts", gridLive: "Runtime live-window charts",
cpu: { title: "CPU usage", durable: "bucketed cumulative-delta utilization · durable history", live: "reported or cumulative-delta utilization · live window", empty: "No retained CPU samples" },
- memory: { title: "Memory usage", durable: "complete target aggregate / configured limit · durable history", live: "working set / configured limit · live window", empty: "No complete retained memory samples" },
- uptime: { title: "Compute uptime", durable: "current Runtime measurement · not retained in durable history", live: "provider started_at → observed_at · allocation series", empty: "Live-only metric", detail: "Select Live to inspect current Runtime uptime" },
+ memory: { title: "Memory usage", durable: "observed Sandbox aggregate / configured limit · durable history", live: "observed Sandbox working set / configured limit · live window", empty: "No retained observed memory samples" },
+ active: { series: "active", sandboxTitle: "Active sandboxes", runtimeTitle: "Runtime active", sumDurable: "observed allocations per retained bucket · durable history", sumLive: "lifecycle state active allocations per snapshot · live window", binaryDurable: "observed allocation in retained bucket · 1 active / 0 inactive", binaryLive: "lifecycle state active · 1 active / 0 inactive", active: "Active", inactive: "Inactive", empty: "No retained active Sandbox samples" },
tokens: { title: "Token throughput", durable: "canonical Session Usage deltas · durable history", live: "Session Usage deltas · missing usage excluded", empty: "No retained token samples", perMinute: "{{value}}/min" },
},
} as const;
diff --git a/apps/web/src/i18n/locales/zh-CN/dashboard.ts b/apps/web/src/i18n/locales/zh-CN/dashboard.ts
index 15cccb818..995f1720b 100644
--- a/apps/web/src/i18n/locales/zh-CN/dashboard.ts
+++ b/apps/web/src/i18n/locales/zh-CN/dashboard.ts
@@ -10,7 +10,7 @@ export const dashboard = {
status: { observed: "已观测", unsupported: "不支持", allocation_pending: "等待分配", runtime_not_running: "未运行", source_not_configured: "数据源不可用", sample_timeout: "采样超时", sample_unavailable: "采样不可用", idle: "空闲", in_progress: "进行中", requires_action: "需要操作", failed: "失败", unknown: "不可用" },
managedProvider: "托管 {{provider}}", managed: "托管", cores: "{{value}} 核", capacityUnknown: "容量未知",
limitUnknown: "上限未知", ofLimit: "上限 {{limit}}", memoryUsed: "已使用 {{percent}}% 内存", allocationUnknown: "分配时间未知", allocated: "已分配 {{duration}}", notReported: "未报告", sessionReported: "会话已报告",
- metrics: { active: "活动 Runtime", activeDetail: "{{managed}} 个托管 · {{unavailable}} 个不可用", cpu: "累计 CPU / 容量", cpuDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了 CPU 时间", memory: "当前内存", memoryDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了用量", tokens: "已报告 Token", tokenDetail: "{{covered}}/{{total}} 个会话报告了用量", noSample: "无当前采样" },
+ metrics: { sandboxState: "Sandbox 状态", sandboxStateValue: "{{active}} 个活动 · {{sleeping}} 个休眠", sandboxStateDetail: "共 {{total}} 个 · {{transitioning}} 个正在转换或等待", cpu: "累计 CPU / 容量", cpuDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了 CPU 时间", memory: "当前内存", memoryDetail: "{{covered}}/{{total}} 个已观测 Runtime 报告了用量", tokens: "已报告 Token", tokenDetail: "{{covered}}/{{total}} 个会话报告了用量", noSample: "无当前采样" },
explorerHint: "搜索并检查准确观测值 · 未知始终保持未知,不会视为零", targetCount: "{{value}} 个目标 · {{snapshot}}", retainedSnapshot: "保留快照", currentSnapshot: "当前快照",
},
trends: {
@@ -27,8 +27,8 @@ export const dashboard = {
pinned: "已固定", hover: "悬停", unavailable: "不可用", allHidden: "已隐藏所有序列", sparse: "采样稀疏", showLegend: "使用图例显示序列", sparseDetail: "{{count}} 个有效点 · 绘制连线需要连续时间桶", emptyDetail: "{{valid}}/2 个有效点 · {{count}} 个快照 · 不会合成历史数据",
table: { series: "序列", latest: "最新值", missing: "缺失采样" }, runtime: "Runtime", usage: "使用率", used: "已使用", configuredLimit: "配置上限", input: "输入", output: "输出", gridDurable: "Runtime 持久历史图表", gridLive: "Runtime 实时窗口图表",
cpu: { title: "CPU 使用率", durable: "按时间桶聚合的累计差值使用率 · 持久历史", live: "已报告或累计差值使用率 · 实时窗口", empty: "无保留的 CPU 采样" },
- memory: { title: "内存使用", durable: "完整目标聚合 / 配置上限 · 持久历史", live: "工作集 / 配置上限 · 实时窗口", empty: "无完整的保留内存采样" },
- uptime: { title: "计算运行时长", durable: "当前 Runtime 测量 · 持久历史不保留", live: "Provider started_at → observed_at · 分配序列", empty: "仅实时指标", detail: "选择“实时”以检查当前 Runtime 运行时长" },
+ memory: { title: "内存使用", durable: "已观测 Sandbox 聚合 / 配置上限 · 持久历史", live: "已观测 Sandbox 工作集 / 配置上限 · 实时窗口", empty: "无保留的已观测内存采样" },
+ active: { series: "活动", sandboxTitle: "活动 Sandbox", runtimeTitle: "Runtime 活动状态", sumDurable: "每个保留时间桶中的已观测分配数 · 持久历史", sumLive: "每个快照中生命周期状态为活动的分配数 · 实时窗口", binaryDurable: "保留时间桶中存在已观测分配 · 1 活动 / 0 非活动", binaryLive: "生命周期状态为活动 · 1 活动 / 0 非活动", active: "活动", inactive: "非活动", empty: "无保留的活动 Sandbox 采样" },
tokens: { title: "Token 吞吐量", durable: "规范会话用量差值 · 持久历史", live: "会话用量差值 · 排除缺失用量", empty: "无保留的 Token 采样", perMinute: "{{value}}/分钟" },
},
} as const;
diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml
index b0276a5a6..b7b463bbd 100644
--- a/contracts/agents-api/openapi.yaml
+++ b/contracts/agents-api/openapi.yaml
@@ -1698,6 +1698,15 @@ definitions:
type: string
instance:
$ref: '#/definitions/v1.RuntimeInstance'
+ lifecycle_state:
+ enum:
+ - active
+ - sleeping
+ - transitioning
+ - pending
+ - stopped
+ type: string
+ x-nullable: true
memory:
allOf:
- $ref: '#/definitions/v1.RuntimeMemoryObservation'
@@ -1751,6 +1760,7 @@ definitions:
- environment_id
- id
- instance
+ - lifecycle_state
- memory
- mode
- object
diff --git a/contracts/agents-api/runtime-history-api.md b/contracts/agents-api/runtime-history-api.md
index efe4d278f..68c4d8953 100644
--- a/contracts/agents-api/runtime-history-api.md
+++ b/contracts/agents-api/runtime-history-api.md
@@ -176,3 +176,9 @@ or malformed data reject the entire response with a 502 client projection error.
Compute uptime is available from current observations only. Retained allocation
series can span compute restarts and unavailable intervals; their earliest start
is not a per-bucket compute start and must not be used to draw an uptime history.
+Clients may project a Dashboard active-Sandbox count by counting distinct
+allocation identities with `observed_count > 0` in each bucket and deduplicating
+the same allocation across Session histories. The single-Session presentation
+collapses any positive count to `1`; a missing or unavailable bucket is currently
+rendered as `0`. This temporary zero-fill policy does not distinguish a sleeping
+Runtime from missing collection coverage.
diff --git a/contracts/agents-api/runtime-observability-design.md b/contracts/agents-api/runtime-observability-design.md
index 228162154..a3fd376c9 100644
--- a/contracts/agents-api/runtime-observability-design.md
+++ b/contracts/agents-api/runtime-observability-design.md
@@ -183,14 +183,20 @@ measurement or lifecycle state.
| --- | --- | --- |
| Allocation age | allocation `created_at` to `released_at` or now | Age of Core's allocation record. |
| Compute uptime | provider `started_at` to sample `observed_at` | Age of the current compute incarnation. |
+| Active sandboxes | distinct active allocations in the selected snapshot or bucket | Count across Sessions on the Dashboard; naturally 0/1 in a single-Session view. |
| Busy duration | Turn `started_at` to `completed_at` or now | Time model work has been active. |
| Idle duration | future durable `idle_since` | Not available in the current design. |
Container restart resets compute uptime but not allocation age. Live CPU deltas
-require the same known compute start as well as the same allocation. Retained
-charts show CPU, memory and tokens; uptime stays in the current/Live view because
-the history contract does not supply each bucket's compute start. Dashboard labels
-must not collapse these values into one generic Runtime duration.
+require the same known compute start as well as the same allocation. Trend charts
+show CPU, memory, active Sandbox count, and tokens. Live active count uses the
+provider-neutral lifecycle state and deduplicates allocation identities; retained
+history counts observed allocation identities in each bucket because lifecycle
+state is not retained yet. A single-Session view therefore remains binary while
+the Dashboard shows the sum across Sessions. Compute uptime stays in current
+target details because the history contract does not supply each bucket's compute
+start. Dashboard labels must not collapse these values into one generic Runtime
+duration.
## 9. Collection behavior
@@ -351,7 +357,9 @@ Every bucket reports explicit observation coverage and nullable CPU/memory
values. CPU utilization may be derived only from ordered cumulative counters
inside one fence; successive intervals are assigned to the bucket containing
their right endpoint and combined by CPU-capacity time. Memory uses the final
-observed value in the bucket. Empty
+observed value in the bucket. Dashboard memory totals aggregate only allocations with
+a complete observed usage/limit pair in that bucket; an unavailable or released
+target does not erase measurements from active targets. Empty
buckets remain gaps. The service rejects cross-scope rows, duplicate series,
overlapping or out-of-range buckets, unsafe provider labels, invalid numeric
values, and results exceeding the total point budget.
@@ -378,6 +386,12 @@ acceptance.
- Observed CPU usage and known configured capacity.
- Observed memory usage and known limits.
- Reported Session tokens, together with the reporting Session count.
+- Confirmed active Sandbox count over time. Each bucket counts managed allocations
+ with an observed provider sample; unavailable or timed-out samples are not
+ presented as confirmed active. A bucket with collection coverage but no observed
+ allocation is zero. The history model retains missing coverage as null; the
+ current Dashboard presentation renders that null as zero until sleeping and
+ collection-failure history are represented separately.
- Data freshness and source coverage.
Aggregates include only present measurements. Each total states its denominator,
diff --git a/contracts/agents-api/runtime-observability.md b/contracts/agents-api/runtime-observability.md
index 5a1f07ad6..143c45b3b 100644
--- a/contracts/agents-api/runtime-observability.md
+++ b/contracts/agents-api/runtime-observability.md
@@ -41,6 +41,12 @@ rendered or aggregated as zero. A whole observation has one of three states:
`observed`, `unsupported`, or `unavailable`. Provider and permission failures are
errors, not ordinary unavailability.
+Managed observations also expose a provider-neutral `lifecycle_state` derived
+from Core's allocation and compute lifecycle: `active`, `sleeping`,
+`transitioning`, `pending`, or `stopped`. Non-managed modes return `null`.
+This field is current control-plane state; it is not inferred from a failed
+provider sample.
+
Docker reports cumulative cgroup CPU time and current cgroup memory usage. CPU and
memory capacity come from the inspected container configuration. Inspect and Stats
are read-only; observation must not renew, restart, create, or stop the container.
@@ -73,6 +79,14 @@ This phase supplies compute uptime evidence and retains the existing durable
allocation and Turn timestamps. It does not infer idle time. CPU quietness,
heartbeat age, connection status, and `kept_at` are not authoritative idle state.
+Web projects active Runtime state differently by scope. The Dashboard shows one
+summed series of distinct allocation identities: live snapshots count
+`lifecycle_state: active`, while retained buckets count successfully observed
+allocations because lifecycle state is not retained yet. The single-Session view
+collapses the same value to `1` or `0`. Missing or unavailable retained values are
+currently rendered as zero, so this presentation intentionally does not yet
+distinguish sleeping from collection failure.
+
Future automatic suspension requires a separate durable control model, including
an activity revision and timestamps such as `idle_since` and
`shutdown_requested_at`. Metrics, an in-memory cache, or a monitoring backend must
diff --git a/contracts/agents-api/v1/runtime_observations.go b/contracts/agents-api/v1/runtime_observations.go
index d82d8d514..56d42834f 100644
--- a/contracts/agents-api/v1/runtime_observations.go
+++ b/contracts/agents-api/v1/runtime_observations.go
@@ -8,6 +8,7 @@ type RuntimeObservation struct {
Mode string `json:"mode" enums:"none,self_hosted,openai_hosted" binding:"required"`
ProviderType *string `json:"provider_type" extensions:"x-nullable" binding:"required" pattern:"^[a-z][a-z0-9_]{0,31}$"`
Instance RuntimeInstance `json:"instance" binding:"required"`
+ LifecycleState *string `json:"lifecycle_state" extensions:"x-nullable" binding:"required" enums:"active,sleeping,transitioning,pending,stopped"`
Status string `json:"status" enums:"observed,unsupported,unavailable" binding:"required"`
Reason *string `json:"reason" extensions:"x-nullable" binding:"required" enums:"runtime_mode_not_observable,allocation_pending,runtime_not_running,source_not_configured,sample_timeout,sample_unavailable"`
AllocationCreatedAt *int64 `json:"allocation_created_at" extensions:"x-nullable" binding:"required" minimum:"0"`
diff --git a/packages/agents-client/src/client.test.ts b/packages/agents-client/src/client.test.ts
index d879ecce6..016c7dd44 100644
--- a/packages/agents-client/src/client.test.ts
+++ b/packages/agents-client/src/client.test.ts
@@ -122,6 +122,7 @@ function runtimeObservation(overrides: Record = {}): Record {
mode: "none",
provider_type: null,
instance: { kind: "none", allocation_id: null, device_id: null, connection_generation: null },
+ lifecycle_state: null,
status: "unsupported",
reason: "runtime_mode_not_observable",
allocation_created_at: null,
@@ -2857,6 +2859,7 @@ describe("OpenAIAgentsClient", () => {
["unknown field", () => ({ ...runtimeObservation(), provider_native_id: "hidden" })],
["foreign Session", () => ({ ...runtimeObservation(), session_id: "55555555-5555-4555-8555-555555555555" })],
["invalid status/reason", () => ({ ...runtimeObservation(), status: "observed", reason: "sample_timeout" })],
+ ["invalid lifecycle state", () => ({ ...runtimeObservation(), lifecycle_state: "paused" })],
["invalid mode/instance", () => ({ ...runtimeObservation(), mode: "none" })],
["negative CPU", () => ({ ...runtimeObservation(), cpu: {
usage_seconds_total: -1, capacity_cores: 2, usage_cores: null, utilization_ratio: null,
diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts
index 6b4f2d47a..c42228f7a 100644
--- a/packages/agents-client/src/client.ts
+++ b/packages/agents-client/src/client.ts
@@ -343,7 +343,7 @@ const unsafeUnknownEventFields = new Set([
]);
const runtimeObservationFields = new Set([
"id", "object", "session_id", "environment_id", "mode", "provider_type", "instance", "status", "reason",
- "allocation_created_at", "resolved_at", "observed_at", "started_at", "cpu", "memory",
+ "lifecycle_state", "allocation_created_at", "resolved_at", "observed_at", "started_at", "cpu", "memory",
]);
const runtimeInstanceFields = new Set(["kind", "allocation_id", "device_id", "connection_generation"]);
const runtimeCPUFields = new Set(["usage_seconds_total", "capacity_cores", "usage_cores", "utilization_ratio"]);
@@ -353,6 +353,7 @@ const runtimeObservationReasons = new Set([
"source_not_configured", "sample_timeout", "sample_unavailable",
]);
const runtimeProviderTypePattern = /^[a-z][a-z0-9_]{0,31}$/;
+const runtimeLifecycleStates = new Set(["active", "sleeping", "transitioning", "pending", "stopped"]);
function utf8Length(value: string): number {
return new TextEncoder().encode(value).length;
}
@@ -1124,14 +1125,16 @@ function projectRuntimeObservation(value: unknown, expectedSessionId?: string):
if (
(isNone && (
value.instance.kind !== "none" || environmentId !== null || value.provider_type !== null ||
- allocationId !== null || deviceId !== null || connectionGeneration !== null || allocationCreatedAt !== null
+ allocationId !== null || deviceId !== null || connectionGeneration !== null || allocationCreatedAt !== null ||
+ value.lifecycle_state !== null
)) ||
(isSelfHosted && (
value.instance.kind !== "self_hosted_connection" || environmentId === null ||
- allocationId !== null || allocationCreatedAt !== null
+ allocationId !== null || allocationCreatedAt !== null || value.lifecycle_state !== null
)) ||
(isManaged && (
value.instance.kind !== "managed_allocation" || environmentId === null || connectionGeneration !== null ||
+ !runtimeLifecycleStates.has(String(value.lifecycle_state)) ||
(allocationId === null && (deviceId !== null || allocationCreatedAt !== null))
))
) return invalidRuntimeObservation();
@@ -1194,6 +1197,7 @@ function projectRuntimeObservation(value: unknown, expectedSessionId?: string):
allocation_id: allocationId, device_id: deviceId, connection_generation: connectionGeneration,
},
status: value.status, reason: value.reason as RuntimeObservation["reason"],
+ lifecycle_state: value.lifecycle_state as RuntimeObservation["lifecycle_state"],
allocation_created_at: allocationCreatedAt, resolved_at: value.resolved_at,
observed_at: observedAt, started_at: startedAt, cpu, memory,
} as RuntimeObservation;
diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts
index f09f65ee2..098108e28 100644
--- a/packages/agents-client/src/types.ts
+++ b/packages/agents-client/src/types.ts
@@ -780,6 +780,7 @@ export interface CreateSessionStreamOptions extends StreamOptions {
}
export type RuntimeObservationStatus = "observed" | "unsupported" | "unavailable";
+export type RuntimeLifecycleState = "active" | "sleeping" | "transitioning" | "pending" | "stopped";
export type RuntimeObservationReason =
| "runtime_mode_not_observable"
| "allocation_pending"
@@ -819,6 +820,7 @@ export interface RuntimeObservedObservation extends RuntimeObservationBase {
device_id: string | null;
connection_generation: null;
};
+ lifecycle_state: RuntimeLifecycleState;
status: "observed";
reason: null;
allocation_created_at: number | null;
@@ -838,6 +840,7 @@ export interface RuntimeUnavailableObservation extends RuntimeObservationBase {
device_id: string | null;
connection_generation: null;
};
+ lifecycle_state: RuntimeLifecycleState;
status: "unavailable";
reason: RuntimeUnavailableReason;
allocation_created_at: number | null;
@@ -852,6 +855,7 @@ export interface RuntimeNoneObservation extends RuntimeObservationBase {
mode: "none";
provider_type: null;
instance: { kind: "none"; allocation_id: null; device_id: null; connection_generation: null };
+ lifecycle_state: null;
status: "unsupported";
reason: "runtime_mode_not_observable";
allocation_created_at: null;
@@ -871,6 +875,7 @@ export interface RuntimeSelfHostedObservation extends RuntimeObservationBase {
device_id: string | null;
connection_generation: string | null;
};
+ lifecycle_state: null;
status: "unsupported";
reason: "runtime_mode_not_observable";
allocation_created_at: null;
diff --git a/services/agents-api/internal/api/runtime_observations.go b/services/agents-api/internal/api/runtime_observations.go
index 54d061a2d..c79694c5a 100644
--- a/services/agents-api/internal/api/runtime_observations.go
+++ b/services/agents-api/internal/api/runtime_observations.go
@@ -179,6 +179,11 @@ func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeO
switch observation.Target.Mode {
case runtimeobs.ModeManaged:
result.Instance.Kind = "managed_allocation"
+ lifecycleState, err := runtimeLifecycleState(observation.Target.Instance)
+ if err != nil {
+ return v1.RuntimeObservation{}, err
+ }
+ result.LifecycleState = &lifecycleState
if observation.Target.Instance.AllocationID != "" {
result.Instance.AllocationID = &observation.Target.Instance.AllocationID
}
@@ -219,3 +224,30 @@ func runtimeObservationResponse(observation runtimeobs.Observation) (v1.RuntimeO
}
return result, nil
}
+
+func runtimeLifecycleState(instance runtimeobs.Instance) (string, error) {
+ switch instance.AllocationState {
+ case "":
+ if instance.AllocationID == "" {
+ return "pending", nil
+ }
+ return "", errors.New("invalid Runtime allocation state")
+ case "creating":
+ return "pending", nil
+ case "cleanup_pending", "released":
+ return "stopped", nil
+ case "running":
+ switch instance.ComputePhase {
+ case "suspended":
+ return "sleeping", nil
+ case "quiescing", "suspending", "restoring", "waking":
+ return "transitioning", nil
+ case "disabled", "running":
+ return "active", nil
+ default:
+ return "", errors.New("invalid Runtime compute phase")
+ }
+ default:
+ return "", errors.New("invalid Runtime allocation state")
+ }
+}
diff --git a/services/agents-api/internal/api/runtime_observations_test.go b/services/agents-api/internal/api/runtime_observations_test.go
index 90dd4ab7f..cbbc952d3 100644
--- a/services/agents-api/internal/api/runtime_observations_test.go
+++ b/services/agents-api/internal/api/runtime_observations_test.go
@@ -94,15 +94,38 @@ func TestRuntimeObservationResponsePreservesObservedZero(t *testing.T) {
now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC)
sessionID, environmentID := uuid.NewString(), uuid.NewString()
value, err := runtimeObservationResponse(runtimeobs.Observation{
- Target: runtimeobs.Target{SessionID: sessionID, EnvironmentID: environmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), DeviceID: uuid.NewString(), AllocationCreatedAt: now.Add(-time.Hour)}},
+ Target: runtimeobs.Target{SessionID: sessionID, EnvironmentID: environmentID, Mode: runtimeobs.ModeManaged, Instance: runtimeobs.Instance{AllocationID: uuid.NewString(), DeviceID: uuid.NewString(), AllocationState: "running", ComputePhase: "running", AllocationCreatedAt: now.Add(-time.Hour)}},
Status: runtimeobs.StatusObserved, ProviderType: "docker", ResolvedAt: now,
Sample: &runtimeobs.Sample{ObservedAt: now, CPUUsageSecondsTotal: &zeroCPU, MemoryUsageBytes: &zeroMemory},
})
- if err != nil || value.CPU == nil || value.CPU.UsageSecondsTotal == nil || *value.CPU.UsageSecondsTotal != 0 || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 {
+ if err != nil || value.LifecycleState == nil || *value.LifecycleState != "active" || value.CPU == nil || value.CPU.UsageSecondsTotal == nil || *value.CPU.UsageSecondsTotal != 0 || value.Memory == nil || value.Memory.UsageBytes == nil || *value.Memory.UsageBytes != 0 {
t.Fatalf("observed zero was lost: %+v %v", value, err)
}
}
+func TestRuntimeLifecycleStateProjectsProviderNeutralPhases(t *testing.T) {
+ for _, item := range []struct {
+ state, phase, want string
+ }{
+ {state: "", phase: "", want: "pending"},
+ {state: "creating", phase: "disabled", want: "pending"},
+ {state: "running", phase: "disabled", want: "active"},
+ {state: "running", phase: "running", want: "active"},
+ {state: "running", phase: "quiescing", want: "transitioning"},
+ {state: "running", phase: "suspending", want: "transitioning"},
+ {state: "running", phase: "suspended", want: "sleeping"},
+ {state: "running", phase: "restoring", want: "transitioning"},
+ {state: "running", phase: "waking", want: "transitioning"},
+ {state: "cleanup_pending", phase: "disabled", want: "stopped"},
+ {state: "released", phase: "disabled", want: "stopped"},
+ } {
+ got, err := runtimeLifecycleState(runtimeobs.Instance{AllocationState: item.state, ComputePhase: item.phase})
+ if err != nil || got != item.want {
+ t.Fatalf("state=%s phase=%s got=%s want=%s err=%v", item.state, item.phase, got, item.want, err)
+ }
+ }
+}
+
func TestRuntimeObservationResponseRejectsTimesOutsidePublicContract(t *testing.T) {
now := time.Date(2026, 9, 22, 8, 0, 0, 0, time.UTC)
preEpoch := time.Unix(-1, 0).UTC()