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 07afbaf05e6a..b3b604b8fee5 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -1455,6 +1455,20 @@ describe("storage cleanup", () => { "terminal-cwd", "terminal-worktree", "recent", + "settled-immediate", + "settled-wait", + "settled-expired", + "settled-auto-recent", + "settled-legacy", + "settled-active", + "settled-pinned-active", + "settled-dirty", + "settled-session", + "settled-project-off", + "settled-project-custom", + "settled-archived", + "settled-woke", + "settled-event", "merged", "unmerged", "unchanged", @@ -1519,12 +1533,29 @@ 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 === "settled-auto-recent" ? { updatedAt: NOW } : {}), + ...(protection.startsWith("settled-") && + protection !== "settled-active" && + protection !== "settled-event" + ? { + settledOverride: "settled" as const, + settledAt: + protection === "settled-expired" || protection === "settled-auto-recent" + ? "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: { threadId: ThreadId.make("storage-thread"), @@ -1539,6 +1570,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 +1593,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 +1617,23 @@ 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" || + protection === "settled-auto-recent" + ? 8 + : 0 + : null, worktreeOnDelete: deleteRule && protection !== "deleted-project-custom", worktreeOnMerge: mergeRule, worktreeUnchanged: unchangedRule, @@ -1640,6 +1692,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 +1706,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 +1744,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 +1831,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 +1883,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 +1933,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 +1977,12 @@ describe("storage cleanup", () => { protection === "project-custom" || protection === "deleted-project-custom" || protection === "none" || + protection === "settled-immediate" || + protection === "settled-expired" || + protection === "settled-legacy" || + protection === "settled-project-custom" || + protection === "settled-archived" || + protection === "settled-event" || protection === "deleted" || protection === "deleted-event" || protection === "deleted-owner" || @@ -1907,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 400ae635018f..20d16b8961e9 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; @@ -207,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* () { @@ -238,11 +252,7 @@ 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; - let eligible = deleted || old; + 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 +323,10 @@ 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 || + latest[0]!.updatedAt !== thread.updatedAt ) return; const finalStatus = yield* git.statusDetailsLocal(worktreePath); @@ -417,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) => @@ -446,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) => { @@ -463,16 +484,21 @@ export const make = Effect.gen(function* () { ) return Effect.void; lastSettings = settings; - return worker.enqueue(undefined); + return requestSweep; }), ); 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 requestSweep; + 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..0b706ef68c88 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); } @@ -83,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 = { @@ -223,6 +225,36 @@ export function StorageSettingsPanel() { /> } /> + {connectedEnvironments.length > 0 && + connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.settledWorktreeCleanup === + 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={ + + 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/environment.ts b/packages/contracts/src/environment.ts index 089133d878a4..4cfa1bb21a8e 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -116,6 +116,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), + settledWorktreeCleanup: 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.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.test.ts b/packages/shared/src/serverSettings.test.ts index 05f625a1bd0f..76e68bccd39a 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -34,6 +34,7 @@ describe("serverSettings helpers", () => { }).storageCleanup, ).toEqual({ worktreeAfterDays: null, + worktreeSettledAfterDays: null, worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, 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,