From b63a9a141e4cfaf94fd72e14a5a156a926083725 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Tue, 3 Nov 2026 23:20:54 -0800 Subject: [PATCH 1/2] fix(server): settle merged PRs even while a thread is snoozed Snooze was excluding threads from auto-settlement entirely, so settle-on-merge never fired. Keep inactivity paused until wake; let a merged or closed PR still settle. Co-authored-by: Cursor --- .../ProjectionSettlement.test.ts | 13 +++---- .../ThreadSettlementService.test.ts | 39 +++++++++++-------- .../ThreadSettlementService.ts | 7 +++- docs/user/thread-sidebar.md | 4 +- 4 files changed, 37 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index 86a4c6c77375d..03273799d1edf 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -273,12 +273,11 @@ it.effect( updatedAt: now, }, }); - yield* createRun( - yield* createThread("snoozed", { - snoozedUntil: DateTime.add(now, { days: 1 }), - snoozedAt: now, - }), - ); + const snoozed = yield* createThread("snoozed", { + snoozedUntil: DateTime.add(now, { days: 1 }), + snoozedAt: now, + }); + yield* createRun(snoozed); const woke = yield* createThread("woke", { snoozedUntil: DateTime.add(now, { days: 1 }), snoozedAt: DateTime.subtract(old, { days: 1 }), @@ -324,7 +323,7 @@ it.effect( ); assert.deepEqual( new Set(eligible.map((thread) => thread.id)), - new Set([idle, completed, queued, woke, background, persistent, rolledBack]), + new Set([idle, completed, queued, snoozed, woke, background, persistent, rolledBack]), ); assert.deepEqual( new Set(eligible.map((thread) => thread.id)), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index e5e5f97416d23..ca189097bba05 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -175,12 +175,13 @@ describe("isAutoSettlementCandidate", () => { expect(ThreadSettlementService.isSnoozed(snoozed, NOW_MS)).toBe(false); }); - it("keeps snoozed threads parked until a fresh failure or timer wake", () => { + it("keeps snoozed threads as candidates so merge settlement can still run", () => { const snoozed = shell({ snoozedUntil: at(60 * 60 * 1_000), snoozedAt: at(-60 * 60 * 1_000), }); - expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false); + expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(true); + expect(ThreadSettlementService.isSnoozed(snoozed, NOW_MS)).toBe(true); expect( ThreadSettlementService.isAutoSettlementCandidate( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), @@ -188,29 +189,26 @@ describe("isAutoSettlementCandidate", () => { ), ).toBe(true); expect( - ThreadSettlementService.isAutoSettlementCandidate( + ThreadSettlementService.isSnoozed( shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-2 * 60 * 60 * 1_000) }), NOW_MS, ), - ).toBe(false); + ).toBe(true); expect( - ThreadSettlementService.isAutoSettlementCandidate( + ThreadSettlementService.isSnoozed( shell({ ...snoozed, status: "failed", latestRunCompletedAt: snoozed.snoozedAt }), NOW_MS, ), - ).toBe(false); + ).toBe(true); expect( - ThreadSettlementService.isAutoSettlementCandidate( + ThreadSettlementService.isSnoozed( shell({ ...snoozed, status: "failed", latestRunCompletedAt: null }), NOW_MS, ), - ).toBe(false); - expect( - ThreadSettlementService.isAutoSettlementCandidate( - shell({ ...snoozed, snoozedUntil: at(-1) }), - NOW_MS, - ), ).toBe(true); + expect( + ThreadSettlementService.isSnoozed(shell({ ...snoozed, snoozedUntil: at(-1) }), NOW_MS), + ).toBe(false); }); }); @@ -2276,7 +2274,7 @@ describe("isSnoozed", () => { ), ).toBe(false); }); - it("keeps a run requested and completed after snoozing parked until the timer", () => { + it("settles a merged PR while snoozed, but not from inactivity alone", () => { const thread = shell({ ...snoozed, status: "completed", @@ -2286,7 +2284,16 @@ describe("isSnoozed", () => { }); expect(ThreadSettlementService.isSnoozed(thread, NOW_MS)).toBe(true); - expect(ThreadSettlementService.isAutoSettlementCandidate(thread, NOW_MS)).toBe(false); + expect(ThreadSettlementService.isAutoSettlementCandidate(thread, NOW_MS)).toBe(true); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread, + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 1, + autoSettleOnMerge: false, + }), + ).toBeNull(); expect( ThreadSettlementService.resolveAutoSettlementAt({ thread, @@ -2295,6 +2302,6 @@ describe("isSnoozed", () => { autoSettleAfterDays: null, autoSettleOnMerge: true, }), - ).toBeNull(); + ).toEqual(at(-DAY_MS)); }); }); diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 512648e6657d2..ffd575b4dec9b 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -173,8 +173,9 @@ export function isAutoSettlementCandidate( // Settling ends a watch, which would silently drop a reviewer's later comments. if (pendingTasks.some(isPullRequestWatchTask)) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; - // Interrupted runs must keep the same snooze protection the sidebar shows. - return !isSnoozed(thread, nowMs); + // Snoozed threads stay candidates so a merged or closed PR can still settle them. + // Inactivity waits for the wake inside resolveAutoSettlementAt. + return true; } /** Match client effectiveSnoozed so agents and sidebar agree. */ @@ -245,6 +246,8 @@ export function resolveAutoSettlementAt(input: { if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { return DateTime.makeUnsafe(activityAtMs); } + // Hide-until-later outranks inactivity: the wake restarts that clock. + if (isSnoozed(thread, input.nowMs)) return null; const hasPullRequest = hasOpenLink || pullRequest !== null || diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 4c02f1c79baee..3579a310e0e9b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -190,7 +190,9 @@ By default, environments settle inactive threads after seven days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. An empty thread's inactivity counts from when it was created. Work in progress, pending questions or approvals, and background work the agent -is waiting on prevent automatic settlement. An open pull request does not prevent inactivity +is waiting on prevent automatic settlement. A snoozed thread does not settle from +inactivity until it wakes, and waking restarts that inactivity window; a merged or +closed pull request can still settle it while it is snoozed. An open pull request does not prevent inactivity settlement unless you turn on **Keep threads with a PR** in **Settings → Threads**, but an old closed or merged pull request does not settle work you resumed after it closed. Only your own messages count as resuming. A turn that From 100eef241951d2e8d24110a2a7c659690eb5ffa5 Mon Sep 17 00:00:00 2001 From: wSedlacek Date: Thu, 12 Nov 2026 04:18:07 -0800 Subject: [PATCH 2/2] fix(server): keep snoozed subagents from auto-settling Co-authored-by: Cursor --- .../src/orchestration-v2/ThreadSettlementService.test.ts | 4 ++++ apps/server/src/orchestration-v2/ThreadSettlementService.ts | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index ca189097bba05..84d0a24bb96aa 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -1012,6 +1012,10 @@ describe("ThreadSettlementServiceV2 worker", () => { marker, child("pinned", { pinnedAt: DateTime.makeUnsafe(NOW) }), child("opted-out", { autoSettleDisabledAt: DateTime.makeUnsafe(NOW) }), + child("snoozed", { + snoozedAt: DateTime.makeUnsafe(NOW), + snoozedUntil: DateTime.makeUnsafe(Date.parse(NOW) + DAY_MS), + }), ]) { yield* store.apply({ type: "thread.created", diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index ffd575b4dec9b..db0bde27c62a6 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -363,10 +363,12 @@ export const make = Effect.gen(function* () { if (nowMs - readAtMs >= QUIET_SYNC_INTERVAL_MS) quietOpenReadAt.delete(key); } // Merge events resweep all candidates because unlinked threads settle through branch lookup. + // Subagents settle without resolveAutoSettlementAt, so keep the prior snooze park for them. const candidates = threads.filter( (thread) => (automaticSettlementEnabled || thread.lineage.relationshipToParent === "subagent") && - isAutoSettlementCandidate(thread, nowMs), + isAutoSettlementCandidate(thread, nowMs) && + (thread.lineage.relationshipToParent !== "subagent" || !isSnoozed(thread, nowMs)), ); // Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span.