diff --git a/docs/release-control/v6/internal/subsystems/alerts.md b/docs/release-control/v6/internal/subsystems/alerts.md index 342bc9952..0fd8d6ae1 100644 --- a/docs/release-control/v6/internal/subsystems/alerts.md +++ b/docs/release-control/v6/internal/subsystems/alerts.md @@ -33,6 +33,20 @@ Unknown timestamps remain unavailable. Truncation is explicit. The Assistant handoff preserves occurrence identity and the latest bounded event window, and labels history as insufficient to establish current health. +### Incident resource error-state ownership + +Resource incident errors follow the same per-resource latest-request ownership +as history and loading. Superseded or disposed reads cannot set an error; +reset clears errors and invalidates pending reads. A retry clears the current +error while retaining cached history until the current request succeeds. +A stale success cannot clear a newer failure. This reconciles the merged error +accessor with lifecycle protection, without changing notification delivery. + +Verification: resource hook lifecycle assertions cover ordering and retry; +`scripts/check-incident-request-ownership.mjs` checks the real hook and panel +in Chromium at desktop and narrow widths, including error state and retry. +Its scripted fixture is not full merged-UI or installed delivery acceptance. + ### Retained-queue recovery feedback has no reading deadline Retry and Dismiss failures have a view-local untimed equivalent beside the diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 263a8af7e..d3cf8d562 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7254,7 +7254,9 @@ disposal invalidates them and prevents new loads. Overlapping reads for differen resources remain independent. Closing a row still permits its in-flight result to populate the existing cache; reopening cached history and explicit refresh are unchanged. Requests are not transport-cancelled. No API, retention or -notification-delivery policy changes. +notification-delivery policy changes. A retry clears the current failed-read +state while retaining cached history until the owning request succeeds, and a +superseded success cannot clear a newer failure. The hook's ten ordinary regression/control cases cover success, catch and finally writes, reset/reopen and disposal. The existing panel tests cover its diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 94ceb897a..96140d5a1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -75,7 +75,9 @@ "Saved operator note and unsaved draft across desktop/phone layout changes, long-note wrapping and persisted reload.", "Timeline/resource handoff into Assistant, reopened composer focus, saved funded history-and-note response across widths.", "Request reset, reverse completion, superseded success/failure, current failure, and success/failure after disposal.", - "Latest per-resource owner controls incident content, failed-read state, notifications, and loading completion." + "Latest per-resource owner controls incident content, failed-read state, notifications, and loading completion.", + "Retry clears the current failed-read state while retaining cached history until the owning response succeeds.", + "Obsolete and disposed failures do not set the resource error accessor." ], "interactions": [ "Expand/collapse native Evidence details by keyboard, All/None event filters and note draft enable/clear.", @@ -85,6 +87,10 @@ "Resume the exact saved upstream session, scroll through its note explanation and inspect actual pixels.", "Open a resource row, overlap refresh, reset or unmount, and resolve or reject deferred reads in controlled order." ], - "notes": "The protected upstream receipt remains exact for 13 unchanged runtime files. The sole combined-content change, useAlertResourceIncidentsState.ts, was freshly exercised with its real panel in 14 Chromium cases at desktop and phone widths after adding ownership to the upstream failed-read signal. Screenshots are retained under /tmp/pulse-incident-ownership. This is component lifecycle evidence, not an installed Alerts route, provider delivery, touch-device, production rollout, backup/restore, or population-reliability receipt.", - "command": "pulse-heavy-run -- node scripts/check-incident-request-ownership.mjs" + "notes": "The protected upstream receipt remains exact for 13 unchanged runtime files. The sole combined-content change, useAlertResourceIncidentsState.ts, was freshly exercised with its real panel in 16 Chromium cases at desktop and phone widths after adding ownership to the upstream failed-read signal. Screenshots are retained under /tmp/pulse-incident-ownership. This is component lifecycle evidence, not an installed Alerts route, provider delivery, touch-device, production rollout, backup/restore, or population-reliability receipt.", + "command": "pulse-heavy-run -- node scripts/check-incident-request-ownership.mjs", + "limitations": [ + "The 16-case qualification route uses fixture lifecycle controls and a scripted API; the supplied panel does not expose the error accessor directly, so that state is asserted through the fixture snapshot.", + "This is not installed acceptance, provider delivery, touch-device, production rollout, backup/restore, or population-reliability proof." + ] } diff --git a/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx b/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx index ab022a545..e71ec08b3 100644 --- a/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx +++ b/frontend-modern/src/features/alerts/__tests__/useAlertResourceIncidentsState.test.tsx @@ -82,8 +82,8 @@ describe('resource incident request ownership', () => { cleanup(); pending.reject(new Error('disposed read')); await load; - expect(notificationStore.error).not.toHaveBeenCalled(); expect(result.resourceIncidentError().host).toBe(false); + expect(notificationStore.error).not.toHaveBeenCalled(); }); it('keeps a reopened request owned after an older reset-era failure', async () => { @@ -99,6 +99,7 @@ describe('resource incident request ownership', () => { old.reject(new Error('reset-era failure')); await load; expect(result.resourceIncidentLoading().host).toBe(true); + expect(result.resourceIncidentError().host).toBe(false); expect(notificationStore.error).not.toHaveBeenCalled(); current.resolve([]); await reopened; @@ -116,10 +117,10 @@ describe('resource incident request ownership', () => { await result.refreshResourceIncidentPanel(); old.reject(new Error('obsolete failure')); await load; + expect(result.resourceIncidentError().host).toBe(false); expect(notificationStore.error).not.toHaveBeenCalled(); expect(result.resourceIncidents().host).toEqual([]); expect(result.resourceIncidentLoading().host).toBe(false); - expect(result.resourceIncidentError().host).toBe(false); }); it('ignores successful reads and new loads after disposal', async () => { @@ -152,7 +153,46 @@ describe('resource incident request ownership', () => { await load; expect(result.resourceIncidents().first).toEqual([]); expect(result.resourceIncidentLoading()).toEqual({ first: false, second: false }); + expect(result.resourceIncidentError()).toEqual({ first: false, second: true }); }); + it('preserves the current error when a superseded success arrives', async () => { + const old = deferred(); + vi.mocked(AlertsAPI.getIncidentsForResource) + .mockReturnValueOnce(old.promise) + .mockRejectedValueOnce(new Error('current failure')); + const { result } = renderHook(useAlertResourceIncidentsState); + const load = result.openResourceIncidentPanel('host', 'Host', 'row'); + await result.refreshResourceIncidentPanel(); + old.resolve([]); + await load; + expect(result.resourceIncidentError().host).toBe(true); + expect(result.resourceIncidents()).toEqual({}); + expect(notificationStore.error).toHaveBeenCalledTimes(1); + }); + + it('clears a current error on retry without discarding cached history', async () => { + const cached = [{ id: 'retained' }] as Incidents; + const retry = deferred(); + vi.mocked(AlertsAPI.getIncidentsForResource) + .mockResolvedValueOnce(cached) + .mockRejectedValueOnce(new Error('refresh failed')) + .mockReturnValueOnce(retry.promise); + const { result } = renderHook(useAlertResourceIncidentsState); + await result.openResourceIncidentPanel('host', 'Host', 'row'); + await result.refreshResourceIncidentPanel(); + expect(result.resourceIncidentError().host).toBe(true); + expect(result.resourceIncidents().host).toEqual(cached); + const refresh = result.refreshResourceIncidentPanel(); + expect(result.resourceIncidentError().host).toBe(false); + expect(result.resourceIncidentLoading().host).toBe(true); + retry.resolve([]); + await refresh; + expect(result.resourceIncidentError().host).toBe(false); + expect(result.resourceIncidents().host).toEqual([]); + result.resetResourceIncidentsState(); + expect(result.resourceIncidentError()).toEqual({}); + }); + it('toggles the opening row and reuses loaded history for another row', async () => { vi.mocked(AlertsAPI.getIncidentsForResource).mockResolvedValue([]); const { result } = renderHook(useAlertResourceIncidentsState); diff --git a/scripts/check-incident-request-ownership.mjs b/scripts/check-incident-request-ownership.mjs index 3012bf3f1..aac89fd2f 100644 --- a/scripts/check-incident-request-ownership.mjs +++ b/scripts/check-incident-request-ownership.mjs @@ -83,6 +83,7 @@ try { "dispose-success", "dispose-failure", "current-failure", + "retry", ]) { const page = await browser.newPage({ viewport: { width, height: 900 } }); await page.goto("http://127.0.0.1:5198/qualification"); @@ -99,8 +100,15 @@ try { (s) => window.finish(0, s === "dispose-failure" ? "error" : "empty"), scenario, ); - } else if (scenario === "current-failure") { + } else if (scenario === "current-failure" || scenario === "retry") { await page.evaluate(() => window.finish(0, "error")); + await page.waitForFunction(() => window.snapshot().error.host === true); + if (scenario === "retry") { + await page.getByRole("button", { name: "Overlap refresh", exact: true }).click(); + await page.waitForFunction(() => window.count() === 2); + assert.equal((await page.evaluate(() => window.snapshot())).error.host, false); + await page.evaluate(() => window.finish(1, "Latest incident")); + } } else { await page .getByRole("button", { name: "Overlap refresh", exact: true }) @@ -143,7 +151,7 @@ try { const snapshot = await page.evaluate(() => window.snapshot()); assert.equal( await page.getByTestId("errors").textContent(), - scenario === "current-failure" ? "1" : "0", + ["current-failure", "retry"].includes(scenario) ? "1" : "0", ); if (scenario === "reset") { assert.deepEqual(snapshot, { incidents: {}, loading: {}, error: {} });