From f2e2c4c2abf71acb5edf42a3b75a828b350530ea Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:23:11 +0000 Subject: [PATCH 1/4] fix(server): settle branch threads immediately on pull request merge --- .../ThreadSettlementReactor.test.ts | 43 ++++++++++++++++++- .../orchestration/ThreadSettlementReactor.ts | 28 ++++++++---- 2 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index f6264200d2f5..002cef40a155 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -161,6 +161,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: }> >([]); const summaryRecovery = yield* Ref.make>([]); + const invalidatedCwds = yield* Ref.make>([]); const updateSettings = (patch: ServerSettingsPatch) => Effect.gen(function* () { @@ -221,7 +222,10 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Effect.andThen(Ref.get(snapshots)), ), }), - Layer.mock(GitManager)({ branchPullRequest }), + Layer.mock(GitManager)({ + branchPullRequest, + invalidateStatus: (cwd) => Ref.update(invalidatedCwds, (cwds) => [...cwds, cwd]), + }), Layer.mock(PullRequestService)({ summary: pullRequestSummary, subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( @@ -251,6 +255,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: branchCalls, summaryCalls, summaryRecovery, + invalidatedCwds, updateSettings, publishMerge: PubSub.publish(mergedPullRequests, { projectId: PROJECT_ID, @@ -446,6 +451,42 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("settles branch threads on a pull request merge without waiting for the next sweep", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"open" | "merged">("open"); + const mergedThreadSettled = yield* Deferred.make(); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("branch-thread", { branch: "saved-feature" })]), + branchPullRequest: () => + Ref.get(state).pipe( + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []); + + yield* Ref.set(state, "merged"); + yield* fixture.publishMerge; + yield* Deferred.await(mergedThreadSettled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("branch-thread")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 9867a855a85e..5cb137d0a912 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -46,15 +46,12 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - const candidates = snapshot.threads.filter( - (thread) => - isAutoSettlementCandidate(thread, now) && - (mergedPullRequest === null || - (thread.linkedPullRequest != null && - thread.linkedPullRequest.projectId === mergedPullRequest.projectId && - thread.linkedPullRequest.repository.toLowerCase() === - mergedPullRequest.repository.toLowerCase() && - thread.linkedPullRequest.number === mergedPullRequest.number)), + // A merge event re-sweeps every candidate, not just the threads linked to + // the merged pull request: most threads carry no link and settle from + // their branch lookup, which would otherwise wait for the next minute's + // sweep on a possibly stale cached answer. + const candidates = snapshot.threads.filter((thread) => + isAutoSettlementCandidate(thread, now), ); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); @@ -76,6 +73,19 @@ export const make = Effect.gen(function* () { }), { concurrency: 8, discard: true }, ); + if (mergedPullRequest !== null) { + // The merge just confirmed a terminal state the lookup caches can still + // call open (branch answers live two minutes, the sweep runs every + // minute). Drop the swept checkouts' cached answers so the merge settles + // its branch threads now instead of on a later sweep. Threads linked to + // the merged pull request settle from the event itself below and need no + // lookup, so they are absent from this map by construction. + const cwds = [...new Set(lookupCwdByThreadId.values())]; + yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { + concurrency: 8, + discard: true, + }); + } const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ From 69ff5ea34d042e36c252a312e19d0d219fdbd769 Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:44:37 +0000 Subject: [PATCH 2/4] fix(server): format settlement sweep --- .../ThreadSettlementReactor.test.ts | 70 ++++++++++--------- .../orchestration/ThreadSettlementReactor.ts | 4 +- 2 files changed, 37 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 002cef40a155..2a5f88c4d788 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -451,40 +451,42 @@ describe("ThreadSettlementReactor", () => { ), ); - it.effect("settles branch threads on a pull request merge without waiting for the next sweep", () => - Effect.scoped( - Effect.gen(function* () { - yield* TestClock.setTime(Date.parse(NOW)); - const state = yield* Ref.make<"open" | "merged">("open"); - const mergedThreadSettled = yield* Deferred.make(); - const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("branch-thread", { branch: "saved-feature" })]), - branchPullRequest: () => - Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), - ), - onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), - }); - - yield* Effect.gen(function* () { - const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; - yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); - assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); - assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []); - - yield* Ref.set(state, "merged"); - yield* fixture.publishMerge; - yield* Deferred.await(mergedThreadSettled); - - assert.deepStrictEqual( - (yield* Ref.get(fixture.commands)).map((command) => command.threadId), - [ThreadId.make("branch-thread")], - ); - assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]); - yield* reactor.drain; - }).pipe(Effect.provide(fixture.layer)); - }), - ), + it.effect( + "settles branch threads on a pull request merge without waiting for the next sweep", + () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const state = yield* Ref.make<"open" | "merged">("open"); + const mergedThreadSettled = yield* Deferred.make(); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([makeThread("branch-thread", { branch: "saved-feature" })]), + branchPullRequest: () => + Ref.get(state).pipe( + Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), []); + + yield* Ref.set(state, "merged"); + yield* fixture.publishMerge; + yield* Deferred.await(mergedThreadSettled); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("branch-thread")], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.invalidatedCwds), ["/workspace/project"]); + yield* reactor.drain; + }).pipe(Effect.provide(fixture.layer)); + }), + ), ); it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 5cb137d0a912..c937dab8f394 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -50,9 +50,7 @@ export const make = Effect.gen(function* () { // the merged pull request: most threads carry no link and settle from // their branch lookup, which would otherwise wait for the next minute's // sweep on a possibly stale cached answer. - const candidates = snapshot.threads.filter((thread) => - isAutoSettlementCandidate(thread, now), - ); + const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( From 9eebe095079e782a171353a28282b106d021e76d Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 00:51:14 +0000 Subject: [PATCH 3/4] fix(server): only settle threads linked to the merged pull request --- .../ThreadSettlementReactor.test.ts | 69 +++++++++++++++++++ .../orchestration/ThreadSettlementReactor.ts | 13 +++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 2a5f88c4d788..c7d795175262 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -489,6 +489,75 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("a merge does not settle threads linked to an unrelated pull request", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const mergedThreadSettled = yield* Deferred.make(); + const mergeLookupStarted = yield* Deferred.make(); + const releaseMergeLookup = yield* Deferred.make(); + const lookupCount = yield* Ref.make(0); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("merged-in-app", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("unrelated-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 99, + url: "https://example.test/owner/repository/pull/99", + }, + }), + ]), + pullRequestSummary: (input) => + Ref.updateAndGet(lookupCount, (count) => count + 1).pipe( + // The initial sweep looks up both linked threads; the merge + // sweep only looks up the unrelated one, since the merged + // thread settles from the event itself. + Effect.tap((count) => + count === 3 ? Deferred.succeed(mergeLookupStarted, undefined) : Effect.void, + ), + Effect.tap((count) => + count === 3 ? Deferred.await(releaseMergeLookup) : Effect.void, + ), + Effect.map(() => makePullRequestSummary({ ...input, state: "open" })), + ), + onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual(yield* Ref.get(fixture.commands), []); + + yield* fixture.publishMerge; + yield* Deferred.await(mergeLookupStarted); + yield* Deferred.await(mergedThreadSettled); + yield* Deferred.succeed(releaseMergeLookup, undefined); + yield* reactor.drain; + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map((command) => command.threadId), + [ThreadId.make("merged-in-app")], + ); + assert.deepStrictEqual( + (yield* Ref.get(fixture.summaryCalls)) + .map((call) => call.number) + .toSorted((left, right) => left - right), + [42, 99, 99], + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index c937dab8f394..6539135adfe6 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -105,7 +105,18 @@ export const make = Effect.gen(function* () { thread: (typeof candidates)[number], ) { if (thread.linkedPullRequest != null) { - if (mergedPullRequest !== null) { + // The event carries the merged state, so only the threads linked to + // that exact pull request settle from it. Every other linked thread + // falls through to a fresh summary lookup below: the merge sweep + // covers all candidates, and an unrelated merge must never settle + // them. + if ( + mergedPullRequest !== null && + thread.linkedPullRequest.projectId === mergedPullRequest.projectId && + thread.linkedPullRequest.repository.toLowerCase() === + mergedPullRequest.repository.toLowerCase() && + thread.linkedPullRequest.number === mergedPullRequest.number + ) { return { state: "merged", updatedAt: mergedPullRequest.mergedAt, From 85d76cc92d5c247e4cf63c3d6af8c08c8cc41acf Mon Sep 17 00:00:00 2001 From: maria-rcks <254055478+maria-rcks@users.noreply.github.com> Date: Fri, 4 Sep 2026 01:02:52 +0000 Subject: [PATCH 4/4] fix(server): refresh branch pull request lookups every minute --- apps/server/src/git/GitManager.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 393a8fd05592..d65417e99e13 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -120,7 +120,13 @@ const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const STATUS_RESULT_CACHE_TTL = Duration.seconds(1); const STATUS_RESULT_CACHE_CAPACITY = 2_048; -const PR_LOOKUP_CACHE_TTL = Duration.minutes(2); +// Matches the automatic settlement sweep cadence so every background sweep +// reads fresh branch state: an external merge settles within about a minute +// instead of waiting out a longer cache. Unpublished branches never reach the +// host (a local probe answers first), and failed lookups still back off +// exponentially via prLookupFailureTtl, so throttling pressure still drops +// under 429s instead of amplifying it. +const PR_LOOKUP_CACHE_TTL = Duration.seconds(60); const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20); const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15); const PR_LOOKUP_CACHE_CAPACITY = 2_048;