Skip to content
Open
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.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
projectWorktreeCleanup: true,
worktreesDirectory: true,
threadAutoSettlementScope: true,
threadAutoSettleSnoozedOnMerge: true,
});
}
}),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export const make = Effect.gen(function* () {
settledWorktreeCleanup: true,
worktreesDirectory: true,
threadAutoSettlementScope: true,
threadAutoSettleSnoozedOnMerge: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
projectProviderInstanceOverrides: true,
Expand Down
180 changes: 180 additions & 0 deletions apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ describe("isAutoSettlementCandidate", () => {
snoozedAt: at(-60 * 60 * 1_000),
});
expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false);
expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS, true)).toBe(true);
expect(
ThreadSettlementService.isAutoSettlementCandidate(
shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }),
Expand Down Expand Up @@ -461,6 +462,18 @@ describe("autoSettlementSettingsKey", () => {
});
assert.notStrictEqual(inherits, never);
});

it("changes when snoozed-on-merge is toggled", () => {
const off = ThreadSettlementService.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleSnoozedOnMerge: false,
});
const on = ThreadSettlementService.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleSnoozedOnMerge: true,
});
assert.notStrictEqual(off, on);
});
});

type AutoSettleCommand = Extract<OrchestrationV2Command, { readonly type: "thread.auto-settle" }>;
Expand Down Expand Up @@ -746,6 +759,102 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
});

