diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 03933d9d0048..8564285a2c13 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -230,6 +230,186 @@ describe("threadHasQueuedTurnStart", () => { }); describe("resolveAutoSettlementAt", () => { + it.each([ + { + name: "keeps a thread active when its snooze ended an hour ago", + snoozedUntil: at(-60 * 60 * 1_000), + latestUserMessageAt: at(-10 * DAY_MS), + autoSettleAfterDays: 3, + expected: null, + }, + { + name: "settles at the wake time after the inactivity window elapses", + snoozedUntil: at(-4 * DAY_MS), + latestUserMessageAt: at(-10 * DAY_MS), + autoSettleAfterDays: 3, + expected: at(-4 * DAY_MS), + }, + { + name: "keeps a thread active when activity after waking is still recent", + snoozedUntil: at(-5 * DAY_MS), + latestUserMessageAt: at(-2 * DAY_MS), + autoSettleAfterDays: 3, + expected: null, + }, + { + name: "keeps a still-snoozed thread parked despite old activity", + snoozedUntil: at(DAY_MS), + latestUserMessageAt: at(-10 * DAY_MS), + autoSettleAfterDays: 3, + expected: null, + }, + { + name: "preserves the activity anchor for a never-snoozed thread", + snoozedUntil: null, + latestUserMessageAt: at(-10 * DAY_MS), + autoSettleAfterDays: 3, + expected: at(-10 * DAY_MS), + }, + { + name: "keeps a woken thread active when inactivity settlement is disabled", + snoozedUntil: at(-4 * DAY_MS), + latestUserMessageAt: at(-10 * DAY_MS), + autoSettleAfterDays: null, + expected: null, + }, + ])("$name", ({ snoozedUntil, latestUserMessageAt, autoSettleAfterDays, expected }) => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ snoozedUntil, latestUserMessageAt }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays, + autoSettleOnMerge: true, + }), + ).toEqual(expected); + }); + + it("ignores a future snooze anchor when completion already woke the thread", () => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ + snoozedAt: at(-11 * DAY_MS), + snoozedUntil: at(DAY_MS), + latestUserMessageAt: at(-10 * DAY_MS), + latestRunCompletedAt: at(-10 * DAY_MS), + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: true, + }), + ).toEqual(at(-10 * DAY_MS)); + }); + + it.each([ + { + name: "preserves early completion as the anchor after the snooze deadline passes", + status: "idle" as const, + snoozedAt: at(-15 * DAY_MS), + latestRunCompletedAt: at(-10 * DAY_MS), + snoozedUntil: at(-DAY_MS), + expected: at(-10 * DAY_MS), + }, + { + name: "preserves early failure as the anchor after the snooze deadline passes", + status: "failed" as const, + snoozedAt: at(-15 * DAY_MS), + latestRunCompletedAt: at(-10 * DAY_MS), + snoozedUntil: at(-DAY_MS), + expected: at(-10 * DAY_MS), + }, + { + name: "preserves activity for a failed thread with an unknown snooze start after its deadline", + status: "failed" as const, + snoozedAt: null, + latestRunCompletedAt: at(-10 * DAY_MS), + snoozedUntil: at(-DAY_MS), + expected: at(-10 * DAY_MS), + }, + { + name: "keeps a timed wake active when completion predates the snooze", + status: "idle" as const, + snoozedAt: at(-15 * DAY_MS), + latestRunCompletedAt: at(-20 * DAY_MS), + snoozedUntil: at(-DAY_MS), + expected: null, + }, + { + name: "settles at the timed wake when completion predates the snooze", + status: "idle" as const, + snoozedAt: at(-15 * DAY_MS), + latestRunCompletedAt: at(-20 * DAY_MS), + snoozedUntil: at(-4 * DAY_MS), + expected: at(-4 * DAY_MS), + }, + ])("$name", ({ status, snoozedAt, latestRunCompletedAt, snoozedUntil, expected }) => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ + status, + snoozedAt, + snoozedUntil, + latestUserMessageAt: at(-20 * DAY_MS), + latestRunRequestedAt: at(-20 * DAY_MS), + latestRunStartedAt: at(-20 * DAY_MS), + latestRunCompletedAt, + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }), + ).toEqual(expected); + }); + + it("preserves the early completion anchor on both sides of the snooze deadline", () => { + const snoozedUntil = at(-DAY_MS); + const completedAt = at(-10 * DAY_MS); + const input = { + thread: shell({ + snoozedAt: at(-15 * DAY_MS), + snoozedUntil, + latestUserMessageAt: at(-20 * DAY_MS), + latestRunRequestedAt: at(-20 * DAY_MS), + latestRunStartedAt: at(-20 * DAY_MS), + latestRunCompletedAt: completedAt, + }), + pullRequest: null, + autoSettleAfterDays: 3, + autoSettleOnMerge: false, + }; + const deadlineMs = DateTime.toEpochMillis(snoozedUntil); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: deadlineMs - 60_000 }), + ).toEqual(completedAt); + expect( + ThreadSettlementService.resolveAutoSettlementAt({ ...input, nowMs: deadlineMs + 60_000 }), + ).toEqual(completedAt); + }); + + it.each(["merged", "closed"] as const)( + "preserves the activity anchor after waking when a pull request is %s", + (state) => { + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + thread: shell({ + snoozedUntil: at(-60 * 60 * 1_000), + latestUserMessageAt: at(-10 * DAY_MS), + }), + pullRequest: { + state, + mergedAt: DateTime.formatIso(at(-5 * DAY_MS)), + closedAt: DateTime.formatIso(at(-5 * DAY_MS)), + }, + nowMs: NOW_MS, + autoSettleAfterDays: 3, + autoSettleOnMerge: state === "merged", + }), + ).toEqual(at(-10 * DAY_MS)); + }, + ); + it("uses the latest activity time when the inactivity window elapses", () => { const idle = shell({ latestUserMessageAt: at(-4 * DAY_MS), @@ -610,6 +790,32 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementServiceV2 worker", () => { + it.effect("does not auto-settle a recently woken thread with old activity during a sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const latestUserMessageAt = DateTime.makeUnsafe(Date.parse(NOW) - 10 * DAY_MS); + const woken = makeThread("recently-woken", { + latestUserMessageAt, + snoozedUntil: DateTime.makeUnsafe(Date.parse(NOW) - 60 * 60 * 1_000), + }); + const inactive = makeThread("never-snoozed", { latestUserMessageAt }); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([woken, inactive]), + settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 3 }, + }); + + 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([ + inactive.id, + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("settles a merged pull request stored only in the thread links", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 74c9de8d6b37..dbb22e529bf1 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -129,6 +129,18 @@ function pullRequestSettles( return pullRequestAtMs >= userAnchorMs; } +/** Whether a snoozed thread woke before its wake time (error or completed work). */ +function snoozeWokeEarly(thread: ProjectionStore.ProjectionSettlementCandidate): boolean { + const snoozedAtMs = toMillis(thread.snoozedAt); + const completedAtMs = toMillis(thread.latestRunCompletedAt); + const wokeOnError = + thread.status === "failed" && + (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); + const wokeOnCompletion = + snoozedAtMs !== null && completedAtMs !== null && completedAtMs > snoozedAtMs; + return wokeOnError || wokeOnCompletion; +} + /** Cheap checks that run before any source control lookup. */ export function isAutoSettlementCandidate( thread: ProjectionStore.ProjectionSettlementCandidate, @@ -144,16 +156,9 @@ export function isAutoSettlementCandidate( if (threadHasQueuedTurnStart(thread, nowMs)) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; - // A snoozed thread that woke early (error or completed work) can settle; - // one still parked on its wake time keeps its stronger statement. - const snoozedAtMs = toMillis(thread.snoozedAt); - const completedAtMs = toMillis(thread.latestRunCompletedAt); - const wokeOnError = - thread.status === "failed" && - (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); - const wokeOnCompletion = - snoozedAtMs !== null && completedAtMs !== null && completedAtMs > snoozedAtMs; - return wokeOnError || wokeOnCompletion; + // A snoozed thread that woke early can settle; one still parked on its + // wake time keeps its stronger statement. + return snoozeWokeEarly(thread); } export function resolveAutoSettlementAt(input: { @@ -197,8 +202,17 @@ export function resolveAutoSettlementAt(input: { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } if (input.autoSettleAfterDays === null || activityAtMs === null) return null; - return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS - ? DateTime.makeUnsafe(activityAtMs) + // A timed wake fires no event, so the inactivity window restarts at the + // wake time; otherwise work idle since before the snooze settles on the + // first sweep after it wakes. A snooze still in the future never anchors, + // and neither does one that woke early: that wake is already in activity. + const snoozedUntilMs = toMillis(thread.snoozedUntil); + const inactiveSinceMs = + snoozedUntilMs !== null && snoozedUntilMs <= input.nowMs && !snoozeWokeEarly(thread) + ? Math.max(activityAtMs, snoozedUntilMs) + : activityAtMs; + return inactiveSinceMs < input.nowMs - input.autoSettleAfterDays * DAY_MS + ? DateTime.makeUnsafe(inactiveSinceMs) : null; }