From 1c443f6c4cbc2f0392b4b5a2b4f7879838b8b681 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:44:59 +0000 Subject: [PATCH 1/4] fix(server): restart inactivity after snoozed threads wake --- .../src/orchestration-v2/Orchestrator.ts | 3 +- .../ProjectionSettlement.test.ts | 29 +++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 7 +++- .../ThreadSettlementService.test.ts | 42 +++++++++++++++++++ .../ThreadSettlementService.ts | 20 ++++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 38 +++++++++++++++++ packages/contracts/src/orchestrationV2.ts | 3 ++ 7 files changed, 138 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..b410e0b61810 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2808,6 +2808,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...thread, snoozedUntil: null, snoozedAt: null, + lastSnoozeWakeAt: alreadyAwake ? thread.lastSnoozeWakeAt : now, updatedAt: alreadyAwake ? thread.updatedAt : now, }; } @@ -2910,7 +2911,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) === Date.parse(thread.limitRecovery.resetAt) - ? { snoozedUntil: null, snoozedAt: null } + ? { snoozedUntil: null, snoozedAt: null, lastSnoozeWakeAt: now } : {}), ...(command.branch === undefined ? {} : { branch: command.branch }), ...(command.worktreePath === undefined ? {} : { worktreePath: command.worktreePath }), diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index f626d0c38583..56650d42b6de 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -140,6 +140,35 @@ const createItem = Effect.fn(function* ( }); }); +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)("%s: manual wakes restart the persisted inactivity clock", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("manually-woken"); + yield* createRun(threadId); + const before = (yield* store.getSettlementCandidates(threadId))[0]!; + const settings = { + pullRequest: null, + nowMs: DateTime.toEpochMillis(now), + autoSettleAfterDays: 1, + autoSettleOnMerge: false, + }; + assert.deepEqual(resolveAutoSettlementAt({ ...settings, thread: before }), old); + yield* store.apply({ + id: EventId.make("event:settlement:manual-wake"), + type: "thread.unsnoozed", + threadId, + occurredAt: now, + payload: { ...(yield* store.getThread(threadId)), lastSnoozeWakeAt: now }, + }); + const after = (yield* store.getSettlementCandidates(threadId))[0]!; + assert.deepEqual(after.lastSnoozeWakeAt, now); + assert.isNull(resolveAutoSettlementAt({ ...settings, thread: after })); + }).pipe(Effect.provide(testLayer)), +); + it.effect.each([ ["sql", layerSql], ["memory", ProjectionStore.layerMemory], diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec75937a9a62..9c51b83830e3 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -191,7 +191,10 @@ export type ProjectionSettlementCandidate = Pick< | "activityRunStatus" | "pendingRuntimeRequest" | "pendingBackgroundTasks" -> & { readonly latestUserAuthoredMessageAt: DateTime.Utc | null }; +> & + Pick & { + readonly latestUserAuthoredMessageAt: DateTime.Utc | null; + }; const ProjectionCheckpointContext = Schema.Struct({ runs: Schema.Array( @@ -5289,6 +5292,7 @@ export const layer: Layer.Layer = autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, + lastSnoozeWakeAt: thread.lastSnoozeWakeAt ?? null, status, latestRunId, latestRunRequestedAt: @@ -5802,6 +5806,7 @@ export const layerMemory: Layer.Layer = Layer.effect( const shell = threadShellFromProjection(projection); return { ...shell, + lastSnoozeWakeAt: projection.thread.lastSnoozeWakeAt ?? null, latestUserAuthoredMessageAt: shell.latestUserAuthoredMessageAt ?? null, }; }) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index a7dd2a89c0bb..bb79b2978979 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -275,6 +275,42 @@ describe("resolveAutoSettlementAt", () => { ).toBeNull(); }); + it.each(["manual", "timer"])("restarts inactivity on a %s snooze wake", (wake) => { + const thread = { + ...shell({ latestRunCompletedAt: at(-4 * DAY_MS) }), + ...(wake === "manual" + ? { lastSnoozeWakeAt: at(-1_000) } + : { snoozedAt: at(-3 * DAY_MS), snoozedUntil: at(-1_000) }), + }; + const input = { + thread, + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 1, + autoSettleOnMerge: false, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: NOW_MS + DAY_MS }), + ).toEqual(at(-1_000)); + }); + + it("does not restart inactivity at the timer after work woke the thread early", () => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ + snoozedAt: at(-5 * DAY_MS), + latestRunCompletedAt: at(-4 * DAY_MS), + snoozedUntil: at(-1_000), + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 1, + autoSettleOnMerge: false, + }), + ).toEqual(at(-4 * DAY_MS)); + }); + it("settles on merge only after the user's last action and preserves the activity time", () => { const thread = shell({ latestUserMessageAt: at(-2 * 60 * 60 * 1_000), @@ -291,6 +327,12 @@ describe("resolveAutoSettlementAt", () => { autoSettleOnMerge: true, }; expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + thread: { ...thread, lastSnoozeWakeAt: at(-1_000) }, + }), + ).toEqual(at(-90 * 60 * 1_000)); expect( ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false }), ).toBeNull(); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 7c64ce02804a..2c8d8b67dc55 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -203,8 +203,24 @@ export function resolveAutoSettlementAt(input: { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } if (input.autoSettleAfterDays === null || activityAtMs === null) return null; - return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS - ? DateTime.makeUnsafe(activityAtMs) + const snoozedUntilMs = toMillis(thread.snoozedUntil); + const snoozedAtMs = toMillis(thread.snoozedAt); + const completedAtMs = toMillis(thread.latestRunCompletedAt); + // Timer wakes are derived. Work that completed after snoozing woke early, + // so its original timer must not restart inactivity a second time. + const timerWakeAtMs = + snoozedUntilMs !== null && + snoozedUntilMs <= input.nowMs && + (snoozedAtMs === null || completedAtMs === null || completedAtMs <= snoozedAtMs) + ? snoozedUntilMs + : null; + const inactivityAtMs = Math.max( + activityAtMs, + toMillis(thread.lastSnoozeWakeAt) ?? activityAtMs, + timerWakeAtMs ?? activityAtMs, + ); + return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS + ? DateTime.makeUnsafe(inactivityAtMs) : null; } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 131f05034051..9a8d3c1a058c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4473,6 +4473,40 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { assert.deepEqual(thread.snoozedAt, firstSnoozedAt); assert.deepEqual(thread.updatedAt, firstUpdatedAt); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-wake"), + threadId, + reason: "user", + }); + const manuallyAwakened = yield* orchestrator.getThreadProjection(threadId); + const wakeAt = manuallyAwakened.thread.lastSnoozeWakeAt; + assert.isNotNull(wakeAt); + assert.isNull(manuallyAwakened.thread.snoozedUntil); + assert.deepEqual(manuallyAwakened.thread.unsettledAt, firstProjection.thread.unsettledAt); + const projections = yield* ProjectionStore.ProjectionStoreV2.pipe( + Effect.provide(ProjectionStore.layer), + ); + const candidate = (yield* projections.getSettlementCandidates(threadId))[0]; + assert.deepEqual(candidate?.lastSnoozeWakeAt, wakeAt); + yield* TestClock.adjust("10 seconds"); + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-wake-again"), + threadId, + reason: "user", + }); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.lastSnoozeWakeAt, + wakeAt, + ); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-wake"), + threadId, + snoozedUntil, + }); + yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -5055,6 +5089,10 @@ it.layer(layerTest)("usage-limit recovery", (it) => { } else { assert.isNull(armedShell.snoozedUntil); assert.isNull(armedShell.snoozedAt); + assert.deepEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.lastSnoozeWakeAt, + yield* DateTime.now, + ); assert.isTrue(armedShell.limitRecovery!.autoResume); } } diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..2b78bb13fefe 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -404,6 +404,8 @@ export const OrchestrationV2AppThread = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** Manual wakes restart inactivity without changing the sidebar's sort position. */ + lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -1996,6 +1998,7 @@ export const OrchestrationV2AppThreadJson = OrchestrationV2AppThread.mapFields(( unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), + lastSnoozeWakeAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtcFromString)), lastVisitedAt: Schema.NullOr(Schema.DateTimeUtcFromString).pipe( From c3a932a5e5f85aba3dba1316acd4bc77d0a4bd92 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Wed, 7 Oct 2026 02:00:54 +0000 Subject: [PATCH 2/4] fix(server): record snooze wake when pinning a thread --- .../src/orchestration-v2/Orchestrator.ts | 1 + .../src/orchestration-v2/runtimeLayer.test.ts | 38 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b410e0b61810..0334f71fc0f8 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2840,6 +2840,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio settledAt: thread.settledOverride === "settled" ? null : thread.settledAt, snoozedUntil: null, snoozedAt: null, + lastSnoozeWakeAt: thread.snoozedUntil == null ? thread.lastSnoozeWakeAt : now, updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now, }; } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9a8d3c1a058c..f2701b1d525e 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4507,6 +4507,44 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { snoozedUntil, }); + yield* orchestrator.dispatch({ + type: "thread.pin", + commandId: CommandId.make("runtime-layer-snoozed-thread-pin-wake"), + threadId, + orderKey: "a0", + }); + const pinAwakened = yield* orchestrator.getThreadProjection(threadId); + const pinWakeAt = yield* DateTime.now; + assert.deepEqual(pinAwakened.thread.lastSnoozeWakeAt, pinWakeAt); + assert.isNull(pinAwakened.thread.snoozedUntil); + assert.isNull(pinAwakened.thread.snoozedAt); + assert.deepEqual(pinAwakened.thread.unsettledAt, firstProjection.thread.unsettledAt); + yield* TestClock.adjust("10 seconds"); + yield* orchestrator.dispatch({ + type: "thread.pin", + commandId: CommandId.make("runtime-layer-snoozed-thread-pin-again"), + threadId, + orderKey: "a1", + }); + const pinnedAgain = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual(pinnedAgain.thread.lastSnoozeWakeAt, pinWakeAt); + assert.equal(pinnedAgain.thread.pinOrderKey, "a0"); + yield* orchestrator.dispatch({ + type: "thread.unpin", + commandId: CommandId.make("runtime-layer-snoozed-thread-unpin-after-wake"), + threadId, + }); + assert.deepEqual( + (yield* projections.getSettlementCandidates(threadId))[0]?.lastSnoozeWakeAt, + pinWakeAt, + ); + yield* orchestrator.dispatch({ + type: "thread.snooze", + commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-pin"), + threadId, + snoozedUntil, + }); + yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", From 9a19121c82b24d78776514479cf90cf362347d67 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:17:09 +0000 Subject: [PATCH 3/4] refactor(server): trim snooze wake inactivity fix and tests --- .../ProjectionSettlement.test.ts | 29 --------- .../src/orchestration-v2/ProjectionStore.ts | 1 - .../ThreadSettlementService.test.ts | 22 ------- .../ThreadSettlementService.ts | 10 +-- .../src/orchestration-v2/runtimeLayer.test.ts | 62 +------------------ 5 files changed, 5 insertions(+), 119 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 56650d42b6de..f626d0c38583 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -140,35 +140,6 @@ const createItem = Effect.fn(function* ( }); }); -it.effect.each([ - ["sql", layerSql], - ["memory", ProjectionStore.layerMemory], -] as const)("%s: manual wakes restart the persisted inactivity clock", ([, testLayer]) => - Effect.gen(function* () { - const store = yield* ProjectionStore.ProjectionStoreV2; - const threadId = yield* createThread("manually-woken"); - yield* createRun(threadId); - const before = (yield* store.getSettlementCandidates(threadId))[0]!; - const settings = { - pullRequest: null, - nowMs: DateTime.toEpochMillis(now), - autoSettleAfterDays: 1, - autoSettleOnMerge: false, - }; - assert.deepEqual(resolveAutoSettlementAt({ ...settings, thread: before }), old); - yield* store.apply({ - id: EventId.make("event:settlement:manual-wake"), - type: "thread.unsnoozed", - threadId, - occurredAt: now, - payload: { ...(yield* store.getThread(threadId)), lastSnoozeWakeAt: now }, - }); - const after = (yield* store.getSettlementCandidates(threadId))[0]!; - assert.deepEqual(after.lastSnoozeWakeAt, now); - assert.isNull(resolveAutoSettlementAt({ ...settings, thread: after })); - }).pipe(Effect.provide(testLayer)), -); - it.effect.each([ ["sql", layerSql], ["memory", ProjectionStore.layerMemory], diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 299d3fa35ce0..8c33faef808b 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -5497,7 +5497,6 @@ export const layer: Layer.Layer = autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, - lastSnoozeWakeAt: thread.lastSnoozeWakeAt ?? null, status, latestRunId, latestRunRequestedAt: diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 92dedac04778..00daff5a8c87 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -296,22 +296,6 @@ describe("resolveAutoSettlementAt", () => { ).toEqual(at(-1_000)); }); - it("does not restart inactivity at the timer after work woke the thread early", () => { - expect( - ThreadSettlementService.resolveAutoSettlementAt({ - thread: shell({ - snoozedAt: at(-5 * DAY_MS), - latestRunCompletedAt: at(-4 * DAY_MS), - snoozedUntil: at(-1_000), - }), - pullRequest: null, - nowMs: NOW_MS, - autoSettleAfterDays: 1, - autoSettleOnMerge: false, - }), - ).toEqual(at(-4 * DAY_MS)); - }); - it("settles on merge only after the user's last action and preserves the activity time", () => { const thread = shell({ latestUserMessageAt: at(-2 * 60 * 60 * 1_000), @@ -328,12 +312,6 @@ describe("resolveAutoSettlementAt", () => { autoSettleOnMerge: true, }; expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toEqual(at(-90 * 60 * 1_000)); - expect( - ThreadSettlementService.resolveAutoSettlementAt({ - ...input, - thread: { ...thread, lastSnoozeWakeAt: at(-1_000) }, - }), - ).toEqual(at(-90 * 60 * 1_000)); expect( ThreadSettlementService.resolveAutoSettlementAt({ ...input, autoSettleOnMerge: false }), ).toBeNull(); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 920666df16e3..263b3e46e51f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -240,19 +240,15 @@ export function resolveAutoSettlementAt(input: { const snoozedUntilMs = toMillis(thread.snoozedUntil); const snoozedAtMs = toMillis(thread.snoozedAt); const completedAtMs = toMillis(thread.latestRunCompletedAt); - // Timer wakes are derived. Work that completed after snoozing woke early, - // so its original timer must not restart inactivity a second time. + // A passed wake timer restarts inactivity, unless completed work woke the thread first. const timerWakeAtMs = snoozedUntilMs !== null && snoozedUntilMs <= input.nowMs && (snoozedAtMs === null || completedAtMs === null || completedAtMs <= snoozedAtMs) ? snoozedUntilMs : null; - const inactivityAtMs = Math.max( - activityAtMs, - toMillis(thread.lastSnoozeWakeAt) ?? activityAtMs, - timerWakeAtMs ?? activityAtMs, - ); + const inactivityAtMs = + latestMillis([activityAtMs, toMillis(thread.lastSnoozeWakeAt), timerWakeAtMs]) ?? activityAtMs; return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS ? DateTime.makeUnsafe(inactivityAtMs) : null; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4a32059a6d91..42a306f9e43b 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4550,27 +4550,11 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { threadId, reason: "user", }); - const manuallyAwakened = yield* orchestrator.getThreadProjection(threadId); - const wakeAt = manuallyAwakened.thread.lastSnoozeWakeAt; - assert.isNotNull(wakeAt); - assert.isNull(manuallyAwakened.thread.snoozedUntil); - assert.deepEqual(manuallyAwakened.thread.unsettledAt, firstProjection.thread.unsettledAt); const projections = yield* ProjectionStore.ProjectionStoreV2.pipe( Effect.provide(ProjectionStore.layer), ); - const candidate = (yield* projections.getSettlementCandidates(threadId))[0]; - assert.deepEqual(candidate?.lastSnoozeWakeAt, wakeAt); - yield* TestClock.adjust("10 seconds"); - yield* orchestrator.dispatch({ - type: "thread.unsnooze", - commandId: CommandId.make("runtime-layer-snoozed-thread-wake-again"), - threadId, - reason: "user", - }); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.lastSnoozeWakeAt, - wakeAt, - ); + const [candidate] = yield* projections.getSettlementCandidates(threadId); + assert.deepEqual(candidate?.lastSnoozeWakeAt, yield* DateTime.now); yield* orchestrator.dispatch({ type: "thread.snooze", commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-wake"), @@ -4578,44 +4562,6 @@ it.layer(layerSharedApplicationDataPlaneTest)("snooze projection", (it) => { snoozedUntil, }); - yield* orchestrator.dispatch({ - type: "thread.pin", - commandId: CommandId.make("runtime-layer-snoozed-thread-pin-wake"), - threadId, - orderKey: "a0", - }); - const pinAwakened = yield* orchestrator.getThreadProjection(threadId); - const pinWakeAt = yield* DateTime.now; - assert.deepEqual(pinAwakened.thread.lastSnoozeWakeAt, pinWakeAt); - assert.isNull(pinAwakened.thread.snoozedUntil); - assert.isNull(pinAwakened.thread.snoozedAt); - assert.deepEqual(pinAwakened.thread.unsettledAt, firstProjection.thread.unsettledAt); - yield* TestClock.adjust("10 seconds"); - yield* orchestrator.dispatch({ - type: "thread.pin", - commandId: CommandId.make("runtime-layer-snoozed-thread-pin-again"), - threadId, - orderKey: "a1", - }); - const pinnedAgain = yield* orchestrator.getThreadProjection(threadId); - assert.deepEqual(pinnedAgain.thread.lastSnoozeWakeAt, pinWakeAt); - assert.equal(pinnedAgain.thread.pinOrderKey, "a0"); - yield* orchestrator.dispatch({ - type: "thread.unpin", - commandId: CommandId.make("runtime-layer-snoozed-thread-unpin-after-wake"), - threadId, - }); - assert.deepEqual( - (yield* projections.getSettlementCandidates(threadId))[0]?.lastSnoozeWakeAt, - pinWakeAt, - ); - yield* orchestrator.dispatch({ - type: "thread.snooze", - commandId: CommandId.make("runtime-layer-snoozed-thread-snooze-after-pin"), - threadId, - snoozedUntil, - }); - yield* orchestrator.dispatch({ type: "message.dispatch", createdBy: "user", @@ -5198,10 +5144,6 @@ it.layer(layerTest)("usage-limit recovery", (it) => { } else { assert.isNull(armedShell.snoozedUntil); assert.isNull(armedShell.snoozedAt); - assert.deepEqual( - (yield* orchestrator.getThreadProjection(threadId)).thread.lastSnoozeWakeAt, - yield* DateTime.now, - ); assert.isTrue(armedShell.limitRecovery!.autoResume); } } From af91f3b3f624c0b33b55dd7bd024dd980e7ebc20 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:54:58 +0000 Subject: [PATCH 4/4] fix(server): keep the snooze timer anchor after interrupted runs --- .../src/orchestration-v2/ThreadSettlementService.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 263b3e46e51f..8b55e25cdc39 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -237,16 +237,10 @@ export function resolveAutoSettlementAt(input: { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } if (input.autoSettleAfterDays === null || activityAtMs === null) return null; + // A passed wake timer restarts inactivity. An earlier wake can only delay settlement. const snoozedUntilMs = toMillis(thread.snoozedUntil); - const snoozedAtMs = toMillis(thread.snoozedAt); - const completedAtMs = toMillis(thread.latestRunCompletedAt); - // A passed wake timer restarts inactivity, unless completed work woke the thread first. const timerWakeAtMs = - snoozedUntilMs !== null && - snoozedUntilMs <= input.nowMs && - (snoozedAtMs === null || completedAtMs === null || completedAtMs <= snoozedAtMs) - ? snoozedUntilMs - : null; + snoozedUntilMs !== null && snoozedUntilMs <= input.nowMs ? snoozedUntilMs : null; const inactivityAtMs = latestMillis([activityAtMs, toMillis(thread.lastSnoozeWakeAt), timerWakeAtMs]) ?? activityAtMs; return inactivityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS