From 666e4bd0c0a791939ad4bd1f407b1fe490a60d2a Mon Sep 17 00:00:00 2001 From: maximkes <22573701+maximkes@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:32:50 +0300 Subject: [PATCH 1/7] Suppress completion notifications for snoozed threads --- ...hreadNotificationCoordinator.logic.test.ts | 33 ++++++++++++++++--- .../ThreadNotificationCoordinator.logic.ts | 9 +++-- .../ThreadNotificationCoordinator.tsx | 3 ++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 2bba09f54dcb1..bdc8bfd28bdc1 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -10,13 +10,15 @@ const working: ThreadNotificationObservation = { attention: null, settled: false, completedAt: null, + snoozed: false, }; const completed = (at: number): ThreadNotificationObservation => ({ attention: null, settled: true, completedAt: at, + snoozed: false, }); -const visible = { archived: false, quiet: false }; +const visible = { archived: false, quiet: false, snoozed: false }; function harness() { const notify = vi.fn(); @@ -37,6 +39,27 @@ describe("createThreadNotificationGate", () => { vi.useRealTimers(); }); + it("does not notify completion while the thread is snoozed", () => { + const { notify, observe } = harness(); + observe(working); + observe({ ...completed(1), snoozed: true }, { ...visible, snoozed: true }); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); + + it("still notifies attention while the thread is snoozed", () => { + const { notify, observe } = harness(); + observe(working); + observe( + { attention: "run-1:approval", settled: false, completedAt: null, snoozed: true }, + { ...visible, snoozed: true }, + ); + expect(notify.mock.calls).toEqual([["thread", "input"]]); + }); + it("drops a completion when the thread resumes within the grace", () => { const { notify, observe } = harness(); observe(working); @@ -65,7 +88,7 @@ describe("createThreadNotificationGate", () => { observe(working); observe(completed(1)); vi.advanceTimersByTime(500); - observe({ attention: "run-1:approval", settled: false, completedAt: null }); + observe({ attention: "run-1:approval", settled: false, completedAt: null, snoozed: false }); expect(notify.mock.calls).toEqual([["thread", "input"]]); vi.advanceTimersByTime(60_000); // The request resolves and the same run reads as completed again. @@ -96,7 +119,7 @@ describe("createThreadNotificationGate", () => { observe(completed(1)); vi.advanceTimersByTime(500); observe(working); - observe({ attention: "run-1:approval", settled: false, completedAt: null }); + observe({ attention: "run-1:approval", settled: false, completedAt: null, snoozed: false }); vi.advanceTimersByTime(60_000); observe(completed(1)); vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); @@ -108,7 +131,7 @@ describe("createThreadNotificationGate", () => { observe(working); observe(completed(1)); vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); - observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe({ attention: "run-1:limited", settled: false, completedAt: null, snoozed: false }); observe(completed(1)); vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); expect(notify.mock.calls).toEqual([ @@ -122,7 +145,7 @@ describe("createThreadNotificationGate", () => { observe(working); observe(completed(1)); vi.advanceTimersByTime(500); - observe({ attention: "run-2:approval", settled: false, completedAt: null }); + observe({ attention: "run-2:approval", settled: false, completedAt: null, snoozed: false }); observe(working); observe(completed(2)); vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index 77edccfbe7741..b4fcc8aef727a 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -9,6 +9,8 @@ export interface ThreadNotificationObservation { readonly settled: boolean; /** When the latest run completed, if it did. */ readonly completedAt: number | null; + /** The thread is intentionally hidden until its snooze wake time. */ + readonly snoozed: boolean; } /** @@ -36,12 +38,13 @@ export function createThreadNotificationGate( key: Key, prior: ThreadNotificationObservation | undefined, next: ThreadNotificationObservation, - options: { readonly archived: boolean; readonly quiet: boolean }, + options: { readonly archived: boolean; readonly quiet: boolean; readonly snoozed: boolean }, ): void { if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); - if (!next.settled || options.archived || options.quiet) cancel(key); + if (!next.settled || options.archived || options.quiet || options.snoozed) cancel(key); // The first sighting, archived threads, and stopped or silent runs never notify completion. - if (!prior || options.archived || options.quiet) skip(key, next.completedAt); + if (!prior || options.archived || options.quiet || options.snoozed) + skip(key, next.completedAt); if (!prior || options.archived) return; if (next.attention && next.attention !== prior.attention) { // Attention replaces the completion before it, so that run must not notify later. diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index fa4fdbb672faa..0787869a45d91 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,3 +1,4 @@ +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { type EnvironmentThreadShell, type PendingSubagentRequest, @@ -217,6 +218,7 @@ export function startThreadNotifications( attention, settled, completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, + snoozed: effectiveSnoozed(thread, { now: new Date().toISOString() }), }; next.set(thread.id, state); changed.push([prior, state]); @@ -227,6 +229,7 @@ export function startThreadNotifications( entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, quiet: state.raw.latestRunStopRequested === true, + snoozed: state.snoozed, }); } }; From 5139014a433a65ac79cfe91a2b7b2a6b1010797e Mon Sep 17 00:00:00 2001 From: maximkes <22573701+maximkes@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:32:51 +0300 Subject: [PATCH 2/7] fix(web): drop pending completion when a thread is snoozed A completion still waiting out its grace was cancelled on snooze but not marked handled, so it notified once the thread woke and settled again with the same timestamp. Co-Authored-By: Claude Opus 5.5 --- .../ThreadNotificationCoordinator.logic.test.ts | 11 +++++++++++ .../components/ThreadNotificationCoordinator.logic.ts | 2 ++ 2 files changed, 13 insertions(+) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index bdc8bfd28bdc1..961b09c3052ab 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -176,6 +176,17 @@ describe("createThreadNotificationGate", () => { expect(notify.mock.calls).toEqual([["thread", "completion"]]); }); + it("drops a pending completion when the thread is snoozed during a runtime blip", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe({ ...working, snoozed: true }, { ...visible, snoozed: true }); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); + it("restarts the grace when a newer completion replaces a pending one", () => { const { notify, observe } = harness(); observe(working); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index b4fcc8aef727a..f9fd93eadd217 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -41,6 +41,8 @@ export function createThreadNotificationGate( options: { readonly archived: boolean; readonly quiet: boolean; readonly snoozed: boolean }, ): void { if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); + // Snoozing drops a completion still waiting out its grace, so it cannot notify after wake. + if (options.snoozed) skip(key, pending.get(key)?.at ?? null); if (!next.settled || options.archived || options.quiet || options.snoozed) cancel(key); // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet || options.snoozed) From d59980b754e9312dd817a1a48e7e27c1f81345a8 Mon Sep 17 00:00:00 2001 From: maximkes <22573701+maximkes@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:43:43 +0300 Subject: [PATCH 3/7] fix(web): keep completions from a snooze suppressed after gaps and late updates - Snoozing now marks the newest settled completion handled, not only one still pending, so a completion cancelled by an earlier working blip cannot notify after wake. - Snooze is also evaluated at the completion time, so a completion that landed during the snooze but was processed after the wake (e.g. in a throttled background tab) stays suppressed. Co-Authored-By: Claude Opus 5.5 --- ...hreadNotificationCoordinator.logic.test.ts | 40 +++++++++++++++++++ .../ThreadNotificationCoordinator.logic.ts | 23 ++++++++++- .../ThreadNotificationCoordinator.tsx | 9 +++-- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 961b09c3052ab..a12d36ce088be 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import { createThreadNotificationGate, + isSnoozedForNotification, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -187,6 +188,18 @@ describe("createThreadNotificationGate", () => { expect(notify).not.toHaveBeenCalled(); }); + it("drops a cancelled completion when the thread is snoozed later in the same blip", () => { + const { notify, observe } = harness(); + observe(working); + observe(completed(1)); + vi.advanceTimersByTime(500); + observe(working); + observe({ ...working, snoozed: true }, { ...visible, snoozed: true }); + observe(completed(1)); + vi.advanceTimersByTime(WORKING_EXIT_GRACE_MS * 2); + expect(notify).not.toHaveBeenCalled(); + }); + it("restarts the grace when a newer completion replaces a pending one", () => { const { notify, observe } = harness(); observe(working); @@ -225,3 +238,30 @@ describe("createThreadNotificationGate", () => { expect(notify).not.toHaveBeenCalled(); }); }); + +describe("isSnoozedForNotification", () => { + const wakeAt = Date.parse("2026-01-01T12:00:00.000Z"); + const snoozedThread = { + snoozedAt: "2026-01-01T11:00:00.000Z", + snoozedUntil: new Date(wakeAt).toISOString(), + hasPendingApprovals: false, + hasPendingUserInput: false, + }; + + it("is snoozed before the wake time", () => { + expect(isSnoozedForNotification(snoozedThread, null, wakeAt - 1)).toBe(true); + }); + + it("keeps a completion from the snooze suppressed when processed after the wake", () => { + expect(isSnoozedForNotification(snoozedThread, wakeAt - 1, wakeAt + 60_000)).toBe(true); + }); + + it("does not suppress a completion after the wake time", () => { + expect(isSnoozedForNotification(snoozedThread, wakeAt + 1, wakeAt + 60_000)).toBe(false); + }); + + it("does not suppress after an early wake for attention", () => { + const raised = { ...snoozedThread, hasPendingApprovals: true }; + expect(isSnoozedForNotification(raised, wakeAt - 1, wakeAt - 1)).toBe(false); + }); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index f9fd93eadd217..c1c9d48fbcfcc 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -1,4 +1,8 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; +import { + effectiveSnoozed, + type ThreadSnoozeShell, +} from "@t3tools/client-runtime/state/thread-settled"; export type ThreadNotificationKind = "input" | "completion"; @@ -13,6 +17,21 @@ export interface ThreadNotificationObservation { readonly snoozed: boolean; } +/** + * Whether completion notifications are suppressed by snooze. A completion that landed + * during the snooze stays suppressed even when its update is processed after the wake. + */ +export function isSnoozedForNotification( + thread: ThreadSnoozeShell, + completedAt: number | null, + now: number, +): boolean { + if (effectiveSnoozed(thread, { now: new Date(now).toISOString() })) return true; + return ( + completedAt !== null && effectiveSnoozed(thread, { now: new Date(completedAt).toISOString() }) + ); +} + /** * Attention notifies at once. Completion waits out the Working shelf's exit grace, * because an orchestrator run can end moments before its continuation starts. Any @@ -41,8 +60,8 @@ export function createThreadNotificationGate( options: { readonly archived: boolean; readonly quiet: boolean; readonly snoozed: boolean }, ): void { if (next.settled && next.completedAt !== null) lastSettled.set(key, next.completedAt); - // Snoozing drops a completion still waiting out its grace, so it cannot notify after wake. - if (options.snoozed) skip(key, pending.get(key)?.at ?? null); + // Snoozing drops the newest completion not yet notified, so it cannot notify after wake. + if (options.snoozed) skip(key, lastSettled.get(key) ?? null); if (!next.settled || options.archived || options.quiet || options.snoozed) cancel(key); // The first sighting, archived threads, and stopped or silent runs never notify completion. if (!prior || options.archived || options.quiet || options.snoozed) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 0787869a45d91..7cef171df29e0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -1,4 +1,3 @@ -import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { type EnvironmentThreadShell, type PendingSubagentRequest, @@ -33,6 +32,7 @@ import { import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic"; import { createThreadNotificationGate, + isSnoozedForNotification, type ThreadNotificationKind, type ThreadNotificationObservation, } from "./ThreadNotificationCoordinator.logic"; @@ -208,8 +208,9 @@ export function startThreadNotifications( status === "limited" ? `${thread.latestRun?.runId ?? ""}:${status}` : null; - const completedAt = Date.parse(thread.latestRun?.completedAt ?? ""); + const completedAtMs = Date.parse(thread.latestRun?.completedAt ?? ""); const settled = status === "ready" && thread.latestRun?.status === "completed"; + const completedAt = settled && Number.isFinite(completedAtMs) ? completedAtMs : null; const state = { raw: rawThread, subagentRequest, @@ -217,8 +218,8 @@ export function startThreadNotifications( status, attention, settled, - completedAt: settled && Number.isFinite(completedAt) ? completedAt : null, - snoozed: effectiveSnoozed(thread, { now: new Date().toISOString() }), + completedAt, + snoozed: isSnoozedForNotification(thread, completedAt, Date.now()), }; next.set(thread.id, state); changed.push([prior, state]); From 18fe5c45f1d599868d26072f7f889c5e09eb5156 Mon Sep 17 00:00:00 2001 From: maximkes <22573701+maximkes@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:50:52 +0300 Subject: [PATCH 4/7] fix(web): don't treat completions before snoozedAt as snoozed Co-Authored-By: Claude Opus 5.5 --- .../components/ThreadNotificationCoordinator.logic.test.ts | 5 +++++ .../src/components/ThreadNotificationCoordinator.logic.ts | 6 +++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index a12d36ce088be..574bd7255602f 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -256,6 +256,11 @@ describe("isSnoozedForNotification", () => { expect(isSnoozedForNotification(snoozedThread, wakeAt - 1, wakeAt + 60_000)).toBe(true); }); + it("does not suppress a completion from before the snooze processed after the wake", () => { + const snoozedAt = Date.parse(snoozedThread.snoozedAt); + expect(isSnoozedForNotification(snoozedThread, snoozedAt - 1, wakeAt + 60_000)).toBe(false); + }); + it("does not suppress a completion after the wake time", () => { expect(isSnoozedForNotification(snoozedThread, wakeAt + 1, wakeAt + 60_000)).toBe(false); }); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index c1c9d48fbcfcc..7336f9d74388b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -27,9 +27,9 @@ export function isSnoozedForNotification( now: number, ): boolean { if (effectiveSnoozed(thread, { now: new Date(now).toISOString() })) return true; - return ( - completedAt !== null && effectiveSnoozed(thread, { now: new Date(completedAt).toISOString() }) - ); + if (completedAt === null) return false; + if (thread.snoozedAt != null && completedAt < Date.parse(thread.snoozedAt)) return false; + return effectiveSnoozed(thread, { now: new Date(completedAt).toISOString() }); } /** From 0457466acf67cfdb8292251c5054d05ae9d2dfe1 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 19:21:38 +0000 Subject: [PATCH 5/7] test(web): preserve attention observations after main sync --- .../ThreadNotificationCoordinator.logic.test.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 756ed01c12664..a83f9684c09c6 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -9,7 +9,8 @@ import { const working: ThreadNotificationObservation = { attention: null, settled: false, - completedAt: null, snoozed: false, + completedAt: null, + snoozed: false, }; const completed = (at: number): ThreadNotificationObservation => ({ attention: null, @@ -50,7 +51,6 @@ describe("createThreadNotificationGate", () => { expect(notify.mock.calls).toEqual([["thread", "input"]]); }); - it("notifies a completion immediately and only once", () => { const { notify, observe } = harness(); observe(working); @@ -79,7 +79,12 @@ describe("createThreadNotificationGate", () => { it("notifies attention once and still notifies the subsequent completion", () => { const { notify, observe } = harness(); observe(working); - const attention = { attention: "run-1:approval", settled: false, completedAt: null }; + const attention = { + attention: "run-1:approval", + settled: false, + completedAt: null, + snoozed: false, + }; observe(attention); expect(notify.mock.calls).toEqual([["thread", "input"]]); observe({ ...attention }); @@ -96,7 +101,7 @@ describe("createThreadNotificationGate", () => { const { notify, observe } = harness(); observe(working); observe(completed(1)); - observe({ attention: "run-1:limited", settled: false, completedAt: null }); + observe({ attention: "run-1:limited", settled: false, completedAt: null, snoozed: false }); observe(completed(1)); expect(notify.mock.calls).toEqual([ ["thread", "completion"], From 53d67fb3d39144f803df93536324a2ddfde9f9d9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 10:55:26 +0000 Subject: [PATCH 6/7] fix(web): compute snooze at observation instead of storing it on state Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/ThreadNotificationCoordinator.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 38dd474e490ab..ecc6eb10ea26b 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -294,7 +294,6 @@ export function startThreadNotifications( settled, completedAt: settled ? completedAt : null, backgroundWake: rawThread.latestRunBackgroundWake === true, - snoozed: isSnoozedForNotification(thread, settled ? completedAt : null, Date.now()), }; next.set(thread.id, state); changed.push([prior, state]); @@ -302,11 +301,12 @@ export function startThreadNotifications( entry.reconnecting = false; entry.previous = next; entry.gate.retain(new Set(next.keys())); + const now = Date.now(); for (const [prior, state] of changed) { entry.gate.observe(state.thread.id, prior, state, { archived: state.thread.archivedAt !== null, quiet: state.raw.latestRunStopRequested === true, - snoozed: state.snoozed, + snoozed: isSnoozedForNotification(state.thread, state.completedAt, now), }); } }; From c53ee664e646d5fc5b706df3a59208a123831bb7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 14 Nov 2026 13:52:56 +0000 Subject: [PATCH 7/7] fix(server): snoozed completions no longer push to mobile The relay published a snoozed thread's completion, so iOS and Android still alerted. It now withholds a completion that landed during the snooze, checked at completion time like the web gate, so a completion processed after the wake stays silent too. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ProjectionStore.ts | 6 ++++ .../src/relay/AgentAwarenessRelay.test.ts | 31 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 18 ++++++++++- 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 32007af08573f..e0b0b3035d5eb 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1081,6 +1081,9 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "creationSource" | "latestRunCompletedAt" | "archivedAt" + | "snoozedUntil" + | "snoozedAt" + | "pendingGuestMessageCount" | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" @@ -8685,6 +8688,9 @@ export const layer: Layer.Layer = projectId: state.thread.projectId, creationSource: state.thread.creationSource, archivedAt: state.thread.archivedAt, + snoozedUntil: state.thread.snoozedUntil ?? null, + snoozedAt: state.thread.snoozedAt ?? null, + pendingGuestMessageCount: pendingGuestMessageCount(state.thread.guestShare), activeRunId: state.activeRunId, latestRunId: state.latestRunId, hasActionableProposedPlan: state.hasActionableProposedPlan, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 68e2289524c09..10388e99fe7c5 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -753,6 +753,37 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect( + "clears a completion that landed while snoozed, even when processed after the wake", + () => + Effect.gen(function* () { + yield* TestClock.setTime(DateTime.toEpochMillis(DateTime.makeUnsafe(NOW))); + const { relay, currentShell, publications } = yield* makeTestRelay(); + const snooze = { + snoozedAt: DateTime.makeUnsafe(NOW), + snoozedUntil: DateTime.makeUnsafe("2026-09-04T13:00:00.000Z"), + }; + yield* Ref.set(currentShell, shell(snooze)); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("2 hours"); + yield* Ref.set( + currentShell, + shell({ + ...snooze, + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:30:00.000Z"), + }), + ); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", null], + ); + }), + ); + it.effect("still publishes a completion when the thread settles during the hold", () => Effect.gen(function* () { const { relay, currentShell, publications } = yield* makeTestRelay(); diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 76f854548deb6..ef2e34799742c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -51,6 +51,7 @@ import { import { EnvironmentSigningKey } from "../cloud/environmentKeys.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts"; +import { isSnoozed } from "../orchestration-v2/ThreadSettlementService.ts"; import * as ProjectService from "../project/ProjectService.ts"; import { forkParked } from "../serverActivation.ts"; @@ -360,12 +361,27 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; return { projectId: input.thread.value.projectId, - state: sanitizeRelayAgentActivityState(state), + state: snoozedCompletion(input.thread.value, state) + ? null + : sanitizeRelayAgentActivityState(state), subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; } +// Checked at completion time, so a completion processed after the wake stays silent too. +function snoozedCompletion( + thread: AgentAwarenessThread, + state: { readonly phase: string } | null, +): boolean { + if (state?.phase !== "completed" || thread.latestRunCompletedAt == null) return false; + const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + if (thread.snoozedAt != null && completedAt < DateTime.toEpochMillis(thread.snoozedAt)) { + return false; + } + return isSnoozed(thread, completedAt); +} + function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number): boolean { return ( thread.latestRunCompletedAt != null &&