diff --git a/apps/server/src/orchestration/decider.snoozed.test.ts b/apps/server/src/orchestration/decider.snoozed.test.ts index 505bb79df034..29dc8d29ce91 100644 --- a/apps/server/src/orchestration/decider.snoozed.test.ts +++ b/apps/server/src/orchestration/decider.snoozed.test.ts @@ -140,7 +140,7 @@ 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({ command: { @@ -154,9 +154,11 @@ it.layer(NodeServices.layer)("snoozed thread decider", (it) => { const events = Array.isArray(reEmit) ? reEmit : [reEmit]; expect(events).toHaveLength(1); 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); + // Fresh stamp resets the raised-hand baseline so a woken thread + // can be snoozed again to the same wake time. + expect(events[0].payload.snoozedAt).toBe(events[0].payload.updatedAt); + expect(events[0].payload.snoozedAt).not.toBe(SNOOZED_AT); + expect(events[0].payload.updatedAt).not.toBe(NOW); } }), ); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 21df39a162e6..db5cc090ed66 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; + // An explicit snooze always stamps fresh, even to the same wake time: + // re-snoozing means "I saw it, not now", so it resets the raised-hand + // baseline. Real retries are deduplicated by commandId receipts, and a + // double-click only moves the stamp by milliseconds. 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.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index dc70ac5548b1..cee5ad05d831 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -160,6 +160,38 @@ describe("remote thread lifecycle commands", () => { ); } + it.effect("re-snoozing to the same wake time restamps the optimistic snoozedAt", () => + Effect.gen(function* () { + const h = yield* makeHarness(); + const source = h.snapshotAtom(ENVIRONMENT_ID); + const initial = { + ...SNAPSHOT, + threads: [ + { + ...SNAPSHOT.threads[0]!, + snoozedUntil: "2099-01-01T00:00:00.000Z", + snoozedAt: "2026-09-01T00:00:00.000Z", + }, + ], + }; + h.registry.set(source, initial); + const result = h.commands.snooze.run(h.registry, { + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + commandId: CommandId.make("snooze-again"), + snoozedUntil: "2099-01-01T00:00:00.000Z", + }, + }); + const preview = h.registry.get(h.visibleAtom)?.threads[0]; + expect(preview?.snoozedUntil).toBe("2099-01-01T00:00:00.000Z"); + expect(preview?.snoozedAt).not.toBe("2026-09-01T00:00:00.000Z"); + const request = yield* Queue.take(h.requests); + yield* Deferred.succeed(request.reply, { sequence: 2 }); + expect((yield* Effect.promise(() => result))._tag).toBe("Success"); + }), + ); + it.effect("keeps the preview after acknowledgement until the matching shell update arrives", () => Effect.gen(function* () { const h = yield* makeHarness(); 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) => ({