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* () {
232232 otlpEndpointCheck: true,
233233 projectWorktreeCleanup: true,
234234 worktreesDirectory: true,
235 threadAutoSettlementScope: true,
235236 threadRestartContinuation: true,
236237 projectSettingsOverrides: true,
237238 projectProviderInstanceOverrides: true,
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+32
......@@ -251,6 +251,38 @@ describe("threadHasQueuedTurnStart", () => {
251251});
252252
253253describe("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
254286 it("uses the latest activity time when the inactivity window elapses", () => {
255287 const idle = shell({
256288 latestUserMessageAt: at(-4 * DAY_MS),
apps/server/src/orchestration-v2/ThreadSettlementService.ts+10-1
......@@ -202,6 +202,7 @@ export function resolveAutoSettlementAt(input: {
202202 readonly nowMs: number;
203203 readonly autoSettleAfterDays: number | null;
204204 readonly autoSettleOnMerge: boolean;
205 readonly autoSettleKeepPullRequests?: boolean;
205206}): DateTime.Utc | null {
206207 const { thread } = input;
207208 let pullRequest = input.pullRequest;
......@@ -236,6 +237,9 @@ export function resolveAutoSettlementAt(input: {
236237 if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
237238 return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
238239 }
240 const hasPullRequest =
241 pullRequest !== null || thread.linkedPullRequest != null || thread.branchPullRequest != null;
242 if (input.autoSettleKeepPullRequests === true && hasPullRequest) return null;
239243 if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
240244 // A passed wake timer restarts inactivity. An earlier wake can only delay settlement.
241245 const snoozedUntilMs = toMillis(thread.snoozedUntil);
......@@ -275,6 +279,7 @@ export function autoSettlementSettingsKey(
275279 return JSON.stringify([
276280 settings.sidebarAutoSettleOnMerge,
277281 settings.sidebarAutoSettleAfterDays,
282 settings.sidebarAutoSettleKeepPullRequests,
278283 // Only entries that touch settlement, in a stable order, so a project
279284 // override on an unrelated key does not queue a sweep. JSON drops
280285 // undefined, so inherit (absent) and never (null) need distinct marks.
......@@ -282,7 +287,8 @@ export function autoSettlementSettingsKey(
282287 .filter(
283288 ([, entry]) =>
284289 entry.sidebarAutoSettleOnMerge !== undefined ||
285 entry.sidebarAutoSettleAfterDays !== undefined,
290 entry.sidebarAutoSettleAfterDays !== undefined ||
291 entry.sidebarAutoSettleKeepPullRequests !== undefined,
286292 )
287293 .sort(([left], [right]) => left.localeCompare(right))
288294 .map(([projectId, entry]) => [
......@@ -291,6 +297,7 @@ export function autoSettlementSettingsKey(
291297 entry.sidebarAutoSettleAfterDays === undefined
292298 ? "inherit"
293299 : entry.sidebarAutoSettleAfterDays,
300 entry.sidebarAutoSettleKeepPullRequests ?? "inherit",
294301 ]),
295302 ]);
296303}
......@@ -343,6 +350,7 @@ export const make = Effect.gen(function* () {
343350 nowMs: DateTime.toEpochMillis(decisionNow),
344351 autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,
345352 autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,
353 autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests,
346354 });
347355 if (settledAt === null) return thread;
348356 const uuid = yield* crypto.randomUUIDv4();
......@@ -445,6 +453,7 @@ export const make = Effect.gen(function* () {
445453 nowMs: DateTime.toEpochMillis(decisionNow),
446454 autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
447455 autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
456 autoSettleKeepPullRequests: settings.sidebarAutoSettleKeepPullRequests,
448457 }) !== null
449458 );
450459 });
apps/web/src/components/settings/SettingsPanels.tsx+27
......@@ -582,6 +582,10 @@ export function useSettingsRestore(onRestored?: () => void) {
582582 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays
583583 ? ["Auto-settle inactive threads"]
584584 : []),
585 ...(settings.sidebarAutoSettleKeepPullRequests !==
586 DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests
587 ? ["Keep threads with a PR"]
588 : []),
585589 ...(settings.sidebarAutoSettleOnMerge !== DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge
586590 ? ["Auto-settle merged threads"]
587591 : []),
......@@ -708,6 +712,7 @@ export function useSettingsRestore(onRestored?: () => void) {
708712 settings.enableProviderUpdateChecks,
709713 settings.continueThreadsAfterServerUpdate,
710714 settings.sidebarAutoSettleAfterDays,
715 settings.sidebarAutoSettleKeepPullRequests,
711716 settings.sidebarAutoSettleOnMerge,
712717 settings.autoResumeLimitedThreads,
713718 settings.snoozeLimitedThreads,
......@@ -815,6 +820,7 @@ export function useSettingsRestore(onRestored?: () => void) {
815820 sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder,
816821 sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled,
817822 sidebarAutoSettleAfterDays: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleAfterDays,
823 sidebarAutoSettleKeepPullRequests: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleKeepPullRequests,
818824 sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge,
819825 autoResumeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.autoResumeLimitedThreads,
820826 snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
......@@ -2203,6 +2209,9 @@ export function GeneralSettingsPanel() {
22032209 connectedEnvironments.every(
22042210 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlement === true,
22052211 );
2212 const supportsAutoSettlementScope = connectedEnvironments.every(
2213 (target) => target.serverConfig?.environment.capabilities.threadAutoSettlementScope === true,
2214 );
22062215 const supportsRestartContinuation =
22072216 connectedEnvironments.length > 0 &&
22082217 connectedEnvironments.every(
......@@ -2483,6 +2492,24 @@ export function GeneralSettingsPanel() {
24832492 }
24842493 />
24852494 ) : 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}
24862513 </>
24872514 ) : null}
24882515 </SettingsSection>
docs/user/thread-sidebar.md+2-1
......@@ -150,7 +150,8 @@ By default, environments settle inactive threads after three days and settle
150150threads whose pull request merged. A closed pull request can also settle an idle
151151thread. Work in progress, pending questions or approvals, and live background work
152152prevent automatic settlement. An open pull request does not prevent inactivity
153settlement, but an old closed or merged pull request does not settle work you
153settlement 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
154155resumed after it closed. Only your own messages count as resuming. A turn that
155156finished 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({
131131 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
132132 /** Server honors the `worktreesDirectory` setting. */
133133 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
134 /** Server can exclude PR-linked threads from inactivity settlement. */
135 threadAutoSettlementScope: Schema.optionalKey(Schema.Boolean),
134136 /** Server persists the opt-in for continuing interrupted threads after restarts. */
135137 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
136138 /** 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 = [
984984 "pullRequestMergeMethod",
985985 "sidebarAutoSettleOnMerge",
986986 "sidebarAutoSettleAfterDays",
987 "sidebarAutoSettleKeepPullRequests",
987988 "continueThreadsAfterServerUpdate",
988989 "responseStreamingMode",
989990] as const;
......@@ -1031,6 +1032,7 @@ export const ProjectSettingsOverrides = Schema.Struct({
10311032 pullRequestMergeMethod: Schema.optionalKey(Schema.NullOr(PullRequestMergeMethod)),
10321033 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
10331034 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1035 sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean),
10341036 continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean),
10351037 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
10361038 providerInstances: Schema.optionalKey(
......@@ -1198,6 +1200,9 @@ export const ServerSettings = Schema.Struct({
11981200 ),
11991201 snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
12001202 autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1203 sidebarAutoSettleKeepPullRequests: Schema.Boolean.pipe(
1204 Schema.withDecodingDefault(Effect.succeed(false)),
1205 ),
12011206 sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))),
12021207 backgroundActivity: BackgroundActivitySettings,
12031208 // Legacy flat fields retained for old settings files and old clients. New
......@@ -1572,6 +1577,7 @@ export const ServerSettingsPatch = Schema.Struct({
15721577 deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean),
15731578 deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),
15741579 sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)),
1580 sidebarAutoSettleKeepPullRequests: Schema.optionalKey(Schema.Boolean),
15751581 sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean),
15761582 autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean),
15771583 snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean),