From 3e5cfc3df3637577b99dfbdfee2100c554844bdd Mon Sep 17 00:00:00 2001
From: "pulse-triage[bot]"
<249995291+pulse-triage[bot]@users.noreply.github.com>
Date: Tue, 13 Oct 2026 07:20:33 +0100
Subject: [PATCH] Keep stale agent memory out of current Web readings
Honour the server-owned stale signal only for the selected Pulse Agent observation. Preserve its original value and time as last known in rows, sorting, metric search and both History consumers, while independent Proxmox/QGA sources remain current. Add regression and connected browser fixtures without a client TTL, wire rewrite or guest operation.
Change-source: pulse-maintainer
---
.../subsystems/frontend-primitives.md | 23 ++
.../v6/internal/subsystems/registry.json | 3 +-
.../guest-memory-agent-stale.cjs | 251 ++++++++++++++++++
.../guest-memory-agent-stale.html | 12 +
.../guest-memory-agent-stale.tsx | 207 +++++++++++++++
.../publication-fixture-server.cjs | 4 +
...resourceDetailDrawerMetricsHistoryModel.ts | 1 +
.../Workloads/__tests__/GuestRow.test.tsx | 43 +++
.../utils/__tests__/memoryObservation.test.ts | 8 +-
.../utils/__tests__/staleAgentMemory.test.ts | 148 +++++++++++
.../src/utils/memoryObservation.ts | 23 +-
11 files changed, 716 insertions(+), 7 deletions(-)
create mode 100644 frontend-modern/browser-tests/guest-memory-agent-stale.cjs
create mode 100644 frontend-modern/browser-tests/guest-memory-agent-stale.html
create mode 100644 frontend-modern/browser-tests/guest-memory-agent-stale.tsx
create mode 100644 frontend-modern/src/utils/__tests__/staleAgentMemory.test.ts
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,
},
);
};