Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () {
threadAutoSettlement: true,
storageCleanup: true,
projectWorktreeCleanup: true,
settledWorktreeCleanup: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
Expand Down
135 changes: 119 additions & 16 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand All @@ -1539,6 +1570,7 @@ describe("storage cleanup", () => {
: {}),
});
const snapshotRead = yield* Deferred.make<void>();
const settledSnapshotRead = yield* Deferred.make<void>();
const deletionStarted = yield* Deferred.make<void>();
const deletionStopped = yield* Deferred.make<void>();
if (protection !== "deleted-event") yield* Deferred.succeed(deletionStopped, undefined);
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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++;
Expand All @@ -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")
Expand Down Expand Up @@ -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,
},
]
: [],
),
),
}),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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" ||
Expand All @@ -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-"));
Expand Down
70 changes: 48 additions & 22 deletions apps/server/src/storageCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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* () {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) => {
Expand All @@ -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"];
Expand Down
Loading
Loading