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
6 changes: 6 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,9 @@ export type AgentAwarenessThread = ProjectThreadAwarenessV2Input["thread"] &
| "creationSource"
| "latestRunCompletedAt"
| "archivedAt"
| "snoozedUntil"
| "snoozedAt"
| "pendingGuestMessageCount"
| "activeRunId"
| "latestRunId"
| "hasActionableProposedPlan"
Expand Down Expand Up @@ -8685,6 +8688,9 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
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,
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
18 changes: 17 additions & 1 deletion apps/server/src/relay/AgentAwarenessRelay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vite-plus/test";

import {
createThreadNotificationGate,
isSnoozedForNotification,
RELEASED_COMPLETION_GRACE_MS,
type ThreadNotificationObservation,
} from "./ThreadNotificationCoordinator.logic";
Expand All @@ -25,7 +26,8 @@ const woke = (at: number): ThreadNotificationObservation => ({
...completed(at),
backgroundWake: true,
});
const visible = { archived: false, quiet: false };
const visible = { archived: false, quiet: false, snoozed: false };
const snoozed = { ...visible, snoozed: true };

function fakeTimers(): Disposable {
vi.useFakeTimers();
Expand All @@ -44,6 +46,16 @@ function harness() {
}

describe("createThreadNotificationGate", () => {
it("skips a completion seen while snoozed, but still notifies attention", () => {
const { notify, observe } = harness();
observe(working);
observe(completed(1), snoozed);
observe({ ...completed(1) });
expect(notify).not.toHaveBeenCalled();
observe({ ...working, attention: "run-2:approval" }, snoozed);
expect(notify.mock.calls).toEqual([["thread", "input"]]);
});

it("notifies a completion immediately and only once", () => {
const { notify, observe } = harness();
observe(working);
Expand Down Expand Up @@ -180,6 +192,16 @@ describe("createThreadNotificationGate", () => {
expect(notify).toHaveBeenCalledTimes(1);
});

it("drops a pending wake completion when the thread is snoozed", () => {
using _timers = fakeTimers();
const { notify, observe } = harness();
observe(waking);
observe(woke(1));
observe({ ...woke(1) }, snoozed);
vi.advanceTimersByTime(RELEASED_COMPLETION_GRACE_MS);
expect(notify).not.toHaveBeenCalled();
});

it("drops a pending wake completion when the thread is removed or the gate is cleared", () => {
using _timers = fakeTimers();
for (const reset of [
Expand All @@ -195,3 +217,30 @@ describe("createThreadNotificationGate", () => {
}
});
});

describe("isSnoozedForNotification", () => {
const snoozedAt = Date.parse("2026-01-01T11:00:00.000Z");
const wakeAt = Date.parse("2026-01-01T12:00:00.000Z");
const thread = {
snoozedAt: new Date(snoozedAt).toISOString(),
snoozedUntil: new Date(wakeAt).toISOString(),
hasPendingApprovals: false,
hasPendingUserInput: false,
};
const later = wakeAt + 60_000;

it.each([
["during the snooze, processed after the wake", thread, wakeAt - 1, later, true],
["before the snooze, processed after the wake", thread, snoozedAt - 1, later, false],
["after the wake", thread, wakeAt + 1, later, false],
[
"after an early wake for attention",
{ ...thread, hasPendingApprovals: true },
wakeAt - 1,
wakeAt - 1,
false,
],
])("a completion %s is suppressed: %s", (_name, shell, completedAt, now, expected) => {
expect(isSnoozedForNotification(shell, completedAt, now)).toBe(expected);
});
});
29 changes: 25 additions & 4 deletions apps/web/src/components/ThreadNotificationCoordinator.logic.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
effectiveSnoozed,
type ThreadSnoozeShell,
} from "@t3tools/client-runtime/state/thread-settled";

export type ThreadNotificationKind = "input" | "completion";

export interface ThreadNotificationObservation {
Expand All @@ -16,6 +21,21 @@ export interface ThreadNotificationObservation {
/** Matches the push relay's completion confirmation: a final wake can start right after a progress wake. */
export const RELEASED_COMPLETION_GRACE_MS = 5_000;

/**
* 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;
if (completedAt === null) return false;
if (thread.snoozedAt != null && completedAt < Date.parse(thread.snoozedAt)) return false;
return effectiveSnoozed(thread, { now: new Date(completedAt).toISOString() });
}

export function createThreadNotificationGate<Key>(
notify: (key: Key, kind: ThreadNotificationKind) => void,
) {
Expand Down Expand Up @@ -64,17 +84,18 @@ export function createThreadNotificationGate<Key>(
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 {
// 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) {
const silenced = options.archived || options.quiet || options.snoozed;
if (pending?.at === next.completedAt && !silenced) {
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 || silenced) skip(key, next.completedAt);
const fresh = attend(key, prior, next);
if (!prior || options.archived) return;
if (fresh) {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/ThreadNotificationCoordinator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { resolveBackgroundThreadRequest } from "./backgroundThreadAnnouncement";
import { resolveSidebarThreadStatus, type SidebarThreadStatus } from "./Sidebar.logic";
import {
createThreadNotificationGate,
isSnoozedForNotification,
type ThreadNotificationKind,
type ThreadNotificationObservation,
} from "./ThreadNotificationCoordinator.logic";
Expand Down Expand Up @@ -300,10 +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: isSnoozedForNotification(state.thread, state.completedAt, now),
});
}
};
Expand Down
Loading