Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion apps/server/src/engine/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -754,6 +754,8 @@ export class AgentService {
if (error instanceof LostLeaseError || context.signal.aborted) throw error;
await context.guard();
const failures = Number(task.state.failures ?? 0) + 1;
// Each streak of failures (after a success or a resume) gets its own alerts.
const failureStreak = Number(task.state.failureStreak ?? 0) + (failures === 1 ? 1 : 0);
const detail = error instanceof Error ? error.message : "Page check failed";
const nextCheckAt = new Date(
Date.now() + Math.min(60, 2 ** failures) * 60000,
Expand All @@ -778,10 +780,11 @@ export class AgentService {
...task.state,
failures,
resumingMonitor: false,
failureStreak,
notice: {
title: "Watch needs attention",
body: detail,
key: `watch-error:${task.id}:${failures >= 5 ? "paused" : "retry"}`,
key: `watch-error:${task.id}:${failureStreak}:${failures >= 5 ? "paused" : "retry"}`,
},
},
};
Expand Down
2 changes: 1 addition & 1 deletion tests/monitor-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ test("the paused alert is delivered when the final failure outcome is lost", asy
assert.equal(task?.status, "paused");
assert.equal((await db.get<Monitor>(owner, "monitors", monitor.id))?.status, "paused");
const pausedId = createHash("sha256")
.update(`watch-error:${monitor.taskId}:paused`)
.update(`watch-error:${monitor.taskId}:1:paused`)
.digest("hex");
const paused = (await read<AgentNotification[]>("/notifications")).filter(
(item) => item.id === pausedId,
Expand Down
34 changes: 34 additions & 0 deletions tests/workflows.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,40 @@ test("failed page checks back off, expose the error, and pause after repeated fa
assert.equal((await db.get<Monitor>(owner, "monitors", monitor.id))?.status, "paused");
});

test("each failure streak of a watch raises its own alerts after it is resumed", async () => {
const monitor = await server.agent.createMonitor(owner, {
title: "Resumed availability",
url: "https://example.com",
});
const failUntilPaused = async () => {
for (let i = 1; i <= 5; i++) {
await server.agent.worker.tick();
const task = await server.agent.getTask(owner, monitor.taskId);
if (i < 5)
await db.compareAndSwap(
owner,
"tasks",
task.id,
{ status: "scheduled" },
{ nextRunAt: "2020-01-01T00:00:00Z" },
);
}
assert.equal((await server.agent.getTask(owner, monitor.taskId)).status, "paused");
};
const alerts = async () =>
(await db.list<AgentNotification>(owner, "notifications")).filter(
(n) => n.taskId === monitor.taskId && n.title === "Watch needs attention",
);
await failUntilPaused();
assert.equal((await alerts()).length, 2, "one retry alert and one paused alert");
await server.agent.controlMonitor(owner, monitor.id, "resume");
await failUntilPaused();
assert.equal((await alerts()).length, 4, "the second streak alerts again");
// Replaying the same outcome must not duplicate the alert.
await server.agent.worker.tick();
assert.equal((await alerts()).length, 4);
});

test("dismissal racing acceptance never creates work for a dismissed idea", async () => {
for (let i = 0; i < 4; i++) {
const idea: Idea = {
Expand Down
Loading