diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index eefc18f7b46..50cabe90c1d 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -305,7 +305,10 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("inactive", { branch: "inactive-feature" }), - makeThread("closed-pr", { linkedPullRequest }), + makeThread("closed-pr", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ...skipped, ], [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], @@ -337,7 +340,7 @@ describe("ThreadSettlementReactor", () => { { threadId: ThreadId.make("closed-pr"), snapshotSequence: 1, - settledAt: "2026-08-20T00:00:00.000Z", + settledAt: "2026-08-27T00:00:00.000Z", }, { threadId: ThreadId.make("inactive"), @@ -346,9 +349,7 @@ describe("ThreadSettlementReactor", () => { }, ], ); - assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ - { cwd: "/workspace/project", branch: "inactive-feature" }, - ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, ]); @@ -589,7 +590,12 @@ describe("ThreadSettlementReactor", () => { const releaseLaterLookup = yield* Deferred.make(); const lookupCount = yield* Ref.make(0); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + snapshot: makeSnapshot([ + makeThread("settings-thread", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ]), settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null, @@ -661,6 +667,7 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("lookup-failed", { + latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest: { projectId: LINKED_PROJECT_ID, repository: "owner/repository", @@ -695,6 +702,121 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("settles inactive linked and branch threads without reading an unavailable host", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("inactive-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("inactive-branch", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-21T00:00:00.000Z", + }), + ]), + branchPullRequest: () => Effect.die(new Error("host unavailable")), + pullRequestSummary: () => + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + detail: "host unavailable", + }), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)) + .map(({ threadId, snapshotSequence, settledAt }) => ({ + threadId, + snapshotSequence, + settledAt, + })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("inactive-branch"), + snapshotSequence: 1, + settledAt: "2026-08-21T00:00:00.000Z", + }, + { + threadId: ThreadId.make("inactive-linked"), + snapshotSequence: 1, + settledAt: "2026-08-20T00:00:00.000Z", + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles an inactive thread before its shared pull request lookup completes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const lookupStarted = yield* Deferred.make(); + const releaseLookup = yield* Deferred.make(); + const linkedPullRequest = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("recent-linked", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("inactive-linked", { linkedPullRequest }), + ]), + pullRequestSummary: () => + Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseLookup)), + Effect.andThen( + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + detail: "host unavailable", + }), + ), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Deferred.await(lookupStarted); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("inactive-linked")], + ); + + yield* Deferred.succeed(releaseLookup, undefined); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("keeps threads active when their pull request project is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -712,7 +834,10 @@ describe("ThreadSettlementReactor", () => { latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest, }), - makeThread("missing-branch-project", { branch: "saved-feature" }), + makeThread("missing-branch-project", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), @@ -750,13 +875,21 @@ describe("ThreadSettlementReactor", () => { makeThread("branch-one", { branch: "saved-feature", worktreePath: "/deleted/worktree-one", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("branch-two", { branch: "saved-feature", worktreePath: "/deleted/worktree-two", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-one", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-two", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), - makeThread("linked-one", { linkedPullRequest }), - makeThread("linked-two", { linkedPullRequest }), ], [ makeProject(PROJECT_ID, "/workspace/project-root"), @@ -803,10 +936,12 @@ describe("ThreadSettlementReactor", () => { makeThread("live-worktree", { branch: "feature/live", worktreePath: "/workspace/project-root/.worktrees/live", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("deleted-worktree", { branch: "feature/deleted", worktreePath: "/workspace/project-root/.worktrees/deleted", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), ], [makeProject(PROJECT_ID, "/workspace/project-root")], diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 70de3c41d7e..77a6546365f 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -51,10 +51,60 @@ export const make = Effect.gen(function* () { // 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)); + + // Return the thread when it still needs a pull request decision. A rejected + // dispatch skips it for this snapshot instead of retrying through a lookup. + const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + const settledAt = resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }); + if (settledAt === null) { + return thread; + } + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.auto-settle", + commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), + threadId: thread.id, + snapshotSequence: snapshot.snapshotSequence, + settledAt, + }); + return null; + }, + (effect, thread) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ), + ); + + // Inactivity needs no host state. Finish these decisions before any lookup + // can fail or wait on the network, including lookups shared by recent threads. + const lookupCandidates = (yield* Effect.forEach( + candidates, + (thread) => settleThread(thread, null), + { + concurrency: 8, + }, + )).filter((thread) => thread !== null); + // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( - candidates, + lookupCandidates, (thread) => Effect.gen(function* () { const project = projects.get(thread.projectId); @@ -99,7 +149,7 @@ export const make = Effect.gen(function* () { cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; - const groups = Map.groupBy(candidates, lookupKey); + const groups = Map.groupBy(lookupCandidates, lookupKey); const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( thread: (typeof candidates)[number], @@ -152,42 +202,9 @@ export const make = Effect.gen(function* () { (group) => Effect.gen(function* () { const pullRequest = yield* pullRequestFor(group[0]!); - yield* Effect.forEach( - group, - (thread) => - Effect.gen(function* () { - const settings = yield* settingsService.getSettings; - const decisionNow = DateTime.formatIso(yield* DateTime.now); - const settledAt = resolveAutoSettlementAt({ - thread, - pullRequest, - now: decisionNow, - autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, - autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, - }); - if (settledAt === null) { - return; - } - const uuid = yield* crypto.randomUUIDv4; - yield* engine.dispatch({ - type: "thread.auto-settle", - commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), - threadId: thread.id, - snapshotSequence: snapshot.snapshotSequence, - settledAt, - }); - }).pipe( - Effect.catchCause((cause) => - Cause.hasInterruptsOnly(cause) - ? Effect.failCause(cause) - : Effect.logWarning("automatic thread settlement skipped", { - threadId: thread.id, - cause: Cause.pretty(cause), - }), - ), - ), - { discard: true }, - ); + yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { + discard: true, + }); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)