diff --git a/src/slack/sent-thread-cache.test.ts b/src/slack/sent-thread-cache.test.ts index 57acea9fac0..05af1958895 100644 --- a/src/slack/sent-thread-cache.test.ts +++ b/src/slack/sent-thread-cache.test.ts @@ -55,4 +55,13 @@ describe("slack sent-thread-cache", () => { vi.spyOn(Date, "now").mockReturnValue(Date.now() + 25 * 60 * 60 * 1000); expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000001")).toBe(false); }); + + it("enforces maximum entries by evicting oldest fresh entries", () => { + for (let i = 0; i < 5001; i += 1) { + recordSlackThreadParticipation("A1", "C123", `1700000000.${String(i).padStart(6, "0")}`); + } + + expect(hasSlackThreadParticipation("A1", "C123", "1700000000.000000")).toBe(false); + expect(hasSlackThreadParticipation("A1", "C123", "1700000000.005000")).toBe(true); + }); }); diff --git a/src/slack/sent-thread-cache.ts b/src/slack/sent-thread-cache.ts index 9cce41b07bd..7fe8037c797 100644 --- a/src/slack/sent-thread-cache.ts +++ b/src/slack/sent-thread-cache.ts @@ -22,6 +22,13 @@ function evictExpired(): void { } } +function evictOldest(): void { + const oldest = threadParticipation.keys().next().value; + if (oldest) { + threadParticipation.delete(oldest); + } +} + export function recordSlackThreadParticipation( accountId: string, channelId: string, @@ -33,6 +40,9 @@ export function recordSlackThreadParticipation( if (threadParticipation.size >= MAX_ENTRIES) { evictExpired(); } + if (threadParticipation.size >= MAX_ENTRIES) { + evictOldest(); + } threadParticipation.set(makeKey(accountId, channelId, threadTs), Date.now()); } diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 5133994b6db..223eceb7c94 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -346,6 +346,55 @@ describe("cron controller", () => { }); }); + it("omits failureAlert.cooldownMs when custom cooldown is left blank", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-alert-no-cooldown" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-alert-no-cooldown" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronEditingJobId: "job-alert-no-cooldown", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "alert job no cooldown", + payloadKind: "agentTurn", + payloadText: "run it", + failureAlertMode: "custom", + failureAlertAfter: "3", + failureAlertCooldownSeconds: "", + failureAlertChannel: "telegram", + failureAlertTo: "123456", + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect(updateCall?.[1]).toMatchObject({ + id: "job-alert-no-cooldown", + patch: { + failureAlert: { + after: 3, + channel: "telegram", + to: "123456", + }, + }, + }); + expect( + (updateCall?.[1] as { patch?: { failureAlert?: { cooldownMs?: number } } })?.patch + ?.failureAlert, + ).not.toHaveProperty("cooldownMs"); + }); + it("includes failureAlert=false when disabled per job", async () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "cron.update") { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 775b4cb650c..72d9ba8a91a 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -579,15 +579,17 @@ function buildFailureAlert(form: CronFormState) { return undefined; } const after = toNumber(form.failureAlertAfter.trim(), 0); - const cooldownSeconds = toNumber(form.failureAlertCooldownSeconds.trim(), 0); + const cooldownRaw = form.failureAlertCooldownSeconds.trim(); + const cooldownSeconds = cooldownRaw.length > 0 ? toNumber(cooldownRaw, 0) : undefined; + const cooldownMs = + cooldownSeconds !== undefined && Number.isFinite(cooldownSeconds) && cooldownSeconds >= 0 + ? Math.floor(cooldownSeconds * 1000) + : undefined; return { after: after > 0 ? Math.floor(after) : undefined, channel: form.failureAlertChannel.trim() || CRON_CHANNEL_LAST, to: form.failureAlertTo.trim() || undefined, - cooldownMs: - Number.isFinite(cooldownSeconds) && cooldownSeconds >= 0 - ? Math.floor(cooldownSeconds * 1000) - : undefined, + ...(cooldownMs !== undefined ? { cooldownMs } : {}), }; }