From 363b65e2f248c328abdd8d92d3b3eaf0cba12ffe Mon Sep 17 00:00:00 2001 From: soup Date: Tue, 10 Nov 2026 21:53:58 +0100 Subject: [PATCH 1/5] fix: snoozed threads no longer alert when a run completes A snoozed thread stays hidden until its wake time, but a run that completed during the snooze still played the completion sound, showed the toast and desktop notification, and sent a mobile push. The web notifier never read the snooze state. It now treats a snoozed thread like an archived one: no alert, and the completion is not replayed after the wake. The push relay's snapshot did not carry the snooze fields. It now does, and a completion on a snoozed thread clears the card without the "completed" state that triggers the alert. An approval, a new question, or a failed run still ends the snooze early and alerts as before. --- .../src/orchestration-v2/ProjectionStore.ts | 6 ++++ .../src/relay/AgentAwarenessRelay.test.ts | 35 +++++++++++++++++++ apps/server/src/relay/AgentAwarenessRelay.ts | 9 ++++- ...ificationCoordinator.badge.browser.test.ts | 25 +++++++++++++ ...hreadNotificationCoordinator.logic.test.ts | 4 +-- .../ThreadNotificationCoordinator.logic.ts | 10 +++--- .../ThreadNotificationCoordinator.tsx | 4 ++- 7 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0c4976dc9059..17f920b06d2e 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1017,6 +1017,9 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "creationSource" | "latestRunCompletedAt" | "archivedAt" + | "snoozedUntil" + | "snoozedAt" + | "pendingGuestMessageCount" | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" @@ -7291,6 +7294,9 @@ export const layer: Layer.Layer = projectId: state.thread.projectId, creationSource: state.thread.creationSource, archivedAt: state.thread.archivedAt, + snoozedUntil: state.thread.snoozedUntil, + snoozedAt: state.thread.snoozedAt, + 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 1463a1a30270..442a4cf61bb4 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -627,6 +627,41 @@ describe("AgentAwarenessRelay", () => { }), ); + it.effect("clears a snoozed thread's completion without alerting, until it asks", () => + Effect.gen(function* () { + const snoozed = { + snoozedUntil: DateTime.makeUnsafe("2999-01-01T00:00:00.000Z"), + snoozedAt: DateTime.makeUnsafe(NOW), + }; + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set(currentShell, shell(snoozed)); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ ...snoozed, status: "completed" })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.deepEqual( + publications.map((publication) => publication.state?.phase ?? null), + ["running", null], + ); + + yield* Ref.set( + currentShell, + shell({ + ...snoozed, + status: "completed", + pendingRuntimeRequest: { + id: RuntimeRequestId.make("request-after-snooze"), + kind: "command", + createdAt: DateTime.makeUnsafe("2026-09-04T12:05:00.000Z"), + }, + }), + ); + yield* relay.publishThread(THREAD_ID); + assert.equal(publications.at(-1)?.state?.phase, "waiting_for_approval"); + }), + ); + it.effect("publishes a cleared thread's tombstone once while it stays cleared", () => 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 c14558762183..e55feb2f46a8 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -50,6 +50,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"; @@ -317,6 +318,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly thread: Option.Option; readonly project: Option.Option; readonly pendingSubagentRequest: SubagentRequestAwareness | null; + readonly nowMs: number; }): { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; @@ -352,9 +354,12 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { const childDrivesWait = input.pendingSubagentRequest !== null && state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; + // A snoozed thread stays hidden until its wake time, so its completion clears the card silently. + const hiddenCompletion = + state?.phase === "completed" && isSnoozed(input.thread.value, input.nowMs); return { projectId: input.thread.value.projectId, - state: sanitizeRelayAgentActivityState(state), + state: hiddenCompletion ? null : sanitizeRelayAgentActivityState(state), subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; @@ -576,6 +581,7 @@ export const make = Effect.gen(function* () { thread, project, pendingSubagentRequest: yield* currentSubagentRequestFor(threadId), + nowMs: (yield* DateTime.now).epochMilliseconds, }); const publishIdentity = agentAwarenessPublishIdentity(snapshot.state, snapshot.subagentWait); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); @@ -643,6 +649,7 @@ export const make = Effect.gen(function* () { thread: Option.some({ ...thread.value, settledOverride: null }), project, pendingSubagentRequest: null, + nowMs, }) : null; if (unsettled?.state?.phase === "completed") { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index aab1de3db672..17f52a9b34c0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -105,6 +105,7 @@ const thread = { id: "thread", title: "Test thread", archivedAt: null as string | null, + snoozedUntil: null as string | null, hasPendingApprovals: false, hasPendingUserInput: false, pendingRequestId: "request-1", @@ -163,6 +164,8 @@ function toV2ThreadShell(input: typeof thread) { ? DateTime.makeUnsafe(latestTurn.completedAt) : undefined, archivedAt: input.archivedAt ? DateTime.makeUnsafe(input.archivedAt) : null, + snoozedUntil: input.snoozedUntil ? DateTime.makeUnsafe(input.snoozedUntil) : null, + snoozedAt: input.snoozedUntil ? SHELL_NOW : null, settledOverride: null, settledAt: null, lastVisitedAt: null, @@ -268,6 +271,28 @@ it("presents completion notifications and sound as soon as the shell completes", expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function)); }); +it("stays quiet while a thread is snoozed, until it asks for approval", async () => { + await using app = setup(); + state.mode = "notifications-and-sound"; + const snoozedUntil = "2999-01-01T00:00:00.000Z"; + const completed = { + snoozedUntil, + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:00:01.000Z" }, + }; + app.start(); + state.shells.set("one", shell({ snoozedUntil })); + state.shells.set("one", shell(completed)); + expect(TestNotification.sent).toEqual([]); + expect(state.sound).not.toHaveBeenCalled(); + + state.shells.set("one", shell({ ...completed, snoozedUntil: null })); + expect(state.sound).not.toHaveBeenCalled(); + + state.shells.set("one", shell({ snoozedUntil, hasPendingApprovals: true })); + expect(TestNotification.sent.map(({ title }) => title)).toEqual(["Approval needed"]); + expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function)); +}); + it.each([false, true])( "presents one completion when a monitor ends with a final wake (disconnected: %s)", async (disconnect) => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts index 965dba45ce8a..45ed5b516f14 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.test.ts @@ -25,7 +25,7 @@ const woke = (at: number): ThreadNotificationObservation => ({ ...completed(at), backgroundWake: true, }); -const visible = { archived: false, quiet: false }; +const visible = { hidden: false, quiet: false }; function fakeTimers(): Disposable { vi.useFakeTimers(); @@ -119,7 +119,7 @@ describe("createThreadNotificationGate", () => { observe(completed(2), { ...visible, quiet: true }); observe(completed(2)); observe(working); - observe(completed(3), { ...visible, archived: true }); + observe(completed(3), { ...visible, hidden: true }); observe(completed(3)); expect(notify).not.toHaveBeenCalled(); observe(working); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts index c8f0ca7df7a9..99912209ce69 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.logic.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.logic.ts @@ -64,19 +64,19 @@ export function createThreadNotificationGate( key: Key, prior: ThreadNotificationObservation | undefined, next: ThreadNotificationObservation, - options: { readonly archived: boolean; readonly quiet: boolean }, + options: { readonly hidden: boolean; readonly quiet: boolean }, ): void { // The grace survives updates that keep the wake's completion; anything else cancels it. const pending = releases.get(key); - if (pending?.at === next.completedAt && !options.archived && !options.quiet) { + if (pending?.at === next.completedAt && !options.hidden && !options.quiet) { if (pending.timer === undefined) release(key, pending.at); return; } 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); + // The first sighting, archived or snoozed threads, and stopped or silent runs never notify completion. + if (!prior || options.hidden || options.quiet) skip(key, next.completedAt); const fresh = attend(key, prior, next); - if (!prior || options.archived) return; + if (!prior || options.hidden) return; if (fresh) { notify(key, "input"); return; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 2051d4ad633a..69d008449212 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -3,6 +3,7 @@ import { type PendingSubagentRequest, presentThreadShell, } from "@t3tools/client-runtime/state/models"; +import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import type { AnyRouter } from "@tanstack/react-router"; import { threadKey } from "@t3tools/client-runtime/state/entities"; @@ -285,9 +286,10 @@ export function startThreadNotifications( entry.reconnecting = false; entry.previous = next; entry.gate.retain(new Set(next.keys())); + const now = new Date().toISOString(); for (const [prior, state] of changed) { entry.gate.observe(state.thread.id, prior, state, { - archived: state.thread.archivedAt !== null, + hidden: state.thread.archivedAt !== null || effectiveSnoozed(state.thread, { now }), quiet: state.raw.latestRunStopRequested === true, }); } From fd83cf93eca1a977492c913920e5ecbdf40b0fa0 Mon Sep 17 00:00:00 2001 From: soup Date: Tue, 10 Nov 2026 22:00:05 +0100 Subject: [PATCH 2/5] fix(server): a wake does not push a completion the snooze hid The relay cleared a snoozed thread's completion, but the next publish after the wake saw a completed thread with no live card and sent the "completed" push late. The relay now remembers when the hidden completion ended and keeps that completion silent. --- .../src/relay/AgentAwarenessRelay.test.ts | 15 ++++++++++++-- apps/server/src/relay/AgentAwarenessRelay.ts | 20 ++++++++++++++++++- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 442a4cf61bb4..1b11c310c247 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -636,7 +636,11 @@ describe("AgentAwarenessRelay", () => { const { relay, currentShell, publications } = yield* makeTestRelay(); yield* Ref.set(currentShell, shell(snoozed)); yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell({ ...snoozed, status: "completed" })); + const completed = { + status: "completed" as const, + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"), + }; + yield* Ref.set(currentShell, shell({ ...snoozed, ...completed })); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; @@ -645,11 +649,18 @@ describe("AgentAwarenessRelay", () => { ["running", null], ); + // Waking must not alert the completion that the snooze hid. + yield* Ref.set(currentShell, shell(completed)); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust("5 seconds"); + yield* relay.drain; + assert.equal(publications.length, 2); + yield* Ref.set( currentShell, shell({ ...snoozed, - status: "completed", + ...completed, pendingRuntimeRequest: { id: RuntimeRequestId.make("request-after-snooze"), kind: "command", diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index e55feb2f46a8..ec8a0c092aa3 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -319,11 +319,14 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly project: Option.Option; readonly pendingSubagentRequest: SubagentRequestAwareness | null; readonly nowMs: number; + /** When the completion that a snooze already hid ended, so a wake does not alert it late. */ + readonly hiddenCompletionAt: number | null; }): { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; /** The child request behind the state's wait, when the parent isn't waiting itself. */ readonly subagentWait: SubagentRequestAwareness | null; + readonly hiddenCompletionAt: number | null; readonly reason: "snapshot" | "thread-not-found" | "project-not-found"; } { if (Option.isNone(input.thread)) { @@ -331,6 +334,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { projectId: null, state: null, subagentWait: null, + hiddenCompletionAt: null, reason: "thread-not-found", }; } @@ -339,6 +343,7 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { projectId: input.thread.value.projectId, state: null, subagentWait: null, + hiddenCompletionAt: null, reason: "project-not-found", }; } @@ -355,11 +360,18 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { input.pendingSubagentRequest !== null && state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; // A snoozed thread stays hidden until its wake time, so its completion clears the card silently. + const completedAt = + input.thread.value.latestRunCompletedAt == null + ? null + : DateTime.toEpochMillis(input.thread.value.latestRunCompletedAt); const hiddenCompletion = - state?.phase === "completed" && isSnoozed(input.thread.value, input.nowMs); + state?.phase === "completed" && + (isSnoozed(input.thread.value, input.nowMs) || + (completedAt !== null && completedAt === input.hiddenCompletionAt)); return { projectId: input.thread.value.projectId, state: hiddenCompletion ? null : sanitizeRelayAgentActivityState(state), + hiddenCompletionAt: hiddenCompletion ? completedAt : null, subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; @@ -418,6 +430,7 @@ export const make = Effect.gen(function* () { // its first alert time through parent renames, relinks and the parent's own requests. const childAlertByThread = new Map(); const subagentRequests = new Map(); + const hiddenCompletionAtByThread = new Map(); const readSecretString = (name: string) => secrets @@ -582,7 +595,11 @@ export const make = Effect.gen(function* () { project, pendingSubagentRequest: yield* currentSubagentRequestFor(threadId), nowMs: (yield* DateTime.now).epochMilliseconds, + hiddenCompletionAt: hiddenCompletionAtByThread.get(threadId) ?? null, }); + if (snapshot.hiddenCompletionAt !== null) { + hiddenCompletionAtByThread.set(threadId, snapshot.hiddenCompletionAt); + } const publishIdentity = agentAwarenessPublishIdentity(snapshot.state, snapshot.subagentWait); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if ( @@ -650,6 +667,7 @@ export const make = Effect.gen(function* () { project, pendingSubagentRequest: null, nowMs, + hiddenCompletionAt: hiddenCompletionAtByThread.get(threadId) ?? null, }) : null; if (unsettled?.state?.phase === "completed") { From b6d4df325c129098dcd19d3e99c2ce445a79bdaf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 02:24:08 +0000 Subject: [PATCH 3/5] fix(server): judge a woken thread's hidden completion by its wake time The relay remembered a completion the snooze hid only in memory, so it pushed the completion late once its memory was gone: after a confirmed clear left no live card, or after publishing was turned off and on. Wake records lastSnoozeWakeAt; a completion with no live card that ended before it is not published. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ProjectionStore.ts | 4 +- .../src/relay/AgentAwarenessRelay.test.ts | 11 ++++- apps/server/src/relay/AgentAwarenessRelay.ts | 41 +++++++------------ 3 files changed, 27 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 0b0528d1ad26..c509c4d424b9 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -1108,7 +1108,8 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] & | "activeRunId" | "latestRunId" | "hasActionableProposedPlan" - >; + > & + Pick; type ShellThreadRow = { readonly thread_id: string; @@ -8786,6 +8787,7 @@ export const layer: Layer.Layer = archivedAt: state.thread.archivedAt, snoozedUntil: state.thread.snoozedUntil ?? null, snoozedAt: state.thread.snoozedAt ?? null, + lastSnoozeWakeAt: state.thread.lastSnoozeWakeAt ?? null, pendingGuestMessageCount: pendingGuestMessageCount(state.thread.guestShare), activeRunId: state.activeRunId, latestRunId: state.latestRunId, diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 68dc875a6a9b..d7d7b6df1118 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -146,6 +146,12 @@ describe("startup agent activity", () => { status: "completed", latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), }), + shell({ + id: ThreadId.make("completed-before-wake"), + status: "completed", + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), + lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:00:02.000Z"), + }), shell({ id: oldFailed, status: "failed", @@ -832,7 +838,10 @@ describe("AgentAwarenessRelay", () => { yield* TestClock.adjust("5 seconds"); yield* relay.drain; - yield* Ref.set(currentShell, shell(completed)); + yield* Ref.set( + currentShell, + shell({ ...completed, lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:02:00.000Z") }), + ); yield* relay.publishThread(THREAD_ID); yield* TestClock.adjust("5 seconds"); yield* relay.drain; diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 39d76349acd7..bdf088b0372c 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -324,14 +324,11 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { readonly thread: Option.Option; readonly project: Option.Option; readonly pendingSubagentRequest: SubagentRequestAwareness | null; - /** When the completion that a snooze already hid ended, so a wake does not alert it late. */ - readonly hiddenCompletionAt: number | null; }): { readonly projectId: string | null; readonly state: RelayAgentActivityState | null; /** The child request behind the state's wait, when the parent isn't waiting itself. */ readonly subagentWait: SubagentRequestAwareness | null; - readonly hiddenCompletionAt: number | null; readonly reason: "snapshot" | "thread-not-found" | "project-not-found"; } { if (Option.isNone(input.thread)) { @@ -339,7 +336,6 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { projectId: null, state: null, subagentWait: null, - hiddenCompletionAt: null, reason: "thread-not-found", }; } @@ -348,7 +344,6 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { projectId: input.thread.value.projectId, state: null, subagentWait: null, - hiddenCompletionAt: null, reason: "project-not-found", }; } @@ -364,20 +359,11 @@ function resolveAgentAwarenessRelayPublishSnapshot(input: { const childDrivesWait = input.pendingSubagentRequest !== null && state?.phase !== projectThreadAwarenessV2(awarenessInput)?.phase; - const completedAt = - input.thread.value.latestRunCompletedAt == null - ? null - : DateTime.toEpochMillis(input.thread.value.latestRunCompletedAt); - // Wake clears the snooze fields, so a completion the snooze hid is remembered by its time. - const hiddenCompletion = - snoozedCompletion(input.thread.value, state) || - (state?.phase === "completed" && - completedAt !== null && - completedAt === input.hiddenCompletionAt); return { projectId: input.thread.value.projectId, - state: hiddenCompletion ? null : sanitizeRelayAgentActivityState(state), - hiddenCompletionAt: hiddenCompletion ? completedAt : null, + state: snoozedCompletion(input.thread.value, state) + ? null + : sanitizeRelayAgentActivityState(state), subagentWait: childDrivesWait ? input.pendingSubagentRequest : null, reason: "snapshot", }; @@ -397,10 +383,17 @@ function snoozedCompletion( } function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number): boolean { - return ( - thread.latestRunCompletedAt != null && - DateTime.toEpochMillis(thread.latestRunCompletedAt) > startedAt - ); + if (thread.latestRunCompletedAt == null) return false; + const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + // Wake clears the snooze fields; a completion that ended before the wake was hidden by it. + if ( + thread.status === "completed" && + thread.lastSnoozeWakeAt != null && + completedAt <= DateTime.toEpochMillis(thread.lastSnoozeWakeAt) + ) { + return false; + } + return completedAt > startedAt; } export function resolveAgentAwarenessRelayActiveThreadIds(input: { @@ -449,7 +442,6 @@ export const make = Effect.gen(function* () { // its first alert time through parent renames, relinks and the parent's own requests. const childAlertByThread = new Map(); const subagentRequests = new Map(); - const hiddenCompletionAtByThread = new Map(); const readSecretString = (name: string) => secrets @@ -613,11 +605,7 @@ export const make = Effect.gen(function* () { thread, project, pendingSubagentRequest: yield* currentSubagentRequestFor(threadId), - hiddenCompletionAt: hiddenCompletionAtByThread.get(threadId) ?? null, }); - if (snapshot.hiddenCompletionAt !== null) { - hiddenCompletionAtByThread.set(threadId, snapshot.hiddenCompletionAt); - } const publishIdentity = agentAwarenessPublishIdentity(snapshot.state, snapshot.subagentWait); const publishedStateByThread = yield* Ref.get(publishedStateByThreadRef); if ( @@ -684,7 +672,6 @@ export const make = Effect.gen(function* () { thread: Option.some({ ...thread.value, settledOverride: null }), project, pendingSubagentRequest: null, - hiddenCompletionAt: hiddenCompletionAtByThread.get(threadId) ?? null, }) : null; if (unsettled?.state?.phase === "completed") { From be7ac9b5880ae9ec6d06a6d9ae42e746e4b29039 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 02:33:27 +0000 Subject: [PATCH 4/5] test(server): let the relay shell fixture carry lastSnoozeWakeAt The client shell contract has no lastSnoozeWakeAt; the relay reads it from the awareness projection, so the fixture takes it from AgentAwarenessThread. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/relay/AgentAwarenessRelay.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index d7d7b6df1118..676e179f14ed 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -49,7 +49,10 @@ const SECOND_THREAD_ID = ThreadId.make("relay-thread-2"); const PROJECT_ID = ProjectId.make("relay-project"); const NOW = "2026-09-04T12:00:00.000Z"; -function shell(overrides: Partial = {}): OrchestrationV2ThreadShell { +type AwarenessShell = OrchestrationV2ThreadShell & + Pick; + +function shell(overrides: Partial = {}): AwarenessShell { return { id: THREAD_ID, projectId: PROJECT_ID, From 5192ba37eb366fd71ebd7aa3bcdd5f0d946cf37b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 02:44:43 +0000 Subject: [PATCH 5/5] fix(server): hide a completion that ended before the wake in every relay snapshot The wake check only gated publishing a completion with no live card. Waking during the completion's confirmation hold, while the running card was still live, let the hold publish completed. Judge lastSnoozeWakeAt in snoozedCompletion so every snapshot hides it. Co-Authored-By: Claude Opus 5.5 --- .../src/relay/AgentAwarenessRelay.test.ts | 69 +++++++++---------- apps/server/src/relay/AgentAwarenessRelay.ts | 22 +++--- 2 files changed, 45 insertions(+), 46 deletions(-) diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 676e179f14ed..b06bdec7dc78 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -149,12 +149,6 @@ describe("startup agent activity", () => { status: "completed", latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), }), - shell({ - id: ThreadId.make("completed-before-wake"), - status: "completed", - latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:00:01.000Z"), - lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:00:02.000Z"), - }), shell({ id: oldFailed, status: "failed", @@ -823,36 +817,41 @@ describe("AgentAwarenessRelay", () => { }), ); - it.effect("does not alert a completion the snooze hid once the wake clears the snooze", () => - Effect.gen(function* () { - const snoozed = { - snoozedUntil: DateTime.makeUnsafe("2999-01-01T00:00:00.000Z"), - snoozedAt: DateTime.makeUnsafe(NOW), - }; - const completed = { - status: "completed" as const, - latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"), - }; - const { relay, currentShell, publications } = yield* makeTestRelay(); - yield* Ref.set(currentShell, shell(snoozed)); - yield* relay.publishThread(THREAD_ID); - yield* Ref.set(currentShell, shell({ ...snoozed, ...completed })); - yield* relay.publishThread(THREAD_ID); - yield* TestClock.adjust("5 seconds"); - yield* relay.drain; + it.effect.each(["1 second", "5 seconds"] as const)( + "does not alert a completion the snooze hid when woken %s after it", + (beforeWake) => + Effect.gen(function* () { + const snoozed = { + snoozedUntil: DateTime.makeUnsafe("2999-01-01T00:00:00.000Z"), + snoozedAt: DateTime.makeUnsafe(NOW), + }; + const completed = { + status: "completed" as const, + latestRunCompletedAt: DateTime.makeUnsafe("2026-09-04T12:01:00.000Z"), + }; + const { relay, currentShell, publications } = yield* makeTestRelay(); + yield* Ref.set(currentShell, shell(snoozed)); + yield* relay.publishThread(THREAD_ID); + yield* Ref.set(currentShell, shell({ ...snoozed, ...completed })); + yield* relay.publishThread(THREAD_ID); + yield* TestClock.adjust(beforeWake); + yield* relay.drain; - yield* Ref.set( - currentShell, - shell({ ...completed, lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:02: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], - ); - }), + yield* Ref.set( + currentShell, + shell({ + ...completed, + lastSnoozeWakeAt: DateTime.makeUnsafe("2026-09-04T12:02: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("publishes a cleared thread's tombstone once while it stays cleared", () => diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index bdf088b0372c..3c9dcf5a0519 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -376,6 +376,13 @@ function snoozedCompletion( ): boolean { if (state?.phase !== "completed" || thread.latestRunCompletedAt == null) return false; const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); + // Wake clears the snooze fields, so a completion that ended before the wake was hidden by it. + if ( + thread.lastSnoozeWakeAt != null && + completedAt <= DateTime.toEpochMillis(thread.lastSnoozeWakeAt) + ) { + return true; + } if (thread.snoozedAt != null && completedAt < DateTime.toEpochMillis(thread.snoozedAt)) { return false; } @@ -383,17 +390,10 @@ function snoozedCompletion( } function terminalWorkSinceStart(thread: AgentAwarenessThread, startedAt: number): boolean { - if (thread.latestRunCompletedAt == null) return false; - const completedAt = DateTime.toEpochMillis(thread.latestRunCompletedAt); - // Wake clears the snooze fields; a completion that ended before the wake was hidden by it. - if ( - thread.status === "completed" && - thread.lastSnoozeWakeAt != null && - completedAt <= DateTime.toEpochMillis(thread.lastSnoozeWakeAt) - ) { - return false; - } - return completedAt > startedAt; + return ( + thread.latestRunCompletedAt != null && + DateTime.toEpochMillis(thread.latestRunCompletedAt) > startedAt + ); } export function resolveAgentAwarenessRelayActiveThreadIds(input: {