diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
index 07375fbcc7..f3603e9e31 100644
--- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md
+++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md
@@ -20,6 +20,29 @@
## Purpose
+### History honours explicit selected-source staleness (13 October 2026)
+
+The shared memory presentation reads the server's explicit `agent.stale`
+invalidation only when the selected observation is from `agent`. A dated
+current carrier then becomes visibly last known, with its original source,
+time and measured value, including zero. It does not supply current sorting,
+metric-filter pressure or guest/canonical History fallbacks. The visible source
+label explains that the supplying Pulse Agent stopped reporting. Missing or
+invalid provenance remains unknown; unavailable telemetry stays unavailable.
+
+QGA and Proxmox observations remain independent of a stale linked Pulse Agent
+and of filesystem deferrals. No client TTL, source timestamp renewal, metadata
+rewrite, API schema, History point or guest operation is added. The source
+can restore current measured zero when it reports again. This is presentation
+safety, not native restart/thaw recovery or release availability.
+
+`staleAgentMemory.test.ts` connects qualification to both History models,
+sorting and metric search, while `GuestRow.test.tsx` mounts same-ID source
+updates in bars and sparklines. The static production-component browser
+fixture `guest-memory-agent-stale.cjs` exercises agent-facet-only canonical
+deltas through the actual Workloads projection, both drawers and trusted
+keyboard/touch controls at desktop and phone widths.
+
### Patrol setup panel uses existing form primitives
`PatrolSetupPanel.tsx` builds the provider choice from `SelectionCardGroup`
diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json
index 434f644fd7..3a8291864f 100644
--- a/docs/release-control/v6/internal/subsystems/registry.json
+++ b/docs/release-control/v6/internal/subsystems/registry.json
@@ -5911,7 +5911,8 @@
"frontend-modern/src/components/Infrastructure/__tests__/resourceDetailDrawerMetricsHistoryModel.branchcov0712.test.ts",
"frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts",
"frontend-modern/src/components/Workloads/__tests__/nodeDrawerModel.branchcov0713.test.ts",
- "frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts"
+ "frontend-modern/src/features/docker/__tests__/dockerHostDrawerModel.test.ts",
+ "frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts"
]
},
{
diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.cjs b/frontend-modern/browser-tests/guest-memory-agent-stale.cjs
new file mode 100644
index 0000000000..5dc58cf9c4
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-memory-agent-stale.cjs
@@ -0,0 +1,251 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const crypto = require('node:crypto');
+const { chromium, webkit } = require('playwright');
+const {
+ createPublicationFixtureServer,
+} = require('/workspace/frontend-modern/browser-tests/publication-fixture-server.cjs');
+const root = '/workspace/frontend-modern';
+const output = '/workspace/tmp/guest-memory-agent-stale';
+const origin = 'http://127.0.0.1:5344';
+const hash = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
+const settle = (page) =>
+ page.evaluate(async () => {
+ await document.fonts.ready;
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
+ });
+
+(async () => {
+ fs.mkdirSync(output, { recursive: true });
+ const result = {
+ result: 'incomplete',
+ playwright: require('playwright/package.json').version,
+ cases: [],
+ captures: [],
+ cleanup: {},
+ driver_sha256: hash(root + '/browser-tests/guest-memory-agent-stale.cjs'),
+ limits:
+ 'Real canonical delta merger, Workloads projection, row/bar/sparkline, guest drawer and canonical History with synthetic source-facet updates and empty History API fixtures. No collector, native restart, thaw, guest operation, installed or release acceptance.',
+ };
+ let server, browser;
+ try {
+ assert.equal(
+ result.playwright,
+ JSON.parse(fs.readFileSync('/workspace/tests/integration/package-lock.json')).packages[
+ 'node_modules/@playwright/test'
+ ].version,
+ );
+ server = createPublicationFixtureServer(
+ root,
+ 5344,
+ 'browser-tests/guest-memory-agent-stale.html',
+ );
+ result.compiled = server.binding;
+ await server.listen();
+ for (const [name, engine, width, dark] of [
+ ['desktop', chromium, 1365, false],
+ ['phone', webkit, 390, true],
+ ]) {
+ browser = await engine.launch(
+ engine === chromium
+ ? { headless: true, channel: 'chromium', args: ['--no-sandbox'] }
+ : { headless: true },
+ );
+ const context = await browser.newContext({
+ viewport: { width, height: 900 },
+ isMobile: width < 768,
+ hasTouch: width < 768,
+ locale: 'en-GB',
+ timezoneId: 'UTC',
+ reducedMotion: 'reduce',
+ });
+ const page = await context.newPage();
+ page.setDefaultTimeout(10_000);
+ const record = {
+ name,
+ width,
+ browser: browser.version(),
+ checks: [],
+ requests: [],
+ errors: [],
+ };
+ result.cases.push(record);
+ page.on('pageerror', (error) => record.errors.push(error.message));
+ await page.route('**/*', (route) => {
+ const request = route.request(),
+ url = new URL(request.url());
+ assert.equal(url.origin, origin, 'external request refused');
+ if (!url.pathname.startsWith('/api/')) return route.continue();
+ record.requests.push({ path: url.pathname, method: request.method() });
+ assert.equal(request.method(), 'GET', 'no mutations');
+ assert(!/diagnostics|\/run|guest-agent|qemu/.test(url.pathname), 'no guest operations');
+ let body = { success: true, data: [], alerts: [], anomalies: [], config: null };
+ if (url.pathname.includes('/charts/'))
+ body = {
+ resourceType: 'vm',
+ resourceId: url.searchParams.get('resourceId') || 'fixture:pve1:101',
+ range: url.searchParams.get('range') || '24h',
+ start: 1791633600000,
+ end: 1791720000000,
+ source: 'store',
+ metrics: {},
+ };
+ if (url.pathname === '/api/license/runtime-capabilities')
+ body = {
+ capabilities: [],
+ limits: [],
+ max_history_days: 7,
+ hosted_mode: false,
+ runtime: { build: 'community' },
+ blocked_capabilities: [],
+ };
+ return route.fulfill({ json: body });
+ });
+ await page.addInitScript(
+ (dark) =>
+ document.addEventListener('DOMContentLoaded', () =>
+ document.documentElement.classList.toggle('dark', dark),
+ ),
+ dark,
+ );
+ await page.goto(origin, { waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => window.__staleAgentMemory?.evidence().projected);
+ const row = page.getByRole('region', { name: 'Workload row', exact: true });
+ const workload = page.getByRole('region', { name: 'Workload drawer', exact: true });
+ const canonical = page.getByRole('region', { name: 'Canonical drawer', exact: true });
+ const notice = row.locator('[data-workload-memory-read-status]');
+ assert.equal(await notice.count(), 0);
+ await page.evaluate(() => window.__staleAgentMemory.stale());
+ await notice.waitFor();
+ assert.match(await notice.innerText(), width < 768 ? /Prior/ : /Last known/);
+ assert.match(await notice.textContent(), /stopped reporting/);
+ await workload
+ .getByText(/Last known · Pulse Agent \(stopped reporting\)/)
+ .first()
+ .waitFor();
+ const evidence = await page.evaluate(() => window.__staleAgentMemory.evidence());
+ assert.equal(evidence.projected.agentStale, true);
+ assert.equal(
+ evidence.projected.memory.observation.state,
+ 'current',
+ 'wire carrier not rewritten',
+ );
+ assert.equal(evidence.projected.memory.observation.observedAt, '2026-10-10T12:00:00Z');
+ assert.equal(evidence.filterHigh, false);
+ assert.deepEqual(evidence.sort, ['independent', 'fixture:pve1:101']);
+ record.source_facet = evidence;
+ record.checks.push(
+ 'agent-only canonical delta invalidates its current carrier; original source/time and value survive; filter/sort exclude it',
+ );
+ const capture = async (label, locator) => {
+ await settle(page);
+ const file = name + '-' + label + '.png';
+ await locator.screenshot({ path: output + '/' + file });
+ result.captures.push({ file, sha256: hash(output + '/' + file) });
+ };
+ await capture('stale-overview', workload);
+ await page.getByLabel('Metric display').selectOption('sparklines');
+ await row
+ .getByRole('img', { name: 'linked-guest memory history, last known 95%', exact: true })
+ .waitFor();
+ await page.getByLabel('Memory basis').selectOption('host');
+ await row
+ .getByRole('img', {
+ name: 'linked-guest host memory share history, last known 48%',
+ exact: true,
+ })
+ .waitFor();
+ await capture('stale-row', row);
+ record.checks.push(
+ 'real display/basis controls preserve retained bar/sparkline accessibility',
+ );
+ const historyTab = async (surface) => {
+ const tab = surface.getByRole('tab', { name: 'History', exact: true });
+ if (width < 768) await tab.tap();
+ else {
+ await tab.focus();
+ await page.keyboard.press('Enter');
+ }
+ const chart = surface.locator('[data-history-group="utilization"]');
+ await chart.locator('[data-history-last-known="memory"]').waitFor();
+ assert.equal(await chart.locator('[data-history-current="memory"]').count(), 0);
+ assert.match(
+ await chart.locator('[data-history-last-known="memory"]').innerText(),
+ /95\.0%.*last known/s,
+ );
+ assert.match(
+ await chart.locator('[data-history-deferred="memory"]').innerText(),
+ /stopped reporting/,
+ );
+ assert.match(
+ await chart.locator('[data-history-deferred="memory"]').innerText(),
+ /2026-10-10 12:00:00 UTC/,
+ );
+ return chart;
+ };
+ const guestChart = await historyTab(workload);
+ const resourceChart = await historyTab(canonical);
+ await capture('stale-history', canonical);
+ record.checks.push(
+ 'keyboard/touch History in both real drawers retains the original value, not a current fallback or invented trend',
+ );
+ for (const source of ['guest-agent-meminfo', 'status-mem']) {
+ await page.evaluate((source) => window.__staleAgentMemory.reading(source, 10), source);
+ await guestChart.locator('[data-history-current="memory"]').waitFor();
+ await resourceChart.locator('[data-history-current="memory"]').waitFor();
+ assert.equal(await guestChart.locator('[data-history-last-known="memory"]').count(), 0);
+ assert.equal(await resourceChart.locator('[data-history-last-known="memory"]').count(), 0);
+ assert.equal(
+ (await page.evaluate(() => window.__staleAgentMemory.evidence())).projected.agentStale,
+ true,
+ );
+ }
+ record.checks.push(
+ 'unrelated QGA/Proxmox readings stay current while linked agent remains stale',
+ );
+ await page.evaluate(() => window.__staleAgentMemory.reading('agent', 0));
+ await guestChart.locator('[data-history-last-known="memory"]').waitFor();
+ assert.match(
+ await guestChart.locator('[data-history-last-known="memory"]').innerText(),
+ /0\.0%.*last known/s,
+ );
+ await page.evaluate(() => window.__staleAgentMemory.recover());
+ await guestChart.locator('[data-history-current="memory"]').waitFor();
+ await resourceChart.locator('[data-history-current="memory"]').waitFor();
+ assert.match(
+ await guestChart.locator('[data-history-current="memory"]').innerText(),
+ /0\.0%.*current/s,
+ );
+ assert.match(
+ await resourceChart.locator('[data-history-current="memory"]').innerText(),
+ /0\.0%.*current/s,
+ );
+ assert.equal(await notice.count(), 0);
+ await capture('reporting-zero', canonical);
+ record.checks.push(
+ 'same-ID reporting source restores measured zero in both drawers; stale zero was not missing or healthy current',
+ );
+ assert.deepEqual(record.errors, []);
+ const layout = await page.evaluate(() => ({
+ client: document.documentElement.clientWidth,
+ scroll: document.documentElement.scrollWidth,
+ }));
+ assert(layout.scroll <= layout.client + 1, JSON.stringify(layout));
+ record.layout = layout;
+ await context.close();
+ await browser.close();
+ browser = null;
+ }
+ result.result = 'passed';
+ result.verified_at = new Date().toISOString();
+ } catch (error) {
+ result.error = String(error.stack || error);
+ throw error;
+ } finally {
+ if (browser) await browser.close();
+ result.cleanup.browser = true;
+ if (server) await server.close();
+ result.cleanup.server = true;
+ fs.writeFileSync(output + '/result.json', JSON.stringify(result, null, 2) + '\n');
+ }
+})();
diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.html b/frontend-modern/browser-tests/guest-memory-agent-stale.html
new file mode 100644
index 0000000000..0e73e26bd0
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-memory-agent-stale.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Source-owned agent memory fixture
+
+
+
+
+
+
diff --git a/frontend-modern/browser-tests/guest-memory-agent-stale.tsx b/frontend-modern/browser-tests/guest-memory-agent-stale.tsx
new file mode 100644
index 0000000000..df77b18f61
--- /dev/null
+++ b/frontend-modern/browser-tests/guest-memory-agent-stale.tsx
@@ -0,0 +1,207 @@
+// A source-facet-only update through the real canonical merger and Workloads
+// projection. No guest operations, native restart or recovery are simulated.
+import { createSignal, Show } from 'solid-js';
+import { render } from 'solid-js/web';
+import { Router, Route } from '@solidjs/router';
+import { DarkModeContext } from '../src/contexts/appRuntime';
+import { useWorkloads } from '../src/hooks/useWorkloads';
+import { mergeCanonicalResourceDeltaSnapshot } from '../src/utils/resourceStateAdapters';
+import { GuestRow } from '../src/components/Workloads/GuestRow';
+import { GuestDrawer } from '../src/components/Workloads/GuestDrawer';
+import { ResourceDetailDrawer } from '../src/components/Infrastructure/ResourceDetailDrawer';
+import { getWorkloadTableLayoutModeForContainer } from '../src/components/Workloads/guestRowModel';
+import { createWorkloadSortComparator } from '../src/components/Workloads/workloadSelectors';
+import { evaluateFilterStack, parseFilterStack } from '../src/utils/searchQuery';
+import type { Resource } from '../src/types/resource';
+import type { MemoryObservation } from '../src/types/api';
+import '../src/index.css';
+
+const capacity = 4 * 1024 ** 3;
+const observedAt = '2026-10-10T12:00:00Z';
+const initial: Resource = {
+ id: 'fixture:pve1:101',
+ type: 'vm',
+ name: 'linked-guest',
+ status: 'online',
+ platformType: 'proxmox-pve',
+ platformScopes: ['proxmox-pve'],
+ sources: ['proxmox', 'agent'],
+ metricsTarget: { resourceType: 'vm', resourceId: 'fixture:pve1:101' },
+ lastSeen: Date.parse(observedAt),
+ cpu: { current: 10 },
+ memory: {
+ current: 95,
+ used: capacity * 0.95,
+ total: capacity,
+ observation: { state: 'current', source: 'agent', observedAt },
+ },
+ disk: { current: 50, used: capacity / 2, total: capacity },
+ proxmox: {
+ vmid: 101,
+ nodeName: 'pve1',
+ instance: 'fixture',
+ runtimeStatus: 'running',
+ guestAgentStatus: 'deferred',
+ diskStatusReason: 'prev-vm-locked',
+ lock: 'backup',
+ },
+ agent: { stale: false, version: 'fixture-version', lastReportAt: observedAt },
+};
+
+// Only the transport is synthetic; the canonical merger, mapper and mounted
+// presentation use production code. No websocket connection is attempted.
+window.__pulseWsStore = {
+ state: { resources: [], activeAlerts: [], recentlyResolved: [] },
+ connected: () => false,
+ initialDataReceived: () => true,
+ resourceSnapshotReceived: () => false,
+ resourceChange: () => ({ version: 0, changedIds: null, changedKeys: null }),
+ changedResourceMetaSince: () => null,
+ changedResourceIdsSince: () => null,
+ shutdown: () => {},
+} as unknown as NonNullable;
+
+function Fixture() {
+ const [snapshot, setSnapshot] = createSignal([initial]);
+ const workloads = useWorkloads(() => true, { resourceSnapshot: snapshot });
+ const guest = () => workloads.workloads()[0];
+ const [mode, setMode] = createSignal<'bars' | 'sparklines'>('bars');
+ const [basis, setBasis] = createSignal<'guest' | 'host'>('guest');
+ const layout = getWorkloadTableLayoutModeForContainer(Math.min(window.innerWidth, 896) - 40);
+ const update = (patch: Partial, keys: (keyof Resource)[]) =>
+ setSnapshot((previous) =>
+ mergeCanonicalResourceDeltaSnapshot(
+ [{ id: initial.id, ...patch } as Resource],
+ previous,
+ new Set([initial.id]),
+ new Map([[initial.id, keys]]),
+ ),
+ );
+ (window as any).__staleAgentMemory = {
+ stale: () =>
+ update(
+ {
+ agent: { ...snapshot()[0].agent, stale: true },
+ lastSeen: Date.parse('2026-10-11T12:00:00Z'),
+ },
+ ['agent', 'lastSeen'],
+ ),
+ reading: (source: string, usage: number, state = 'current') =>
+ update(
+ {
+ memory: {
+ current: usage,
+ used: (capacity * usage) / 100,
+ total: capacity,
+ observation: { state, source, observedAt } as MemoryObservation,
+ },
+ },
+ ['memory'],
+ ),
+ recover: () =>
+ update(
+ {
+ agent: { ...snapshot()[0].agent, stale: false, lastReportAt: '2026-10-11T12:00:00Z' },
+ memory: {
+ current: 0,
+ used: 0,
+ total: capacity,
+ observation: { state: 'current', source: 'agent', observedAt: '2026-10-11T12:00:00Z' },
+ },
+ },
+ ['agent', 'memory'],
+ ),
+ evidence: () => ({
+ canonical: snapshot()[0],
+ projected: guest(),
+ filterHigh: evaluateFilterStack(guest(), parseFilterStack('memory>90')),
+ sort: [
+ guest(),
+ {
+ ...guest(),
+ id: 'independent',
+ memory: {
+ ...guest().memory,
+ usage: 10,
+ observation: { state: 'current', source: 'guest-agent-meminfo', observedAt },
+ },
+ },
+ ]
+ .sort(createWorkloadSortComparator('memory', 'desc')!)
+ .map((value) => value.id),
+ }),
+ };
+ return (
+
+ Source-owned memory evidence
+ Synthetic source updates. No guest checks are sent.
+
+
+
+
+
+
+
+
+
+
+ );
+}
+render(
+ () => (
+
+ (
+ document.documentElement.classList.contains('dark')}
+ >
+
+
+ )}
+ />
+
+ ),
+ document.getElementById('root')!,
+);
diff --git a/frontend-modern/browser-tests/publication-fixture-server.cjs b/frontend-modern/browser-tests/publication-fixture-server.cjs
index f92a1a1164..0cf096780f 100644
--- a/frontend-modern/browser-tests/publication-fixture-server.cjs
+++ b/frontend-modern/browser-tests/publication-fixture-server.cjs
@@ -11,6 +11,8 @@ const runtimePaths = [
'src/components/Workloads/GuestRow.tsx',
'src/components/Workloads/MetricMiniSparkline.tsx',
'src/components/Workloads/guestDrawerModel.ts',
+ 'src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts',
+ 'src/utils/memoryObservation.ts',
'src/features/patrol/PatrolAttentionWorkbench.tsx',
];
const entryPaths = [
@@ -18,6 +20,7 @@ const entryPaths = [
'browser-tests/guest-disk-provenance.html',
'browser-tests/guest-reading-help.html',
'browser-tests/guest-row-memory-provenance.html',
+ 'browser-tests/guest-memory-agent-stale.html',
'browser-tests/patrol-rule-removal.html',
];
const harnessPaths = [
@@ -28,6 +31,7 @@ const harnessPaths = [
'browser-tests/guest-disk-provenance.cjs',
'browser-tests/guest-reading-help.cjs',
'browser-tests/guest-row-memory-provenance.cjs',
+ 'browser-tests/guest-memory-agent-stale.cjs',
'browser-tests/patrol-rule-removal.cjs',
'browser-tests/patrol-rule-removal-final.cjs',
];
diff --git a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
index a8f78cb0a0..719e5cfbb4 100644
--- a/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
+++ b/frontend-modern/src/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel.ts
@@ -171,6 +171,7 @@ const memoryReading = (resource: Resource) =>
resource.memory ? finiteMetric(getMemoryPercent(resource)) : undefined,
resource.memory?.observation,
isProxmoxGuest(resource),
+ { agentStale: resource.agent?.stale === true },
);
const guestDiskReading = (resource: Resource) => {
diff --git a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
index 6eb1bd0ac0..820cd5f0cc 100644
--- a/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
+++ b/frontend-modern/src/components/Workloads/__tests__/GuestRow.test.tsx
@@ -1219,6 +1219,49 @@ describe('GuestRow', () => {
const observation = (state: string, date = observedAt, source = 'guest-agent-meminfo') =>
makeMemory({ observation: { state, source, observedAt: date } });
const noticeSelector = '[data-workload-memory-read-status]';
+ it.each(['bars', 'sparklines'] as const)(
+ 'invalidates only a stale agent-owned carrier in the mounted %s row',
+ (mode) => {
+ const [guest, setGuest] = createSignal(
+ makeGuest({ memory: observation('current', observedAt, 'agent') }),
+ );
+ const view = render(() => (
+
+ ));
+ expect(view.container.querySelector(noticeSelector)).toBeNull();
+ setGuest({ ...guest(), agentStale: true, lastSeen: '2026-10-01T12:00:00Z' });
+ const notice = view.container.querySelector(noticeSelector)!;
+ expect(notice).toHaveTextContent('Last known');
+ expect(notice).toHaveTextContent('stopped reporting');
+ expect(notice).toHaveTextContent('2026-09-30 11:00:00 UTC');
+ expect(notice).not.toHaveTextContent('2026-10-01');
+ if (mode === 'bars')
+ expect(screen.getByTestId('memory-bar')).toHaveAttribute(
+ 'data-reading-state',
+ 'last-known',
+ );
+ else
+ expect(
+ screen.getByRole('img', { name: 'test-vm memory history, last known 50%' }),
+ ).toBeInTheDocument();
+ setGuest({ ...guest(), memory: observation('current') });
+ expect(view.container.querySelector(noticeSelector)).toBeNull();
+ setGuest({
+ ...guest(),
+ agentStale: false,
+ memory: observation('current', observedAt, 'agent'),
+ });
+ expect(view.container.querySelector(noticeSelector)).toBeNull();
+ },
+ );
const history = {
getGuestMetricSeries: vi.fn(() => [
{
diff --git a/frontend-modern/src/utils/__tests__/memoryObservation.test.ts b/frontend-modern/src/utils/__tests__/memoryObservation.test.ts
index f5ba44db2c..1f5b125f39 100644
--- a/frontend-modern/src/utils/__tests__/memoryObservation.test.ts
+++ b/frontend-modern/src/utils/__tests__/memoryObservation.test.ts
@@ -255,7 +255,7 @@ describe('current workload memory selection', () => {
expect(iso).not.toHaveBeenCalled();
});
- it.each(['guest-agent-meminfo', 'agent', 'status-mem'])(
+ it.each(['guest-agent-meminfo', 'status-mem'])(
'selects current %s memory independently of a QEMU disk deferral',
(source) => {
const input = current({
@@ -268,6 +268,12 @@ describe('current workload memory selection', () => {
},
);
+ it('selects a reporting agent independently of a QEMU disk deferral', () => {
+ const input = current({ diskStatusReason: 'prev-vm-locked', lock: 'backup' });
+ input.memory.observation!.source = 'agent';
+ expect(getCurrentWorkloadMemoryUsage(input)).toBe(25);
+ });
+
it('cannot use current metadata to revive unavailable telemetry', () => {
const input = current();
input.memory.usageUnavailable = true;
diff --git a/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts b/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts
new file mode 100644
index 0000000000..ae24c7a611
--- /dev/null
+++ b/frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts
@@ -0,0 +1,148 @@
+import { describe, expect, it } from 'vitest';
+import type { Resource } from '@/types/resource';
+import type { WorkloadGuest } from '@/types/workloads';
+import {
+ getCurrentWorkloadMemoryUsage,
+ getWorkloadMemoryObservationPresentation,
+} from '@/utils/memoryObservation';
+import { evaluateFilterStack, parseFilterStack } from '@/utils/searchQuery';
+import { createWorkloadSortComparator } from '@/components/Workloads/workloadSelectors';
+import {
+ getGuestDrawerCurrentMetrics,
+ getGuestDrawerDeferredMetrics,
+} from '@/components/Workloads/guestDrawerModel';
+import {
+ getResourceMetricsHistoryCurrentMetrics,
+ getResourceMetricsHistoryDeferredMetrics,
+} from '@/components/Infrastructure/resourceDetailDrawerMetricsHistoryModel';
+
+const observedAt = '2026-10-10T12:00:00Z';
+const guest = (source = 'agent', state = 'current', usage = 95): WorkloadGuest =>
+ ({
+ id: 'fixture:pve:101',
+ name: 'linked-guest',
+ type: 'qemu',
+ status: 'running',
+ vmid: 101,
+ node: 'pve',
+ instance: 'fixture',
+ platformScopes: ['proxmox-pve'],
+ agentStale: true,
+ // Another producer can renew this without a new agent report.
+ lastSeen: '2026-10-11T12:00:00Z',
+ memory: {
+ total: 100,
+ used: usage,
+ free: 100 - usage,
+ usage,
+ observation: { state, source, observedAt },
+ },
+ }) as WorkloadGuest;
+
+const resource = (input: WorkloadGuest): Resource => ({
+ id: input.id,
+ name: input.name,
+ type: 'vm',
+ status: 'online',
+ platformType: 'proxmox-pve',
+ lastSeen: Date.parse(input.lastSeen!),
+ agent: { stale: input.agentStale },
+ memory: { current: input.memory.usage, observation: input.memory.observation },
+});
+
+describe('source-owned stale agent memory', () => {
+ it.each([0, 95])(
+ 'retains a value (%s) without asserting a current agent measurement',
+ (usage) => {
+ const input = guest('agent', 'current', usage);
+ const original = structuredClone(input);
+ const reading = getWorkloadMemoryObservationPresentation(input, { includeCurrent: false });
+ expect(reading?.state).toBe('last-known');
+ expect(reading?.summary).toContain('Last known · Pulse Agent (stopped reporting)');
+ expect(reading?.summary).toContain('2026-10-10 12:00:00 UTC');
+ expect(reading?.message).toContain('stopped reporting');
+ expect(reading?.message).not.toContain('2026-10-11');
+ expect(getCurrentWorkloadMemoryUsage(input)).toBeNull();
+ expect(input).toEqual(original);
+ },
+ );
+
+ it.each(['guest-agent-meminfo', 'guest-agent-meminfo-derived', 'status-mem', 'available-field'])(
+ 'does not downgrade independent current %s memory when the linked agent is stale',
+ (source) => {
+ const input = guest(source);
+ expect(getCurrentWorkloadMemoryUsage(input)).toBe(95);
+ expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('current');
+ expect(getWorkloadMemoryObservationPresentation(input)?.message).not.toContain(
+ 'stopped reporting',
+ );
+ expect(getResourceMetricsHistoryCurrentMetrics(resource(input)).memory).toBe(95);
+ },
+ );
+
+ it('never supplies missing provenance or revives unavailable telemetry', () => {
+ for (const state of ['unavailable', 'future-state']) {
+ const input = guest('agent', state);
+ expect(getCurrentWorkloadMemoryUsage(input)).toBeNull();
+ expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe(
+ state === 'unavailable' ? 'unavailable' : 'unknown',
+ );
+ }
+ for (const time of [undefined, 'invalid', '2099-01-01T00:00:00Z']) {
+ const input = guest();
+ input.memory.observation!.observedAt = time;
+ expect(getCurrentWorkloadMemoryUsage(input)).toBeNull();
+ expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('unknown');
+ }
+ const input = guest();
+ input.memory.usageUnavailable = true;
+ expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('unavailable');
+ });
+
+ it('does not rank or filter a retained high reading as current pressure', () => {
+ const stale = guest();
+ const current = { ...guest('guest-agent-meminfo', 'current', 10), id: 'current' };
+ for (const direction of ['asc', 'desc'] as const) {
+ expect([stale, current].sort(createWorkloadSortComparator('memory', direction)!)).toEqual([
+ current,
+ stale,
+ ]);
+ }
+ for (const query of ['memory>90', 'memory<100', 'memory=95']) {
+ expect(evaluateFilterStack(stale, parseFilterStack(query))).toBe(false);
+ }
+ });
+
+ it.each([0, 95])(
+ 'keeps the value (%s) as original last-known evidence in both History consumers',
+ (usage) => {
+ const input = guest('agent', 'current', usage);
+ const canonical = resource(input);
+ expect(getGuestDrawerCurrentMetrics(input).memory).toBeUndefined();
+ expect(getResourceMetricsHistoryCurrentMetrics(canonical).memory).toBeUndefined();
+ for (const deferred of [
+ getGuestDrawerDeferredMetrics(input),
+ getResourceMetricsHistoryDeferredMetrics(canonical),
+ ]) {
+ expect(deferred.memory.lastKnownValue).toBe(usage);
+ expect(deferred.memory.message).toContain('2026-10-10 12:00:00 UTC');
+ expect(deferred.memory.message).toContain('stopped reporting');
+ }
+ },
+ );
+
+ it('restores current measured zero only when its source reports again', () => {
+ const input = guest('agent', 'current', 0);
+ input.agentStale = false;
+ input.memory.observation!.observedAt = '2026-10-11T12:00:00Z';
+ expect(getCurrentWorkloadMemoryUsage(input)).toBe(0);
+ expect(getWorkloadMemoryObservationPresentation(input)?.state).toBe('current');
+ expect(getWorkloadMemoryObservationPresentation(input)?.message).not.toContain(
+ 'stopped reporting',
+ );
+ expect(getGuestDrawerCurrentMetrics(input).memory).toBe(0);
+ expect(getResourceMetricsHistoryCurrentMetrics(resource(input)).memory).toBe(0);
+ expect(getGuestDrawerDeferredMetrics(input)).not.toHaveProperty('memory');
+ expect(getResourceMetricsHistoryDeferredMetrics(resource(input))).not.toHaveProperty('memory');
+ });
+});
diff --git a/frontend-modern/src/utils/memoryObservation.ts b/frontend-modern/src/utils/memoryObservation.ts
index 8592b557e7..a497e10ecc 100644
--- a/frontend-modern/src/utils/memoryObservation.ts
+++ b/frontend-modern/src/utils/memoryObservation.ts
@@ -75,13 +75,16 @@ const memoryObservationState = (
value: number | undefined,
observation: MemoryObservation | undefined,
timestamp: number | null,
+ agentStale = false,
): MemoryObservationPresentation['state'] =>
!isMemoryPercent(value) || observation?.state === 'unavailable'
? 'unavailable'
: observation?.state === 'last-known'
? 'last-known'
: observation?.state === 'current' && timestamp !== null
- ? 'current'
+ ? agentStale && observation.source === 'agent'
+ ? 'last-known'
+ : 'current'
: 'unknown';
const requiresWorkloadMemoryObservation = (guest: WorkloadGuest): boolean => {
@@ -116,6 +119,7 @@ export const getCurrentWorkloadMemoryUsage = (guest: WorkloadGuest): number | nu
memory.usage,
memory.observation,
memoryObservationTime(memory.observation),
+ guest.agentStale === true,
) !== 'current'
) {
return null;
@@ -142,13 +146,17 @@ export const getMemoryObservationPresentation = (
value: number | undefined,
observation: MemoryObservation | undefined,
requiresObservation = false,
- options: { includeCurrent?: boolean; guestKind?: MemoryGuestKind } = {},
+ options: { includeCurrent?: boolean; guestKind?: MemoryGuestKind; agentStale?: boolean } = {},
): MemoryObservationPresentation | null => {
// Unannotated unrelated platforms retain their existing behaviour. Legacy
// Proxmox readings lack authority to assert current freshness.
if (!requiresObservation && !observation) return null;
const timestamp = memoryObservationTime(observation);
- const state = memoryObservationState(value, observation, timestamp);
+ // An agent-facet update can arrive without replacing the selected memory
+ // carrier. Honour the server's explicit source invalidation, but only for
+ // that agent's own reading: QGA and Proxmox remain independent. Do not add a
+ // client lease, renew the observation time or mutate its wire metadata.
+ const state = memoryObservationState(value, observation, timestamp, options.agentStale);
// An unavailable reading shows no number, so there is nothing to qualify.
const mayIncludeCache =
state !== 'unavailable' &&
@@ -180,10 +188,14 @@ export const getMemoryObservationPresentation = (
: 'Unknown source';
const time = observed ? `Observed: ${observed}.` : 'Observation time unknown.';
const cacheNote = mayIncludeCache ? getCacheNote(options.guestKind) : undefined;
+ const sourceStatus =
+ state !== 'unavailable' && options.agentStale && observation?.source === 'agent'
+ ? ' The Pulse Agent that supplied this reading stopped reporting.'
+ : '';
return {
state,
- summary: `${label} · ${source}${mayIncludeCache ? ' (may include cache)' : ''}${observed ? ` · ${observed}` : ' · time unknown'}`,
- message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}${cacheNote ? ` ${cacheNote}` : ''}`,
+ summary: `${label} · ${source}${sourceStatus ? ' (stopped reporting)' : ''}${mayIncludeCache ? ' (may include cache)' : ''}${observed ? ` · ${observed}` : ' · time unknown'}`,
+ message: `${label}. Source: ${source}. ${time}${state === 'last-known' || state === 'unknown' ? ' Not a current measurement.' : ''}${sourceStatus}${cacheNote ? ` ${cacheNote}` : ''}`,
mayIncludeCache,
...(cacheNote ? { cacheNote } : {}),
};
@@ -216,6 +228,7 @@ export const getWorkloadMemoryObservationPresentation = (
{
...options,
guestKind: getWorkloadMemoryGuestKind(guest),
+ agentStale: guest.agentStale === true,
},
);
};