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
28312831 ...thread,
28322832 snoozedUntil: null,
28332833 snoozedAt: null,
2834 lastSnoozeWakeAt: alreadyAwake ? thread.lastSnoozeWakeAt : now,
28342835 updatedAt: alreadyAwake ? thread.updatedAt : now,
28352836 };
28362837 }
......@@ -2862,6 +2863,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
28622863 settledAt: thread.settledOverride === "settled" ? null : thread.settledAt,
28632864 snoozedUntil: null,
28642865 snoozedAt: null,
2866 lastSnoozeWakeAt: thread.snoozedUntil == null ? thread.lastSnoozeWakeAt : now,
28652867 updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now,
28662868 };
28672869 }
......@@ -2933,7 +2935,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
29332935 thread.snoozedUntil != null &&
29342936 DateTime.toEpochMillis(thread.snoozedUntil) ===
29352937 Date.parse(thread.limitRecovery.resetAt)
2936 ? { snoozedUntil: null, snoozedAt: null }
2938 ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: now }
29372939 : {}),
29382940 ...(command.branch === undefined ? {} : { branch: command.branch }),
29392941 ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }),
apps/server/src/orchestration-v2/ProjectionStore.ts+5-1
......@@ -204,7 +204,10 @@ export type ProjectionSettlementCandidate = Pick<
204204 | "activityRunStatus"
205205 | "pendingRuntimeRequest"
206206 | "pendingBackgroundTasks"
207> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null };
207> &
208 Pick<OrchestrationV2AppThread, "lastSnoozeWakeAt"> & {
209 readonly latestUserAuthoredMessageAt: DateTime.Utc | null;
210 };
208211
209212const ProjectionCheckpointContext = Schema.Struct({
210213 runs: Schema.Array(
......@@ -6035,6 +6038,7 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
60356038 const shell = threadShellFromProjection(projection);
60366039 return {
60376040 ...shell,
6041 lastSnoozeWakeAt: projection.thread.lastSnoozeWakeAt ?? null,
60386042 latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null,
60396043 };
60406044 })
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+20
......@@ -276,6 +276,26 @@ describe("resolveAutoSettlementAt", () => {
276276 ).toBeNull();
277277 });
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
279299 it("settles on merge only after the user's last action and preserves the activity time", () => {
280300 const thread = shell({
281301 latestUserMessageAt: at(-2 * 60 * 60 * 1_000),
apps/server/src/orchestration-v2/ThreadSettlementService.ts+8-2
......@@ -237,8 +237,14 @@ export function resolveAutoSettlementAt(input: {
237237 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
238238 }
239239 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
240 return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS
241 ? DateTime.makeUnsafe(activityAtMs)
240 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.
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)
242248 : null;
243249}
244250
apps/server/src/orchestration-v2/runtimeLayer.test.ts+18
......@@ -4548,6 +4548,24 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => {
45484548 assert.deepEqual(thread.snoozedAt, firstSnoozedAt);
45494549 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
45514569 yield* orchestrator.dispatch({
45524570 type: "message.dispatch",
45534571 createdBy: "user",
packages/contracts/src/orchestrationV2.ts+3
......@@ -404,6 +404,8 @@ export const OrchestrationV2AppThread = Schema.Struct({
404404 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
405405 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
406406 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)),
407409 limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)),
408410 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
409411 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)),
......@@ -1999,6 +2001,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields((
19992001 unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
20002002 snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
20012003 snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
2004 lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
20022005 pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
20032006 autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)),
20042007 lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe(