diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index ccf74f7d193e..cc489975359f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -358,6 +358,53 @@ describe("resolveAutoSettlementAt", () => { ).toBeNull(); }); + it("does not settle on pull requests that were already merged when linked", () => { + const link = (number: number, linkedAt: DateTime.Utc, mergedAt: DateTime.Utc) => ({ + host: "example.test", + repository: "owner/repository", + number, + url: `https://example.test/owner/repository/pull/${number}`, + source: "agent" as const, + linkedAt: DateTime.formatIso(linkedAt), + snapshot: { + state: "merged" as const, + title: "Pull request", + headBranch: `feature-${number}`, + baseBranch: "main", + isDraft: false, + updatedAt: DateTime.formatIso(mergedAt), + syncedAt: DateTime.formatIso(linkedAt), + mergedAt: DateTime.formatIso(mergedAt), + }, + stack: null, + }); + // The user asked the agent to merge pull requests; it merged one, then + // linked it to the thread as a record of the work. + const recorded = link(1, at(-50 * 60 * 1_000), at(-55 * 60 * 1_000)); + const input = { + thread: shell({ + latestUserMessageAt: at(-2 * 60 * 60 * 1_000), + latestRunCompletedAt: at(-45 * 60 * 1_000), + pullRequests: [recorded], + }), + pullRequest: null, + nowMs: NOW_MS, + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }; + expect(ThreadSettlementService.resolveAutoSettlementAt(input)).toBeNull(); + // A pull request linked while open still settles the thread when it merges. + expect( + ThreadSettlementService.resolveAutoSettlementAt({ + ...input, + thread: { + ...input.thread, + pullRequests: [recorded, link(2, at(-90 * 60 * 1_000), at(-40 * 60 * 1_000))], + }, + }), + ).toEqual(at(-45 * 60 * 1_000)); + }); + it("settles inactive threads even when their pull request remains open", () => { const input = { thread: shell({ latestUserMessageAt: at(-30 * DAY_MS) }), @@ -833,6 +880,64 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("does not look up a pull request that was already merged when linked", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const reference = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged-then-linked", { + linkedPullRequest: reference, + pullRequests: [ + { + host: "example.test", + repository: reference.repository, + number: reference.number, + url: reference.url, + source: "agent", + linkedAt: "2026-08-28T00:00:00.000Z", + snapshot: { + state: "merged", + title: "Pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: "2026-08-27T00:00:00.000Z", + syncedAt: "2026-08-28T00:00:00.000Z", + mergedAt: "2026-08-27T00:00:00.000Z", + }, + stack: null, + }, + ], + }), + ]), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("reevaluates immediately after a pull request merge", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8b55e25cdc39..ddde63d783e4 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -77,6 +77,34 @@ function pullRequestMatchesProject( ); } +type SettlementPullRequestLink = NonNullable< + ProjectionStore.ProjectionSettlementCandidate["pullRequests"] +>[number]; + +/** When the linked pull request merged or closed, or -Infinity if it has not. */ +function linkTerminalAtMs(link: SettlementPullRequestLink): number { + const snapshot = link.snapshot; + const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; + const timestamp = Date.parse(value ?? ""); + return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; +} + +/** + * The links whose outcome the thread waits on. A link made after its pull + * request had already merged or closed records work the thread did, such as an + * agent merging pull requests it was asked to merge, so it cannot settle the + * thread. + */ +function settlementPullRequestLinks( + links: ReadonlyArray, +): ReadonlyArray { + return visibleThreadPullRequests(links).filter((link) => { + if (link.snapshot === null || link.snapshot.state === "open") return true; + const linkedAtMs = Date.parse(link.linkedAt); + return Number.isNaN(linkedAtMs) || linkTerminalAtMs(link) >= linkedAtMs; + }); +} + /** * A recent user message stays queued until a run adopts its timestamp. * Absolute age bounds client clock skew in both directions and stops stale @@ -205,17 +233,11 @@ export function resolveAutoSettlementAt(input: { }): DateTime.Utc | null { const { thread } = input; let pullRequest = input.pullRequest; - const links = visibleThreadPullRequests(thread.pullRequests ?? []); + const links = settlementPullRequestLinks(thread.pullRequests ?? []); if (links.some((link) => link.snapshot === null || link.snapshot.state === "open")) return null; if (links.length > 0) { - const terminalAt = (link: (typeof links)[number]) => { - const snapshot = link.snapshot; - const value = snapshot?.state === "merged" ? snapshot.mergedAt : snapshot?.closedAt; - const timestamp = Date.parse(value ?? ""); - return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp; - }; const latest = links.reduce((current, candidate) => - terminalAt(candidate) > terminalAt(current) ? candidate : current, + linkTerminalAtMs(candidate) > linkTerminalAtMs(current) ? candidate : current, ); pullRequest = latest.snapshot === null