From cfaaca00eabacec0d1d5c618a1570b9c1009d917 Mon Sep 17 00:00:00 2001 From: wangtao <328929485+wangtaotaotao95@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:23:11 +0800 Subject: [PATCH] fix: alert again when a watch condition clears and reappears --- apps/server/src/engine/service.ts | 11 ++++++- tests/monitor-recovery.test.ts | 48 +++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 3f2af061a..c8c6dfd81 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -1031,6 +1031,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); 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. @@ -1070,12 +1075,16 @@ export class AgentService { lastHash: currentHash, resumingMonitor: false, matched, + alertSequence, failures: 0, notice: shouldNotify ? { title: monitor.title, body: `Condition met at ${observation.url}: ${text.slice(0, 240)}`, - key: `monitor:${monitor.id}:${currentHash}`, + key: + monitor.condition === "change" + ? `monitor:${monitor.id}:${currentHash}` + : `monitor:${monitor.id}:${alertSequence}:${currentHash}`, } : null, }, diff --git a/tests/monitor-recovery.test.ts b/tests/monitor-recovery.test.ts index 8d40eb234..acd8ca98f 100644 --- a/tests/monitor-recovery.test.ts +++ b/tests/monitor-recovery.test.ts @@ -135,6 +135,54 @@ test("a page change stays alertable when the task outcome is lost after the base assert.match(found[0].body, /One table at 7 pm/); }); +for (const scenario of [ + { + condition: "contains", + value: "available now", + pages: ["Sold out", "Available now", "Sold out", "Available now"], + expectedAlerts: 2, + }, + { + condition: "price_below", + value: "50", + pages: ["Price: $80", "Price: $40", "Price: $80", "Price: $40"], + expectedAlerts: 2, + }, +]) { + test(`${scenario.condition} alerts on recurring changes while deduplicating outcome replay`, async () => { + const monitor = await read( + "/monitors", + { + title: `Recurring ${scenario.condition}`, + url: "sample://availability", + condition: scenario.condition, + value: scenario.value, + intervalMinutes: 1, + }, + 201, + ); + const alerts = async () => + (await read("/notifications")).filter( + (item) => item.taskId === monitor.taskId, + ); + for (const text of scenario.pages) { + await read("/sample-page", { text }); + await read(`/monitors/${monitor.id}/control`, { action: "check" }); + await server.agent.worker.tick(); + // A restart can publish the same saved notice again; that is not a new alert. + await maintain(); + await maintain(); + } + assert.equal((await alerts()).length, scenario.expectedAlerts); + for (let i = 0; i < 2; i++) { + await read(`/monitors/${monitor.id}/control`, { action: "check" }); + await server.agent.worker.tick(); + } + assert.equal((await alerts()).length, scenario.expectedAlerts, "unchanged pages stay quiet"); + await read(`/monitors/${monitor.id}/control`, { action: "stop" }); + }); +} + test("resuming a monitor fails without activating it when the task transition does not commit", async () => { await read("/sample-page", { text: "No tables available" }); const monitor = await createMonitor("Resume availability");