Skip to content
Closed
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
14 changes: 8 additions & 6 deletions apps/server/src/orchestration/decider.snoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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);
}
}),
);
Expand Down
16 changes: 6 additions & 10 deletions apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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,
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/client-runtime/src/state/threadCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ export function createThreadEnvironmentAtoms<R, E>(
hasPendingApprovals: false,
hasPendingUserInput: false,
snoozedUntil: input.snoozedUntil,
snoozedAt: thread.snoozedUntil === input.snoozedUntil ? (thread.snoozedAt ?? now) : now,
snoozedAt: now,
},
),
unsnooze: optimistic.wrap(commands.unsnooze, (thread) => ({
Expand Down
Loading