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
10 changes: 6 additions & 4 deletions apps/server/src/orchestration/decider.snoozed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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);
}
}),
);
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;
// 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",
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
32 changes: 32 additions & 0 deletions packages/client-runtime/src/state/threadCommands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
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