authorgravatar for 79945749+0bkevin@users.noreply.github.comKevin Bravo <79945749+0bkevin@users.noreply.github.com> 2026-08-15 08:27:23-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 14:27:23+02:00
log07e668dc48ce58fc9244ceeef62b9af929293a2a
tree118cd5b1995f83caa9fd9578e959e85e704a0771
parent4fc80fcbdc1ed33c46296dbad08517f2968856e6
signature Signed by PGP key B5690EEEBB952194

fix(server): settle snoozed threads immediately (#5379)


2 files changed, 102 insertions(+), 13 deletions(-)

apps/server/src/orchestration/decider.settled.test.ts+78-1
......@@ -24,6 +24,11 @@ function makeReadModel(
2424 session: OrchestrationSession | null = null,
2525 activities: OrchestrationThread["activities"] = [],
2626 messages: OrchestrationThread["messages"] = [],
27 lifecycle: {
28 readonly pinnedAt?: string | null;
29 readonly snoozedUntil?: string | null;
30 readonly snoozedAt?: string | null;
31 } = {},
2732): OrchestrationReadModel {
2833 return {
2934 snapshotSequence: 0,
......@@ -44,6 +49,9 @@ function makeReadModel(
4449 archivedAt,
4550 settledOverride,
4651 settledAt: settledOverride === "settled" ? SETTLED_AT : null,
52 snoozedUntil: lifecycle.snoozedUntil ?? null,
53 snoozedAt: lifecycle.snoozedAt ?? (lifecycle.snoozedUntil != null ? SETTLED_AT : null),
54 pinnedAt: lifecycle.pinnedAt ?? null,
4755 deletedAt: null,
4856 messages,
4957 proposedPlans: [],
......@@ -69,7 +77,7 @@ function makeSession(status: OrchestrationSession["status"]): OrchestrationSessi
6977}
7078
7179it.layer(NodeServices.layer)("settled thread decider", (it) => {
72 it.effect("settles active threads and re-emits idempotently for settled ones", () =>
80 it.effect("settles awake threads without a redundant wake and re-emits idempotently", () =>
7381 Effect.gen(function* () {
7482 const event = yield* decideOrchestrationCommand({
7583 command: {
......@@ -108,6 +116,75 @@ it.layer(NodeServices.layer)("settled thread decider", (it) => {
108116 }),
109117 );
110118
119 it.effect("settling a snoozed thread also wakes it", () =>
120 Effect.gen(function* () {
121 const result = yield* decideOrchestrationCommand({
122 command: {
123 type: "thread.settle",
124 commandId: CommandId.make("cmd-settle-snoozed"),
125 threadId: ThreadId.make("thread-1"),
126 },
127 readModel: makeReadModel(null, null, null, [], [], {
128 snoozedUntil: "1970-01-02T09:00:00.000Z",
129 }),
130 });
131 const events = Array.isArray(result) ? result : [result];
132 expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);
133 const settled = events.find((entry) => entry.type === "thread.settled");
134 const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");
135 if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {
136 expect(unsnoozed.payload.reason).toBe("user");
137 expect(unsnoozed.payload.updatedAt).toBe(settled.payload.updatedAt);
138 }
139 }),
140 );
141
142 it.effect("repeated settle repairs legacy settled and snoozed state", () =>
143 Effect.gen(function* () {
144 const result = yield* decideOrchestrationCommand({
145 command: {
146 type: "thread.settle",
147 commandId: CommandId.make("cmd-settle-snoozed-again"),
148 threadId: ThreadId.make("thread-1"),
149 },
150 readModel: makeReadModel("settled", null, null, [], [], {
151 snoozedUntil: "1970-01-02T09:00:00.000Z",
152 }),
153 });
154 const events = Array.isArray(result) ? result : [result];
155 expect(events.map((entry) => entry.type)).toEqual(["thread.settled", "thread.unsnoozed"]);
156 const settled = events.find((entry) => entry.type === "thread.settled");
157 const unsnoozed = events.find((entry) => entry.type === "thread.unsnoozed");
158 if (settled?.type === "thread.settled" && unsnoozed?.type === "thread.unsnoozed") {
159 expect(settled.payload.settledAt).toBe(SETTLED_AT);
160 expect(settled.payload.updatedAt).toBe(NOW);
161 expect(unsnoozed.payload.updatedAt).not.toBe(NOW);
162 }
163 }),
164 );
165
166 it.effect("settling a pinned and snoozed thread clears the pin and snooze", () =>
167 Effect.gen(function* () {
168 const result = yield* decideOrchestrationCommand({
169 command: {
170 type: "thread.settle",
171 commandId: CommandId.make("cmd-settle-pinned-snoozed"),
172 threadId: ThreadId.make("thread-1"),
173 },
174 readModel: makeReadModel(null, null, null, [], [], {
175 pinnedAt: SETTLED_AT,
176 snoozedUntil: "1970-01-02T09:00:00.000Z",
177 }),
178 });
179 const events = Array.isArray(result) ? result : [result];
180 expect(events.map((entry) => entry.type)).toEqual([
181 "thread.settled",
182 "thread.unpinned",
183 "thread.unsnoozed",
184 ]);
185 }),
186 );
187
111188 it.effect("rejects settling a thread with a live session", () =>
112189 Effect.gen(function* () {
113190 for (const status of ["starting", "running"] as const) {
apps/server/src/orchestration/decider.ts+24-12
......@@ -509,16 +509,11 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
509509 updatedAt: alreadySettled ? thread.updatedAt : occurredAt,
510510 },
511511 };
512 // Settling is "I'm done with this": it clears a pin the same way it
513 // parks the thread. Without this, settling a pinned thread would only
514 // stamp invisible state — the pin would hold the card in place until
515 // a separate unpin.
516 if (thread.pinnedAt == null) {
517 return settledEvent;
518 }
519 return [
520 settledEvent,
521 {
512 // Settling is "I'm done with this": clear states that would keep the
513 // row pinned or snoozed instead of showing the new settled state.
514 const companionEvents: Array<Omit<OrchestrationEvent, "sequence">> = [];
515 if (thread.pinnedAt != null) {
516 companionEvents.push({
522517 ...(yield* withEventBase({
523518 aggregateKind: "thread",
524519 aggregateId: command.threadId,
......@@ -530,8 +525,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
530525 threadId: command.threadId,
531526 updatedAt: occurredAt,
532527 },
533 },
534 ];
528 });
529 }
530 if (thread.snoozedUntil != null) {
531 companionEvents.push({
532 ...(yield* withEventBase({
533 aggregateKind: "thread",
534 aggregateId: command.threadId,
535 occurredAt,
536 commandId: command.commandId,
537 })),
538 type: "thread.unsnoozed",
539 payload: {
540 threadId: command.threadId,
541 reason: "user",
542 updatedAt: occurredAt,
543 },
544 });
545 }
546 return companionEvents.length > 0 ? [settledEvent, ...companionEvents] : settledEvent;
535547 }
536548
537549 case "thread.unsettle": {