authorgravatar for admin@snappeh.comTristan Knight <admin@snappeh.com> 2026-10-14 19:14:52+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 18:14:52+00:00
logbf8ffe81a63c82d3de5fbaa9991fd5f30034a452
tree480e957d641187acede881dfd390f2eddf3fd276
parent464fad9a433c246b4a5b73c8640787c174c2c195
signature Signed by PGP key B5690EEEBB952194

feat(settings): inactivity auto-settle can keep threads with a PR (#14642)

Co-authored-by: maria-rcks <maria@kuuro.net>

7 files changed, 80 insertions(+), 2 deletions(-)

apps/server/src/environment/ServerEnvironment.ts+1
...@@ -232,6 +232,7 @@ export const make = Effect.gen(function* () {...@@ -232,6 +232,7 @@ export const make = Effect.gen(function* () {
232 otlpEndpointCheck: true,232 otlpEndpointCheck: true,
233 projectWorktreeCleanup: true,233 projectWorktreeCleanup: true,
234 worktreesDirectory: true,234 worktreesDirectory: true,
235 threadAutoSettlementScope: true,
235 threadRestartContinuation: true,236 threadRestartContinuation: true,
236 projectSettingsOverrides: true,237 projectSettingsOverrides: true,
237 projectProviderInstanceOverrides: true,238 projectProviderInstanceOverrides: true,
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+32
...@@ -251,6 +251,38 @@ describe("threadHasQueuedTurnStart", () => {...@@ -251,6 +251,38 @@ describe("threadHasQueuedTurnStart", () => {
251});251});
252252
253describe("resolveAutoSettlementAt", () => {253describe("resolveAutoSettlementAt", () => {
254 it("keeps threads with a PR from inactivity settlement only when asked", () => {
255 const input = {
256 thread: shell({
257 latestUserMessageAt: at(-4 * DAY_MS),
258 linkedPullRequest: {
259 projectId: ProjectId.make("project-1"),
260 repository: "owner/repo",
261 number: 1,
262 url: "https://example.test/owner/repo/pull/1",
263 },
264 }),
265 pullRequest: null,
266 nowMs: NOW_MS,
267 autoSettleAfterDays: 3,
268 autoSettleOnMerge: true,
269 autoSettleKeepPullRequests: true,
270 };
271 expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull();
272 expect(
273 ThreadSettlementService.resolveAutoSettlementAt({
274 ...input,
275 autoSettleKeepPullRequests: false,
276 }),
277 ).toEqual(at(-4 * DAY_MS));
278 expect(
279 ThreadSettlementService.resolveAutoSettlementAt({
280 ...input,
281 pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
282 }),
283 ).toEqual(at(-4 * DAY_MS));
284 });
285
254 it("uses the latest activity time when the inactivity window elapses", () => {286 it("uses the latest activity time when the inactivity window elapses", () => {
255 const idle = shell({287 const idle = shell({
256 latestUserMessageAt: at(-4 * DAY_MS),288 latestUserMessageAt: at(-4 * DAY_MS),
apps/server/src/orchestration-v2/ThreadSettlementService.ts+10-1
...@@ -202,6 +202,7 @@ export function resolveAutoSettlementAt(input: {...@@ -202,6 +202,7 @@ export function resolveAutoSettlementAt(input: {
202 readonly nowMs: number;202 readonly nowMs: number;
203 readonly autoSettleAfterDays: number | null;203 readonly autoSettleAfterDays: number | null;
204 readonly autoSettleOnMerge: boolean;204 readonly autoSettleOnMerge: boolean;
205 readonly autoSettleKeepPullRequests?: boolean;
205}): DateTime.Utc | null {206}): DateTime.Utc | null {
206 const { thread } = input;207 const { thread } = input;
207 let pullRequest = input.pullRequest;208 let pullRequest = input.pullRequest;
...@@ -236,6 +237,9 @@ export function resolveAutoSettlementAt(input: {...@@ -236,6 +237,9 @@ export function resolveAutoSettlementAt(input: {
236 if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {237 if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
237 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);238 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
238 }239 }
240 const hasPullRequest =
241 pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null;
242 if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null;
239 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;243 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
240 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.244 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.
241 const snoozedUntilMs = toMillis(thread.snoozedUntil);245 const snoozedUntilMs = toMillis(thread.snoozedUntil);
...@@ -275,6 +279,7 @@ export function autoSettlementSettingsKey(...@@ -275,6 +279,7 @@ export function autoSettlementSettingsKey(
275 return JSON.stringify([279 return JSON.stringify([
276 settings.sidebarAutoSettleOnMerge,280 settings.sidebarAutoSettleOnMerge,
277 settings.sidebarAutoSettleAfterDays,281 settings.sidebarAutoSettleAfterDays,
282 settings.sidebarAutoSettleKeepPullRequests,
278 // Only entries that touch settlement, in a stable order, so a project283 // Only entries that touch settlement, in a stable order, so a project
279 // override on an unrelated key does not queue a sweep. JSON drops284 // override on an unrelated key does not queue a sweep. JSON drops
280 // undefined, so inherit (absent) and never (null) need distinct marks.285 // undefined, so inherit (absent) and never (null) need distinct marks.
...@@ -282,7 +287,8 @@ export function autoSettlementSettingsKey(...@@ -282,7 +287,8 @@ export function autoSettlementSettingsKey(
282 .filter(287 .filter(
283 ([, entry]) =>288 ([, entry]) =>
284 entry.sidebarAutoSettleOnMerge !== undefined ||289 entry.sidebarAutoSettleOnMerge !== undefined ||
285 entry.sidebarAutoSettleAfterDays !== undefined,290 entry.sidebarAutoSettleAfterDays !== undefined ||
291 entry.sidebarAutoSettleKeepPullRequests !== undefined,
286 )292 )
287 .sort(([left], [right]) => left.localeCompare(right))293 .sort(([left], [right]) => left.localeCompare(right))
288 .map(([projectId, entry]) => [294 .map(([projectId, entry]) => [
...@@ -291,6 +297,7 @@ export function autoSettlementSettingsKey(...@@ -291,6 +297,7 @@ export function autoSettlementSettingsKey(
291 entry.sidebarAutoSettleAfterDays === undefined297 entry.sidebarAutoSettleAfterDays === undefined
292 ? "inherit"298 ? "inherit"
293 : entry.sidebarAutoSettleAfterDays,299 : entry.sidebarAutoSettleAfterDays,
300 entry.sidebarAutoSettleKeepPullRequests ?? "inherit",
294 ]),301 ]),
295 ]);302 ]);
296}303}
...@@ -343,6 +350,7 @@ export const make = Effect.gen(function* () {...@@ -343,6 +350,7 @@ export const make = Effect.gen(function* () {
343 nowMs: DateTime.toEpochMillis(decisionNow),350 nowMs: DateTime.toEpochMillis(decisionNow),
344 autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,351 autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,
345 autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,352 autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,
353 autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests,
346 });354 });
347 if (settledAt === null) return thread;355 if (settledAt === null) return thread;
348 const uuid = yield* crypto.randomUUIDv4();356 const uuid = yield* crypto.randomUUIDv4();
...@@ -445,6 +453,7 @@ export const make = Effect.gen(function* () {...@@ -445,6 +453,7 @@ export const make = Effect.gen(function* () {
445 nowMs: DateTime.toEpochMillis(decisionNow),453 nowMs: DateTime.toEpochMillis(decisionNow),
446 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,454 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
447 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,455 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
456 autoSettleKeepPullRequests: settings.sidebarAutoSettleKeepPullRequests,
448 }) !== null457 }) !== null
449 );458 );
450 });459 });
apps/web/src/components/settings/SettingsPanels.tsx+27
...@@ -582,6 +582,10 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -582,6 +582,10 @@ export function useSettingsRestore(onRestored?: () => void) {
582 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays582 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
583 ? ["Auto-settle inactive threads"]583 ? ["Auto-settle inactive threads"]
584 : []),584 : []),
585 ...(settings.sidebarAutoSettleKeepPullRequests !==
586 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests
587 ? ["Keep threads with a PR"]
588 : []),
585 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge589 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
586 ? ["Auto-settle merged threads"]590 ? ["Auto-settle merged threads"]
587 : []),591 : []),
...@@ -708,6 +712,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -708,6 +712,7 @@ export function useSettingsRestore(onRestored?: () => void) {
708 settings.enableProviderUpdateChecks,712 settings.enableProviderUpdateChecks,
709 settings.continueThreadsAfterServerUpdate,713 settings.continueThreadsAfterServerUpdate,
710 settings.sidebarAutoSettleAfterDays,714 settings.sidebarAutoSettleAfterDays,
715 settings.sidebarAutoSettleKeepPullRequests,
711 settings.sidebarAutoSettleOnMerge,716 settings.sidebarAutoSettleOnMerge,
712 settings.autoResumeLimitedThreads,717 settings.autoResumeLimitedThreads,
713 settings.snoozeLimitedThreads,718 settings.snoozeLimitedThreads,
...@@ -815,6 +820,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -815,6 +820,7 @@ export function useSettingsRestore(onRestored?: () => void) {
815 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,820 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
816 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,821 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
817 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,822 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
823 sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests,
818 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,824 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
819 autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,825 autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,
820 snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,826 snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
...@@ -2203,6 +2209,9 @@ export function GeneralSettingsPanel() {...@@ -2203,6 +2209,9 @@ export function GeneralSettingsPanel() {
2203 connectedEnvironments.every(2209 connectedEnvironments.every(
2204 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,2210 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
2205 );2211 );
2212 const supportsAutoSettlementScope = connectedEnvironments.every(
2213 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true,
2214 );
2206 const supportsRestartContinuation =2215 const supportsRestartContinuation =
2207 connectedEnvironments.length > 0 &&2216 connectedEnvironments.length > 0 &&
2208 connectedEnvironments.every(2217 connectedEnvironments.every(
...@@ -2483,6 +2492,24 @@ export function GeneralSettingsPanel() {...@@ -2483,6 +2492,24 @@ export function GeneralSettingsPanel() {
2483 }2492 }
2484 />2493 />
2485 ) : null}2494 ) : null}
2495 {settings.sidebarAutoSettleAfterDays !== null && supportsAutoSettlementScope ? (
2496 <SettingsRow
2497 serverScoped
2498 settingKeys={["sidebarAutoSettleKeepPullRequests"]}
2499 title="Keep threads with a PR"
2500 description="Inactivity does not settle threads with a pull request. Merge and close still do."
2501 control={
2502 <ScopedSwitch
2503 settingKeys={["sidebarAutoSettleKeepPullRequests"]}
2504 checked={settings.sidebarAutoSettleKeepPullRequests}
2505 onCheckedChange={(checked) =>
2506 updateSettings({ sidebarAutoSettleKeepPullRequests: checked })
2507 }
2508 aria-label="Keep threads with a PR"
2509 />
2510 }
2511 />
2512 ) : null}
2486 </>2513 </>
2487 ) : null}2514 ) : null}
2488 </SettingsSection>2515 </SettingsSection>
docs/user/thread-sidebar.md+2-1
...@@ -150,7 +150,8 @@ By default, environments settle inactive threads after three days and settle...@@ -150,7 +150,8 @@ By default, environments settle inactive threads after three days and settle
150threads whose pull request merged. A closed pull request can also settle an idle150threads whose pull request merged. A closed pull request can also settle an idle
151thread. Work in progress, pending questions or approvals, and live background work151thread. Work in progress, pending questions or approvals, and live background work
152prevent automatic settlement. An open pull request does not prevent inactivity152prevent automatic settlement. An open pull request does not prevent inactivity
153settlement, but an old closed or merged pull request does not settle work you153settlement unless you turn on **Keep threads with a PR** in **Settings → General**,
154but an old closed or merged pull request does not settle work you
154resumed after it closed. Only your own messages count as resuming. A turn that155resumed after it closed. Only your own messages count as resuming. A turn that
155finished background work or a pull request watch starts on its own does not.156finished background work or a pull request watch starts on its own does not.
156157
packages/contracts/src/environment.ts+2
...@@ -131,6 +131,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -131,6 +131,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
131 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),131 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
132 /** Server honors the `worktreesDirectory` setting. */132 /** Server honors the `worktreesDirectory` setting. */
133 worktreesDirectory: Schema.optionalKey(Schema.Boolean),133 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
134 /** Server can exclude PR-linked threads from inactivity settlement. */
135 threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean),
134 /** Server persists the opt-in for continuing interrupted threads after restarts. */136 /** Server persists the opt-in for continuing interrupted threads after restarts. */
135 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),137 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
136 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */138 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
packages/contracts/src/settings.ts+6
...@@ -984,6 +984,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [...@@ -984,6 +984,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
984 "pullRequestMergeMethod",984 "pullRequestMergeMethod",
985 "sidebarAutoSettleOnMerge",985 "sidebarAutoSettleOnMerge",
986 "sidebarAutoSettleAfterDays",986 "sidebarAutoSettleAfterDays",
987 "sidebarAutoSettleKeepPullRequests",
987 "continueThreadsAfterServerUpdate",988 "continueThreadsAfterServerUpdate",
988 "responseStreamingMode",989 "responseStreamingMode",
989] as const;990] as const;
...@@ -1031,6 +1032,7 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1031,6 +1032,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
1031 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),1032 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
1032 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1033 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
1033 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1034 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1035 sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean),
1034 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),1036 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
1035 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),1037 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
1036 providerInstances: Schema.optionalKey(1038 providerInstances: Schema.optionalKey(
...@@ -1198,6 +1200,9 @@ export const ServerSettings = Schema.Struct({...@@ -1198,6 +1200,9 @@ export const ServerSettings = Schema.Struct({
1198 ),1200 ),
1199 snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),1201 snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1200 autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),1202 autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1203 sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe(
1204 Schema.withDecodingDefault(Effect.succeed(false)),
1205 ),
1201 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),1206 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
1202 backgroundActivity: BackgroundActivitySettings,1207 backgroundActivity: BackgroundActivitySettings,
1203 // Legacy flat fields retained for old settings files and old clients. New1208 // Legacy flat fields retained for old settings files and old clients. New
...@@ -1572,6 +1577,7 @@ export const ServerSettingsPatch = Schema.Struct({...@@ -1572,6 +1577,7 @@ export const ServerSettingsPatch = Schema.Struct({
1572 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),1577 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
1573 deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),1578 deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
1574 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),1579 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1580 sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean),
1575 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),1581 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
1576 autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),1582 autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),
1577 snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean),1583 snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean),