From 4032c34f36e42b0bea863b05270b76195df71d65 Mon Sep 17 00:00:00 2001 From: Alexander Hill Date: Fri, 10 Apr 2026 21:26:01 -0500 Subject: [PATCH] matrix: reset decrypt tracker across healthy sync epochs (cherry picked from commit 0d4631123616a287b42bb5dd87f035c0e2531a20) (cherry picked from commit 8a1481ff03b9ae5c8c35f1c44322b47e53552e24) --- .../matrix/src/matrix/monitor/events.test.ts | 82 +++++++++++++++++++ .../matrix/src/matrix/monitor/events.ts | 13 ++- 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 0184bf43555..0920a2cef33 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -1680,6 +1680,88 @@ describe("registerMatrixMonitorEvents verification routing", () => { } }); + it("resets tracked failures when healthy sync restarts before the old window expires", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-10T16:21:00.000Z")); + try { + let healthySyncSinceMs = Date.now() - 60_000; + const { logger, failedDecryptListener } = createHarness({ + accountId: "ops", + getHealthySyncSinceMs: () => healthySyncSinceMs, + }); + if (!failedDecryptListener) { + throw new Error("room.failed_decryption listener was not registered"); + } + + for (const index of [1, 2, 3]) { + await failedDecryptListener( + `!room-first-${index}:example.org`, + { + event_id: `$enc-first-${index}`, + sender: `@alice-first-${index}:matrix.example.org`, + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now() - index * 1_000, + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + } + + healthySyncSinceMs = Date.now(); + + for (const index of [1, 2, 3]) { + await failedDecryptListener( + `!room-second-${index}:example.org`, + { + event_id: `$enc-second-${index}`, + sender: `@alice-second-${index}:matrix.example.org`, + type: EventType.RoomMessageEncrypted, + origin_server_ts: Date.now() + index, + content: {}, + }, + new Error("The sender's device has not sent us the keys for this message."), + ); + } + + expect(logger.warn).toHaveBeenNthCalledWith( + 5, + "Failed to decrypt fresh post-healthy-sync message", + expect.objectContaining({ + eventId: "$enc-second-1", + freshAfterHealthySync: true, + postHealthySyncFailureCount: 1, + }), + ); + expect(logger.warn).toHaveBeenNthCalledWith( + 6, + "Failed to decrypt fresh post-healthy-sync message", + expect.objectContaining({ + eventId: "$enc-second-2", + freshAfterHealthySync: true, + postHealthySyncFailureCount: 2, + }), + ); + expect(logger.warn).toHaveBeenNthCalledWith( + 7, + "Failed to decrypt fresh post-healthy-sync message", + expect.objectContaining({ + eventId: "$enc-second-3", + freshAfterHealthySync: true, + postHealthySyncFailureCount: 3, + }), + ); + expect(logger.warn).toHaveBeenNthCalledWith( + 8, + "matrix: repeated fresh encrypted messages are still failing to decrypt after Matrix resumed healthy sync. This device may still be missing new room keys. Check 'openclaw matrix verify status --verbose --account ops' and 'openclaw matrix devices list --account ops'.", + expect.objectContaining({ + sampleEventIds: ["$enc-second-1", "$enc-second-2", "$enc-second-3"], + }), + ); + } finally { + vi.useRealTimers(); + } + }); + it("does not throw when getUserId fails during decrypt guidance lookup", async () => { const { logger, logVerboseMessage, failedDecryptListener } = createHarness({ accountId: "ops", diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index 907b3105fb2..6f65278cbec 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -58,6 +58,12 @@ function createMatrixPostHealthySyncDecryptFailureTracker(params: { }) { let observations: MatrixPostHealthySyncDecryptFailureObservation[] = []; let warningEmitted = false; + let trackedHealthySyncSinceMs: number | undefined; + + const resetObservations = () => { + observations = []; + warningEmitted = false; + }; const pruneObservations = (nowMs: number) => { observations = observations.filter( @@ -71,10 +77,15 @@ function createMatrixPostHealthySyncDecryptFailureTracker(params: { return { recordFailure(roomId: string, event: MatrixRawEvent, error: Error) { const nowMs = Date.now(); + const healthySyncSinceMs = params.getHealthySyncSinceMs?.(); + if (healthySyncSinceMs !== trackedHealthySyncSinceMs) { + trackedHealthySyncSinceMs = healthySyncSinceMs; + resetObservations(); + } if ( !isFreshPostHealthySyncDecryptFailure({ event, - healthySyncSinceMs: params.getHealthySyncSinceMs?.(), + healthySyncSinceMs, graceMs: params.startupGraceMs, nowMs, })