From 0da3aa95d92197b5db76914abb6a34718a72b22c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 08:41:25 +0000 Subject: [PATCH 01/10] test(server): reproduce issue 24797, PR lookup runs in an emptied worktree folder Co-Authored-By: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index d0b2deacc11ea..6f6f7aef93cb4 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -180,6 +180,95 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); + it.effect( + "looks up a branch from the project root when its worktree is no longer a repository", + () => + Effect.scoped( + Effect.gen(function* () { + // A worktree folder can outlive its .git; git in that folder fails with not_a_repository. + const thread = { + ...threadShell("emptied-worktree-thread"), + branch: "feature/emptied", + worktreePath: "/workspace/worktree", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const lookupCwds: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getShellSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], + }), + ), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeed(Option.some(project)), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input) => + Effect.sync(() => { + lookupCwds.push(input.cwd); + return null; + }), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + // The worktree folder exists but holds nothing, not even .git. + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(path === thread.worktreePath), + }), + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); + + expect(lookupCwds).toEqual([project.workspaceRoot]); + }), + ), + ); + it.effect.each([ { state: "closed", sameBranch: false }, { state: "merged", sameBranch: false }, From d9033f42f3fa3757fb54eddff31013d15568ef11 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 25 Oct 2026 08:43:22 +0000 Subject: [PATCH 02/10] fix(server): PR lookup falls back to the project root when a worktree lost its .git Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/ThreadPullRequestService.test.ts | 4 ++++ .../src/orchestration-v2/ThreadPullRequestService.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 6f6f7aef93cb4..4a4bee9d74a99 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -16,6 +16,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -156,6 +157,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), ), FileSystem.layerNoop({}), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; @@ -255,6 +257,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { FileSystem.layerNoop({ exists: (path) => Effect.succeed(path === thread.worktreePath), }), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; @@ -364,6 +367,7 @@ describe("ThreadPullRequestServiceV2 reads", () => { }), ), FileSystem.layerNoop({}), + Path.layer, ); yield* Effect.gen(function* () { const service = yield* ThreadPullRequestService.make; diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index d7855abf7a911..cb55b3be4f67f 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -15,6 +15,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -103,6 +104,7 @@ export const make = Effect.gen(function* () { const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const pendingBackfill = new Map(); @@ -182,10 +184,12 @@ export const make = Effect.gen(function* () { refresh: request.refresh, }); if (first.branch !== null && repository === null) return finishBackfill(group); - const worktreeExists = - first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); + // A worktree folder can outlive its .git, and git there fails with not_a_repository. + const worktreeIsRepository = + first.worktreePath !== null && + (yield* fileSystem.exists(path.join(first.worktreePath, ".git"))); const cwd = - worktreeExists && first.worktreePath !== null + worktreeIsRepository && first.worktreePath !== null ? first.worktreePath : project.workspaceRoot; const detected = From 36d7cef4aaef1c53a52de0437f16d2865761784b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 08:04:03 +0000 Subject: [PATCH 03/10] test(server): takeover tests for PR lookup in emptied worktrees Replace the PR's bespoke emptied-worktree test with one parameterised sweep over a thread's worktree state: a linked worktree (.git present) looks up in its own folder, while a folder without .git and a missing folder both fall back to the project root. Co-Authored-By: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 177 +++++++++--------- 1 file changed, 93 insertions(+), 84 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 8a0ef98c66116..837a974d3136a 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -181,94 +181,103 @@ describe("ThreadPullRequestServiceV2 reads", () => { ), ); - it.effect( - "looks up a branch from the project root when its worktree is no longer a repository", - () => - Effect.scoped( - Effect.gen(function* () { - // A worktree folder can outlive its .git; git in that folder fails with not_a_repository. - const thread = { - ...threadShell("emptied-worktree-thread"), - branch: "feature/emptied", - worktreePath: "/workspace/worktree", - }; - const project: OrchestrationProjectShell = { - id: thread.projectId, - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }; - const activation = yield* Deferred.make(); - const sweepStarted = yield* Deferred.make(); - const lookupCwds: Array = []; - const dependencies = Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.never, - getShellSnapshot: () => - Deferred.succeed(sweepStarted, undefined).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: [thread], - archivedThreads: [], - }), - ), - }), - Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => Effect.succeed([project]), - getShell: () => Effect.succeed(Option.some(project)), - }), - Layer.mock(GitManager.GitManager)({ - branchPullRequest: (input) => - Effect.sync(() => { - lookupCwds.push(input.cwd); - return null; - }), - }), - Layer.mock(PullRequestService.PullRequestService)({}), - Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ - resolve: () => - Effect.succeed({ - canonicalKey: "github.com/acme/project", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:acme/project.git", - }, - provider: SourceControlProviderKind.make("github"), - owner: "acme", - name: "project", + it.effect.each([ + { + worktree: "a linked worktree", + existingPaths: ["/workspace/worktree", "/workspace/worktree/.git"], + cwd: "/workspace/worktree", + }, + // A folder that outlived its .git makes git fail with not_a_repository. + { + worktree: "a folder without .git", + existingPaths: ["/workspace/worktree"], + cwd: "/workspace/project", + }, + { worktree: "a missing folder", existingPaths: [], cwd: "/workspace/project" }, + ] as const)("looks up a branch whose worktree is $worktree in $cwd", ({ existingPaths, cwd }) => + Effect.scoped( + Effect.gen(function* () { + const thread = { + ...threadShell("worktree-thread"), + branch: "feature/worktree", + worktreePath: "/workspace/worktree", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const lookupCwds: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getShellSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], }), - }), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), - Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), + ), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input) => + Effect.sync(() => { + lookupCwds.push(input.cwd); + return null; }), - ), - // The worktree folder exists but holds nothing, not even .git. - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(path === thread.worktreePath), + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), }), - Path.layer, - ); - yield* Effect.gen(function* () { - const service = yield* ThreadPullRequestService.make; - yield* service.start(); - yield* Deferred.succeed(activation, undefined); - yield* Deferred.await(sweepStarted); - yield* service.drain; - }).pipe(Effect.provide(dependencies)); + ), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(existingPaths.some((entry) => entry === path)), + }), + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); - expect(lookupCwds).toEqual([project.workspaceRoot]); - }), - ), + expect(lookupCwds).toEqual([cwd]); + }), + ), ); it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => From 756637e62664a4c1b279d885bcdd5148083afe5a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 08:07:18 +0000 Subject: [PATCH 04/10] refactor(server): inline the worktree .git check for PR lookup cwd Co-Authored-By: Claude Opus 5.5 --- .../server/src/orchestration-v2/ThreadPullRequestService.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index dcf8c256e5c78..db49304662a1b 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -191,11 +191,9 @@ export const make = Effect.gen(function* () { }); if (first.branch !== null && repository === null) return finishBackfill(group); // A worktree folder can outlive its .git, and git there fails with not_a_repository. - const worktreeIsRepository = - first.worktreePath !== null && - (yield* fileSystem.exists(path.join(first.worktreePath, ".git"))); const cwd = - worktreeIsRepository && first.worktreePath !== null + first.worktreePath !== null && + (yield* fileSystem.exists(path.join(first.worktreePath, ".git"))) ? first.worktreePath : project.workspaceRoot; const detected = From 228bb8b6a1b60e18c923e8dddf6f0308c0484ec9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 09:30:40 +0000 Subject: [PATCH 05/10] fix(server): settlement sweep skips emptied worktree folders too The settlement sweep picked its PR-lookup cwd with the same folder-exists check, so a worktree folder without .git still failed every minute with not_a_repository. Use the worktree only when /.git exists. Co-Authored-By: Claude Opus 5.5 --- .../ThreadSettlementService.test.ts | 42 +++++++++++++++++++ .../ThreadSettlementService.ts | 11 +++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 0a0996c0ede0c..a782be344b0c9 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -25,6 +25,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -704,6 +705,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: FileSystem.layerNoop({ exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), }), + Path.layer, ); return { @@ -865,6 +867,46 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("looks up a branch in its worktree only while the worktree has .git", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("linked-worktree", { + branch: "linked", + worktreePath: "/worktrees/linked", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + // A folder that outlived its .git makes git fail with not_a_repository. + makeThread("emptied-worktree", { + branch: "emptied", + worktreePath: "/worktrees/emptied", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + ]), + existingWorktreePaths: [ + "/worktrees/linked", + "/worktrees/linked/.git", + "/worktrees/emptied", + ], + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + const cwdByBranch = Object.fromEntries( + (yield* Ref.get(fixture.branchCalls)).map((call) => [call.branch, call.cwd]), + ); + expect(cwdByBranch).toEqual({ + linked: "/worktrees/linked", + emptied: "/workspace/project", + }); + }).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 6a3d4fffb0399..773a466da8604 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -19,6 +19,7 @@ import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FiberSet from "effect/FiberSet"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import * as Schedule from "effect/Schedule"; import * as Semaphore from "effect/Semaphore"; import type * as Scope from "effect/Scope"; @@ -297,6 +298,7 @@ export const make = Effect.gen(function* () { const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; // A shell merge or close sends no event, and the branch lookup caches an open pull request for // a minute, so these threads' next lookup reads the host fresh. const shellClosedThreads = new Set(); @@ -376,12 +378,13 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const project = projects.get(thread.projectId); if (project === undefined || thread.branch === null) return; - const worktreeExists = - thread.worktreePath !== null && - (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); + // A worktree folder can outlive its .git, and git there fails with not_a_repository. lookupCwdByThreadId.set( thread.id, - worktreeExists && thread.worktreePath !== null + thread.worktreePath !== null && + (yield* fileSystem + .exists(path.join(thread.worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => false))) ? thread.worktreePath : project.workspaceRoot, ); From 1543555c8c7d4090a68ea6f6ea05e3524bdce242 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 10:07:45 +0000 Subject: [PATCH 06/10] fix(server): skip settlement when a worktree cannot be checked A failed .git probe (for example EACCES) was read as "no .git", so the sweep looked the branch up from the project root and could settle the thread. Log and skip that thread instead; a missing .git still falls back. Co-Authored-By: Claude Opus 5.5 --- .../ThreadSettlementService.test.ts | 47 ++++++++++++++++++- .../ThreadSettlementService.ts | 8 ++-- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index a782be344b0c9..0d6106135bb6d 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -26,6 +26,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -557,6 +558,7 @@ interface HarnessOptions { readonly branchPullRequest?: GitManager.GitManager["Service"]["branchPullRequest"]; readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"]; readonly existingWorktreePaths?: ReadonlyArray; + readonly unreadablePaths?: ReadonlyArray; readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect; readonly onScriptRun?: ( input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput, @@ -703,7 +705,17 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed(Crypto.Crypto, testCrypto), FileSystem.layerNoop({ - exists: (path) => Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), + exists: (path) => + options.unreadablePaths?.includes(path) + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(options.existingWorktreePaths?.includes(path) ?? false), }), Path.layer, ); @@ -907,6 +919,39 @@ describe("ThreadSettlementServiceV2 worker", () => { ), ); + it.effect("does not settle a thread whose worktree cannot be checked", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("unreadable-worktree", { + branch: "unreadable", + worktreePath: "/worktrees/unreadable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + makeThread("readable-worktree", { + branch: "readable", + worktreePath: "/worktrees/readable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + ]), + existingWorktreePaths: ["/worktrees/readable", "/worktrees/readable/.git"], + unreadablePaths: ["/worktrees/unreadable/.git"], + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("merged")), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementService.ThreadSettlementServiceV2; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + ThreadId.make("readable-worktree"), + ]); + }).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 773a466da8604..6126133d31ea3 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -382,13 +382,11 @@ export const make = Effect.gen(function* () { lookupCwdByThreadId.set( thread.id, thread.worktreePath !== null && - (yield* fileSystem - .exists(path.join(thread.worktreePath, ".git")) - .pipe(Effect.orElseSucceed(() => false))) + (yield* fileSystem.exists(path.join(thread.worktreePath, ".git"))) ? thread.worktreePath : project.workspaceRoot, ); - }), + }).pipe(Effect.ignore({ log: "Warn", message: "checking a thread worktree failed" })), { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { @@ -496,7 +494,7 @@ export const make = Effect.gen(function* () { if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); if (cwd === undefined) { - return yield* Effect.die(new Error("thread project not found")); + return yield* Effect.die(new Error("thread lookup directory not resolved")); } const refresh = group.filter((member) => shellClosedThreads.delete(member.id)).length > 0; return yield* git.branchPullRequest({ cwd, branch: thread.branch }, { refresh }); From 35bb0963949405ec497069be9844b45667594127 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 10:40:24 +0000 Subject: [PATCH 07/10] fix(server): keep the worktree when its .git cannot be checked Skipping the thread left no lookup directory, so a thread with a merged linked pull request settled without its branch recheck. On a failed .git probe keep the worktree instead, as main did: git fails there and the sweep skips the thread, linked or not. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ThreadSettlementService.test.ts | 5 ++++- .../src/orchestration-v2/ThreadSettlementService.ts | 9 ++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 0d6106135bb6d..1c05225ff965e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -938,7 +938,10 @@ describe("ThreadSettlementServiceV2 worker", () => { ]), existingWorktreePaths: ["/worktrees/readable", "/worktrees/readable/.git"], unreadablePaths: ["/worktrees/unreadable/.git"], - branchPullRequest: () => Effect.succeed(makeBranchPullRequest("merged")), + branchPullRequest: (input) => + input.cwd === "/worktrees/unreadable" + ? Effect.die(new Error("git: permission denied")) + : Effect.succeed(makeBranchPullRequest("merged")), }); yield* Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 6126133d31ea3..c8e743980e338 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -379,14 +379,17 @@ export const make = Effect.gen(function* () { const project = projects.get(thread.projectId); if (project === undefined || thread.branch === null) return; // A worktree folder can outlive its .git, and git there fails with not_a_repository. + // An unreadable .git keeps the worktree, so git fails there and the thread is skipped. lookupCwdByThreadId.set( thread.id, thread.worktreePath !== null && - (yield* fileSystem.exists(path.join(thread.worktreePath, ".git"))) + (yield* fileSystem + .exists(path.join(thread.worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))) ? thread.worktreePath : project.workspaceRoot, ); - }).pipe(Effect.ignore({ log: "Warn", message: "checking a thread worktree failed" })), + }), { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { @@ -494,7 +497,7 @@ export const make = Effect.gen(function* () { if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); if (cwd === undefined) { - return yield* Effect.die(new Error("thread lookup directory not resolved")); + return yield* Effect.die(new Error("thread project not found")); } const refresh = group.filter((member) => shellClosedThreads.delete(member.id)).length > 0; return yield* git.branchPullRequest({ cwd, branch: thread.branch }, { refresh }); From db3bdea7bb3505a311209a308f91ab94e6cdee12 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 10:45:55 +0000 Subject: [PATCH 08/10] test(server): cover a merged linked pull request behind an unreadable worktree Co-Authored-By: Claude Opus 5.5 --- .../ThreadSettlementService.test.ts | 63 ++++++++++++++----- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 1c05225ff965e..0378d1f14db1e 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -924,22 +924,57 @@ describe("ThreadSettlementServiceV2 worker", () => { Effect.gen(function* () { yield* TestClock.setTime(Date.parse(NOW)); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([ - makeThread("unreadable-worktree", { - branch: "unreadable", - worktreePath: "/worktrees/unreadable", - latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), - }), - makeThread("readable-worktree", { - branch: "readable", - worktreePath: "/worktrees/readable", - latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), - }), - ]), + snapshot: makeSnapshot( + [ + makeThread("unreadable-worktree", { + branch: "unreadable", + worktreePath: "/worktrees/unreadable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + // A merged linked pull request still needs the branch recheck before settling. + makeThread("unreadable-linked-worktree", { + branch: "feature", + worktreePath: "/worktrees/unreadable-linked", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 41, + url: "https://example.test/owner/repository/pull/41", + }, + }), + makeThread("readable-worktree", { + branch: "readable", + worktreePath: "/worktrees/readable", + latestUserMessageAt: DateTime.makeUnsafe("2026-08-27T00:00:00.000Z"), + }), + ], + [ + { + ...makeProject(), + repositoryIdentity: { + canonicalKey: "example.test/owner/repository", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "owner", + name: "repository", + }, + }, + ], + ), existingWorktreePaths: ["/worktrees/readable", "/worktrees/readable/.git"], - unreadablePaths: ["/worktrees/unreadable/.git"], + unreadablePaths: ["/worktrees/unreadable/.git", "/worktrees/unreadable-linked/.git"], + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: NOW, + }), branchPullRequest: (input) => - input.cwd === "/worktrees/unreadable" + input.cwd.startsWith("/worktrees/unreadable") ? Effect.die(new Error("git: permission denied")) : Effect.succeed(makeBranchPullRequest("merged")), }); From 8d6e64c6c2779f420da8cb60cf6f6f816756769c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 11:51:58 +0000 Subject: [PATCH 09/10] fix(server): keep the worktree when PR discovery cannot check its .git A failed .git probe aborted the discovery group, so a branchless thread with a stale branch pull request was no longer cleared. Treat a probe error like the settlement sweep does: keep the worktree as the lookup cwd. Co-Authored-By: Claude Opus 5.5 --- .../ThreadPullRequestService.test.ts | 188 ++++++++++-------- .../ThreadPullRequestService.ts | 5 +- 2 files changed, 111 insertions(+), 82 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 837a974d3136a..d8a6dcc091c83 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -17,6 +17,7 @@ import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; @@ -185,99 +186,124 @@ describe("ThreadPullRequestServiceV2 reads", () => { { worktree: "a linked worktree", existingPaths: ["/workspace/worktree", "/workspace/worktree/.git"], + unreadablePaths: [], cwd: "/workspace/worktree", }, // A folder that outlived its .git makes git fail with not_a_repository. { worktree: "a folder without .git", existingPaths: ["/workspace/worktree"], + unreadablePaths: [], cwd: "/workspace/project", }, - { worktree: "a missing folder", existingPaths: [], cwd: "/workspace/project" }, - ] as const)("looks up a branch whose worktree is $worktree in $cwd", ({ existingPaths, cwd }) => - Effect.scoped( - Effect.gen(function* () { - const thread = { - ...threadShell("worktree-thread"), - branch: "feature/worktree", - worktreePath: "/workspace/worktree", - }; - const project: OrchestrationProjectShell = { - id: thread.projectId, - title: "Project", - workspaceRoot: "/workspace/project", - defaultModelSelection: null, - scripts: [], - createdAt: "2026-08-01T00:00:00.000Z", - updatedAt: "2026-08-01T00:00:00.000Z", - }; - const activation = yield* Deferred.make(); - const sweepStarted = yield* Deferred.make(); - const lookupCwds: Array = []; - const dependencies = Layer.mergeAll( - Layer.mock(Orchestrator.OrchestratorV2)({ - streamDomainEvents: Stream.never, - getShellSnapshot: () => - Deferred.succeed(sweepStarted, undefined).pipe( - Effect.as({ - schemaVersion: 2, - snapshotSequence: 1, - threads: [thread], - archivedThreads: [], + { + worktree: "a folder whose .git cannot be checked", + existingPaths: ["/workspace/worktree"], + unreadablePaths: ["/workspace/worktree/.git"], + cwd: "/workspace/worktree", + }, + { + worktree: "a missing folder", + existingPaths: [], + unreadablePaths: [], + cwd: "/workspace/project", + }, + ] as const)( + "looks up a branch whose worktree is $worktree in $cwd", + ({ existingPaths, unreadablePaths, cwd }) => + Effect.scoped( + Effect.gen(function* () { + const thread = { + ...threadShell("worktree-thread"), + branch: "feature/worktree", + worktreePath: "/workspace/worktree", + }; + const project: OrchestrationProjectShell = { + id: thread.projectId, + title: "Project", + workspaceRoot: "/workspace/project", + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-01T00:00:00.000Z", + }; + const activation = yield* Deferred.make(); + const sweepStarted = yield* Deferred.make(); + const lookupCwds: Array = []; + const dependencies = Layer.mergeAll( + Layer.mock(Orchestrator.OrchestratorV2)({ + streamDomainEvents: Stream.never, + getShellSnapshot: () => + Deferred.succeed(sweepStarted, undefined).pipe( + Effect.as({ + schemaVersion: 2, + snapshotSequence: 1, + threads: [thread], + archivedThreads: [], + }), + ), + }), + Layer.mock(ProjectStore.ProjectStoreV2)({ + listShells: () => Effect.succeed([project]), + getShell: () => Effect.succeedSome(project), + }), + Layer.mock(GitManager.GitManager)({ + branchPullRequest: (input) => + Effect.sync(() => { + lookupCwds.push(input.cwd); + return null; }), - ), - }), - Layer.mock(ProjectStore.ProjectStoreV2)({ - listShells: () => Effect.succeed([project]), - getShell: () => Effect.succeedSome(project), - }), - Layer.mock(GitManager.GitManager)({ - branchPullRequest: (input) => - Effect.sync(() => { - lookupCwds.push(input.cwd); - return null; - }), - }), - Layer.mock(PullRequestService.PullRequestService)({}), - Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ - resolve: () => - Effect.succeed({ - canonicalKey: "github.com/acme/project", - locator: { - source: "git-remote" as const, - remoteName: "origin", - remoteUrl: "git@github.com:acme/project.git", - }, - provider: SourceControlProviderKind.make("github"), - owner: "acme", - name: "project", + }), + Layer.mock(PullRequestService.PullRequestService)({}), + Layer.mock(RepositoryIdentityResolver.RepositoryIdentityResolver)({ + resolve: () => + Effect.succeed({ + canonicalKey: "github.com/acme/project", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:acme/project.git", + }, + provider: SourceControlProviderKind.make("github"), + owner: "acme", + name: "project", + }), + }), + Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), + Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(1), + digest: (_algorithm, data) => Effect.succeed(data), }), - }), - Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), - Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), - Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), + ), + FileSystem.layerNoop({ + exists: (path) => + unreadablePaths.some((entry) => entry === path) + ? Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "exists", + pathOrDescriptor: path, + }), + ) + : Effect.succeed(existingPaths.some((entry) => entry === path)), }), - ), - FileSystem.layerNoop({ - exists: (path) => Effect.succeed(existingPaths.some((entry) => entry === path)), - }), - Path.layer, - ); - yield* Effect.gen(function* () { - const service = yield* ThreadPullRequestService.make; - yield* service.start(); - yield* Deferred.succeed(activation, undefined); - yield* Deferred.await(sweepStarted); - yield* service.drain; - }).pipe(Effect.provide(dependencies)); + Path.layer, + ); + yield* Effect.gen(function* () { + const service = yield* ThreadPullRequestService.make; + yield* service.start(); + yield* Deferred.succeed(activation, undefined); + yield* Deferred.await(sweepStarted); + yield* service.drain; + }).pipe(Effect.provide(dependencies)); - expect(lookupCwds).toEqual([cwd]); - }), - ), + expect(lookupCwds).toEqual([cwd]); + }), + ), ); it.effect("leaves delegated child threads to their own refresh instead of the sweep", () => diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts index db49304662a1b..e0602c26e0d57 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.ts @@ -191,9 +191,12 @@ export const make = Effect.gen(function* () { }); if (first.branch !== null && repository === null) return finishBackfill(group); // A worktree folder can outlive its .git, and git there fails with not_a_repository. + // An unreadable .git keeps the worktree rather than failing the whole group. const cwd = first.worktreePath !== null && - (yield* fileSystem.exists(path.join(first.worktreePath, ".git"))) + (yield* fileSystem + .exists(path.join(first.worktreePath, ".git")) + .pipe(Effect.orElseSucceed(() => true))) ? first.worktreePath : project.workspaceRoot; const detected = From 4798b2fbf178ef4c73cd9e4f0d3363ffa5e9e78b Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:10:43 -0800 Subject: [PATCH 10/10] test(server): provide SecureRandom to the emptied worktree lookup test Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/ThreadPullRequestService.test.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts index 074bef0a52ee8..9c2ff9ef18f7a 100644 --- a/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadPullRequestService.test.ts @@ -269,11 +269,8 @@ describe("ThreadPullRequestServiceV2 reads", () => { Layer.mock(BackgroundPolicy.BackgroundPolicy)({}), Layer.succeed(ServerActivation.ServerActivation, Deferred.await(activation)), Layer.succeed( - Crypto.Crypto, - Crypto.make({ - randomBytes: (size) => new Uint8Array(size).fill(1), - digest: (_algorithm, data) => Effect.succeed(data), - }), + SecureRandom.SecureRandom, + SecureRandom.make((size) => new Uint8Array(size).fill(1)), ), FileSystem.layerNoop({ exists: (path) =>