From 7fff5d561aaca17fa1a7186940e3174756abdce2 Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Sun, 11 Oct 2026 16:30:05 +0200 Subject: [PATCH 1/2] fix(server): threads no longer settle after merging PRs they were asked to merge A thread asked to merge pull requests would link each one after merging it. Settle-on-merge then read those links as the thread's own work shipping and settled it while the agent was still working. A link made after its pull request had already merged or closed is now a record of work, not an outcome the thread waits on, so it no longer settles it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ThreadSettlementService.test.ts | 47 +++++++++++++++++++ .../ThreadSettlementService.ts | 40 ++++++++++++---- 2 files changed, 78 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index ccf74f7d193e..9375836270e5 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) }), diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 8b55e25cdc39..df41b9f9664e 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 @@ -376,7 +398,7 @@ export const make = Effect.gen(function* () { { concurrency: 8 }, )) .filter((thread) => thread !== null) - .filter((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).length === 0); + .filter((thread) => settlementPullRequestLinks(thread.pullRequests ?? []).length === 0); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( From 65cafe587adec4c6ec58330dfdeb5fda03ccfd7f Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Sun, 11 Oct 2026 16:45:14 +0200 Subject: [PATCH 2/2] fix(server): keep linked threads out of the branch pull request lookup Threads with only already-merged links went to the branch lookup, which reads the legacy linkedPullRequest reference first. When that reference was one of the excluded links, its merged summary still settled the thread. Linked threads skip the lookup again, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ThreadSettlementService.test.ts | 58 +++++++++++++++++++ .../ThreadSettlementService.ts | 2 +- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 9375836270e5..cc489975359f 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -880,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 df41b9f9664e..ddde63d783e4 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -398,7 +398,7 @@ export const make = Effect.gen(function* () { { concurrency: 8 }, )) .filter((thread) => thread !== null) - .filter((thread) => settlementPullRequestLinks(thread.pullRequests ?? []).length === 0); + .filter((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).length === 0); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach(