From bf8ffe81a63c82d3de5fbaa9991fd5f30034a452 Mon Sep 17 00:00:00 2001 From: Tristan Knight Date: Wed, 14 Oct 2026 19:14:52 +0100 Subject: [PATCH] feat(settings): inactivity auto-settle can keep threads with a PR (#14642) Co-authored-by: maria-rcks --- .../src/environment/ServerEnvironment.ts | 1 + .../ThreadSettlementService.test.ts | 32 +++++++++++++++++++ .../ThreadSettlementService.ts | 11 ++++++- .../components/settings/SettingsPanels.tsx | 27 ++++++++++++++++ docs/user/thread-sidebar.md | 3 +- packages/contracts/src/environment.ts | 2 ++ packages/contracts/src/settings.ts | 6 ++++ 7 files changed, 80 insertions(+), 2 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index b2667a9edd3fd2940b2265f8be80d2efb0d88049..27141c636fe0c88e8f982fd130bec4e8588d9cd7 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -232,6 +232,7 @@ export const make = Effect.gen(function* () { otlpEndpointCheck: true, projectWorktreeCleanup: true, worktreesDirectory: true, + threadAutoSettlementScope: true, threadRestartContinuation: true, projectSettingsOverrides: true, projectProviderInstanceOverrides: true, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index ccf74f7d193e6dead28956571e9608ad4982070e..7016a17dcd8922e4a8a4f59f8c2e737dbb2c3840 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -251,6 +251,38 @@ describe("threadHasQueuedTurnStart", () => { }); describe("resolveAutoSettlementAt", () => { + it("keeps threads with a PR from inactivity settlement only when asked", () => { + const input = { + thread: shell({ + latestUserMessageAt: at(-4 * DAY_MS), + linkedPullRequest: { + projectId: ProjectId.make("project-1"), + repository: "owner/repo", + number: 1, + url: "https://example.test/owner/repo/pull/1", + }, + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + autoSettleKeepPullRequests: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + autoSettleKeepPullRequests: false, + }), + ).toEqual(at(-4 * DAY_MS)); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) }, + }), + ).toEqual(at(-4 * DAY_MS)); + }); + it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index bd7a7fd58dcb01491963e678884b413c8c7ad1ae..4a3540ef02912486b57d0966dbf27bffbc4798bf 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -202,6 +202,7 @@ export function resolveAutoSettlementAt(input: { readonly nowMs: number; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean; + readonly autoSettleKeepPullRequests?: boolean; }): DateTime.Utc | null { const { thread } = input; let pullRequest = input.pullRequest; @@ -236,6 +237,9 @@ export function resolveAutoSettlementAt(input: { if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } + const hasPullRequest = + pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null; + if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null; 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); @@ -275,6 +279,7 @@ export function autoSettlementSettingsKey( return JSON.stringify([ settings.sidebarAutoSettleOnMerge, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleKeepPullRequests, // Only entries that touch settlement, in a stable order, so a project // override on an unrelated key does not queue a sweep. JSON drops // undefined, so inherit (absent) and never (null) need distinct marks. @@ -282,7 +287,8 @@ export function autoSettlementSettingsKey( .filter( ([, entry]) => entry.sidebarAutoSettleOnMerge !== undefined || - entry.sidebarAutoSettleAfterDays !== undefined, + entry.sidebarAutoSettleAfterDays !== undefined || + entry.sidebarAutoSettleKeepPullRequests !== undefined, ) .sort(([left], [right]) => left.localeCompare(right)) .map(([projectId, entry]) => [ @@ -291,6 +297,7 @@ export function autoSettlementSettingsKey( entry.sidebarAutoSettleAfterDays === undefined ? "inherit" : entry.sidebarAutoSettleAfterDays, + entry.sidebarAutoSettleKeepPullRequests ?? "inherit", ]), ]); } @@ -343,6 +350,7 @@ export const make = Effect.gen(function* () { nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays, autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge, + autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests, }); if (settledAt === null) return thread; const uuid = yield* crypto.randomUUIDv4(); @@ -445,6 +453,7 @@ export const make = Effect.gen(function* () { nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + autoSettleKeepPullRequests: settings.sidebarAutoSettleKeepPullRequests, }) !== null ); }); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 2d5e6fb002231ed3edd9bff3b9b0f18cf6cfd933..e408312cddf9d64630d8ae1889ab146805c01932 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -582,6 +582,10 @@ export function useSettingsRestore(onRestored?: () => void) { DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays ? ["Auto-settle inactive threads"] : []), + ...(settings.sidebarAutoSettleKeepPullRequests !== + DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests + ? ["Keep threads with a PR"] + : []), ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge ? ["Auto-settle merged threads"] : []), @@ -708,6 +712,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.enableProviderUpdateChecks, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, + settings.sidebarAutoSettleKeepPullRequests, settings.sidebarAutoSettleOnMerge, settings.autoResumeLimitedThreads, settings.snoozeLimitedThreads, @@ -815,6 +820,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays, + sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests, sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads, snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads, @@ -2203,6 +2209,9 @@ export function GeneralSettingsPanel() { connectedEnvironments.every( (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true, ); + const supportsAutoSettlementScope = connectedEnvironments.every( + (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true, + ); const supportsRestartContinuation = connectedEnvironments.length > 0 && connectedEnvironments.every( @@ -2483,6 +2492,24 @@ export function GeneralSettingsPanel() { } /> ) : null} + {settings.sidebarAutoSettleAfterDays !== null && supportsAutoSettlementScope ? ( + + updateSettings({ sidebarAutoSettleKeepPullRequests: checked }) + } + aria-label="Keep threads with a PR" + /> + } + /> + ) : null} ) : null} diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 80af3a815b6765c6be73d02e8453003bb24738bf..2a9735f61c6da18b96f3aeaefcb1c8fa811ae021 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -150,7 +150,8 @@ By default, environments settle inactive threads after three days and settle threads whose pull request merged. A closed pull request can also settle an idle thread. Work in progress, pending questions or approvals, and live background work prevent automatic settlement. An open pull request does not prevent inactivity -settlement, but an old closed or merged pull request does not settle work you +settlement unless you turn on **Keep threads with a PR** in **Settings → General**, +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 finished background work or a pull request watch starts on its own does not. diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index a4763076639b332f764245b50717e15d8fc0d58f..22e82e5a8d5b05debc4e19e4902646058d9b18b3 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -131,6 +131,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), + /** Server can exclude PR-linked threads from inactivity settlement. */ + threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean), /** Server persists the opt-in for continuing interrupted threads after restarts. */ threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */ diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index c0de88f4ac6ec7b733524675d49a7e89063a5040..a47f793e5777bbb8296e2fdb6b66535ec0838034 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -984,6 +984,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "pullRequestMergeMethod", "sidebarAutoSettleOnMerge", "sidebarAutoSettleAfterDays", + "sidebarAutoSettleKeepPullRequests", "continueThreadsAfterServerUpdate", "responseStreamingMode", ] as const; @@ -1031,6 +1032,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), responseStreamingMode: Schema.optionalKey(ResponseStreamingMode), providerInstances: Schema.optionalKey( @@ -1198,6 +1200,9 @@ export const ServerSettings = Schema.Struct({ ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, // Legacy flat fields retained for old settings files and old clients. New @@ -1572,6 +1577,7 @@ export const ServerSettingsPatch = Schema.Struct({ deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), + sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), -- 2.54.0