describe("ThreadSettlementServiceV2 worker", () => {
it.effect("skips a snoozed thread with a merged link until snoozed-on-merge is enabled", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const thread = makeThread("snoozed-merged-link", {
snoozedAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
snoozedUntil: DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"),
pullRequests: [
{
host: "example.test",
repository: "owner/repository",
number: 42,
url: "https://example.test/owner/repository/pull/42",
source: "manual",
linkedAt: "2026-08-20T00:00:00.000Z",
snapshot: {
state: "merged",
title: "Pull request",
headBranch: "feature",
baseBranch: "main",
isDraft: false,
updatedAt: NOW,
syncedAt: NOW,
mergedAt: NOW,
},
stack: null,
},
],
});
const fixture = yield* makeHarness({
snapshot: makeSnapshot([thread]),
settings: {
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleSnoozedOnMerge: false,
},
});

yield* Effect.gen(function* () {
const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
yield* startHarness(service, fixture.activation, fixture.snapshotReads);
expect(yield* Ref.get(fixture.commands)).toEqual([]);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("settles a snoozed thread with a merged link when snoozed-on-merge is enabled", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const thread = makeThread("snoozed-merged-link-opt-in", {
snoozedAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"),
snoozedUntil: DateTime.makeUnsafe("2026-08-29T00:00:00.000Z"),
pullRequests: [
{
host: "example.test",
repository: "owner/repository",
number: 42,
url: "https://example.test/owner/repository/pull/42",
source: "manual",
linkedAt: "2026-08-20T00:00:00.000Z",
snapshot: {
state: "merged",
title: "Pull request",
headBranch: "feature",
baseBranch: "main",
isDraft: false,
updatedAt: NOW,
syncedAt: NOW,
mergedAt: NOW,
},
stack: null,
},
],
});
const fixture = yield* makeHarness({
snapshot: makeSnapshot([thread]),
settings: {
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleSnoozedOnMerge: true,
},
});

yield* Effect.gen(function* () {
const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
yield* startHarness(service, fixture.activation, fixture.snapshotReads);
expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([
thread.id,
]);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("settles a merged pull request stored only in the thread links", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -1450,5 +1559,76 @@ describe("isSnoozed", () => {
autoSettleOnMerge: true,
}),
).toBeNull();
expect(
ThreadSettlementService.resolveAutoSettlementAt({
thread,
pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
nowMs: NOW_MS,
autoSettleAfterDays: null,
autoSettleOnMerge: true,
autoSettleSnoozedOnMerge: true,
}),
).toEqual(at(-DAY_MS));
});

it("ignores a merge that finished before the current snooze started", () => {
const thread = shell({
...snoozed,
status: "completed",
latestRunCompletedAt: at(-DAY_MS),
});

expect(
ThreadSettlementService.resolveAutoSettlementAt({
thread,
pullRequest: {
state: "merged",
mergedAt: DateTime.formatIso(at(-3 * DAY_MS)),
},
nowMs: NOW_MS,
autoSettleAfterDays: null,
autoSettleOnMerge: true,
autoSettleSnoozedOnMerge: true,
}),
).toBeNull();
});

it("settles an earlier merge once the snooze timer has expired", () => {
const thread = shell({
snoozedAt: at(-2 * DAY_MS),
snoozedUntil: at(-1_000),
status: "completed",
latestRunCompletedAt: at(-DAY_MS),
});

expect(ThreadSettlementService.isSnoozed(thread, NOW_MS)).toBe(false);
expect(
ThreadSettlementService.resolveAutoSettlementAt({
thread,
pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-3 * DAY_MS)) },
nowMs: NOW_MS,
autoSettleAfterDays: null,
autoSettleOnMerge: true,
}),
).not.toBeNull();
});

it("requires merge settlement to be enabled before snoozed-on-merge applies", () => {
const thread = shell({
...snoozed,
status: "completed",
latestRunCompletedAt: at(-DAY_MS),
});

expect(
ThreadSettlementService.resolveAutoSettlementAt({
thread,
pullRequest: { state: "merged", mergedAt: DateTime.formatIso(at(-1)) },
nowMs: NOW_MS,
autoSettleAfterDays: null,
autoSettleOnMerge: false,
autoSettleSnoozedOnMerge: true,
}),
).toBeNull();
});
});
47 changes: 40 additions & 7 deletions apps/server/src/orchestration-v2/ThreadSettlementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,11 @@ export function threadHasQueuedTurnStart(
function pullRequestSettles(
thread: Pick<
ProjectionStore.ProjectionSettlementCandidate,
"createdAt" | "latestUserAuthoredMessageAt"
"createdAt" | "latestUserAuthoredMessageAt" | "snoozedAt"
>,
pullRequest: SettlementPullRequest,
autoSettleOnMerge: boolean,
snoozed: boolean,
): boolean {
if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) {
return false;
Expand All @@ -139,12 +140,24 @@ function pullRequestSettles(
if (userAnchorMs === null) return false;
const pullRequestAtMs = Date.parse(terminalAt);
if (Number.isNaN(pullRequestAtMs)) return false;
return pullRequestAtMs >= userAnchorMs;
if (pullRequestAtMs < userAnchorMs) return false;
const snoozedAtMs = toMillis(thread.snoozedAt);
// `snoozedAt` outlives the timer, so only an active snooze can reject an earlier merge.
if (
snoozed &&
pullRequest.state === "merged" &&
snoozedAtMs !== null &&
pullRequestAtMs < snoozedAtMs
) {
return false;
}
return true;
}

export function isAutoSettlementCandidate(
thread: Omit<ProjectionStore.ProjectionSettlementCandidate, "latestUserAuthoredMessageAt">,
nowMs: number,
settleSnoozedOnMerge = false,
): boolean {
if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;
Expand All @@ -155,7 +168,7 @@ export function isAutoSettlementCandidate(
if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false;
if (threadHasQueuedTurnStart(thread, nowMs)) return false;
// Interrupted runs must keep the same snooze protection the sidebar shows.
return !isSnoozed(thread, nowMs);
return settleSnoozedOnMerge || !isSnoozed(thread, nowMs);
}

/** Match client effectiveSnoozed so agents and sidebar agree. */
Expand Down Expand Up @@ -191,6 +204,7 @@ export function resolveAutoSettlementAt(input: {
readonly autoSettleAfterDays: number | null;
readonly autoSettleOnMerge: boolean;
readonly autoSettleKeepPullRequests?: boolean;
readonly autoSettleSnoozedOnMerge?: boolean;
}): DateTime.Utc | null {
const { thread } = input;
let pullRequest = input.pullRequest;
Expand All @@ -214,17 +228,24 @@ export function resolveAutoSettlementAt(input: {
closedAt: latest.snapshot.closedAt ?? null,
};
}
if (!isAutoSettlementCandidate(thread, input.nowMs)) return null;
const settleSnoozedOnMerge = input.autoSettleOnMerge && input.autoSettleSnoozedOnMerge === true;
if (!isAutoSettlementCandidate(thread, input.nowMs, settleSnoozedOnMerge)) return null;
const snoozed = isSnoozed(thread, input.nowMs);
const activityAtMs =
latestMillis([
toMillis(thread.latestUserMessageAt),
toMillis(thread.latestRunRequestedAt),
toMillis(thread.latestRunStartedAt),
toMillis(thread.latestRunCompletedAt),
]) ?? DateTime.toEpochMillis(thread.createdAt);
if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
if (
pullRequest !== null &&
(!snoozed || pullRequest.state === "merged") &&
pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge, snoozed)
) {
return DateTime.makeUnsafe(activityAtMs);
}
if (snoozed) return null;
const hasPullRequest =
hasOpenLink ||
pullRequest !== null ||
Expand Down Expand Up @@ -269,12 +290,14 @@ export function autoSettlementSettingsKey(
settings.sidebarAutoSettleOnMerge,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleKeepPullRequests,
settings.sidebarAutoSettleSnoozedOnMerge,
Object.entries(settings.projectSettingsOverrides)
.filter(
([, entry]) =>
entry.sidebarAutoSettleOnMerge !== undefined ||
entry.sidebarAutoSettleAfterDays !== undefined ||
entry.sidebarAutoSettleKeepPullRequests !== undefined,
entry.sidebarAutoSettleKeepPullRequests !== undefined ||
entry.sidebarAutoSettleSnoozedOnMerge !== undefined,
)
.sort(([left], [right]) => left.localeCompare(right))
.map(([projectId, entry]) => [
Expand All @@ -284,6 +307,7 @@ export function autoSettlementSettingsKey(
? "inherit"
: entry.sidebarAutoSettleAfterDays,
entry.sidebarAutoSettleKeepPullRequests ?? "inherit",
entry.sidebarAutoSettleSnoozedOnMerge ?? "inherit",
]),
]);
}
Expand Down Expand Up @@ -319,7 +343,14 @@ export const make = Effect.gen(function* () {
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const projects = new Map(projectShells.map((project) => [project.id, project]));
// Merge events resweep all candidates because unlinked threads settle through branch lookup.
const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
const candidates = threads.filter((thread) => {
const { settings: projectSettings } = resolveProjectSettings(settings, thread.projectId);
return isAutoSettlementCandidate(
thread,
nowMs,
projectSettings.sidebarAutoSettleOnMerge && projectSettings.sidebarAutoSettleSnoozedOnMerge,
);
});

// Runs per candidate thread, thousands per sweep on busy servers; the sweep owns the span.
const settleThread = Effect.fnUntraced(
Expand All @@ -337,6 +368,7 @@ export const make = Effect.gen(function* () {
autoSettleAfterDays: currentSettings.sidebarAutoSettleAfterDays,
autoSettleOnMerge: currentSettings.sidebarAutoSettleOnMerge,
autoSettleKeepPullRequests: currentSettings.sidebarAutoSettleKeepPullRequests,
autoSettleSnoozedOnMerge: currentSettings.sidebarAutoSettleSnoozedOnMerge,
});
if (settledAt === null) return thread;
const uuid = yield* crypto.randomUUIDv4();
Expand Down Expand Up @@ -433,6 +465,7 @@ export const make = Effect.gen(function* () {
autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
autoSettleKeepPullRequests: settings.sidebarAutoSettleKeepPullRequests,
autoSettleSnoozedOnMerge: settings.sidebarAutoSettleSnoozedOnMerge,
}) !== null
);
});
Expand Down
Loading
Loading