authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-03 14:03:42-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-03 14:03:42-04:00
logc78f05a45e1c2b274d8b0ab2b4d2d88b4767c968
treedf417f431353de58e2ff56433f0066e142021cf2
parent4e89d74436a167c01f25a6a5283638843398ea3a
signature Signed by PGP key B5690EEEBB952194

fix(server): reuse pr state when settling threads (#9459)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

2 files changed, 69 insertions(+), 7 deletions(-)

apps/server/src/orchestration/ThreadSettlementReactor.test.ts+42
...@@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest";...@@ -17,6 +17,7 @@ import { assert, describe, it } from "@effect/vitest";
17import * as Crypto from "effect/Crypto";17import * as Crypto from "effect/Crypto";
18import * as Deferred from "effect/Deferred";18import * as Deferred from "effect/Deferred";
19import * as Effect from "effect/Effect";19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
20import * as Layer from "effect/Layer";21import * as Layer from "effect/Layer";
21import * as PubSub from "effect/PubSub";22import * as PubSub from "effect/PubSub";
22import * as Queue from "effect/Queue";23import * as Queue from "effect/Queue";
...@@ -134,6 +135,7 @@ interface HarnessOptions {...@@ -134,6 +135,7 @@ interface HarnessOptions {
134 readonly settings?: ServerSettings;135 readonly settings?: ServerSettings;
135 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];136 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
136 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];137 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
138 readonly existingWorktreePaths?: ReadonlyArray<string>;
137 readonly onDispatch?: (139 readonly onDispatch?: (
138 command: AutoSettleCommand,140 command: AutoSettleCommand,
139 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;141 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
...@@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
235 Layer.succeed(ServerSettingsService, serverSettings),237 Layer.succeed(ServerSettingsService, serverSettings),
236 Layer.succeed(ServerActivation, Deferred.await(activation)),238 Layer.succeed(ServerActivation, Deferred.await(activation)),
237 Layer.succeed(Crypto.Crypto, testCrypto),239 Layer.succeed(Crypto.Crypto, testCrypto),
240 FileSystem.layerNoop({
241 exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
242 }),
238 );243 );
239244
240 return {245 return {
...@@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => {...@@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => {
647 ),652 ),
648 );653 );
649654
655 it.effect("looks up the branch pull request from a thread's live worktree", () =>
656 Effect.scoped(
657 Effect.gen(function* () {
658 yield* TestClock.setTime(Date.parse(NOW));
659 const fixture = yield* makeHarness({
660 snapshot: makeSnapshot(
661 [
662 makeThread("live-worktree", {
663 branch: "feature/live",
664 worktreePath: "/workspace/project-root/.worktrees/live",
665 }),
666 makeThread("deleted-worktree", {
667 branch: "feature/deleted",
668 worktreePath: "/workspace/project-root/.worktrees/deleted",
669 }),
670 ],
671 [makeProject(PROJECT_ID, "/workspace/project-root")],
672 ),
673 existingWorktreePaths: ["/workspace/project-root/.worktrees/live"],
674 });
675
676 yield* Effect.gen(function* () {
677 const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
678 yield* startHarness(reactor, fixture.activation, fixture.snapshotReads);
679
680 assert.deepStrictEqual(
681 new Set(yield* Ref.get(fixture.branchCalls)),
682 new Set([
683 { cwd: "/workspace/project-root/.worktrees/live", branch: "feature/live" },
684 { cwd: "/workspace/project-root", branch: "feature/deleted" },
685 ]),
686 );
687 }).pipe(Effect.provide(fixture.layer));
688 }),
689 ),
690 );
691
650 it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>692 it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
651 Effect.scoped(693 Effect.scoped(
652 Effect.gen(function* () {694 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+27-7
...@@ -5,6 +5,7 @@ import * as Context from "effect/Context";...@@ -5,6 +5,7 @@ import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";5import * as Crypto from "effect/Crypto";
6import * as DateTime from "effect/DateTime";6import * as DateTime from "effect/DateTime";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
9import * as Schedule from "effect/Schedule";10import * as Schedule from "effect/Schedule";
10import type * as Scope from "effect/Scope";11import type * as Scope from "effect/Scope";
...@@ -37,6 +38,7 @@ export const make = Effect.gen(function* () {...@@ -37,6 +38,7 @@ export const make = Effect.gen(function* () {
37 const git = yield* GitManager.GitManager;38 const git = yield* GitManager.GitManager;
38 const pullRequests = yield* PullRequestService.PullRequestService;39 const pullRequests = yield* PullRequestService.PullRequestService;
39 const crypto = yield* Crypto.Crypto;40 const crypto = yield* Crypto.Crypto;
41 const fileSystem = yield* FileSystem.FileSystem;
4042
41 const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (43 const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
42 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,44 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
...@@ -54,6 +56,26 @@ export const make = Effect.gen(function* () {...@@ -54,6 +56,26 @@ export const make = Effect.gen(function* () {
54 mergedPullRequest.repository.toLowerCase() &&56 mergedPullRequest.repository.toLowerCase() &&
55 thread.linkedPullRequest.number === mergedPullRequest.number)),57 thread.linkedPullRequest.number === mergedPullRequest.number)),
56 );58 );
59 // Use the same cwd as the sidebar so both paths share GitManager's PR cache.
60 const lookupCwdByThreadId = new Map<string, string>();
61 yield* Effect.forEach(
62 candidates,
63 (thread) =>
64 Effect.gen(function* () {
65 const project = projects.get(thread.projectId);
66 if (project === undefined || thread.linkedPullRequest != null) return;
67 const worktreeExists =
68 thread.worktreePath !== null &&
69 (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
70 lookupCwdByThreadId.set(
71 thread.id,
72 worktreeExists && thread.worktreePath !== null
73 ? thread.worktreePath
74 : project.workspaceRoot,
75 );
76 }),
77 { concurrency: 8, discard: true },
78 );
57 const lookupKey = (thread: (typeof candidates)[number]) => {79 const lookupKey = (thread: (typeof candidates)[number]) => {
58 if (thread.linkedPullRequest != null) {80 if (thread.linkedPullRequest != null) {
59 return JSON.stringify([81 return JSON.stringify([
...@@ -64,11 +86,9 @@ export const make = Effect.gen(function* () {...@@ -64,11 +86,9 @@ export const make = Effect.gen(function* () {
64 ]);86 ]);
65 }87 }
66 if (thread.branch === null) return JSON.stringify(["none", thread.id]);88 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
67 const project = projects.get(thread.projectId);89 const cwd = lookupCwdByThreadId.get(thread.id);
68 return JSON.stringify(90 return JSON.stringify(
69 project === undefined91 cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
70 ? ["missing-project", thread.id]
71 : ["branch", project.workspaceRoot, thread.branch],
72 );92 );
73 };93 };
74 const groups = Map.groupBy(candidates, lookupKey);94 const groups = Map.groupBy(candidates, lookupKey);
...@@ -100,11 +120,11 @@ export const make = Effect.gen(function* () {...@@ -100,11 +120,11 @@ export const make = Effect.gen(function* () {
100 } satisfies SettlementPullRequest;120 } satisfies SettlementPullRequest;
101 }121 }
102 if (thread.branch === null) return null;122 if (thread.branch === null) return null;
103 const project = projects.get(thread.projectId);123 const cwd = lookupCwdByThreadId.get(thread.id);
104 if (project === undefined) {124 if (cwd === undefined) {
105 return yield* Effect.die(new Error("thread project not found"));125 return yield* Effect.die(new Error("thread project not found"));
106 }126 }
107 return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch });127 return yield* git.branchPullRequest({ cwd, branch: thread.branch });
108 });128 });
109129
110 yield* Effect.forEach(130 yield* Effect.forEach(