diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 505bb79df034..de1e42030115 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -140,9 +140,11 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { }), ); - it.effect("re-emits idempotently for a duplicate snooze to the same wake time", () => + it.effect("re-snoozing to the SAME wake time stamps fresh", () => Effect.gen(function* () { - const reEmit = yield* decideOrchestrationCommand({ + // A thread that raised its hand after SNOOZED_AT must hide again when + // the user re-picks the same preset. + const event = yield* decideOrchestrationCommand({ command: { type: "thread.snooze", commandId: CommandId.make("cmd-snooze-again"), @@ -151,12 +153,12 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { }, readModel: makeReadModel({ snoozedUntil: FUTURE_WAKE }), }); - const events = Array.isArray(reEmit) ? reEmit : [reEmit]; + const events = Array.isArray(event) ? event : [event]; expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.snoozed"); if (events[0]?.type === "thread.snoozed") { - // Original snoozedAt preserved; updatedAt must not churn. - expect(events[0].payload.snoozedAt).toBe(SNOOZED_AT); - expect(events[0].payload.updatedAt).toBe(NOW); + expect(events[0].payload.snoozedAt).not.toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).toBe(events[0].payload.snoozedAt); } }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 21df39a162e6..8f8a0398a12f 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -668,14 +668,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `thread ${command.threadId} has a queued turn start and cannot be snoozed`, }); } - // Re-snoozing an already-snoozed thread to the SAME wake time is a - // duplicate (double-click, raced clients): re-emit with the original - // timestamps so the projection is a no-op. A different wake time is a - // real change and stamps fresh. - const existingSnoozedAt = - thread.snoozedUntil === command.snoozedUntil && thread.snoozedAt != null - ? thread.snoozedAt - : null; + // Every snooze stamps fresh, even to the same wake time: snoozedAt is + // the baseline a later failure or completion must beat to raise the + // thread's hand, and re-snoozing a woken thread is the user saying + // "seen it, not now". Retries are already deduplicated by commandId. return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -687,8 +683,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, snoozedUntil: command.snoozedUntil, - snoozedAt: existingSnoozedAt ?? occurredAt, - updatedAt: existingSnoozedAt !== null ? thread.updatedAt : occurredAt, + snoozedAt: occurredAt, + updatedAt: occurredAt, }, }; } diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index bb70356aab6a..76c8b28f7af7 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -303,7 +303,7 @@ export function createThreadEnvironmentAtoms( hasPendingApprovals: false, hasPendingUserInput: false, snoozedUntil: input.snoozedUntil, - snoozedAt: thread.snoozedUntil === input.snoozedUntil ? (thread.snoozedAt ?? now) : now, + snoozedAt: now, }, ), unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({