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";
1717import * as Crypto from "effect/Crypto";
1818import * as Deferred from "effect/Deferred";
1919import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
2021import * as Layer from "effect/Layer";
2122import * as PubSub from "effect/PubSub";
2223import * as Queue from "effect/Queue";
......@@ -134,6 +135,7 @@ interface HarnessOptions {
134135 readonly settings?: ServerSettings;
135136 readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"];
136137 readonly pullRequestSummary?: PullRequestService["Service"]["summary"];
138 readonly existingWorktreePaths?: ReadonlyArray<string>;
137139 readonly onDispatch?: (
138140 command: AutoSettleCommand,
139141 ) => Effect.Effect<void, OrchestrationCommandInvariantError>;
......@@ -235,6 +237,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
235237 Layer.succeed(ServerSettingsService, serverSettings),
236238 Layer.succeed(ServerActivation, Deferred.await(activation)),
237239 Layer.succeed(Crypto.Crypto, testCrypto),
240 FileSystem.layerNoop({
241 exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false),
242 }),
238243 );
239244
240245 return {
......@@ -647,6 +652,43 @@ describe("ThreadSettlementReactor", () => {
647652 ),
648653 );
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
650692 it.effect("carries the snapshot guard and survives a stale dispatch rejection", () =>
651693 Effect.scoped(
652694 Effect.gen(function* () {
apps/server/src/orchestration/ThreadSettlementReactor.ts+27-7
......@@ -5,6 +5,7 @@ import * as Context from "effect/Context";
55import * as Crypto from "effect/Crypto";
66import * as DateTime from "effect/DateTime";
77import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
89import * as Layer from "effect/Layer";
910import * as Schedule from "effect/Schedule";
1011import type * as Scope from "effect/Scope";
......@@ -37,6 +38,7 @@ export const make = Effect.gen(function* () {
3738 const git = yield* GitManager.GitManager;
3839 const pullRequests = yield* PullRequestService.PullRequestService;
3940 const crypto = yield* Crypto.Crypto;
41 const fileSystem = yield* FileSystem.FileSystem;
4042
4143 const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* (
4244 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
......@@ -54,6 +56,26 @@ export const make = Effect.gen(function* () {
5456 mergedPullRequest.repository.toLowerCase() &&
5557 thread.linkedPullRequest.number === mergedPullRequest.number)),
5658 );
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 );
5779 const lookupKey = (thread: (typeof candidates)[number]) => {
5880 if (thread.linkedPullRequest != null) {
5981 return JSON.stringify([
......@@ -64,11 +86,9 @@ export const make = Effect.gen(function* () {
6486 ]);
6587 }
6688 if (thread.branch === null) return JSON.stringify(["none", thread.id]);
67 const project = projects.get(thread.projectId);
89 const cwd = lookupCwdByThreadId.get(thread.id);
6890 return JSON.stringify(
69 project === undefined
70 ? ["missing-project", thread.id]
71 : ["branch", project.workspaceRoot, thread.branch],
91 cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
7292 );
7393 };
7494 const groups = Map.groupBy(candidates, lookupKey);
......@@ -100,11 +120,11 @@ export const make = Effect.gen(function* () {
100120 } satisfies SettlementPullRequest;
101121 }
102122 if (thread.branch === null) return null;
103 const project = projects.get(thread.projectId);
104 if (project === undefined) {
123 const cwd = lookupCwdByThreadId.get(thread.id);
124 if (cwd === undefined) {
105125 return yield* Effect.die(new Error("thread project not found"));
106126 }
107 return yield* git.branchPullRequest({ cwd: project.workspaceRoot, branch: thread.branch });
127 return yield* git.branchPullRequest({ cwd, branch: thread.branch });
108128 });
109129
110130 yield* Effect.forEach(