From d465cc0c71634916d2482d35a5316b2750d78ac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BB=BA=E5=86=9B?= Date: Tue, 6 Oct 2026 13:28:26 +0800 Subject: [PATCH] fix: alert again when a change watch returns to a seen page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A change-condition watch keyed its dedup notice on the page content hash alone, so a page that changed A→B→A never alerted on the return to A. The alertSequence mechanism already added for contains/price conditions in 96c2525 now covers change too: every notification-worthy observation bumps the committed sequence and keys the notice on sequence+hash, so publication replays still deduplicate. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/server/src/engine/service.ts | 15 ++++++--------- tests/agent-api.test.ts | 4 +++- tests/monitor-recovery.test.ts | 6 ++++++ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index c7b94c985..71c05b440 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -1062,11 +1062,11 @@ export class AgentService { : this.matchesPrice(text, Number(monitor.value)); const previouslyMatched = Boolean(task.state.matched); const shouldNotify = matched && (monitor.condition === "change" || !previouslyMatched); - // A condition becoming true again is a new event, even with identical page text. - // Commit its sequence with the outcome so publication retries still deduplicate. - const alertSequence = - Number(task.state.alertSequence ?? 0) + - (shouldNotify && monitor.condition !== "change" ? 1 : 0); + // A notification-worthy observation is a new event, even when the page text is + // identical to an earlier one (a change back to a seen state, or a condition that + // cleared and reappeared). Commit its sequence with the outcome so publication + // retries still deduplicate on the same saved notice key. + const alertSequence = Number(task.state.alertSequence ?? 0) + (shouldNotify ? 1 : 0); const nextCheckAt = new Date(Date.now() + monitor.intervalMinutes * 60000).toISOString(); await ctx.guard(); // Worker lease is checked before each publication; monitor control also invalidates that lease. @@ -1112,10 +1112,7 @@ export class AgentService { ? { title: monitor.title, body: `Condition met at ${observation.url}: ${text.slice(0, 240)}`, - key: - monitor.condition === "change" - ? `monitor:${monitor.id}:${currentHash}` - : `monitor:${monitor.id}:${alertSequence}:${currentHash}`, + key: `monitor:${monitor.id}:${alertSequence}:${currentHash}`, } : null, }, diff --git a/tests/agent-api.test.ts b/tests/agent-api.test.ts index 4de0cdfe8..2daf0bc04 100644 --- a/tests/agent-api.test.ts +++ b/tests/agent-api.test.ts @@ -296,7 +296,9 @@ test("sample monitor saves its baseline and deduplicates notifications for repea await server.agent.worker.tick(); } const found = await notifications(); - assert.equal(found.length, 2); + // Identical repeats stay quiet; the final flip back to a previously seen + // page is a new change event and alerts again. + assert.equal(found.length, 3); assert.ok(found.every((item) => !item.read)); const readNotification = await read(`/notifications/${found[0].id}/read`, {}); assert.equal(readNotification.read, true); diff --git a/tests/monitor-recovery.test.ts b/tests/monitor-recovery.test.ts index 493051e8d..19ca5c066 100644 --- a/tests/monitor-recovery.test.ts +++ b/tests/monitor-recovery.test.ts @@ -137,6 +137,12 @@ test("a page change stays alertable when the task outcome is lost after the base }); for (const scenario of [ + { + condition: "change", + value: "", + pages: ["Sold out", "Available now", "Sold out", "Available now"], + expectedAlerts: 3, + }, { condition: "contains", value: "available now",