From 631e99db88010945520eba47e8d8d55d224b502f Mon Sep 17 00:00:00 2001 From: Bil0000 Date: Wed, 23 Sep 2026 16:21:15 +0300 Subject: [PATCH 1/6] feat(storage): clean up settled worktrees after retention --- .../ThreadSettlementReactor.test.ts | 111 +++++++++++++++--- apps/server/src/storageCleanup.ts | 30 +++-- .../components/settings/StorageSettings.tsx | 22 +++- .../settings/scopedSettings.test.ts | 15 +++ docs/user/project-settings.md | 10 +- packages/contracts/src/settings.test.ts | 28 +++++ packages/contracts/src/settings.ts | 11 ++ packages/shared/src/projectSettings.test.ts | 2 + packages/shared/src/projectSettings.ts | 18 ++- packages/shared/src/serverSettings.ts | 1 + 10 files changed, 215 insertions(+), 33 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 07afbaf05e6a..ef55c21d9996 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -1455,6 +1455,17 @@ describe("storage cleanup", () => { "terminal-cwd", "terminal-worktree", "recent", + "settled-immediate", + "settled-wait", + "settled-expired", + "settled-active", + "settled-dirty", + "settled-session", + "settled-project-off", + "settled-project-custom", + "settled-archived", + "settled-woke", + "settled-event", "merged", "unmerged", "unchanged", @@ -1519,12 +1530,20 @@ describe("storage cleanup", () => { yield* fs.writeFileString(recentImage, "recent"); const recent = DateTime.toDateUtc(DateTime.makeUnsafe(NOW)); yield* fs.utimes(recentImage, recent, recent); - const thread = makeThread("storage-thread", { + let thread = makeThread("storage-thread", { branch: "feature", worktreePath, latestUserMessageAt: protection === "recent" ? "2026-08-26T00:00:00.000Z" : "2026-08-01T00:00:00.000Z", - ...(protection === "session" + ...(protection.startsWith("settled-") && + protection !== "settled-active" && + protection !== "settled-event" + ? { + settledOverride: "settled" as const, + settledAt: protection === "settled-expired" ? "2026-08-01T00:00:00.000Z" : NOW, + } + : {}), + ...(protection === "session" || protection === "settled-session" ? { session: { threadId: ThreadId.make("storage-thread"), @@ -1539,6 +1558,7 @@ describe("storage cleanup", () => { : {}), }); const snapshotRead = yield* Deferred.make(); + const settledSnapshotRead = yield* Deferred.make(); const deletionStarted = yield* Deferred.make(); const deletionStopped = yield* Deferred.make(); if (protection !== "deleted-event") yield* Deferred.succeed(deletionStopped, undefined); @@ -1561,14 +1581,20 @@ describe("storage cleanup", () => { ServerSettingsService.layerTest({ projectSettingsOverrides: { [PROJECT_ID]: - protection === "project-off" || protection === "deleted-project-off" + protection === "project-off" || + protection === "deleted-project-off" || + protection === "settled-project-off" ? { worktreeCleanup: { mode: "off" as const } } - : protection === "project-custom" || protection === "deleted-project-custom" + : protection === "project-custom" || + protection === "deleted-project-custom" || + protection === "settled-project-custom" ? { worktreeCleanup: { mode: "custom" as const, rules: { worktreeAfterDays: protection === "project-custom" ? 8 : null, + worktreeSettledAfterDays: + protection === "settled-project-custom" ? 0 : null, worktreeOnDelete: deleteRule, worktreeOnMerge: false, worktreeUnchanged: false, @@ -1579,9 +1605,21 @@ describe("storage cleanup", () => { }, storageCleanup: { worktreeAfterDays: - deleteRule || mergeRule || unchangedRule || protection === "project-custom" + deleteRule || + mergeRule || + unchangedRule || + protection === "project-custom" || + protection.startsWith("settled-") ? null : 8, + worktreeSettledAfterDays: + protection === "settled-project-custom" + ? null + : protection.startsWith("settled-") + ? protection === "settled-wait" || protection === "settled-expired" + ? 8 + : 0 + : null, worktreeOnDelete: deleteRule && protection !== "deleted-project-custom", worktreeOnMerge: mergeRule, worktreeUnchanged: unchangedRule, @@ -1640,6 +1678,11 @@ describe("storage cleanup", () => { getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 2 }), getShellSnapshot: () => Deferred.succeed(snapshotRead, undefined).pipe( + Effect.tap(() => + thread.settledOverride === "settled" + ? Deferred.succeed(settledSnapshotRead, undefined) + : Effect.void, + ), Effect.andThen( Effect.sync(() => { snapshotReads++; @@ -1649,7 +1692,8 @@ describe("storage cleanup", () => { protection === "deleted-project-custom" ? [] : [makeProject(PROJECT_ID, config.baseDir)]; - const threads = tombstoned ? [] : [thread]; + const threads = + tombstoned || protection === "settled-archived" ? [] : [thread]; if (protection === "deleted-shared") threads.push({ ...thread, id: ThreadId.make("surviving-thread") }); if (protection === "deleted-project") @@ -1686,15 +1730,17 @@ describe("storage cleanup", () => { getArchivedShellSnapshot: () => Effect.succeed( makeSnapshot( - protection === "shared" - ? [ - { - ...thread, - id: ThreadId.make("archived-sharing-thread"), - archivedAt: NOW, - }, - ] - : [], + protection === "settled-archived" + ? [{ ...thread, archivedAt: NOW }] + : protection === "shared" + ? [ + { + ...thread, + id: ThreadId.make("archived-sharing-thread"), + archivedAt: NOW, + }, + ] + : [], ), ), }), @@ -1771,7 +1817,9 @@ describe("storage cleanup", () => { branch: cwd === secondWorktreePath ? "feature-two" : "feature", upstreamRef: null, hasWorkingTreeChanges: - protection === "dirty" || protection === "deleted-dirty", + protection === "dirty" || + protection === "deleted-dirty" || + protection === "settled-dirty", workingTree: { files: [], insertions: 0, deletions: 0 }, hasUpstream: false, aheadCount: 0, @@ -1821,6 +1869,13 @@ describe("storage cleanup", () => { .pipe(Effect.orDie) : Effect.void, ), + Effect.tap(() => + protection === "settled-woke" && headReads === 1 + ? Effect.sync(() => { + thread = { ...thread, settledOverride: "active", settledAt: null }; + }) + : Effect.void, + ), ), removeWorktree: (input) => { assert.strictEqual(input.force, false); @@ -1864,6 +1919,25 @@ describe("storage cleanup", () => { yield* cleanup.start(); yield* Deferred.await(snapshotRead); yield* cleanup.drain; + if (protection === "settled-event") { + assert.strictEqual(yield* fs.exists(worktreePath), true); + thread = { ...thread, settledOverride: "settled", settledAt: NOW }; + yield* PubSub.publish(domainEvents, { + type: "thread.settled", + sequence: 2, + eventId: EventId.make("storage-thread-settled"), + aggregateKind: "thread", + aggregateId: thread.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { threadId: thread.id, settledAt: NOW, updatedAt: NOW }, + }); + yield* Deferred.await(settledSnapshotRead); + yield* cleanup.drain; + } if (protection === "deleted-event") { assert.strictEqual(yield* fs.exists(worktreePath), true); tombstoned = true; @@ -1889,6 +1963,11 @@ describe("storage cleanup", () => { protection === "project-custom" || protection === "deleted-project-custom" || protection === "none" || + protection === "settled-immediate" || + protection === "settled-expired" || + protection === "settled-project-custom" || + protection === "settled-archived" || + protection === "settled-event" || protection === "deleted" || protection === "deleted-event" || protection === "deleted-owner" || diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 400ae635018f..8bae5919764d 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -48,6 +48,7 @@ const DAY_MS = 86_400_000; const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || + rules.worktreeSettledAfterDays !== null || rules.worktreeOnMerge || rules.worktreeOnDelete || rules.worktreeUnchanged; @@ -242,7 +243,13 @@ export const make = Effect.gen(function* () { !deleted && settings.worktreeAfterDays !== null && storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; - let eligible = deleted || old; + const settled = + !deleted && + settings.worktreeSettledAfterDays !== null && + thread.settledOverride === "settled" && + thread.settledAt !== null && + Date.parse(thread.settledAt) <= now - settings.worktreeSettledAfterDays * DAY_MS; + let eligible = deleted || old || settled; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); @@ -313,7 +320,9 @@ export const make = Effect.gen(function* () { latest.length !== 1 || latest[0]!.id !== thread.id || !storageCleanupThreadIdle(latest[0]!, now) || - storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) + storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) || + latest[0]!.settledOverride !== thread.settledOverride || + latest[0]!.settledAt !== thread.settledAt ) return; const finalStatus = yield* git.statusDetailsLocal(worktreePath); @@ -467,12 +476,17 @@ export const make = Effect.gen(function* () { }), ); yield* forkParked( - Stream.runForEach(events, (event) => - event.type === "thread.deleted" && - anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete) - ? worker.enqueue(undefined) - : Effect.void, - ), + Stream.runForEach(events, (event) => { + if ( + (event.type === "thread.deleted" && + anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete)) || + ((event.type === "thread.settled" || + (event.type === "thread.session-set" && event.payload.session.status === "stopped")) && + anyWorktreePolicy(lastSettings, (rules) => rules.worktreeSettledAfterDays === 0)) + ) + return worker.enqueue(undefined); + return Effect.void; + }), ); }); return { start, drain: worker.drain } satisfies StorageCleanup["Service"]; diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 360979eead7a..cac85b801ce3 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -25,10 +25,12 @@ function RetentionControl({ label, value, onChange, + minimum = 1, }: { label: string; value: number | null; onChange: (value: number | null) => void; + minimum?: number; }) { const [draft, setDraft] = useState(value); const [savedValue, setSavedValue] = useState(value); @@ -42,7 +44,7 @@ function RetentionControl({ {value !== null ? ( { if (next === null) setDraft(value); else { - const days = Math.min(3650, Math.max(1, Math.round(next))); + const days = Math.min(3650, Math.max(minimum, Math.round(next))); setDraft(days); onChange(days); } @@ -223,6 +225,22 @@ export function StorageSettingsPanel() { /> } /> + + updateWorktree({ worktreeSettledAfterDays }) + } + /> + } + /> { mode: "custom", rules: { worktreeAfterDays: 12, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, @@ -178,12 +179,14 @@ describe("scoped settings writes", () => { expect(policies).toEqual([ { worktreeAfterDays: 12, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, }, { worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, @@ -290,6 +293,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 8, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, @@ -302,6 +306,15 @@ describe("scoped settings writes", () => { const plan = planScopedSettingsPatch(project, [machine, customized], { worktreeCleanup: { mode: "custom", rules: { worktreeOnDelete: true } }, }); + const settledPlan = planScopedSettingsPatch(project, [machine, customized], { + worktreeCleanup: { mode: "custom", rules: { worktreeSettledAfterDays: 0 } }, + }); + expect( + settledPlan.serverWrites.map((write) => write.patch.projectSettingsOverrides), + ).toMatchObject([ + { [projectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } }, + { [laptopProjectId]: { worktreeCleanup: { rules: { worktreeSettledAfterDays: 0 } } } }, + ]); expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([ { [projectId]: { @@ -310,6 +323,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 8, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, @@ -323,6 +337,7 @@ describe("scoped settings writes", () => { mode: "custom", rules: { worktreeAfterDays: 30, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index fe3d96ba472c..ef5f49e42006 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -66,16 +66,18 @@ applies when the project and environment are both on **Inherit**. Open **Settings → Storage** to enable automatic cleanup on one machine or all connected environments. Policies are off by default and run on the server at startup, when changed, and -hourly. Offline machines keep their existing policies. +hourly. Immediate settled cleanup also runs when a thread settles. Offline machines keep their +existing policies. Select a project to set **Automatic worktree cleanup** to **Inherit**, **Off**, or **Custom**. Inherit follows each machine's rules; Off keeps that project's worktrees until you remove them manually. Custom applies separate worktree rules to the selected project or checkout. Browser captures and log retention remain machine-wide. -Worktrees can be removed after a chosen number of inactive days, after merging, or when they -have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` +Worktrees can be removed after a chosen number of inactive days, after a thread settles, after +merging, or when they have no commits beyond the default branch. Set settled retention to 0 days +to remove a worktree as soon as it is safe after settlement. Only T3-managed worktrees are eligible. +Active sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` prevent removal. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires the commits to be included in the remote default branch, so squash merges may need the inactivity rule instead. diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 16b9418ab7cc..39ffea34497c 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import * as Schema from "effect/Schema"; +import { ProjectId } from "./baseSchemas.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; import { ClientSettingsSchema, @@ -25,6 +26,7 @@ describe("storage cleanup settings", () => { expect(decodeServerSettings({}).worktreeCleanup).toBeNull(); expect(decodeServerSettings({}).storageCleanup).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, @@ -40,6 +42,9 @@ describe("storage cleanup settings", () => { expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: null } })).toEqual({ storageCleanup: { worktreeAfterDays: null }, }); + expect(decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: 0 } })).toEqual({ + storageCleanup: { worktreeSettledAfterDays: 0 }, + }); }); it("accepts partial custom patches but requires complete stored project rules", () => { @@ -55,6 +60,23 @@ describe("storage cleanup settings", () => { }, }), ).toThrow(); + expect( + decodeServerSettings({ + projectSettingsOverrides: { + project: { + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: null, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }, + }, + }).projectSettingsOverrides[ProjectId.make("project")]?.worktreeCleanup, + ).toMatchObject({ mode: "custom", rules: { worktreeSettledAfterDays: null } }); }); it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { @@ -62,6 +84,12 @@ describe("storage cleanup settings", () => { decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), ).toThrow(); }); + + it.each([-1, 1.5, 3651])("rejects invalid settled retention %s", (days) => { + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeSettledAfterDays: days } }), + ).toThrow(); + }); }); describe("ClientSettings rich text composer", () => { diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a8cce4487549..6270a87af95d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -981,9 +981,15 @@ export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +const SettledWorktreeRetentionDays = Schema.NullOr( + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 3650 })), +); export const WorktreeCleanupRules = Schema.Struct({ worktreeAfterDays: StorageRetentionDays, + worktreeSettledAfterDays: SettledWorktreeRetentionDays.pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, worktreeUnchanged: Schema.Boolean, @@ -1069,6 +1075,9 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { }); expect(resolveWorktreeCleanup(off, projectId)).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, @@ -356,6 +357,7 @@ describe("resolveWorktreeCleanup", () => { }); expect(resolveWorktreeCleanup(edited, null)).toEqual({ worktreeAfterDays: 15, + worktreeSettledAfterDays: null, worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..1eade2536528 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -221,11 +221,23 @@ export function resolveWorktreeCleanup( if (policy?.mode === "off") return { worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeAfterDays, + worktreeSettledAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeAfterDays, + worktreeSettledAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index d847cb2b0795..f880baa4e97e 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -333,6 +333,7 @@ export function applyServerSettingsPatch( mode: "custom" as const, rules: { worktreeAfterDays: next.storageCleanup.worktreeAfterDays, + worktreeSettledAfterDays: next.storageCleanup.worktreeSettledAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, worktreeUnchanged: next.storageCleanup.worktreeUnchanged, From cf261444d0406f6f90df96f6d4591ce623a4aaee Mon Sep 17 00:00:00 2001 From: Bil0000 Date: Wed, 23 Sep 2026 16:41:10 +0300 Subject: [PATCH 2/6] fix(storage): handle legacy settlement and older servers --- .../src/environment/ServerEnvironment.ts | 1 + .../ThreadSettlementReactor.test.ts | 8 ++++ apps/server/src/storageCleanup.ts | 2 +- .../components/settings/StorageSettings.tsx | 37 +++++++++++-------- packages/contracts/src/environment.ts | 1 + packages/shared/src/serverSettings.test.ts | 1 + 6 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index d58c014d86e8..533cda01b656 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () { threadAutoSettlement: true, storageCleanup: true, projectWorktreeCleanup: true, + settledWorktreeCleanup: true, threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index ef55c21d9996..21c4892b9817 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -1458,7 +1458,9 @@ describe("storage cleanup", () => { "settled-immediate", "settled-wait", "settled-expired", + "settled-legacy", "settled-active", + "settled-pinned-active", "settled-dirty", "settled-session", "settled-project-off", @@ -1543,6 +1545,11 @@ describe("storage cleanup", () => { settledAt: protection === "settled-expired" ? "2026-08-01T00:00:00.000Z" : NOW, } : {}), + ...(protection === "settled-legacy" + ? { settledOverride: null, settledAt: NOW } + : protection === "settled-pinned-active" + ? { settledOverride: "active" as const, settledAt: NOW } + : {}), ...(protection === "session" || protection === "settled-session" ? { session: { @@ -1965,6 +1972,7 @@ describe("storage cleanup", () => { protection === "none" || protection === "settled-immediate" || protection === "settled-expired" || + protection === "settled-legacy" || protection === "settled-project-custom" || protection === "settled-archived" || protection === "settled-event" || diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 8bae5919764d..7e2ac4f69975 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -246,7 +246,7 @@ export const make = Effect.gen(function* () { const settled = !deleted && settings.worktreeSettledAfterDays !== null && - thread.settledOverride === "settled" && + thread.settledOverride !== "active" && thread.settledAt !== null && Date.parse(thread.settledAt) <= now - settings.worktreeSettledAfterDays * DAY_MS; let eligible = deleted || old || settled; diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index cac85b801ce3..90107dec0b18 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -225,22 +225,27 @@ export function StorageSettingsPanel() { /> } /> - - updateWorktree({ worktreeSettledAfterDays }) - } - /> - } - /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === true, + ) && ( + + updateWorktree({ worktreeSettledAfterDays }) + } + /> + } + /> + )} { }).storageCleanup, ).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, From 7920d935b3445454be79742358ab203700b75320 Mon Sep 17 00:00:00 2001 From: Bil0000 Date: Wed, 23 Sep 2026 16:49:13 +0300 Subject: [PATCH 3/6] fix(storage): start settled retention after settlement --- .../src/orchestration/ThreadSettlementReactor.test.ts | 11 +++++++++-- apps/server/src/storageCleanup.ts | 6 ++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 21c4892b9817..cb9756d73344 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -1458,6 +1458,7 @@ describe("storage cleanup", () => { "settled-immediate", "settled-wait", "settled-expired", + "settled-auto-recent", "settled-legacy", "settled-active", "settled-pinned-active", @@ -1537,12 +1538,16 @@ describe("storage cleanup", () => { worktreePath, latestUserMessageAt: protection === "recent" ? "2026-08-26T00:00:00.000Z" : "2026-08-01T00:00:00.000Z", + ...(protection === "settled-auto-recent" ? { updatedAt: NOW } : {}), ...(protection.startsWith("settled-") && protection !== "settled-active" && protection !== "settled-event" ? { settledOverride: "settled" as const, - settledAt: protection === "settled-expired" ? "2026-08-01T00:00:00.000Z" : NOW, + settledAt: + protection === "settled-expired" || protection === "settled-auto-recent" + ? "2026-08-01T00:00:00.000Z" + : NOW, } : {}), ...(protection === "settled-legacy" @@ -1623,7 +1628,9 @@ describe("storage cleanup", () => { protection === "settled-project-custom" ? null : protection.startsWith("settled-") - ? protection === "settled-wait" || protection === "settled-expired" + ? protection === "settled-wait" || + protection === "settled-expired" || + protection === "settled-auto-recent" ? 8 : 0 : null, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 7e2ac4f69975..6bad22346b69 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -248,7 +248,8 @@ export const make = Effect.gen(function* () { settings.worktreeSettledAfterDays !== null && thread.settledOverride !== "active" && thread.settledAt !== null && - Date.parse(thread.settledAt) <= now - settings.worktreeSettledAfterDays * DAY_MS; + Math.max(Date.parse(thread.settledAt), Date.parse(thread.updatedAt)) <= + now - settings.worktreeSettledAfterDays * DAY_MS; let eligible = deleted || old || settled; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); @@ -322,7 +323,8 @@ export const make = Effect.gen(function* () { !storageCleanupThreadIdle(latest[0]!, now) || storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) || latest[0]!.settledOverride !== thread.settledOverride || - latest[0]!.settledAt !== thread.settledAt + latest[0]!.settledAt !== thread.settledAt || + latest[0]!.updatedAt !== thread.updatedAt ) return; const finalStatus = yield* git.statusDetailsLocal(worktreePath); From c27223f5e54d4f3fdd73f90bb018325921dc1a04 Mon Sep 17 00:00:00 2001 From: Bil0000 Date: Wed, 23 Sep 2026 17:03:47 +0300 Subject: [PATCH 4/6] fix(storage): require connected scope for settled cleanup --- .../components/settings/StorageSettings.tsx | 47 ++++++++++--------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 90107dec0b18..6df3037bcefd 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -85,7 +85,7 @@ function RetentionControl({ } export function StorageSettingsPanel() { - const { scope, connectedEnvironments, targets, target } = useSettingsScope(); + const { scope, environments, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); const isProjectScope = scope.kind === "project" || scope.kind === "checkout"; const settings = { @@ -225,27 +225,30 @@ export function StorageSettingsPanel() { /> } /> - {connectedEnvironments.every( - (environment) => - environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === true, - ) && ( - - updateWorktree({ worktreeSettledAfterDays }) - } - /> - } - /> - )} + {environments.length > 0 && + environments.every( + (environment) => + environment.connection.phase === "connected" && + environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === + true, + ) && ( + + updateWorktree({ worktreeSettledAfterDays }) + } + /> + } + /> + )} Date: Wed, 23 Sep 2026 17:09:08 +0300 Subject: [PATCH 5/6] fix(storage): show supported connected scope --- .../src/components/settings/StorageSettings.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 6df3037bcefd..0b706ef68c88 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -225,16 +225,22 @@ export function StorageSettingsPanel() { /> } /> - {environments.length > 0 && - environments.every( + {connectedEnvironments.length > 0 && + connectedEnvironments.every( (environment) => - environment.connection.phase === "connected" && environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === - true, + true, ) && ( connectedEnvironments.length + ? "Offline machines will not get this setting" + : undefined, + ] + .filter(Boolean) + .join(". ")} description="Remove worktrees this many days after their thread settles. Set 0 days to remove them as soon as they are safe to remove. Branches and thread history are kept." serverScoped={!isProjectScope} control={ From 429b43557304696c5570df297a355b1b121e2cad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 24 Sep 2026 01:04:33 -0700 Subject: [PATCH 6/6] fix(storage): coalesce cleanup sweeps and skip Git for ineligible worktrees With 0-day settled retention, every thread.settled and stopped session-set event queued its own full sweep, and each sweep ran git status, rev-parse and ls-files in every idle worktree before checking the time rules. One auto-settle burst of ~100 threads (plus their session stops) queued ~200 sweeps of ~30s of Git work each on a machine with ~350 worktrees. Collapse queued sweep requests into one pending sweep, and decide the inactivity and settled rules before touching Git so worktrees no rule can remove are skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ThreadSettlementReactor.test.ts | 9 ++++ apps/server/src/storageCleanup.ts | 54 +++++++++++-------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index cb9756d73344..b3b604b8fee5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -2001,6 +2001,15 @@ describe("storage cleanup", () => { : [], ); assert.strictEqual(fetches, mergeRule || unchangedRule ? 1 : 0); + // Time rules decide without Git, so ineligible worktrees are never inspected. + if ( + protection === "recent" || + protection === "settled-wait" || + protection === "settled-auto-recent" || + protection === "settled-active" || + protection === "settled-pinned-active" + ) + assert.strictEqual(headReads, 0); assert.strictEqual(thread.worktreePath, worktreePath); assert.strictEqual(thread.branch, "feature"); assert.strictEqual(yield* fs.exists(oldImage), protection.startsWith("files-")); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 6bad22346b69..20d16b8961e9 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -208,10 +208,23 @@ export const make = Effect.gen(function* () { const project = deleted ? { workspaceRoot: thread.workspaceRoot } : snapshot.projects.find((entry) => entry.id === thread.projectId); + const old = + !deleted && + settings.worktreeAfterDays !== null && + storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; + const settled = + !deleted && + settings.worktreeSettledAfterDays !== null && + thread.settledOverride !== "active" && + thread.settledAt !== null && + Math.max(Date.parse(thread.settledAt), Date.parse(thread.updatedAt)) <= + now - settings.worktreeSettledAfterDays * DAY_MS; if ( project === undefined || (!deleted && !storageCleanupThreadIdle(thread, now)) || - hasTerminal(worktreePath) + hasTerminal(worktreePath) || + // Only the merge and unchanged rules need Git to decide eligibility. + (!deleted && !old && !settled && !settings.worktreeUnchanged && !settings.worktreeOnMerge) ) continue; yield* Effect.gen(function* () { @@ -239,17 +252,6 @@ export const make = Effect.gen(function* () { .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) ) return; - const old = - !deleted && - settings.worktreeAfterDays !== null && - storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; - const settled = - !deleted && - settings.worktreeSettledAfterDays !== null && - thread.settledOverride !== "active" && - thread.settledAt !== null && - Math.max(Date.parse(thread.settledAt), Date.parse(thread.updatedAt)) <= - now - settings.worktreeSettledAfterDays * DAY_MS; let eligible = deleted || old || settled; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); @@ -428,14 +430,24 @@ export const make = Effect.gen(function* () { Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })), ); }); + // Settle and session-stop events arrive in bursts; one queued sweep covers them all. + let sweepQueued = false; const worker = yield* makeDrainableWorker(() => - sweep().pipe( + Effect.sync(() => { + sweepQueued = false; + }).pipe( + Effect.andThen(sweep()), Effect.catchCauseIf( (cause) => !Cause.hasInterruptsOnly(cause), (cause) => Effect.logWarning("storage cleanup failed", { cause }), ), ), ); + const requestSweep = Effect.suspend(() => { + if (sweepQueued) return Effect.void; + sweepQueued = true; + return worker.enqueue(undefined); + }); const start = Effect.fn("StorageCleanup.start")(function* () { const unsubscribe = yield* terminals.subscribeMetadata((event) => @@ -457,13 +469,11 @@ export const make = Effect.gen(function* () { const events = yield* engine.subscribeDomainEvents; let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie); yield* forkParked( - worker - .enqueue(undefined) - .pipe( - Effect.andThen(worker.drain), - Effect.repeat(Schedule.spaced("1 hour")), - Effect.asVoid, - ), + requestSweep.pipe( + Effect.andThen(worker.drain), + Effect.repeat(Schedule.spaced("1 hour")), + Effect.asVoid, + ), ); yield* forkParked( Stream.runForEach(changes, (settings) => { @@ -474,7 +484,7 @@ export const make = Effect.gen(function* () { ) return Effect.void; lastSettings = settings; - return worker.enqueue(undefined); + return requestSweep; }), ); yield* forkParked( @@ -486,7 +496,7 @@ export const make = Effect.gen(function* () { (event.type === "thread.session-set" && event.payload.session.status === "stopped")) && anyWorktreePolicy(lastSettings, (rules) => rules.worktreeSettledAfterDays === 0)) ) - return worker.enqueue(undefined); + return requestSweep; return Effect.void; }), );