authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 16:22:38-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 16:22:38-03:00
log0a4d789128100ed7ccd6d42f454946aea8edfca8
tree856d2617f0e61e467eb1094cfb05772381ba73cd
parent5722496b79079ac317a984f3baca8c88f41af070
signature Signed by PGP key B5690EEEBB952194

fix(server): restart inactivity after snoozed threads wake (#16674)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

6 files changed, 57 insertions(+), 4 deletions(-)

apps/server/src/orchestration-v2/Orchestrator.ts+3-1
...@@ -2831,6 +2831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2831,6 +2831,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2831 ...thread,2831 ...thread,
2832 snoozedUntil: null,2832 snoozedUntil: null,
2833 snoozedAt: null,2833 snoozedAt: null,
2834 lastSnoozeWakeAt: alreadyAwake ? thread.lastSnoozeWakeAt : now,
2834 updatedAt: alreadyAwake ? thread.updatedAt : now,2835 updatedAt: alreadyAwake ? thread.updatedAt : now,
2835 };2836 };
2836 }2837 }
...@@ -2862,6 +2863,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2862,6 +2863,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2862 settledAt: thread.settledOverride === "settled" ? null : thread.settledAt,2863 settledAt: thread.settledOverride === "settled" ? null : thread.settledAt,
2863 snoozedUntil: null,2864 snoozedUntil: null,
2864 snoozedAt: null,2865 snoozedAt: null,
2866 lastSnoozeWakeAt: thread.snoozedUntil == null ? thread.lastSnoozeWakeAt : now,
2865 updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now,2867 updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now,
2866 };2868 };
2867 }2869 }
...@@ -2933,7 +2935,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio...@@ -2933,7 +2935,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2933 thread.snoozedUntil != null &&2935 thread.snoozedUntil != null &&
2934 DateTime.toEpochMillis(thread.snoozedUntil) ===2936 DateTime.toEpochMillis(thread.snoozedUntil) ===
2935 Date.parse(thread.limitRecovery.resetAt)2937 Date.parse(thread.limitRecovery.resetAt)
2936 ? { snoozedUntil: null, snoozedAt: null }2938 ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: now }
2937 : {}),2939 : {}),
2938 ...(command.branch === undefined ? {} : { branch: command.branch }),2940 ...(command.branch === undefined ? {} : { branch: command.branch }),
2939 ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }),2941 ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }),
apps/server/src/orchestration-v2/ProjectionStore.ts+5-1
...@@ -204,7 +204,10 @@ export type ProjectionSettlementCandidate = Pick<...@@ -204,7 +204,10 @@ export type ProjectionSettlementCandidate = Pick<
204 | "activityRunStatus"204 | "activityRunStatus"
205 | "pendingRuntimeRequest"205 | "pendingRuntimeRequest"
206 | "pendingBackgroundTasks"206 | "pendingBackgroundTasks"
207> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null };207> &
208 Pick<OrchestrationV2AppThread, "lastSnoozeWakeAt"> & {
209 readonly latestUserAuthoredMessageAt: DateTime.Utc | null;
210 };
208211
209const ProjectionCheckpointContext = Schema.Struct({212const ProjectionCheckpointContext = Schema.Struct({
210 runs: Schema.Array(213 runs: Schema.Array(
...@@ -6035,6 +6038,7 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(...@@ -6035,6 +6038,7 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
6035 const shell = threadShellFromProjection(projection);6038 const shell = threadShellFromProjection(projection);
6036 return {6039 return {
6037 ...shell,6040 ...shell,
6041 lastSnoozeWakeAt: projection.thread.lastSnoozeWakeAt ?? null,
6038 latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null,6042 latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null,
6039 };6043 };
6040 })6044 })
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+20
...@@ -276,6 +276,26 @@ describe("resolveAutoSettlementAt", () => {...@@ -276,6 +276,26 @@ describe("resolveAutoSettlementAt", () => {
276 ).toBeNull();276 ).toBeNull();
277 });277 });
278278
279 it.each(["manual", "timer"])("restarts inactivity on a %s snooze wake", (wake) => {
280 const thread = {
281 ...shell({ latestRunCompletedAt: at(-4 * DAY_MS) }),
282 ...(wake === "manual"
283 ? { lastSnoozeWakeAt: at(-1_000) }
284 : { snoozedAt: at(-3 * DAY_MS), snoozedUntil: at(-1_000) }),
285 };
286 const input = {
287 thread,
288 pullRequest: null,
289 nowMs: NOW_MS,
290 autoSettleAfterDays: 1,
291 autoSettleOnMerge: false,
292 };
293 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
294 expect(
295 ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: NOW_MS + DAY_MS }),
296 ).toEqual(at(-1_000));
297 });
298
279 it("settles on merge only after the user's last action and preserves the activity time", () => {299 it("settles on merge only after the user's last action and preserves the activity time", () => {
280 const thread = shell({300 const thread = shell({
281 latestUserMessageAt: at(-2 * 60 * 60 * 1_000),301 latestUserMessageAt: at(-2 * 60 * 60 * 1_000),
apps/server/src/orchestration-v2/ThreadSettlementService.ts+8-2
...@@ -237,8 +237,14 @@ export function resolveAutoSettlementAt(input: {...@@ -237,8 +237,14 @@ export function resolveAutoSettlementAt(input: {
237 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);237 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
238 }238 }
239 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;239 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
240 return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS240 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.
241 ? DateTime.makeUnsafe(activityAtMs)241 const snoozedUntilMs = toMillis(thread.snoozedUntil);
242 const timerWakeAtMs =
243 snoozedUntilMs !== null && snoozedUntilMs <= input.nowMs ? snoozedUntilMs : null;
244 const inactivityAtMs =
245 latestMillis([activityAtMs, toMillis(thread.lastSnoozeWakeAt), timerWakeAtMs]) ?? activityAtMs;
246 return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS
247 ? DateTime.makeUnsafe(inactivityAtMs)
242 : null;248 : null;
243}249}
244250
apps/server/src/orchestration-v2/runtimeLayer.test.ts+18
...@@ -4548,6 +4548,24 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {...@@ -4548,6 +4548,24 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
4548 assert.deepEqual(thread.snoozedAt, firstSnoozedAt);4548 assert.deepEqual(thread.snoozedAt, firstSnoozedAt);
4549 assert.deepEqual(thread.updatedAt, firstUpdatedAt);4549 assert.deepEqual(thread.updatedAt, firstUpdatedAt);
45504550
4551 yield* orchestrator.dispatch({
4552 type: "thread.unsnooze",
4553 commandId: CommandId.make("runtime-layer-snoozed-thread-wake"),
4554 threadId,
4555 reason: "user",
4556 });
4557 const projections = yield* ProjectionStore.ProjectionStoreV2.pipe(
4558 Effect.provide(ProjectionStore.layer),
4559 );
4560 const [candidate] = yield* projections.getSettlementCandidates(threadId);
4561 assert.deepEqual(candidate?.lastSnoozeWakeAt, yield* DateTime.now);
4562 yield* orchestrator.dispatch({
4563 type: "thread.snooze",
4564 commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-wake"),
4565 threadId,
4566 snoozedUntil,
4567 });
4568
4551 yield* orchestrator.dispatch({4569 yield* orchestrator.dispatch({
4552 type: "message.dispatch",4570 type: "message.dispatch",
4553 createdBy: "user",4571 createdBy: "user",
packages/contracts/src/orchestrationV2.ts+3
...@@ -404,6 +404,8 @@ export const OrchestrationV2AppThread = Schema.Struct({...@@ -404,6 +404,8 @@ export const OrchestrationV2AppThread = Schema.Struct({
404 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),404 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
405 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),405 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
406 snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),406 snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
407 /** Manual wakes restart inactivity without changing the sidebar's sort position. */
408 lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
407 limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)),409 limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)),
408 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),410 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
409 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),411 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
...@@ -1999,6 +2001,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((...@@ -1999,6 +2001,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((
1999 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),2001 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2000 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),2002 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2001 snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),2003 snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2004 lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2002 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),2005 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2003 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),2006 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2004 lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe(2007 lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe(